feat: complete legacy parity and modernize operator UI
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using CorePagePlanProvider =
|
||||
MBN_STOCK_WEBVIEW.Core.Playout.Scenes.ILegacyScenePagePlanProvider;
|
||||
using MBN_STOCK_WEBVIEW.Infrastructure;
|
||||
using MBN_STOCK_WEBVIEW.LegacyApplication;
|
||||
using MBN_STOCK_WEBVIEW.LegacyBridge;
|
||||
@@ -22,6 +24,7 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
private readonly CancellationTokenSource _lifetimeCancellation = new();
|
||||
private readonly SemaphoreSlim _intentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedLocalSelectionIntentGate = new(1, 1);
|
||||
private readonly SemaphoreSlim _orderedPlayoutIntentGate = new(1, 1);
|
||||
private readonly PlayoutLaunchAuthorization _playoutLaunchAuthorization;
|
||||
private readonly WindowSubclassProcedure _windowSubclassProcedure;
|
||||
@@ -86,11 +89,13 @@ public sealed partial class MainWindow : Window
|
||||
IWorldStockSearchService worldStockSearchService;
|
||||
IManualFinancialScreenService manualFinancialService;
|
||||
IStockSearchService manualFinancialStockSearchService;
|
||||
IOperatorCatalogStockSearchService operatorCatalogStockSearchService;
|
||||
INamedPlaylistPersistenceService namedPlaylistPersistenceService;
|
||||
IThemeCatalogPersistenceService themeCatalogPersistenceService;
|
||||
IExpertCatalogPersistenceService expertCatalogPersistenceService;
|
||||
IStockMasterIdentityValidationService stockMasterIdentityValidationService;
|
||||
IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService;
|
||||
CorePagePlanProvider? fixedPagePlanProvider = null;
|
||||
string? initializationError = null;
|
||||
try
|
||||
{
|
||||
@@ -107,6 +112,8 @@ public sealed partial class MainWindow : Window
|
||||
legacyOverridePath: selectedLegacyDatabaseIni,
|
||||
preferLegacyOverride: selectedLegacyDatabaseIni is not null);
|
||||
DataQueryExecutor.Configure(_databaseRuntime.Executor);
|
||||
fixedPagePlanProvider = LegacySceneRuntimeFactory.CreatePagePlanProvider(
|
||||
_databaseRuntime.Executor);
|
||||
var databaseMutationAuthorization =
|
||||
new LaunchDatabaseMutationAuthorization(_playoutLaunchAuthorization);
|
||||
stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor);
|
||||
@@ -131,8 +138,11 @@ public sealed partial class MainWindow : Window
|
||||
_databaseRuntime.Options.Resilience,
|
||||
errorDetector: null,
|
||||
mutationAuthorization: databaseMutationAuthorization));
|
||||
manualFinancialStockSearchService = new LegacyStockSearchService(
|
||||
var sharedStockSearchService = new LegacyStockSearchService(
|
||||
_databaseRuntime.Executor);
|
||||
manualFinancialStockSearchService = sharedStockSearchService;
|
||||
operatorCatalogStockSearchService =
|
||||
new LegacyOperatorCatalogStockSearchService(sharedStockSearchService);
|
||||
namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService(
|
||||
_databaseRuntime.Executor,
|
||||
new OracleNamedPlaylistMutationExecutor(
|
||||
@@ -179,6 +189,8 @@ public sealed partial class MainWindow : Window
|
||||
initializationError);
|
||||
manualFinancialStockSearchService = new UnavailableStockSearchService(
|
||||
initializationError);
|
||||
operatorCatalogStockSearchService =
|
||||
new UnavailableOperatorCatalogStockSearchService(initializationError);
|
||||
namedPlaylistPersistenceService = new UnavailableNamedPlaylistPersistenceService(
|
||||
initializationError);
|
||||
themeCatalogPersistenceService = new UnavailableThemeCatalogPersistenceService(
|
||||
@@ -205,6 +217,12 @@ public sealed partial class MainWindow : Window
|
||||
"Data",
|
||||
"종목비교.dat"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Data", "종목비교.dat"));
|
||||
ILegacyComparisonPairImportService? comparisonImporter =
|
||||
initializationError is not null || _databaseRuntime is null
|
||||
? null
|
||||
: new LegacyComparisonPairImportService(
|
||||
_databaseRuntime.Executor,
|
||||
TrustedLegacyComparisonFileSource.CreateDefault());
|
||||
var manualListsWorkflow = CreateManualListsWorkflow(stockLookup);
|
||||
_controller = new LegacyOperatorController(
|
||||
stockLookup,
|
||||
@@ -217,7 +235,8 @@ public sealed partial class MainWindow : Window
|
||||
comparisonWorkflow: new LegacyComparisonWorkflowController(
|
||||
new LegacyComparisonDataService(stockLookup, worldStockSearchService),
|
||||
comparisonStore,
|
||||
runtimeUiCatalogs.ComparisonLayout),
|
||||
runtimeUiCatalogs.ComparisonLayout,
|
||||
comparisonImporter),
|
||||
manualFinancialWorkflow: new LegacyManualFinancialWorkflow(
|
||||
manualFinancialService,
|
||||
manualFinancialStockSearchService),
|
||||
@@ -230,10 +249,11 @@ public sealed partial class MainWindow : Window
|
||||
expertSelectionService,
|
||||
themeCatalogPersistenceService,
|
||||
expertCatalogPersistenceService,
|
||||
manualFinancialStockSearchService,
|
||||
operatorCatalogStockSearchService,
|
||||
stockMasterIdentityValidationService,
|
||||
operatorCatalogSchemaValidationService),
|
||||
cutRows: cutMenuComposition.Rows);
|
||||
cutRows: cutMenuComposition.Rows,
|
||||
fixedPagePlanProvider: fixedPagePlanProvider);
|
||||
if (initializationError is not null)
|
||||
{
|
||||
_controller.ReportError(initializationError);
|
||||
@@ -324,6 +344,18 @@ public sealed partial class MainWindow : Window
|
||||
LegacyBridgeProtocol.TrustedHost,
|
||||
webRoot,
|
||||
CoreWebView2HostResourceAccessKind.DenyCors);
|
||||
// The package keeps one persistent WebView2 profile while Debug MSIX
|
||||
// payloads can be replaced without changing their public URL/version.
|
||||
// Ignore that profile's HTTP cache for this WebView session so every
|
||||
// launch reads index.html and all relative assets from this payload.
|
||||
await coreWebView.CallDevToolsProtocolMethodAsync(
|
||||
"Network.setCacheDisabled",
|
||||
"{\"cacheDisabled\":true}");
|
||||
if (_closing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
coreWebView.Settings.AreDevToolsEnabled =
|
||||
System.Diagnostics.Debugger.IsAttached;
|
||||
coreWebView.Settings.AreBrowserAcceleratorKeysEnabled = false;
|
||||
@@ -412,6 +444,7 @@ public sealed partial class MainWindow : Window
|
||||
private async Task ProcessIntentAsync(LegacyUiIntent intent)
|
||||
{
|
||||
var gateEntered = false;
|
||||
var orderedLocalSelectionIntentEntered = false;
|
||||
var orderedPlayoutIntentEntered = false;
|
||||
var databaseGateEntered = false;
|
||||
var dispatchStarted = false;
|
||||
@@ -428,10 +461,24 @@ public sealed partial class MainWindow : Window
|
||||
_lifetimeCancellation.Token);
|
||||
if (orderedPlayoutIntentEntered)
|
||||
{
|
||||
await _orderedLocalSelectionIntentGate.WaitAsync(
|
||||
_lifetimeCancellation.Token);
|
||||
orderedLocalSelectionIntentEntered = true;
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
}
|
||||
else if (IsQueuedLocalSelectionIntent(intent))
|
||||
{
|
||||
// WinForms handled these gestures on its one UI message queue. Keep
|
||||
// their arrival order and let an in-progress automatic refresh finish
|
||||
// instead of silently losing clicks, double-clicks, or keyboard focus.
|
||||
await _orderedLocalSelectionIntentGate.WaitAsync(
|
||||
_lifetimeCancellation.Token);
|
||||
orderedLocalSelectionIntentEntered = true;
|
||||
await _intentGate.WaitAsync(_lifetimeCancellation.Token);
|
||||
gateEntered = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep the zero-timeout admission used by database and edit intents;
|
||||
@@ -500,6 +547,15 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined())
|
||||
{
|
||||
if (intent is LegacyCutPointerDownIntent cutPointer)
|
||||
{
|
||||
// A ListView click is still a selection gesture even when its
|
||||
// second click could have appended a row. Preserve selection
|
||||
// and focus while stripping append authority in quarantine.
|
||||
PostState(SelectCutWithoutAppend(cutPointer));
|
||||
return;
|
||||
}
|
||||
|
||||
PostState(ReportIntentFailure(
|
||||
intent,
|
||||
"이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요."));
|
||||
@@ -516,11 +572,22 @@ public sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation())
|
||||
if (IsOperatorMutationIntent(intent) &&
|
||||
!CanAcceptOperatorMutation(intent, out var mutationRejectionMessage))
|
||||
{
|
||||
if (intent is LegacyCutPointerDownIntent cutPointer)
|
||||
{
|
||||
// An automatic refresh can start after the Web snapshot enabled
|
||||
// the list but before native admission. Do not drop that click:
|
||||
// apply selection/focus only and prevent a double-click append
|
||||
// until the known busy interval has ended.
|
||||
PostState(SelectCutWithoutAppend(cutPointer));
|
||||
return;
|
||||
}
|
||||
|
||||
PostState(ReportIntentFailure(
|
||||
intent,
|
||||
"PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요."));
|
||||
mutationRejectionMessage));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -545,6 +612,8 @@ public sealed partial class MainWindow : Window
|
||||
!(searchIntent.Trigger == LegacySearchTrigger.Button &&
|
||||
searchIntent.Text.Length == 0)) ||
|
||||
intent is LegacySelectTabIntent or
|
||||
LegacyActivateFixedActionIntent or
|
||||
LegacyActivateFixedSectionIntent or
|
||||
LegacySearchThemesIntent or LegacySelectThemeIntent or
|
||||
LegacyActivateThemeResultIntent or
|
||||
LegacySearchExpertsIntent or LegacySelectExpertIntent or
|
||||
@@ -561,6 +630,7 @@ public sealed partial class MainWindow : Window
|
||||
LegacySearchOperatorCatalogIntent or
|
||||
LegacySelectOperatorCatalogResultIntent or
|
||||
LegacyBeginCreateThemeCatalogIntent or
|
||||
LegacyChangeCreateThemeMarketIntent or
|
||||
LegacyBeginCreateExpertCatalogIntent or
|
||||
LegacySearchOperatorCatalogStocksIntent or
|
||||
LegacyActivateOperatorCatalogStockIntent or
|
||||
@@ -569,6 +639,7 @@ public sealed partial class MainWindow : Window
|
||||
LegacySaveOperatorCatalogIntent or
|
||||
LegacyConfirmNativeDialogIntent or
|
||||
LegacyAddComparisonPairIntent or
|
||||
LegacyImportComparisonPairsIntent or
|
||||
LegacyMoveComparisonPairIntent or
|
||||
LegacyDeleteSelectedComparisonPairIntent or
|
||||
LegacyDeleteAllComparisonPairsIntent or
|
||||
@@ -579,6 +650,7 @@ public sealed partial class MainWindow : Window
|
||||
LegacyActivateManualFinancialResultIntent or
|
||||
LegacySearchManualFinancialStocksIntent or
|
||||
LegacySaveManualFinancialIntent or
|
||||
LegacySaveManualFinancialRawIntent or
|
||||
LegacyDeleteManualFinancialIntent or
|
||||
LegacyDeleteAllManualFinancialIntent or
|
||||
LegacyOpenManualListIntent or
|
||||
@@ -658,6 +730,11 @@ public sealed partial class MainWindow : Window
|
||||
_intentGate.Release();
|
||||
}
|
||||
|
||||
if (orderedLocalSelectionIntentEntered)
|
||||
{
|
||||
_orderedLocalSelectionIntentGate.Release();
|
||||
}
|
||||
|
||||
if (orderedPlayoutIntentEntered)
|
||||
{
|
||||
_orderedPlayoutIntentGate.Release();
|
||||
@@ -665,9 +742,31 @@ public sealed partial class MainWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private LegacyOperatorSnapshot SelectCutWithoutAppend(
|
||||
LegacyCutPointerDownIntent pointer) =>
|
||||
_controller.CutPointerDown(
|
||||
pointer.PhysicalIndex,
|
||||
pointer.Control,
|
||||
pointer.Shift,
|
||||
pointer.X,
|
||||
pointer.Y,
|
||||
pointer.TimestampMilliseconds,
|
||||
allowAppend: false);
|
||||
|
||||
private static bool IsOrderedPlayoutIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyExecutePlayoutIntent or LegacyGateAPrepareIntent;
|
||||
|
||||
private static bool IsQueuedLocalSelectionIntent(LegacyUiIntent intent) => intent is
|
||||
LegacySelectStockIntent or
|
||||
LegacySelectOperatorCatalogStockIntent or
|
||||
LegacyActivateOperatorCatalogStockIntent or
|
||||
LegacyCutPointerDownIntent or
|
||||
LegacyCutKeyDownIntent or
|
||||
LegacyClearCutSelectionIntent or
|
||||
LegacySelectPlaylistRowIntent or
|
||||
LegacyActivatePlaylistRowIntent or
|
||||
LegacySelectPlaylistBoundaryIntent;
|
||||
|
||||
private static bool IsOperatorSettingsIntent(LegacyUiIntent intent) => intent is
|
||||
LegacyChooseOperatorFolderIntent or
|
||||
LegacyResetOperatorFolderIntent or
|
||||
@@ -878,6 +977,8 @@ public sealed partial class MainWindow : Window
|
||||
_controller.ClearComparisonTargets(),
|
||||
LegacyAddComparisonPairIntent =>
|
||||
await _controller.AddComparisonPairAsync(cancellationToken),
|
||||
LegacyImportComparisonPairsIntent =>
|
||||
_controller.RequestLegacyComparisonPairImport(),
|
||||
LegacySelectComparisonPairIntent selection =>
|
||||
_controller.SelectComparisonPair(selection.RowId),
|
||||
LegacyMoveComparisonPairIntent move =>
|
||||
@@ -961,6 +1062,11 @@ public sealed partial class MainWindow : Window
|
||||
await _controller.BeginCreateThemeCatalogAsync(
|
||||
create.Market,
|
||||
cancellationToken),
|
||||
LegacyChangeCreateThemeMarketIntent change =>
|
||||
await _controller.ChangeCreateThemeCatalogMarketAsync(
|
||||
change.Market,
|
||||
change.EditorName,
|
||||
cancellationToken),
|
||||
LegacyBeginCreateExpertCatalogIntent =>
|
||||
await _controller.BeginCreateExpertCatalogAsync(cancellationToken),
|
||||
LegacySearchOperatorCatalogStocksIntent search =>
|
||||
@@ -1030,6 +1136,7 @@ public sealed partial class MainWindow : Window
|
||||
LegacySaveCurrentNamedPlaylistToIntent save =>
|
||||
await _controller.SaveCurrentNamedPlaylistToAsync(
|
||||
save.DefinitionId,
|
||||
save.ShowSavedAlert,
|
||||
cancellationToken),
|
||||
LegacyDeleteSelectedNamedPlaylistIntent delete =>
|
||||
await DeleteNamedPlaylistForDispatchAsync(
|
||||
@@ -1073,6 +1180,10 @@ public sealed partial class MainWindow : Window
|
||||
await _controller.SaveManualFinancialAsync(
|
||||
save.Record,
|
||||
cancellationToken),
|
||||
LegacySaveManualFinancialRawIntent save =>
|
||||
await _controller.SaveManualFinancialRawAsync(
|
||||
save.Record,
|
||||
cancellationToken),
|
||||
LegacyDeleteManualFinancialIntent =>
|
||||
await _controller.DeleteManualFinancialAsync(
|
||||
all: false,
|
||||
@@ -1358,6 +1469,9 @@ public sealed partial class MainWindow : Window
|
||||
// logical DIP coordinate space used by XAML. The bridge also carries
|
||||
// XamlRoot.RasterizationScale so JavaScript can account for a zoom
|
||||
// value retained by an older WebView profile without private API use.
|
||||
// The original WinForms controls also followed GetDoubleClickTime;
|
||||
// carrying it through the same settings-change path prevents a valid
|
||||
// slow Windows double-click from being split into a single click.
|
||||
return LegacyInteractionMetrics.FromPixelsAtDpi(
|
||||
GetSystemMetricsForDpi(
|
||||
SmCxDrag,
|
||||
@@ -1366,7 +1480,8 @@ public sealed partial class MainWindow : Window
|
||||
SmCyDrag,
|
||||
LegacyInteractionMetrics.CssPixelsPerInch),
|
||||
LegacyInteractionMetrics.CssPixelsPerInch,
|
||||
ReadHostRasterizationScale());
|
||||
hostRasterizationScale: ReadHostRasterizationScale(),
|
||||
doubleClickTimeMilliseconds: (int)GetDoubleClickTime());
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
@@ -1401,6 +1516,9 @@ public sealed partial class MainWindow : Window
|
||||
[DllImport("user32.dll", ExactSpelling = true)]
|
||||
private static extern int GetSystemMetricsForDpi(int nIndex, uint dpi);
|
||||
|
||||
[DllImport("user32.dll", ExactSpelling = true)]
|
||||
private static extern uint GetDoubleClickTime();
|
||||
|
||||
[DllImport("comctl32.dll", ExactSpelling = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetWindowSubclass(
|
||||
@@ -1502,6 +1620,12 @@ public sealed partial class MainWindow : Window
|
||||
|
||||
public bool CanMutate => false;
|
||||
|
||||
public Task<ThemeCatalogStoredResult> ReadStoredAsync(
|
||||
ThemeSelectionIdentity identity,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ThemeCatalogStoredResult>(
|
||||
new InvalidOperationException(_message));
|
||||
|
||||
public Task<bool> ThemeNameExistsAsync(
|
||||
ThemeMarket market,
|
||||
string themeTitle,
|
||||
@@ -1818,6 +1942,28 @@ public sealed partial class MainWindow : Window
|
||||
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableOperatorCatalogStockSearchService :
|
||||
IOperatorCatalogStockSearchService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
public UnavailableOperatorCatalogStockSearchService(string message)
|
||||
{
|
||||
_message = message;
|
||||
}
|
||||
|
||||
public Task<StockSearchResult> SearchAsync(
|
||||
string query,
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
|
||||
|
||||
public Task<StockSearchResult> SearchAllAsync(
|
||||
int maximumResults = LegacyStockSearchService.DefaultMaximumResults,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<StockSearchResult>(new InvalidOperationException(_message));
|
||||
}
|
||||
|
||||
private sealed class UnavailableOverseasStockSearchService : IOverseasStockSearchService
|
||||
{
|
||||
private readonly string _message;
|
||||
|
||||
Reference in New Issue
Block a user