MDataTable 을 미리보기에서 실제로 돌린다 — 태그만 채우면 반쯤 빈 것을 못 본다
"태그와 MDataTable도 환자 선택하면 해당 정보로 조회되는지 확인가능해야" 한다는 요구.
확인해 보니 미리보기가 DataTableViewModel 을 아예 건너뛰고 있었다(PrintService:87,143).
서식의 값 상당수가 태그가 아니라 이 관으로 오는데(운영 실측 Rows 형 1,624건)
그쪽은 미리보기에 존재하지 않았다.
## 레거시 구조를 먼저 확인했다
TK_PREVIEW 는 meLoadMode 를 Runtime 으로 바꾸고 TestPatientSetting() 으로
환자 선택 창을 띄운다(frmSheetDesigner.vb:452-462). 고르는 것만으로는 화면이 안 바뀐다 —
moPatInfoBiz 는 필드에 담기기만 하고(:634-674), 서식을 다시 열어야
ucLoadSheetBase(moWrkInfoBiz, moPatInfoBiz, …, EN_LoadType.Edit) 로 로드되면서
그때 태그와 MDataTable 이 실행된다(:216). 탭마다 별도 인스턴스라
환자를 바꿔도 이미 열린 탭은 옛 환자를 계속 들고 있다.
SheetMe 는 1단계다. UsePatient 가 해석기를 갈고 즉시 다시 그린다.
창이 하나뿐이라 잔상도 없다. 이 차이는 의도한 것이다.
## 치환 엔진(QuerySubstitution)
clsMDataTable.ConvertQuery 를 옮겼다. 한 칸이라도 다르면 미리보기가 운영과
다른 SQL 을 돌린다 — "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고
사람이 확인했다고 믿고 넘어가므로 미리보기가 없는 것보다 나쁘다.
처음에 갈래 하나를 반대로 만들었다. 대문자 Item 처럼 정규식이 안 맞는 경우를
"토큰이 남는다"로 단정했는데, GetPropertyInfo 는 정규식 실패에 ""를 돌려주고(:118)
호출부가 그걸 빈 문자열로 치환한다(:66-70) — 즉 지워진다.
토큰이 남는 갈래는 <b>접두어 불일치</b>뿐이다(oBaseObj 가 Nothing 이라 Replace 를 안 한다).
소스를 읽어 고쳤다. 두 갈래를 섞으면 안 되는 이유는 하나는 ORA 구문오류가 되고
다른 하나는 조건이 사라진 SQL 이 조용히 도는 것이라서다.
SQL 에서는 같아지는 것들도 사람에게는 갈라서 말한다 —
Unknown(아직 안 옮긴 속성) / Empty(값이 빔) / NotAVariable(접두어 틀림).
셋은 고칠 곳이 전부 다르다.
## 짐작하지 않는 변수원(PatientQueryVariableSource)
DataRow 접근형이 이 기능의 대부분을 실어 준다 — PatInfDR.item("아무컬럼") 은
SELECT * 결과 사전을 그대로 조회하면 되므로 <b>내가 컬럼을 알 필요가 없다.</b>
짐작할 것이 없으니 조용히 틀릴 일도 없다.
스칼라도 대부분 그 다섯 행의 컬럼 하나라, 컬럼 이름만 적고 있으면 값·없으면 모른다고
답한다. 존재 여부는 DB 가 판정한다. UDF 산출값(Age·Sex)은 넣지 않았다.
## 값이 안 나올 때를 위한 창
종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄만 나온다.
그것으로는 쿼리가 틀렸는가·치환이 빈 값이 됐는가·이 환자에게 자료가 없는가를 못 가른다.
그래서 데이터소스 창이 <b>치환을 마친 SQL</b>을 그대로 보여 준다.
값이 안 나왔을 때 봐야 하는 것은 결과가 아니라 무엇을 물었는가다.
레거시에는 이걸 볼 수단이 없었다 — 런타임이 빈 catch 로 삼켜 "빈칸"만 남았다.
진단 화면 표본도 <b>실패 상태</b>로 찍는다. 성공 화면만 회귀 대상으로 두면
정작 사람이 오래 들여다보는 화면이 검사에서 빠진다.
## 안전
- 실행은 OracleQueryWorkbench.Trial 을 그대로 쓴다 — SELECT/WITH 문이 이미 거기 있다.
문을 두 군데 두면 한쪽이 느슨해진다.
- 치환 값은 레거시처럼 원문 그대로 박힌다(그래야 같은 SQL 이다). 따옴표가 섞인 값은
QuotedValues 로 드러내고 실행은 SELECT/WITH 문이 막는다.
- 데이터소스당 한 번만 실행하고 캐시한다 — 배선 40개면 왕복 40회가 된다.
- 환자를 바꾸면 러너를 새로 만든다. 재사용하면 태그만 바뀌고 표는 옛 환자가 남는데
그건 아무도 눈치채지 못한다. 서식을 갈아탈 때도 다시 만든다(옛 서식의 쿼리를 쓰게 된다).
## 아직 안 되는 것
Select 형의 필터(DataTable.Select 메모리 문법)는 옮기지 않았다.
무시하고 행 번호만 쓰면 다른 행의 값이 조용히 찍히므로, 값을 내지 않고 사유를 말한다.
운영 다수파인 Rows 형(76%)은 된다.
## 게이트
- dotnet test 323/323 (치환 판정 10건 신규)
- --edit-smoke 실패 0 (배선 판정 7건 추가, 대조군 포함)
- --db-patient ①~⑱ 전건 통과. ⑯ 치환한 SQL 이 실제로 1행을 뽑고,
⑱ 대조군은 환자 없이 같은 쿼리가 "WHERE ComNum = AND ... = ''" 로 깨진다 —
이 대조가 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다
- --dialog-shots FAIL 0 (08c-datasource-result 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-smoke 1,271건 diff 0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f50a7cff4b
commit
9bff3dd5f6
@@ -0,0 +1,81 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.DataSourceResultDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="데이터소스 조회 결과" Width="980" Height="640"
|
||||
MinWidth="760" MinHeight="440"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
<DockPanel Margin="12">
|
||||
|
||||
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<Button Content="SQL 복사" Padding="14,4" Click="OnCopySql"
|
||||
ToolTip="치환을 마친 SQL 을 클립보드로 — 쿼리 편집기나 SQL 도구에 그대로 붙일 수 있습니다"/>
|
||||
<Button Content="닫기" Padding="18,4" Margin="8,0,0,0" IsCancel="True" IsDefault="True"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
<ListBox x:Name="SourceList" SelectionChanged="OnSourceChanged"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
AutomationProperties.Name="데이터소스 목록">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<!-- 실패한 것을 목록에서 바로 알아야 한다 — 하나씩 눌러 보게 만들면 안 된다 -->
|
||||
<TextBlock Text="{Binding Mark}" Width="16" FontWeight="Bold">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Ok}" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
<TextBlock Text="{Binding Suffix}" Margin="6,0,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||
|
||||
<DockPanel Grid.Column="2">
|
||||
<!--
|
||||
치환을 마친 SQL 이 이 창의 핵심이다. 값이 안 나왔을 때 사람이 봐야 하는 것은
|
||||
결과가 아니라 <b>무엇을 물었는가</b>다 — 치환이 빈 값이 되면 조건이 사라진
|
||||
SQL 이 되고, 그건 SQL 을 봐야만 알 수 있다.
|
||||
-->
|
||||
<TextBlock DockPanel.Dock="Top" Text="치환을 마친 SQL" Margin="0,0,0,4"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
<TextBox x:Name="SqlBox" DockPanel.Dock="Top" Height="150" Margin="0,0,0,8"
|
||||
IsReadOnly="True" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto" FontFamily="Consolas"
|
||||
AutomationProperties.Name="치환을 마친 SQL"/>
|
||||
|
||||
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,6" TextWrapping="Wrap"/>
|
||||
|
||||
<DataGrid x:Name="ResultGrid" IsReadOnly="True" AutoGenerateColumns="True"
|
||||
HeadersVisibility="Column" GridLinesVisibility="All"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
AutomationProperties.Name="조회 결과"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// MDataTable 조회 결과를 펼쳐 본다.
|
||||
///
|
||||
/// <b>왜 이 창이 필요한가.</b> 종이에는 <c>[MDataTable1.ALGYON — 조회 결과가 0행입니다]</c>처럼
|
||||
/// 한 줄만 나온다. 그것으로는 세 가지를 가릴 수 없다 —
|
||||
/// 쿼리 자체가 틀렸는가, 치환이 빈 값이 되어 조건이 사라졌는가, 이 환자에게 정말 자료가 없는가.
|
||||
/// 셋은 고칠 곳이 전부 다르다.
|
||||
///
|
||||
/// 그래서 <b>치환을 마친 SQL</b>을 그대로 보여 준다. 값이 안 나왔을 때 사람이 봐야 하는 것은
|
||||
/// 결과가 아니라 무엇을 물었는가다. 레거시에는 이걸 볼 수단이 없었다 —
|
||||
/// 런타임이 빈 catch 로 삼켜서 현장에서 "빈칸"만 남았다.
|
||||
/// </summary>
|
||||
public partial class DataSourceResultDialogView : Window
|
||||
{
|
||||
#region Nested Types
|
||||
/// <summary>목록 한 줄 — 실패한 것을 눌러 보지 않고도 알아야 한다</summary>
|
||||
private sealed record Row(string Name, bool Ok, string Suffix, MDataTableResult Source)
|
||||
{
|
||||
public string Mark => Ok ? "✓" : "✕";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
/// <summary>진단(--dialog-shots)이 직접 만든다 — 앱 코드는 ShowFor 를 쓸 것</summary>
|
||||
internal DataSourceResultDialogView(MDataTableRunner runner)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var results = runner.RunAll();
|
||||
var rows = results.Select(r => new Row(
|
||||
r.Name, r.Ok,
|
||||
r.Ok ? $"{r.Rows.Count}행" : "실패",
|
||||
r)).ToList();
|
||||
SourceList.ItemsSource = rows;
|
||||
|
||||
HeaderText.Text = runner.HasContext
|
||||
? $"데이터소스 {rows.Count}개 · 실패 {rows.Count(r => !r.Ok)}개."
|
||||
+ " 최대 20행까지만 읽습니다(읽기 전용 · SELECT/WITH 만 실행)."
|
||||
: "환자를 고르지 않았습니다 — 치환 변수가 전부 빈 값이 되어 결과가 실제와 다릅니다."
|
||||
+ " 미리보기 창에서 '환자 선택'을 먼저 하세요.";
|
||||
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
SourceList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public static void ShowFor(MDataTableRunner runner, Window? owner)
|
||||
=> new DataSourceResultDialogView(runner) { Owner = owner }.ShowDialog();
|
||||
|
||||
private void OnSourceChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (SourceList.SelectedItem is not Row row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var result = row.Source;
|
||||
SqlBox.Text = result.Sql;
|
||||
|
||||
var notes = new List<string>();
|
||||
if (result.Error is { Length: > 0 } error)
|
||||
{
|
||||
notes.Add("실패: " + error);
|
||||
}
|
||||
else
|
||||
{
|
||||
notes.Add($"{result.Rows.Count}행 · 컬럼 {result.Columns.Count}개");
|
||||
}
|
||||
if (result.Unknown.Count > 0)
|
||||
{
|
||||
// 여기가 가장 흔한 원인이다 — 아직 안 옮긴 변수가 빈 문자열로 박혀 조건이 사라진다.
|
||||
// SQL 만 봐서는 "원래 그런 조건인지" 알 수 없으므로 따로 말한다.
|
||||
notes.Add("아직 옮기지 않은 변수가 빈 값으로 들어갔습니다: "
|
||||
+ string.Join(", ", result.Unknown));
|
||||
}
|
||||
StatusText.Text = string.Join(" · ", notes);
|
||||
StatusText.Foreground = (System.Windows.Media.Brush)FindResource(
|
||||
result.Ok && result.Unknown.Count == 0 ? "B.Muted" : "B.Danger");
|
||||
|
||||
ResultGrid.ItemsSource = ToTable(result)?.DefaultView;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 결과를 DataGrid 가 받을 표로. 컬럼명이 중복되거나 비면 <c>DataTable</c> 이 예외를 낸다 —
|
||||
/// 운영 쿼리에는 별칭 없는 표현식(<c>COUNT(*)</c> 등)이 흔하므로 여기서 이름을 만들어 준다.
|
||||
/// </summary>
|
||||
private static DataTable? ToTable(MDataTableResult result)
|
||||
{
|
||||
if (result.Columns.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var table = new DataTable();
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var name in result.Columns)
|
||||
{
|
||||
var candidate = name.Length > 0 ? name : "컬럼";
|
||||
var unique = candidate;
|
||||
var n = 2;
|
||||
while (!used.Add(unique))
|
||||
{
|
||||
unique = $"{candidate}_{n++}";
|
||||
}
|
||||
table.Columns.Add(unique, typeof(string));
|
||||
}
|
||||
foreach (var cells in result.Rows)
|
||||
{
|
||||
var values = new object[table.Columns.Count];
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = i < cells.Length ? cells[i] ?? string.Empty : string.Empty;
|
||||
}
|
||||
table.Rows.Add(values);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private void OnCopySql(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SqlBox.Text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(SqlBox.Text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 클립보드는 다른 프로세스가 잡고 있으면 실패한다 — 조용히 넘기면 복사된 줄 안다
|
||||
DialogService.Notify(DialogKind.Warning, "복사", "클립보드에 넣지 못했습니다.", ex.Message, this);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -30,6 +30,13 @@
|
||||
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
|
||||
<Button x:Name="PatientClearButton" Content="환자 해제" Padding="10,3" Click="OnClearPatient"
|
||||
IsEnabled="False" ToolTip="환자 없이 — 태그는 사유만 보입니다"/>
|
||||
<!--
|
||||
데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
|
||||
"[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지,
|
||||
치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다.
|
||||
-->
|
||||
<Button x:Name="DataSourceButton" Content="데이터소스..." Padding="10,3" Click="OnShowDataSources"
|
||||
IsEnabled="False" ToolTip="MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"/>
|
||||
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
|
||||
MaxWidth="360" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
|
||||
|
||||
@@ -39,6 +39,15 @@ public partial class PreviewWindow : Window
|
||||
/// 화면이 실제와 다른 것을 보여 준다.
|
||||
/// </summary>
|
||||
private SheetMe.Core.Catalog.ITagValueResolver tags;
|
||||
|
||||
/// <summary>
|
||||
/// MDataTable 실행기 — <c>DataTableField</c> 배선의 값을 낸다.
|
||||
/// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
|
||||
/// </summary>
|
||||
private MDataTableRunner? fields;
|
||||
|
||||
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
|
||||
private SheetMe.Data.Stores.PatientContext? patient;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
@@ -61,7 +70,9 @@ public partial class PreviewWindow : Window
|
||||
}
|
||||
};
|
||||
Subscribe();
|
||||
BuildPages();
|
||||
// 환자 없이도 러너를 만든다 — 데이터소스가 몇 개 있는지는 지금 알 수 있어야 하고,
|
||||
// 그래야 '데이터소스' 버튼이 처음부터 맞는 상태로 뜬다.
|
||||
UsePatient(null, string.Empty);
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -97,7 +108,9 @@ public partial class PreviewWindow : Window
|
||||
Unsubscribe();
|
||||
designer = next;
|
||||
Subscribe();
|
||||
BuildPages();
|
||||
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
|
||||
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
|
||||
UsePatient(patient, PatientText.Text);
|
||||
}
|
||||
|
||||
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
|
||||
@@ -151,7 +164,7 @@ public partial class PreviewWindow : Window
|
||||
BorderBrush = System.Windows.Media.Brushes.LightGray,
|
||||
BorderThickness = new Thickness(1),
|
||||
Margin = new Thickness(0, 0, 0, 20),
|
||||
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags),
|
||||
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields),
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
};
|
||||
@@ -192,12 +205,34 @@ public partial class PreviewWindow : Window
|
||||
/// </summary>
|
||||
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
|
||||
{
|
||||
patient = picked;
|
||||
tags = picked is null ? session : new PatientTagResolver(picked, session);
|
||||
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
|
||||
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
|
||||
fields = new MDataTableRunner(designer.Document,
|
||||
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
|
||||
PatientText.Text = picked is null ? "환자 없음" : label;
|
||||
PatientClearButton.IsEnabled = picked is not null;
|
||||
DataSourceButton.IsEnabled = fields.Names.Count > 0;
|
||||
BuildPages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 <b>왜</b>인지는 여기서만 알 수 있다.
|
||||
///
|
||||
/// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다.
|
||||
/// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다.
|
||||
/// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다.
|
||||
/// </summary>
|
||||
private void OnShowDataSources(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (fields is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DataSourceResultDialogView.ShowFor(fields, this);
|
||||
}
|
||||
|
||||
/// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
|
||||
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user