You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
593 lines
22 KiB
593 lines
22 KiB
#nullable enable
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading.Tasks;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Controls.Primitives;
|
|
using System.Windows.Threading;
|
|
using Housing.Services;
|
|
|
|
namespace Housing.Login
|
|
{
|
|
public sealed class StartupLoginWindowResult
|
|
{
|
|
public string Maker { get; set; } = string.Empty;
|
|
public string Model { get; set; } = string.Empty;
|
|
public string Variant1 { get; set; } = string.Empty;
|
|
public string Variant2 { get; set; } = string.Empty;
|
|
public string Operator { get; set; } = string.Empty;
|
|
public string Password { get; set; } = string.Empty;
|
|
public string LineNo { get; set; } = string.Empty;
|
|
public string LotNo { get; set; } = string.Empty;
|
|
public string JigNo { get; set; } = string.Empty;
|
|
}
|
|
|
|
public partial class StartupLoginWindow : Window
|
|
{
|
|
private const string OperatorHistoryFieldName = "Operator";
|
|
private const string SuppressedOperatorHistoryValue = "su";
|
|
private readonly LoginHistoryStore _loginHistoryStore;
|
|
private readonly LoginSelectionOptions _loginOptions;
|
|
private Popup? _openHistoryPopup;
|
|
|
|
public StartupLoginWindowResult Result { get; private set; } = new();
|
|
|
|
public StartupLoginWindow()
|
|
{
|
|
_loginHistoryStore = LoginHistoryStore.Load(GetLoginHistoryPath());
|
|
_loginOptions = LoginSelectionOptions.Load(GetLoginOptionsPath());
|
|
RemoveSuppressedOperatorHistory();
|
|
InitializeComponent();
|
|
ApplyLoginOptions();
|
|
AttachLoginHistoryMenus();
|
|
AttachSelectionDropDowns();
|
|
PasswordBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup();
|
|
LoginButton.GotKeyboardFocus += (_, _) => CloseHistoryPopup();
|
|
PasswordBox.KeyDown += (s, e) => { if (e.Key == System.Windows.Input.Key.Enter) LogInButton_Click(LoginButton, e); };
|
|
OperatorTextBox.KeyDown += (s, e) => { if (e.Key == System.Windows.Input.Key.Enter) LogInButton_Click(LoginButton, e); };
|
|
MakerComboBox.Focus();
|
|
}
|
|
|
|
private enum LoginCheckStatus
|
|
{
|
|
Success,
|
|
InvalidCredentials,
|
|
DbTimeoutOrError
|
|
}
|
|
|
|
private async void LogInButton_Click(object sender, RoutedEventArgs e)
|
|
{
|
|
CloseHistoryPopup();
|
|
|
|
if (!TryReadRequiredFields(out var loginResult, out var password))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string originalButtonText = "ACCESS / LOG IN";
|
|
if (sender is Button button)
|
|
{
|
|
originalButtonText = button.Content?.ToString() ?? "ACCESS / LOG IN";
|
|
button.IsEnabled = false;
|
|
button.Content = "⏳ DB 접속 확인 중...";
|
|
button.UpdateLayout();
|
|
// WPF Dispatcher 렌더링 강제 갱신으로 클릭 즉시 화면 피드백 표기
|
|
Dispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.Render);
|
|
}
|
|
|
|
System.Windows.Input.Mouse.OverrideCursor = System.Windows.Input.Cursors.Wait;
|
|
|
|
try
|
|
{
|
|
var checkResult = await CheckLoginAsync(loginResult.Operator, password);
|
|
|
|
if (checkResult == LoginCheckStatus.InvalidCredentials)
|
|
{
|
|
MessageBox.Show(this, "등록되지 않은 ID/PW입니다.", "Login", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
PasswordBox.Clear();
|
|
PasswordBox.Focus();
|
|
return;
|
|
}
|
|
else if (checkResult == LoginCheckStatus.DbTimeoutOrError)
|
|
{
|
|
MessageBox.Show(this, "DB 연결 상태 또는 ID/PW를 확인해 주십시오.", "로그인 오류", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
PasswordBox.Clear();
|
|
PasswordBox.Focus();
|
|
return;
|
|
}
|
|
|
|
loginResult.Password = password; // 비밀번호를 결과 객체에도 담아둠
|
|
Result = loginResult;
|
|
SaveLoginHistory(loginResult);
|
|
DialogResult = true;
|
|
Close();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(this, $"로그인 확인 실패: {ex.Message}", "Login", MessageBoxButton.OK, MessageBoxImage.Error);
|
|
OperatorTextBox.Focus();
|
|
OperatorTextBox.SelectAll();
|
|
}
|
|
finally
|
|
{
|
|
System.Windows.Input.Mouse.OverrideCursor = null;
|
|
if (sender is Button loginButton)
|
|
{
|
|
loginButton.IsEnabled = true;
|
|
loginButton.Content = originalButtonText;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task<LoginCheckStatus> CheckLoginAsync(string loginId, string password)
|
|
{
|
|
// 1. LoginOptions.json의 로컬 계정 정보에서 먼저 찾기
|
|
if (_loginOptions.LoginAccounts != null && _loginOptions.LoginAccounts.Count > 0)
|
|
{
|
|
var matched = _loginOptions.LoginAccounts.Any(account =>
|
|
string.Equals(account.Id, loginId, StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(account.Password, password, StringComparison.Ordinal));
|
|
|
|
if (matched)
|
|
{
|
|
return LoginCheckStatus.Success;
|
|
}
|
|
}
|
|
|
|
// 2. 오프라인 모드일 경우 통과
|
|
var loginSettings = LoginSettings.Load(GetDatabaseIniPath());
|
|
if (loginSettings.OfflinePreview)
|
|
{
|
|
return LoginCheckStatus.Success;
|
|
}
|
|
|
|
// 3. DB 접속 및 계정 조회 (3초 하드 제한)
|
|
try
|
|
{
|
|
var repository = new LoginAccountRepository(
|
|
DatabaseSettings.Load(GetDatabaseIniPath()),
|
|
loginSettings);
|
|
|
|
var dbCheckTask = repository.ExistsAsync(loginId, password);
|
|
var timeoutTask = Task.Delay(3000); // 3초 하드 제한
|
|
|
|
var completedTask = await Task.WhenAny(dbCheckTask, timeoutTask);
|
|
if (completedTask == dbCheckTask)
|
|
{
|
|
bool isExist = await dbCheckTask;
|
|
return isExist ? LoginCheckStatus.Success : LoginCheckStatus.InvalidCredentials;
|
|
}
|
|
else
|
|
{
|
|
System.Diagnostics.Debug.WriteLine("[로그인 타임아웃] DB 응답 3초 초과 -> 타임아웃/DB연결안내 팝업 표출");
|
|
return LoginCheckStatus.DbTimeoutOrError;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
System.Diagnostics.Debug.WriteLine($"[로그인 오류] DB 연결 예외 발생: {ex.Message}");
|
|
return LoginCheckStatus.DbTimeoutOrError;
|
|
}
|
|
}
|
|
|
|
private bool TryReadRequiredFields(out StartupLoginWindowResult result, out string password)
|
|
{
|
|
result = new StartupLoginWindowResult();
|
|
password = string.Empty;
|
|
|
|
if (!TryReadRequiredComboBox(MakerComboBox, "Maker", out var maker) ||
|
|
!TryReadRequiredComboBox(ModelComboBox, "Model", out var model) ||
|
|
!TryReadTextBox(OperatorTextBox, "ID", out var loginId) ||
|
|
!TryReadPasswordBox(PasswordBox, "PW", out password) ||
|
|
!TryReadRequiredComboBox(LineNoComboBox, "Line", out var lineNo) ||
|
|
!TryReadTextBox(LotNoTextBox, "Lot No", out var lotNo) ||
|
|
!TryReadRequiredComboBox(JigNoComboBox, "Jig No", out var jigNo))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(lotNo) && lotNo.Length < 2)
|
|
{
|
|
lotNo = lotNo.PadLeft(2, '0');
|
|
}
|
|
|
|
var variant1 = ReadOptionalComboBox(Variant1ComboBox);
|
|
var variant2 = ReadOptionalComboBox(Variant2ComboBox);
|
|
|
|
result = new StartupLoginWindowResult
|
|
{
|
|
Maker = maker,
|
|
Model = model,
|
|
Variant1 = variant1,
|
|
Variant2 = variant2,
|
|
Operator = loginId,
|
|
LineNo = lineNo,
|
|
LotNo = lotNo,
|
|
JigNo = jigNo
|
|
};
|
|
|
|
return true;
|
|
}
|
|
|
|
private void ApplyLoginOptions()
|
|
{
|
|
SetComboBoxOptions(MakerComboBox, _loginOptions.Makers, allowBlank: false, historyFieldName: "Maker");
|
|
SetComboBoxOptions(ModelComboBox, _loginOptions.Models, allowBlank: false, historyFieldName: "Model");
|
|
SetComboBoxOptions(Variant1ComboBox, _loginOptions.Variant1Values, allowBlank: true, historyFieldName: "Variant1");
|
|
SetComboBoxOptions(Variant2ComboBox, _loginOptions.Variant2Values, allowBlank: true, historyFieldName: "Variant2");
|
|
SetComboBoxOptions(LineNoComboBox, _loginOptions.LineValues, allowBlank: false, historyFieldName: "LineNo");
|
|
SetComboBoxOptions(JigNoComboBox, _loginOptions.JigNoValues, allowBlank: false, historyFieldName: "JigNo");
|
|
}
|
|
|
|
private void SetComboBoxOptions(
|
|
ComboBox comboBox,
|
|
IReadOnlyList<string> values,
|
|
bool allowBlank,
|
|
string historyFieldName)
|
|
{
|
|
var items = new List<LoginOptionItem>();
|
|
if (allowBlank)
|
|
{
|
|
items.Add(new LoginOptionItem("(None)", string.Empty));
|
|
}
|
|
|
|
foreach (var value in values.Where(value => !string.IsNullOrWhiteSpace(value)))
|
|
{
|
|
if (items.Any(item => string.Equals(item.Value, value, StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
items.Add(new LoginOptionItem(value, value));
|
|
}
|
|
|
|
// 옵션 항목 개수를 최대 3개로 제한
|
|
items = items.Take(3).ToList();
|
|
|
|
comboBox.ItemsSource = items;
|
|
comboBox.DisplayMemberPath = nameof(LoginOptionItem.DisplayText);
|
|
|
|
var rememberedValue = _loginHistoryStore.GetValues(historyFieldName).FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(rememberedValue) &&
|
|
TrySelectComboBoxValue(comboBox, rememberedValue))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (allowBlank)
|
|
{
|
|
comboBox.SelectedIndex = 0;
|
|
}
|
|
else if (items.Count == 1)
|
|
{
|
|
comboBox.SelectedIndex = 0;
|
|
}
|
|
}
|
|
|
|
private static bool TrySelectComboBoxValue(ComboBox comboBox, string value)
|
|
{
|
|
for (var index = 0; index < comboBox.Items.Count; index++)
|
|
{
|
|
if (comboBox.Items[index] is LoginOptionItem item &&
|
|
string.Equals(item.Value, value, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
comboBox.SelectedIndex = index;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private void AttachSelectionDropDowns()
|
|
{
|
|
foreach (var comboBox in new[]
|
|
{
|
|
MakerComboBox,
|
|
ModelComboBox,
|
|
Variant1ComboBox,
|
|
Variant2ComboBox,
|
|
LineNoComboBox,
|
|
JigNoComboBox
|
|
})
|
|
{
|
|
comboBox.GotKeyboardFocus += (_, _) => CloseHistoryPopup();
|
|
comboBox.DropDownOpened += (_, _) => CloseHistoryPopup();
|
|
}
|
|
}
|
|
|
|
private void AttachLoginHistoryMenus()
|
|
{
|
|
AttachHistoryMenu(OperatorTextBox, OperatorHistoryFieldName, PasswordBox);
|
|
AttachHistoryMenu(LotNoTextBox, "LotNo", JigNoComboBox);
|
|
}
|
|
|
|
private void AttachHistoryMenu(TextBox textBox, string fieldName, Control nextFocusControl)
|
|
{
|
|
textBox.GotKeyboardFocus += (_, _) => ShowHistoryMenu(textBox, fieldName, nextFocusControl);
|
|
textBox.PreviewMouseLeftButtonDown += (_, _) =>
|
|
{
|
|
if (textBox.IsKeyboardFocusWithin)
|
|
{
|
|
ShowHistoryMenu(textBox, fieldName, nextFocusControl);
|
|
}
|
|
};
|
|
}
|
|
|
|
private void ShowHistoryMenu(TextBox textBox, string fieldName, Control nextFocusControl)
|
|
{
|
|
// 최근 기록 히스토리 항목 개수 최대 3개로 제한
|
|
var values = _loginHistoryStore.GetValues(fieldName).Take(3).ToList();
|
|
if (values.Count == 0)
|
|
{
|
|
CloseHistoryPopup();
|
|
return;
|
|
}
|
|
|
|
CloseHistoryPopup();
|
|
|
|
var menuWidth = Math.Max(160, textBox.ActualWidth);
|
|
var stackPanel = new StackPanel();
|
|
var popup = new Popup
|
|
{
|
|
PlacementTarget = textBox,
|
|
Placement = PlacementMode.Bottom,
|
|
HorizontalOffset = 0,
|
|
StaysOpen = true,
|
|
AllowsTransparency = true,
|
|
Focusable = false
|
|
};
|
|
|
|
foreach (var value in values)
|
|
{
|
|
var rowGrid = new Grid
|
|
{
|
|
Width = menuWidth - 2,
|
|
Background = textBox.Background
|
|
};
|
|
rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
rowGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
|
|
var selectBtn = new Button
|
|
{
|
|
Content = value,
|
|
Padding = new Thickness(10, 6, 10, 6),
|
|
BorderThickness = new Thickness(0),
|
|
Background = System.Windows.Media.Brushes.Transparent,
|
|
Foreground = textBox.Foreground,
|
|
HorizontalContentAlignment = HorizontalAlignment.Left
|
|
};
|
|
selectBtn.Click += (_, _) =>
|
|
{
|
|
textBox.Text = value;
|
|
textBox.CaretIndex = textBox.Text.Length;
|
|
CloseHistoryPopup();
|
|
FocusControl(nextFocusControl);
|
|
};
|
|
Grid.SetColumn(selectBtn, 0);
|
|
rowGrid.Children.Add(selectBtn);
|
|
|
|
var deleteBtn = new Button
|
|
{
|
|
Content = "✕",
|
|
Width = 28,
|
|
Height = 28,
|
|
Padding = new Thickness(0),
|
|
BorderThickness = new Thickness(0),
|
|
Background = System.Windows.Media.Brushes.Transparent,
|
|
Foreground = System.Windows.Media.Brushes.Red,
|
|
FontWeight = FontWeights.Bold,
|
|
ToolTip = "이력 삭제"
|
|
};
|
|
deleteBtn.Click += (s, e) =>
|
|
{
|
|
_loginHistoryStore.Forget(fieldName, value);
|
|
_loginHistoryStore.Save();
|
|
ShowHistoryMenu(textBox, fieldName, nextFocusControl);
|
|
};
|
|
Grid.SetColumn(deleteBtn, 1);
|
|
rowGrid.Children.Add(deleteBtn);
|
|
|
|
stackPanel.Children.Add(rowGrid);
|
|
}
|
|
|
|
popup.Child = new Border
|
|
{
|
|
Width = menuWidth,
|
|
MaxHeight = 180,
|
|
Background = textBox.Background,
|
|
BorderBrush = textBox.BorderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
Child = new ScrollViewer
|
|
{
|
|
MaxHeight = 180,
|
|
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
|
Content = stackPanel
|
|
}
|
|
};
|
|
|
|
_openHistoryPopup = popup;
|
|
popup.IsOpen = true;
|
|
}
|
|
|
|
private void CloseHistoryPopup()
|
|
{
|
|
if (_openHistoryPopup is not null)
|
|
{
|
|
_openHistoryPopup.IsOpen = false;
|
|
_openHistoryPopup = null;
|
|
}
|
|
}
|
|
|
|
private void FocusControl(Control control)
|
|
{
|
|
Dispatcher.BeginInvoke(new Action(() =>
|
|
{
|
|
control.Focus();
|
|
|
|
if (control is TextBox textBox)
|
|
{
|
|
textBox.CaretIndex = textBox.Text.Length;
|
|
}
|
|
else if (control is PasswordBox passwordBox)
|
|
{
|
|
passwordBox.Focus();
|
|
}
|
|
}), DispatcherPriority.Input);
|
|
}
|
|
|
|
private void SaveLoginHistory(StartupLoginWindowResult result)
|
|
{
|
|
_loginHistoryStore.Remember("Maker", result.Maker);
|
|
_loginHistoryStore.Remember("Model", result.Model);
|
|
_loginHistoryStore.Remember("Variant1", result.Variant1);
|
|
_loginHistoryStore.Remember("Variant2", result.Variant2);
|
|
RememberOperatorHistory(result.Operator);
|
|
_loginHistoryStore.Remember("LineNo", result.LineNo);
|
|
_loginHistoryStore.Remember("LotNo", result.LotNo);
|
|
_loginHistoryStore.Remember("JigNo", result.JigNo);
|
|
_loginHistoryStore.Save();
|
|
}
|
|
|
|
private void RememberOperatorHistory(string operatorId)
|
|
{
|
|
if (IsSuppressedOperatorHistoryValue(operatorId))
|
|
{
|
|
_loginHistoryStore.Forget(OperatorHistoryFieldName, operatorId);
|
|
return;
|
|
}
|
|
|
|
_loginHistoryStore.Remember(OperatorHistoryFieldName, operatorId);
|
|
}
|
|
|
|
private void RemoveSuppressedOperatorHistory()
|
|
{
|
|
try
|
|
{
|
|
if (_loginHistoryStore.Forget(OperatorHistoryFieldName, SuppressedOperatorHistoryValue))
|
|
{
|
|
_loginHistoryStore.Save();
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Login history cleanup is optional and should not block the login window.
|
|
}
|
|
}
|
|
|
|
private static bool IsSuppressedOperatorHistoryValue(string value)
|
|
{
|
|
return string.Equals(value.Trim(), SuppressedOperatorHistoryValue, StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private bool TryReadRequiredComboBox(ComboBox comboBox, string fieldName, out string value)
|
|
{
|
|
value = ReadComboBoxValue(comboBox);
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
MessageBox.Show(
|
|
this,
|
|
$"{fieldName} value is required. Edit LoginOptions.json and select a value.",
|
|
"Login",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
comboBox.Focus();
|
|
comboBox.IsDropDownOpen = true;
|
|
return false;
|
|
}
|
|
|
|
private static string ReadOptionalComboBox(ComboBox comboBox)
|
|
{
|
|
return ReadComboBoxValue(comboBox);
|
|
}
|
|
|
|
private static string ReadComboBoxValue(ComboBox comboBox)
|
|
{
|
|
return comboBox.SelectedItem is LoginOptionItem item
|
|
? item.Value.Trim()
|
|
: comboBox.Text.Trim();
|
|
}
|
|
|
|
private bool TryReadTextBox(TextBox textBox, string fieldName, out string value)
|
|
{
|
|
value = textBox.Text.Trim();
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
MessageBox.Show(this, $"{fieldName}을(를) 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
textBox.Focus();
|
|
textBox.SelectAll();
|
|
return false;
|
|
}
|
|
|
|
private bool TryReadPasswordBox(PasswordBox passwordBox, string fieldName, out string value)
|
|
{
|
|
value = passwordBox.Password;
|
|
if (!string.IsNullOrWhiteSpace(value))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
MessageBox.Show(this, $"{fieldName}을(를) 입력하세요.", "Login", MessageBoxButton.OK, MessageBoxImage.Warning);
|
|
passwordBox.Focus();
|
|
passwordBox.SelectAll();
|
|
return false;
|
|
}
|
|
|
|
private static string GetDatabaseIniPath()
|
|
{
|
|
return Path.Combine(AppContext.BaseDirectory, "Database.ini");
|
|
}
|
|
|
|
private static string GetLoginOptionsPath()
|
|
{
|
|
// 1. 실행 파일과 동일한 디렉토리에 있는 LoginOptions.json을 최우선적으로 직접 사용
|
|
var bundledPath = Path.Combine(AppContext.BaseDirectory, "LoginOptions.json");
|
|
if (File.Exists(bundledPath))
|
|
{
|
|
return bundledPath;
|
|
}
|
|
|
|
// 2. 실행 디렉토리에 존재하지 않는 경우, 로컬 AppData 폴백 처리 (하위 호환성)
|
|
var directory = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"Housing");
|
|
var filePath = Path.Combine(directory, "LoginOptions.json");
|
|
|
|
if (!File.Exists(filePath))
|
|
{
|
|
Directory.CreateDirectory(directory);
|
|
LoginSelectionOptions.CreateDefaultFile(filePath);
|
|
}
|
|
|
|
return filePath;
|
|
}
|
|
|
|
private static string GetLoginHistoryPath()
|
|
{
|
|
return Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
|
"Housing",
|
|
"LoginHistory.json");
|
|
}
|
|
|
|
private sealed class LoginOptionItem
|
|
{
|
|
public string DisplayText { get; }
|
|
public string Value { get; }
|
|
public LoginOptionItem(string displayText, string value)
|
|
{
|
|
DisplayText = displayText;
|
|
Value = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|