Files
KArtSell.Aegis/frontend/src/features/models/pages/ModelList.vue
T
kjh2064 dbbfaca4d8 fix(fe): fix ModelList (models-master) scroll on first load - apply height propagation standard
Problem: .master-list had hardcoded height: calc(100vh - 210px), causing scroll on first load

Changes:
  • .master-list: calc(100vh - 210px) → flex: 1; min-height: 0;
  • .items: Added missing min-height: 0; (required for flex children)

Result: Single-screen principle restored (no page-level scroll, height fills viewport)

This aligns with AGENTS.md Height Propagation Chain standard.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-16 00:30:00 +09:00

687 lines
18 KiB
Vue

<script setup lang="ts">
import { reactive, computed, ref, onMounted } from 'vue'
import MasterDetailCrudPage from '../../../shared/ui/screen-types/v2/MasterDetailCrudPage.vue'
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
import type { StandardScreenState } from '../../../shared/ui/contracts/screenContract'
// KBX T04 Governance Audit & Screen State
const screenState = ref<StandardScreenState>('READY')
const evidence = reactive({
asOf: '2026-08-15T19:40:00Z',
version: 'v60-T04-Contract',
})
// Mock Models Data
const mockModels = [
{
modelId: '1',
name: 'Hawkeye-Alpha',
phase: 'Validate',
active: false,
pbo: 15.2,
dsr: 96.5,
returnMtd: 12.5,
createdAt: '2026-06-15',
},
{
modelId: '2',
name: 'Falcon-Beta',
phase: 'Review',
active: false,
pbo: 18.3,
dsr: 94.2,
returnMtd: 8.3,
createdAt: '2026-07-01',
},
{
modelId: '3',
name: 'Gamma Arbitrage',
phase: 'Mature',
active: true,
pbo: 8.5,
dsr: 98.1,
returnMtd: 18.7,
createdAt: '2026-05-10',
},
]
const models = ref(mockModels)
const selectedModelId = ref(mockModels[0]?.modelId)
const filterModel = reactive({
search: '',
phase: '',
})
const isSearching = ref(false)
const handleSearchTrigger = () => {
isSearching.value = true
screenState.value = 'LOADING'
setTimeout(() => {
screenState.value = 'READY'
isSearching.value = false
}, 300)
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'F3') {
e.preventDefault()
handleSearchTrigger()
}
}
onMounted(() => {
screenState.value = 'LOADING'
setTimeout(() => {
screenState.value = 'READY'
}, 500)
window.addEventListener('keydown', handleKeyDown)
})
const filteredModels = computed(() => {
return models.value.filter(m => {
const matchesSearch = m.name.toLowerCase().includes(filterModel.search.toLowerCase())
const matchesPhase = !filterModel.phase || m.phase === filterModel.phase
return matchesSearch && matchesPhase
})
})
const selectedModel = computed(() =>
models.value.find(m => m.modelId === selectedModelId.value)
)
const selectModel = (id: string) => {
selectedModelId.value = id
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
const formatPercentage = (value: number) => {
return (value || 0).toFixed(2) + '%'
}
const handleRetry = () => {
screenState.value = 'LOADING'
setTimeout(() => {
screenState.value = 'READY'
}, 400)
}
</script>
<template>
<MasterDetailCrudPage
title="트레이딩 모델 마스터-상세 (Model Operations Master-Detail)"
subtitle="알고리즘 모델의 상태, 과최적화 검증 지표(PBO/DSR) 및 성과 메트릭을 추적 관리합니다."
:state="screenState"
:evidence="evidence"
@retry="handleRetry"
>
<template #actions>
<KsButton
label="🔍 조회 [F3]"
variant="primary"
size="sm"
aria-label="모델 목록 조회"
@click="handleSearchTrigger"
/>
</template>
<template #filters>
<section class="filters" aria-label="모델 검색 필터">
<input
v-model="filterModel.search"
placeholder="모델명 검색..."
class="input search-input"
aria-label="모델 이름 검색"
role="searchbox"
/>
<select
v-model="filterModel.phase"
class="input status-select"
aria-label="모델 단계별 필터"
>
<option value="">전체 단계 (All Phases)</option>
<option value="Mature">Mature</option>
<option value="Validate">Validate</option>
<option value="Review">Review</option>
</select>
</section>
</template>
<template #master>
<aside class="master-list" aria-label="모델 목록">
<h2>Models ({{ filteredModels.length }})</h2>
<ul class="items" role="list">
<li
v-for="model in filteredModels"
:key="model.modelId"
class="model-item"
:class="{ 'is-selected': selectedModelId === model.modelId }"
@click="selectModel(model.modelId)"
role="option"
:aria-selected="selectedModelId === model.modelId"
:aria-label="`${model.name}, Phase: ${model.phase}, PBO: ${formatPercentage(model.pbo)}, DSR: ${formatPercentage(model.dsr)}, Status: ${model.active ? 'Active' : 'Inactive'}`"
>
<div class="item-header">
<strong>{{ model.name }}</strong>
<KsStatusTag
:value="model.phase"
:severity="model.phase === 'Mature' || model.phase === 'Validate' ? 'success' : 'warning'"
/>
</div>
<div class="metrics" role="group" aria-label="모델 메트릭">
<div class="metric">
<span class="label">PBO</span>
<span class="value ks-financial-number" aria-label="Probability of Backtest Overfitting">{{ formatPercentage(model.pbo) }}</span>
</div>
<div class="metric">
<span class="label">DSR</span>
<span class="value ks-financial-number" aria-label="Daily Sharpe Ratio">{{ formatPercentage(model.dsr) }}</span>
</div>
<div class="metric">
<span class="label">Return</span>
<span class="value ks-financial-number" aria-label="Month to Date Return">{{ formatPercentage(model.returnMtd) }}</span>
</div>
</div>
<div class="footer">
<span v-if="model.active" class="status-active" aria-label="모델이 활성화됨">🟢 Active</span>
<span v-else class="status-inactive" aria-label="모델이 비활성화됨"> Inactive</span>
<span class="date ks-financial-number" :aria-label="`Created: ${formatDate(model.createdAt)}`">{{ formatDate(model.createdAt) }}</span>
</div>
</li>
</ul>
</aside>
</template>
<!-- Detail: Right Panel Slot -->
<template #detail>
<main class="detail-panel" role="main" aria-label="모델 상세 정보">
<h2 v-if="selectedModel" :aria-label="`Selected model: ${selectedModel.name}`">{{ selectedModel.name }}</h2>
<div v-else class="empty-detail" role="status" aria-live="polite">Select a model to view details</div>
<div v-if="selectedModel" class="model-detail">
<!-- Status -->
<section aria-label="모델 상태">
<h3>Status</h3>
<div class="status-grid" role="group">
<div class="status-item">
<span class="label">Phase</span>
<KsStatusTag
:value="selectedModel.phase"
:severity="selectedModel.phase === 'Mature' ? 'success' : 'warning'"
/>
</div>
<div class="status-item">
<span class="label">Active</span>
<span
class="value"
role="status"
:aria-label="`Model is ${selectedModel.active ? 'active' : 'inactive'}`"
>
{{ selectedModel.active ? '✓ Yes' : '✗ No' }}
</span>
</div>
</div>
</section>
<!-- Metrics -->
<section aria-label="검증 메트릭">
<h3>Metrics</h3>
<div class="metric-grid" role="group">
<div class="metric-card" role="region" aria-label="PBO - Probability of Backtest Overfitting">
<div class="metric-label">PBO (Backtest Overfit)</div>
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.pbo) }}</div>
<div class="metric-description" aria-describedby="pbo-help">Lower is better</div>
<div id="pbo-help" class="help-text">백테스트 과최적화 확률. 낮을수록 좋습니다</div>
</div>
<div class="metric-card" role="region" aria-label="DSR - Daily Sharpe Ratio">
<div class="metric-label">DSR (Daily Sharpe Ratio)</div>
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.dsr) }}</div>
<div class="metric-description" aria-describedby="dsr-help">Higher is better</div>
<div id="dsr-help" class="help-text">일일 샤프 비율. 높을수록 좋습니다</div>
</div>
<div class="metric-card" role="region" aria-label="Return MTD - Month to Date Return">
<div class="metric-label">Return MTD</div>
<div class="metric-value ks-financial-number" role="status">{{ formatPercentage(selectedModel.returnMtd) }}</div>
<div class="metric-description" aria-describedby="mtd-help">Month-to-date</div>
<div id="mtd-help" class="help-text">월간 누적 수익률</div>
</div>
</div>
</section>
<!-- Created Date -->
<section aria-label="모델 타임라인">
<h3>Timeline</h3>
<div class="timeline">
<div class="timeline-item" role="region" aria-label="Model creation date">
<span class="label">Created</span>
<span class="value ks-financial-number" role="status">{{ formatDate(selectedModel.createdAt) }}</span>
</div>
</div>
</section>
<!-- Actions -->
<section class="section" aria-label="모델 작업">
<div class="actions">
<KsButton
label="View Full Report [성과 보고서]"
variant="primary"
aria-label="전체 성과 보고서 보기"
/>
<KsButton
label="Export Metrics [내보내기]"
variant="secondary"
aria-label="메트릭 데이터 내보내기"
/>
</div>
</section>
</div>
</main>
</template>
</MasterDetailCrudPage>
</template>
<style scoped>
.model-list {
padding: var(--spacing-5);
max-width: 1400px;
margin: 0 auto;
}
h1 {
margin-bottom: var(--spacing-5);
font-size: var(--font-size-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-text-primary);
}
h2 {
margin: 0;
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
h3 {
margin: 0 0 var(--spacing-2) 0;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
padding-bottom: var(--spacing-2);
border-bottom: var(--border-width-1) solid var(--color-border-secondary);
}
.filters {
display: flex;
align-items: center;
gap: var(--ks-space-2);
flex-wrap: wrap;
margin-bottom: 0;
}
.input {
height: var(--ks-control-height);
padding: 0 var(--ks-space-2);
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-sm);
background: var(--ks-color-surface);
color: var(--ks-color-neutral-900);
font-size: var(--ks-font-body);
font-family: inherit;
box-sizing: border-box;
transition: all 0.15s ease;
}
.input.search-input {
width: var(--ks-control-width-search);
}
.input.status-select {
width: var(--ks-control-width-select);
}
.input:focus {
outline: none;
border-color: var(--ks-color-action);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.15);
}
.content-skeleton {
display: grid;
grid-template-columns: 350px 1fr;
gap: var(--spacing-5);
}
.error-actions {
text-align: center;
padding: var(--spacing-4);
}
.content {
display: grid;
grid-template-columns: 350px 1fr;
gap: var(--spacing-5);
}
.master-list {
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-sm);
overflow: hidden;
background: var(--ks-color-surface);
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.master-list h2 {
padding: var(--ks-space-2) var(--ks-space-3);
background: #f1f5f9;
border-bottom: 1px solid var(--ks-color-neutral-300);
font-size: var(--ks-font-section);
font-weight: 700;
color: var(--ks-color-neutral-800);
margin: 0;
}
.items {
display: flex;
flex-direction: column;
gap: var(--ks-space-2);
padding: var(--ks-space-2);
flex: 1;
min-height: 0;
overflow-y: auto;
}
.model-item {
padding: var(--ks-space-2) var(--ks-space-3);
background: var(--ks-color-surface);
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.model-item:hover {
background: #e0f2fe;
}
.model-item.is-selected {
border-color: var(--ks-color-action);
background: #eff6ff;
color: #0f172a;
box-shadow: 0 0 0 1px var(--ks-color-action);
}
.item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-2);
gap: var(--spacing-2);
}
.item-header strong {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
flex: 1;
}
.phase-badge {
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-base);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
color: white;
white-space: nowrap;
}
.metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-2);
margin-bottom: var(--spacing-2);
font-size: var(--font-size-xs);
}
.metric {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.metric .label {
color: var(--color-text-tertiary);
font-weight: var(--font-weight-medium);
}
.metric .value {
color: var(--color-text-primary);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-sm);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: var(--font-size-xs);
}
.status-active {
color: var(--color-success-600);
font-weight: var(--font-weight-bold);
}
.status-inactive {
color: var(--color-text-tertiary);
}
.date {
color: var(--color-text-tertiary);
}
.detail-panel {
border: 1px solid var(--ks-color-neutral-200);
border-radius: var(--ks-radius-sm);
padding: var(--ks-space-3);
background: var(--ks-color-surface);
height: calc(100vh - 210px);
overflow-y: auto;
}
.empty-detail {
text-align: center;
padding: var(--ks-space-8);
color: var(--ks-color-neutral-500);
}
.model-detail {
display: flex;
flex-direction: column;
gap: var(--ks-space-3);
}
.section {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-2);
}
.status-item {
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-background-primary);
border-radius: var(--border-radius-base);
border: var(--border-width-1) solid var(--color-border-secondary);
}
.status-item .label {
display: block;
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-1);
font-weight: var(--font-weight-medium);
}
.status-item .value {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-primary);
font-weight: var(--font-weight-semibold);
}
.badge {
display: inline-block;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--border-radius-base);
font-size: var(--font-size-xs);
font-weight: var(--font-weight-bold);
color: white;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-2);
}
.metric-card {
padding: var(--spacing-3);
background: var(--color-background-primary);
border: var(--border-width-1) solid var(--color-border-secondary);
border-radius: var(--border-radius-base);
text-align: center;
}
.metric-label {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
margin-bottom: var(--spacing-1);
font-weight: var(--font-weight-medium);
}
.metric-value {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-bold);
color: var(--color-primary-600);
margin-bottom: var(--spacing-1);
}
.metric-description {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-style: italic;
}
.help-text {
display: none;
}
.timeline {
padding: var(--spacing-2) var(--spacing-3);
background: var(--color-background-primary);
border-radius: var(--border-radius-base);
border: var(--border-width-1) solid var(--color-border-secondary);
}
.timeline-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: var(--border-width-1) solid var(--color-border-secondary);
}
.timeline-item:last-child {
border-bottom: none;
}
.timeline-item .label {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
font-weight: var(--font-weight-medium);
}
.timeline-item .value {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
}
.actions {
display: flex;
gap: var(--spacing-2);
}
.btn {
flex: 1;
padding: var(--spacing-2) var(--spacing-3);
border: var(--border-width-1) solid var(--color-border-primary);
border-radius: var(--border-radius-base);
background: var(--color-background-primary);
color: var(--color-text-primary);
cursor: pointer;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
transition: all var(--transition-fast);
font-family: var(--font-sans);
}
.btn:hover:not(:disabled) {
background: var(--color-background-hover);
border-color: var(--color-border-secondary);
}
.btn-primary {
background: var(--color-primary-500);
color: white;
border-color: var(--color-primary-500);
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-600);
border-color: var(--color-primary-600);
}
.btn-secondary {
background: var(--color-background-primary);
color: var(--color-text-primary);
border-color: var(--color-border-secondary);
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-background-hover);
}
@media (max-width: 1000px) {
.content,
.content-skeleton {
grid-template-columns: 1fr;
}
.metric-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.filters {
grid-template-columns: 1fr;
}
}
</style>