feat(ui): Blazor WebAssembly 마이그레이션 및 API-First 로그인 구현
This commit is contained in:
@@ -13,12 +13,12 @@
|
||||
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
<HeadOutlet @rendermode="InteractiveWebAssembly" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<FluentDesignSystemProvider>
|
||||
<Routes />
|
||||
<Routes @rendermode="InteractiveWebAssembly" />
|
||||
<ReconnectModal />
|
||||
</FluentDesignSystemProvider>
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject Microsoft.AspNetCore.Hosting.IWebHostEnvironment WebHostEnvironment
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@using System.IO
|
||||
@using System.Text.Json
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
@using QuantEngine.Web.Infrastructure
|
||||
|
||||
<FluentStack Orientation="Orientation.Vertical" Class="h-100 w-100">
|
||||
<!-- Header -->
|
||||
<FluentHeader>
|
||||
<FluentStack Orientation="Orientation.Horizontal" VerticalAlignment="VerticalAlignment.Center"
|
||||
Style="width: 100%; padding: 8px 16px; gap: 16px;">
|
||||
<FluentButton OnClick="@(() => navOpen = !navOpen)"
|
||||
Title="Toggle Navigation"
|
||||
Style="background: transparent; border: none; cursor: pointer;">
|
||||
☰
|
||||
</FluentButton>
|
||||
<h1 style="margin: 0; font-size: 20px; font-weight: 600;">QuantEngine v@appVersion</h1>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div style="margin-left: auto; display: flex; align-items: center; gap: 12px;">
|
||||
<span style="font-size: 13px; color: var(--neutral-foreground-hint);">관리자 (@context.User.Identity?.Name)</span>
|
||||
<FluentButton OnClick="HandleLogoutAsync" Style="color: #ff5252; background: transparent; border: 1px solid rgba(255, 82, 82, 0.2); cursor: pointer; padding: 4px 12px; border-radius: 4px;">
|
||||
로그아웃
|
||||
</FluentButton>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</FluentStack>
|
||||
</FluentHeader>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<FluentStack Orientation="Orientation.Horizontal" Class="flex-1" Style="overflow: hidden;">
|
||||
<!-- Navigation Sidebar -->
|
||||
@if (navOpen)
|
||||
{
|
||||
<nav style="width: 240px; background: var(--neutral-layer-1); border-right: 1px solid var(--neutral-stroke-1); padding: 12px; overflow-y: auto;">
|
||||
<NavMenu />
|
||||
<div style="margin-top: auto; padding-top: 12px; border-top: 1px solid var(--neutral-stroke-1); margin-top: 12px; font-size: 11px; color: var(--neutral-foreground-3); line-height: 1.5;">
|
||||
<div style="font-weight: 500; margin-bottom: 2px;">QuantEngine v@appVersion</div>
|
||||
<div style="font-size: 10px; opacity: 0.85;">배포: @buildTime</div>
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
|
||||
<!-- Page Content -->
|
||||
<FluentStack Orientation="Orientation.Vertical" Class="flex-1" Style="overflow-y: auto; padding: 24px;">
|
||||
@Body
|
||||
</FluentStack>
|
||||
</FluentStack>
|
||||
</FluentStack>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<p>An unhandled error has occurred.</p>
|
||||
<a href="." class="btn btn-primary">Reload</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.h-100 {
|
||||
height: 100%;
|
||||
}
|
||||
.w-100 {
|
||||
width: 100%;
|
||||
}
|
||||
.flex-1 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private bool navOpen = true;
|
||||
private string appVersion = "Local Debug";
|
||||
private string buildTime = "N/A";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
try
|
||||
{
|
||||
var versionFilePath = Path.Combine(WebHostEnvironment.WebRootPath, "version.json");
|
||||
if (File.Exists(versionFilePath))
|
||||
{
|
||||
var jsonContent = File.ReadAllText(versionFilePath);
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(jsonContent);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("version", out var versionProp))
|
||||
{
|
||||
appVersion = versionProp.GetString() ?? "Local Debug";
|
||||
}
|
||||
if (root.TryGetProperty("built", out var builtProp))
|
||||
{
|
||||
buildTime = builtProp.GetString() ?? "N/A";
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fail-safe default fallback values
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleLogoutAsync()
|
||||
{
|
||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||
await customProvider.MarkUserAsLoggedOutAsync();
|
||||
NavigationManager.NavigateTo("login");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
@using Microsoft.FluentUI.AspNetCore.Components
|
||||
|
||||
<FluentNavMenu>
|
||||
<FluentNavLink Href="/" Match="NavLinkMatch.All">
|
||||
Dashboard
|
||||
</FluentNavLink>
|
||||
<FluentNavLink Href="/operations" Match="NavLinkMatch.Prefix">
|
||||
Operations
|
||||
</FluentNavLink>
|
||||
</FluentNavMenu>
|
||||
@@ -1,105 +0,0 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
@page "/"
|
||||
@using QuantEngine.Core.Infrastructure
|
||||
@inject IWebHostEnvironment Environment
|
||||
|
||||
<PageTitle>Quant Engine - Dashboard</PageTitle>
|
||||
|
||||
<h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 600;">Quant Engine</h1>
|
||||
<p style="margin: 0 0 16px 0; color: var(--neutral-foreground-2); font-size: 14px;">
|
||||
루트 화면은 운영 진입점입니다. 가짜 성과 수치 없이 현재 스냅샷 상태와 리포트 경로만 보여줍니다.
|
||||
</p>
|
||||
|
||||
<!-- Top 3 Cards -->
|
||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Operational Report</p>
|
||||
<h3 style="margin: 0; font-size: 20px; font-weight: 600;">@ReportStateLabel</h3>
|
||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">@ReportPath</p>
|
||||
</div>
|
||||
</FluentCard>
|
||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Sections</p>
|
||||
<h3 style="margin: 0; font-size: 20px; font-weight: 600;">@SectionCountLabel</h3>
|
||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">Temp/operational_report.json</p>
|
||||
</div>
|
||||
</FluentCard>
|
||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Primary Route</p>
|
||||
<FluentButton Appearance="ButtonAppearance.Primary" Href="/operations" Style="margin-top: 8px;">
|
||||
Open Operations
|
||||
</FluentButton>
|
||||
</div>
|
||||
</FluentCard>
|
||||
</FluentStack>
|
||||
|
||||
<!-- Current State & Routing Notes -->
|
||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
||||
<FluentCard Style="flex: 2; min-width: 300px;">
|
||||
<div style="padding: 16px;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Current State</h3>
|
||||
<FluentStack Orientation="Orientation.Vertical" VerticalGap="8">
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Status:</strong> <FluentBadge Appearance="BadgeAppearance.Filled">@ReportChipLabel</FluentBadge></p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Generated:</strong> @GeneratedAtLabel</p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Source:</strong> @SourceLabel</p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Decision feed:</strong> @DecisionFeedLabel</p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Factor feed:</strong> @FactorFeedLabel</p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Raw feed:</strong> @RawFeedLabel</p>
|
||||
</FluentStack>
|
||||
</div>
|
||||
</FluentCard>
|
||||
|
||||
<FluentCard Style="flex: 1; min-width: 250px;">
|
||||
<div style="padding: 16px;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Routing Notes</h3>
|
||||
<ul style="margin: 0; padding-left: 16px; font-size: 14px;">
|
||||
<li>운영 데이터는 snapshot 우선입니다.</li>
|
||||
<li>Excel/GAS 의존 문구는 운영 경로에서 제거 대상입니다.</li>
|
||||
<li>숫자는 provenance 없으면 표시하지 않습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</FluentCard>
|
||||
</FluentStack>
|
||||
|
||||
<!-- Coverage Summary -->
|
||||
<FluentCard>
|
||||
<div style="padding: 16px;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Coverage Summary</h3>
|
||||
@if (Sections.Count == 0)
|
||||
{
|
||||
<div style="padding: 12px; background: var(--warning-background-1); border: 1px solid var(--warning-stroke-1); border-radius: 4px; color: var(--warning-foreground-1); font-size: 14px;">
|
||||
DATA_MISSING: operational_report.json이 비어 있거나 아직 생성되지 않았습니다.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentDataGrid Items="@Sections.AsQueryable()">
|
||||
<PropertyColumn Property="@(x => x.Name)" Title="Name" />
|
||||
<PropertyColumn Property="@(x => x.Title)" Title="Title" />
|
||||
<PropertyColumn Property="@(x => x.Preview)" Title="Preview" />
|
||||
</FluentDataGrid>
|
||||
}
|
||||
</div>
|
||||
</FluentCard>
|
||||
|
||||
@code {
|
||||
private readonly List<OperationalReportSection> Sections = new();
|
||||
private string ReportStateLabel = "DATA_MISSING";
|
||||
private string ReportChipLabel = "DATA_MISSING";
|
||||
private string SectionCountLabel = "0";
|
||||
private string GeneratedAtLabel = "n/a";
|
||||
private string SourceLabel = "n/a";
|
||||
private string DecisionFeedLabel = "DISCONNECTED";
|
||||
private string FactorFeedLabel = "DISCONNECTED";
|
||||
private string RawFeedLabel = "DISCONNECTED";
|
||||
private string ReportPath = "n/a";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
|
||||
var report = OperationalReportLoader.Load(ReportPath);
|
||||
Sections.AddRange(report.Sections);
|
||||
SectionCountLabel = report.SectionCount.ToString();
|
||||
GeneratedAtLabel = report.GeneratedAt;
|
||||
SourceLabel = report.SourceJson;
|
||||
ReportStateLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
|
||||
ReportChipLabel = Sections.Count > 0 ? "READY" : "DATA_MISSING";
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
@page "/login"
|
||||
@attribute [AllowAnonymous]
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<PageTitle>로그인 - QuantEngine</PageTitle>
|
||||
|
||||
<div class="auth-container">
|
||||
<div class="auth-card">
|
||||
<div class="brand-section">
|
||||
<img src="images/quant_engine_logo.jpg" alt="QuantEngine Logo" class="brand-logo" />
|
||||
<h1 class="brand-title">QuantEngine</h1>
|
||||
<p class="brand-subtitle">은퇴자산포트폴리오 투자 관리 시스템</p>
|
||||
</div>
|
||||
|
||||
<form @onsubmit="HandleLoginAsync" class="auth-form">
|
||||
<div class="form-group">
|
||||
<label for="username">관리자 아이디</label>
|
||||
<input type="text" id="username" class="form-control" @bind="Username" placeholder="아이디를 입력하세요" autocomplete="username" />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">비밀번호</label>
|
||||
<input type="password" id="password" class="form-control" @bind="Password" placeholder="비밀번호를 입력하세요" autocomplete="current-password" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<div class="error-message">
|
||||
<svg class="error-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span>@ErrorMessage</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="submit" class="btn-submit" disabled="@IsSubmitting">
|
||||
@if (IsSubmitting)
|
||||
{
|
||||
<span class="spinner"></span>
|
||||
<span>인증 중...</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>로그인</span>
|
||||
}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
background: linear-gradient(135deg, #090a15 0%, #12142d 100%);
|
||||
font-family: 'Roboto', 'Inter', sans-serif;
|
||||
color: #ffffff;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 9999;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Ambient background glow */
|
||||
.auth-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(0, 242, 254, 0.08) 0%, rgba(79, 172, 254, 0) 70%);
|
||||
top: -10%;
|
||||
left: -10%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auth-container::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(79, 172, 254, 0.08) 0%, rgba(0, 242, 254, 0) 70%);
|
||||
bottom: -10%;
|
||||
right: -10%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
backdrop-filter: blur(25px);
|
||||
-webkit-backdrop-filter: blur(25px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
border-radius: 20px;
|
||||
padding: 48px;
|
||||
width: 440px;
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
animation: fadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.brand-section {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid rgba(0, 242, 254, 0.3);
|
||||
box-shadow: 0 0 20px rgba(0, 242, 254, 0.15);
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.brand-logo:hover {
|
||||
transform: rotate(5deg) scale(1.05);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(90deg, #00f2fe 0%, #4facfe 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 6px 0 0 0;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
border-color: rgba(0, 242, 254, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 12px rgba(0, 242, 254, 0.15);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 12px 16px;
|
||||
color: #f87171;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-submit {
|
||||
background: linear-gradient(90deg, #00f2fe 0%, #4facfe 100%);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
color: #0b0c15;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 242, 254, 0.2);
|
||||
}
|
||||
|
||||
.btn-submit:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 242, 254, 0.35);
|
||||
}
|
||||
|
||||
.btn-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-submit:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(11, 12, 21, 0.25);
|
||||
border-top-color: #0b0c15;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@code {
|
||||
private string Username { get; set; } = string.Empty;
|
||||
private string Password { get; set; } = string.Empty;
|
||||
private string ErrorMessage { get; set; } = string.Empty;
|
||||
private bool IsSubmitting { get; set; } = false;
|
||||
|
||||
private async Task HandleLoginAsync()
|
||||
{
|
||||
ErrorMessage = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(Username) || string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
ErrorMessage = "아이디와 비밀번호를 모두 입력해 주세요.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsSubmitting = true;
|
||||
|
||||
try
|
||||
{
|
||||
// Verify against configurations in appsettings.json
|
||||
var expectedUser = Configuration["AdminSettings:Username"] ?? "admin";
|
||||
var expectedPass = Configuration["AdminSettings:Password"] ?? "quant123!";
|
||||
|
||||
if (Username == expectedUser && Password == expectedPass)
|
||||
{
|
||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||
await customProvider.MarkUserAsAuthenticatedAsync(Username);
|
||||
|
||||
// Redirect back to home dashboard
|
||||
NavigationManager.NavigateTo("");
|
||||
}
|
||||
else
|
||||
{
|
||||
ErrorMessage = "아이디 또는 비밀번호가 올바르지 않습니다.";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorMessage = $"로그인 중 오류가 발생했습니다: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsSubmitting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -1,115 +0,0 @@
|
||||
@page "/operations"
|
||||
@using QuantEngine.Core.Infrastructure
|
||||
@inject IWebHostEnvironment Environment
|
||||
|
||||
<PageTitle>Quant Engine - Operations</PageTitle>
|
||||
|
||||
<h1 style="margin: 0 0 8px 0; font-size: 28px; font-weight: 600;">Operational Report</h1>
|
||||
<p style="margin: 0 0 16px 0; color: var(--neutral-foreground-2); font-size: 14px;">
|
||||
이 페이지는 `Temp/operational_report.json`만 읽습니다. DB 연결과 무관하게 동일한 결과를 보여주는 운영 고정 화면입니다.
|
||||
</p>
|
||||
|
||||
<!-- Metadata Cards -->
|
||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Schema</p>
|
||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@SchemaVersion</h3>
|
||||
</div>
|
||||
</FluentCard>
|
||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Sections</p>
|
||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@SectionCountLabel</h3>
|
||||
</div>
|
||||
</FluentCard>
|
||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Source</p>
|
||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@SourceJson</h3>
|
||||
</div>
|
||||
</FluentCard>
|
||||
<FluentCard Style="flex: 1; min-width: 150px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 8px 0; color: var(--neutral-foreground-2); font-size: 12px; font-weight: 500;">Generated</p>
|
||||
<h3 style="margin: 0; font-size: 18px; font-weight: 600;">@GeneratedAt</h3>
|
||||
</div>
|
||||
</FluentCard>
|
||||
</FluentStack>
|
||||
|
||||
<!-- Highlight Sections Grid -->
|
||||
<FluentStack Orientation="Orientation.Horizontal" HorizontalGap="16" Wrap="true" Style="margin-bottom: 16px;">
|
||||
@foreach (var section in HighlightSections)
|
||||
{
|
||||
<FluentCard Style="flex: 1; min-width: 200px;">
|
||||
<div style="padding: 16px;">
|
||||
<p style="margin: 0 0 4px 0; color: var(--neutral-foreground-2); font-size: 12px;">@(section.Name)</p>
|
||||
<h3 style="margin: 4px 0; font-size: 16px; font-weight: 600;">@(section.Title)</h3>
|
||||
<p style="margin: 8px 0 0 0; color: var(--neutral-foreground-3); font-size: 12px;">@(section.Preview)</p>
|
||||
</div>
|
||||
</FluentCard>
|
||||
}
|
||||
</FluentStack>
|
||||
|
||||
<!-- Report Health -->
|
||||
<FluentCard Style="margin-bottom: 16px;">
|
||||
<div style="padding: 16px;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Report Health</h3>
|
||||
<FluentStack Orientation="Orientation.Vertical" VerticalGap="8">
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Status:</strong> <FluentBadge Appearance="BadgeAppearance.Filled">@HealthLabel</FluentBadge></p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Path:</strong> @ReportPath</p>
|
||||
<p style="margin: 0; font-size: 14px;"><strong>Sections rendered:</strong> @RenderedSectionCountLabel</p>
|
||||
</FluentStack>
|
||||
</div>
|
||||
</FluentCard>
|
||||
|
||||
<!-- Sections Table -->
|
||||
<FluentCard>
|
||||
<div style="padding: 16px;">
|
||||
<h3 style="margin: 0 0 12px 0; font-size: 16px; font-weight: 600;">Sections</h3>
|
||||
@if (Sections.Count == 0)
|
||||
{
|
||||
<div style="padding: 12px; background: var(--warning-background-1); border: 1px solid var(--warning-stroke-1); border-radius: 4px; color: var(--warning-foreground-1); font-size: 14px;">
|
||||
DATA_MISSING: operational_report.json에 표시할 섹션이 없습니다.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<FluentDataGrid Items="@Sections.AsQueryable()">
|
||||
<PropertyColumn Property="@(x => x.Name)" Title="Name" />
|
||||
<PropertyColumn Property="@(x => x.Title)" Title="Title" />
|
||||
<PropertyColumn Property="@(x => x.Preview)" Title="Preview" />
|
||||
</FluentDataGrid>
|
||||
}
|
||||
</div>
|
||||
</FluentCard>
|
||||
|
||||
@code {
|
||||
private readonly List<OperationalReportSection> Sections = new();
|
||||
private readonly List<OperationalReportSection> HighlightSections = new();
|
||||
private string SchemaVersion = "n/a";
|
||||
private string SourceJson = "n/a";
|
||||
private string GeneratedAt = "n/a";
|
||||
private string SectionCountLabel = "0";
|
||||
private string RenderedSectionCountLabel = "0";
|
||||
private string HealthLabel = "DATA_MISSING";
|
||||
private string ReportPath = "n/a";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
ReportPath = Path.GetFullPath(Path.Combine(Environment.ContentRootPath, "..", "..", "..", "Temp", "operational_report.json"));
|
||||
|
||||
var report = OperationalReportLoader.Load(ReportPath);
|
||||
SchemaVersion = report.SchemaVersion;
|
||||
SourceJson = report.SourceJson;
|
||||
GeneratedAt = report.GeneratedAt;
|
||||
Sections.AddRange(report.Sections);
|
||||
|
||||
HighlightSections.Clear();
|
||||
HighlightSections.AddRange(Sections.Take(4));
|
||||
|
||||
SectionCountLabel = report.SectionCount.ToString();
|
||||
RenderedSectionCountLabel = Sections.Count.ToString();
|
||||
HealthLabel = Sections.Count > 0 ? "PASS" : "DATA_MISSING";
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
@code {
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NavigationManager.NavigateTo("login");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
@using QuantEngine.Web.Client
|
||||
@using QuantEngine.Web.Client.Pages
|
||||
@using QuantEngine.Web.Client.Layout
|
||||
|
||||
<CascadingAuthenticationState>
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Router AppAssembly="typeof(Dashboard).Assembly" NotFoundPage="typeof(NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
|
||||
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)">
|
||||
<NotAuthorized>
|
||||
<RedirectToLogin />
|
||||
</NotAuthorized>
|
||||
|
||||
Reference in New Issue
Block a user