V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -1,82 +1,43 @@
|
||||
<template>
|
||||
<div class="ingestion-status">
|
||||
<div class="header">
|
||||
<h1>Market Data Ingestion</h1>
|
||||
<p class="subtitle">Monitor data collection status</p>
|
||||
</div>
|
||||
<BatchOperationsPageV2 title="시장 데이터 수집 현황" subtitle="데이터 수집 작업 상태를 모니터링합니다." :state="state">
|
||||
<template #actions>
|
||||
<KsButton severity="secondary" label="새로고침" @click="refreshAll" />
|
||||
</template>
|
||||
|
||||
<div class="content">
|
||||
<!-- Status summary -->
|
||||
<div v-if="job" class="status-card">
|
||||
<template #timeline>
|
||||
<div v-if="job">
|
||||
<div class="status-header">
|
||||
<h2>Job {{ job.jobId.substring(0, 8) }}</h2>
|
||||
<span :class="['status-badge', `status-${job.status.toLowerCase()}`]">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
<KsStatusTag :value="job.status" :severity="statusSeverity(job.status)" />
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="stat">
|
||||
<span class="label">Rows Processed</span>
|
||||
<span class="value">{{ job.rowsProcessed.toLocaleString() }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<span class="label">Rows Failed</span>
|
||||
<span class="value error">{{ job.rowsFailed }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat">
|
||||
<span class="label">Quality Score</span>
|
||||
<span class="value">{{ calculateQualityScore(job) }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="stat" v-if="job.durationSeconds">
|
||||
<span class="label">Duration</span>
|
||||
<span class="value">{{ job.durationSeconds }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="job.errorMessage" class="error-section">
|
||||
<strong>Error:</strong> {{ job.errorMessage }}
|
||||
<div class="stat"><span class="label">처리된 행</span><span class="value">{{ formatQuantity(job.rowsProcessed, 0) }}</span></div>
|
||||
<div class="stat"><span class="label">실패한 행</span><span class="value error">{{ formatQuantity(job.rowsFailed, 0) }}</span></div>
|
||||
<div class="stat"><span class="label">품질 점수</span><span class="value">{{ formatPercent(calculateQualityScore(job) / 100, 0) }}</span></div>
|
||||
<div v-if="job.durationSeconds" class="stat"><span class="label">소요 시간</span><span class="value">{{ job.durationSeconds }}초</span></div>
|
||||
</div>
|
||||
<p v-if="job.errorMessage" class="error-section"><strong>오류:</strong> {{ job.errorMessage }}</p>
|
||||
</div>
|
||||
<p v-else class="hint">수집 상태를 불러오는 중입니다...</p>
|
||||
</template>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="loading">
|
||||
<p>Fetching ingestion status...</p>
|
||||
</div>
|
||||
<template #records>
|
||||
<DataGridShell :rows="recentJobs" :columns="historyColumns" empty-message="최근 수집 이력이 없습니다." />
|
||||
</template>
|
||||
|
||||
<!-- Historical jobs -->
|
||||
<div class="history-section">
|
||||
<h3>Recent Ingestions</h3>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Status</th>
|
||||
<th>Rows</th>
|
||||
<th>Duration</th>
|
||||
<th>Completed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, idx) in recentJobs" :key="idx" :class="`status-${item.status.toLowerCase()}`">
|
||||
<td>{{ item.jobId.substring(0, 8) }}</td>
|
||||
<td><span :class="['status-badge', `status-${item.status.toLowerCase()}`]">{{ item.status }}</span></td>
|
||||
<td>{{ item.rowsProcessed }}</td>
|
||||
<td>{{ item.durationSeconds ? `${item.durationSeconds}s` : '—' }}</td>
|
||||
<td>{{ item.completedAt ? new Date(item.completedAt).toLocaleDateString() : '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #reprocess>
|
||||
<p class="hint">진행 중이거나 대기 중인 작업이 있으면 10초 간격으로 자동 새로고침됩니다.</p>
|
||||
</template>
|
||||
</BatchOperationsPageV2>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import BatchOperationsPageV2 from '../../../shared/ui/screen-types/v2/BatchOperationsPageV2.vue'
|
||||
import DataGridShell from '../../../shared/ui/DataGridShell.vue'
|
||||
import { KsButton, KsStatusTag } from '../../../shared/ui/components'
|
||||
import type { UiGridColumn, UiSeverity } from '../../../shared/ui/adapter/contracts'
|
||||
import { formatAsOf, formatPercent, formatQuantity } from '../../../shared/formatters/financial'
|
||||
|
||||
interface IngestionJob {
|
||||
jobId: string
|
||||
@@ -93,6 +54,23 @@ const job = ref<IngestionJob | null>(null)
|
||||
const recentJobs = ref<IngestionJob[]>([])
|
||||
const isLoading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const state = ref<'LOADING' | 'READY'>('LOADING')
|
||||
|
||||
const historyColumns: UiGridColumn[] = [
|
||||
{ field: 'jobId', header: 'Job ID', formatter: value => String(value).substring(0, 8) },
|
||||
{ field: 'status', header: '상태' },
|
||||
{ field: 'rowsProcessed', header: '처리 행', formatter: value => formatQuantity(value as number, 0) },
|
||||
{ field: 'durationSeconds', header: '소요 시간', formatter: value => value ? `${value}초` : '—' },
|
||||
{ field: 'completedAt', header: '완료 시각', formatter: value => value ? formatAsOf(value as string) : '—' }
|
||||
]
|
||||
|
||||
function statusSeverity(status: string): UiSeverity {
|
||||
const normalized = status.toLowerCase()
|
||||
if (normalized === 'completed') return 'success'
|
||||
if (normalized === 'failed') return 'danger'
|
||||
if (normalized === 'running') return 'info'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
// Fetch latest job status from API
|
||||
const fetchLatestJob = async () => {
|
||||
@@ -136,12 +114,18 @@ const fetchRecentJobs = async () => {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch recent jobs:', err)
|
||||
error.value = 'Failed to load job history'
|
||||
error.value = '수집 이력을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
state.value = 'READY'
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
state.value = 'LOADING'
|
||||
Promise.all([fetchLatestJob(), fetchRecentJobs()]).finally(() => { state.value = 'READY' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchLatestJob()
|
||||
fetchRecentJobs()
|
||||
@@ -164,153 +148,13 @@ const calculateQualityScore = (job: IngestionJob): number => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ingestion-status {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.status-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.status-completed {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-running {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-failed {
|
||||
background-color: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.status-queued {
|
||||
background-color: #f59e0b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat .label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.stat .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat .value.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: #fee2e2;
|
||||
border-left: 4px solid #ef4444;
|
||||
color: #7f1d1d;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.history-section h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.history-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-table thead {
|
||||
background-color: var(--surface-secondary);
|
||||
}
|
||||
|
||||
.history-table th {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.history-table td {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-table tbody tr.status-completed {
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.history-table tbody tr.status-failed {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--ks-space-4); }
|
||||
.status-header h2 { margin: 0; font-size: var(--ks-font-section); }
|
||||
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: var(--ks-space-3); }
|
||||
.stat { display: flex; flex-direction: column; gap: var(--ks-space-1); }
|
||||
.stat .label { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); }
|
||||
.stat .value { font-size: var(--ks-font-page); font-weight: 600; }
|
||||
.stat .value.error { color: var(--ks-color-danger); }
|
||||
.error-section { margin-top: var(--ks-space-3); padding: var(--ks-space-3); background: color-mix(in srgb, var(--ks-color-danger) 8%, var(--ks-color-surface)); border-left: 4px solid var(--ks-color-danger); border-radius: var(--ks-radius-sm); }
|
||||
.hint { color: var(--ks-color-text-muted); font-size: var(--ks-font-caption); }
|
||||
</style>
|
||||
|
||||
@@ -1,131 +1,67 @@
|
||||
<template>
|
||||
<div class="market-data-ingestion">
|
||||
<div class="header">
|
||||
<h1>📊 Market Data Ingestion</h1>
|
||||
<p class="subtitle">Schedule KRX historical data collection</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Configuration Card -->
|
||||
<div class="config-card">
|
||||
<h2>1. Select Data Source & Period</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Data Source</label>
|
||||
<select v-model="form.dataSource">
|
||||
<option value="KRX">KRX (Korea Exchange) - KOSPI/KOSDAQ Daily</option>
|
||||
<option value="OpenDart">OpenDart - Financial Disclosures (T+2)</option>
|
||||
<option value="Stub">Stub (Test Data)</option>
|
||||
</select>
|
||||
<p class="hint">
|
||||
<strong>KRX:</strong> Stock prices (Open/High/Low/Close/Volume)
|
||||
<strong>OpenDart:</strong> Corporate disclosures & filings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>From Date</label>
|
||||
<input
|
||||
v-model="form.fromDate"
|
||||
type="date"
|
||||
:min="minDate"
|
||||
:max="maxDate"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<p class="hint">Earliest: {{ minDate }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>To Date</label>
|
||||
<input
|
||||
v-model="form.toDate"
|
||||
type="date"
|
||||
:min="form.fromDate || minDate"
|
||||
:max="maxDate"
|
||||
placeholder="YYYY-MM-DD"
|
||||
/>
|
||||
<p class="hint">Latest: {{ maxDate }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick presets -->
|
||||
<div class="presets">
|
||||
<button @click="setPreset('1y')" class="preset-btn">Last 1 Year</button>
|
||||
<button @click="setPreset('2y')" class="preset-btn">Last 2 Years</button>
|
||||
<button @click="setPreset('5y')" class="preset-btn">Last 5 Years</button>
|
||||
<button @click="setPreset('all')" class="preset-btn">All Available</button>
|
||||
</div>
|
||||
<PageLayout title="시장 데이터 수집" subtitle="KRX 과거 시세 데이터 수집을 예약합니다.">
|
||||
<KsFormSection title="1. 데이터 소스 및 기간 선택">
|
||||
<KsFormGrid :columns="1" aria-label="데이터 소스 및 기간">
|
||||
<KsSelect v-model="form.dataSource" label="데이터 소스" :options="dataSourceOptions" />
|
||||
<p class="hint"><strong>KRX:</strong> 시세 데이터(시가/고가/저가/종가/거래량) · <strong>OpenDart:</strong> 기업 공시</p>
|
||||
</KsFormGrid>
|
||||
<KsFormGrid :columns="2" aria-label="수집 기간">
|
||||
<KsDateField v-model="form.fromDate" label="시작일" :min="minDateValue" :max="maxDateValue" />
|
||||
<KsDateField v-model="form.toDate" label="종료일" :min="form.fromDate ? new Date(form.fromDate) : minDateValue" :max="maxDateValue" />
|
||||
</KsFormGrid>
|
||||
<div class="presets">
|
||||
<KsButton severity="secondary" label="최근 1년" @click="setPreset('1y')" />
|
||||
<KsButton severity="secondary" label="최근 2년" @click="setPreset('2y')" />
|
||||
<KsButton severity="secondary" label="최근 5년" @click="setPreset('5y')" />
|
||||
<KsButton severity="secondary" label="전체 가능 기간" @click="setPreset('all')" />
|
||||
</div>
|
||||
</KsFormSection>
|
||||
|
||||
<!-- Validation & Summary -->
|
||||
<div v-if="validationErrors.length" class="error-card">
|
||||
<h3>⚠️ Validation Errors</h3>
|
||||
<ul>
|
||||
<li v-for="(err, idx) in validationErrors" :key="idx">{{ err }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<KsFormSection v-if="validationErrors.length" title="입력 오류">
|
||||
<KsValidationSummary :errors="validationErrorObjects" />
|
||||
</KsFormSection>
|
||||
|
||||
<div v-if="!validationErrors.length" class="summary-card">
|
||||
<h3>📋 Collection Summary</h3>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-item">
|
||||
<span class="label">Data Source:</span>
|
||||
<span class="value">{{ form.dataSource }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Period:</span>
|
||||
<span class="value">{{ form.fromDate }} to {{ form.toDate }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Days:</span>
|
||||
<span class="value">{{ daysCount }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="label">Est. Rows:</span>
|
||||
<span class="value">{{ estimatedRows }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<KsFormSection v-else title="수집 요약">
|
||||
<dl class="summary-grid">
|
||||
<div class="summary-item"><dt>데이터 소스</dt><dd>{{ form.dataSource }}</dd></div>
|
||||
<div class="summary-item"><dt>기간</dt><dd>{{ form.fromDate }} ~ {{ form.toDate }}</dd></div>
|
||||
<div class="summary-item"><dt>일수</dt><dd>{{ formatQuantity(daysCount, 0) }}</dd></div>
|
||||
<div class="summary-item"><dt>예상 행 수</dt><dd>{{ estimatedRows }}</dd></div>
|
||||
</dl>
|
||||
<template #actions>
|
||||
<KsButton severity="secondary" label="초기화" @click="resetForm" />
|
||||
<KsButton severity="primary" :label="isLoading ? '처리 중...' : '수집 예약'" :loading="isLoading" :disabled="validationErrors.length > 0" @click="triggerIngestion" />
|
||||
</template>
|
||||
</KsFormSection>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="actions">
|
||||
<button
|
||||
@click="triggerIngestion"
|
||||
:disabled="isLoading || validationErrors.length > 0"
|
||||
class="btn-primary"
|
||||
>
|
||||
<span v-if="!isLoading">🚀 Schedule Ingestion</span>
|
||||
<span v-else>⏳ Processing...</span>
|
||||
</button>
|
||||
<button @click="resetForm" class="btn-secondary">↻ Reset</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div v-if="jobId" class="success-card">
|
||||
<h3>✅ Job Scheduled Successfully</h3>
|
||||
<div class="job-info">
|
||||
<p><strong>Job ID:</strong> {{ jobId }}</p>
|
||||
<p><strong>Status:</strong> Queued</p>
|
||||
<p><strong>Queued At:</strong> {{ new Date().toLocaleString() }}</p>
|
||||
<p class="hint">The ingestion will run in the background. Check the status in History tab.</p>
|
||||
</div>
|
||||
<router-link to="/ops/market-data-history" class="btn-link">
|
||||
📈 View Collection History →
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<KsFormSection v-if="jobId" title="수집 작업이 등록되었습니다">
|
||||
<dl class="summary-grid">
|
||||
<div class="summary-item"><dt>Job ID</dt><dd class="mono">{{ jobId }}</dd></div>
|
||||
<div class="summary-item"><dt>상태</dt><dd><KsStatusTag value="대기" severity="info" /></dd></div>
|
||||
<div class="summary-item"><dt>등록 시각</dt><dd>{{ formatAsOf(new Date()) }}</dd></div>
|
||||
</dl>
|
||||
<p class="hint">수집은 백그라운드에서 실행됩니다. 진행 상태는 수집 이력 화면에서 확인할 수 있습니다.</p>
|
||||
<RouterLink to="/ops/market-data-history">수집 이력 보기 →</RouterLink>
|
||||
</KsFormSection>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import PageLayout from '../../../shared/ui/layouts/PageLayout.vue'
|
||||
import { KsButton, KsDateField, KsFormGrid, KsFormSection, KsSelect, KsStatusTag, KsValidationSummary } from '../../../shared/ui/components'
|
||||
import { formatAsOf, formatQuantity } from '../../../shared/formatters/financial'
|
||||
|
||||
const router = useRouter()
|
||||
const isLoading = ref(false)
|
||||
const jobId = ref<string | null>(null)
|
||||
|
||||
const dataSourceOptions = [
|
||||
{ label: 'KRX (한국거래소) - KOSPI/KOSDAQ 일봉', value: 'KRX' },
|
||||
{ label: 'OpenDart - 기업 공시(T+2)', value: 'OpenDart' },
|
||||
{ label: 'Stub - 테스트 데이터', value: 'Stub' }
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
dataSource: 'KRX',
|
||||
fromDate: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
|
||||
@@ -134,25 +70,29 @@ const form = ref({
|
||||
|
||||
const minDate = '2015-01-01' // KRX historical data starts here
|
||||
const maxDate = new Date().toISOString().split('T')[0] // Today
|
||||
const minDateValue = new Date(minDate)
|
||||
const maxDateValue = new Date(maxDate)
|
||||
|
||||
const validationErrors = computed(() => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!form.value.fromDate) errors.push('From Date is required')
|
||||
if (!form.value.toDate) errors.push('To Date is required')
|
||||
if (!form.value.fromDate) errors.push('시작일은 필수입니다.')
|
||||
if (!form.value.toDate) errors.push('종료일은 필수입니다.')
|
||||
|
||||
if (form.value.fromDate && form.value.toDate) {
|
||||
if (form.value.fromDate > form.value.toDate) {
|
||||
errors.push('From Date must be before To Date')
|
||||
errors.push('시작일은 종료일보다 앞서야 합니다.')
|
||||
}
|
||||
if (form.value.toDate > maxDate) {
|
||||
errors.push('To Date cannot be in the future')
|
||||
errors.push('종료일은 오늘 이후일 수 없습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
})
|
||||
|
||||
const validationErrorObjects = computed(() => validationErrors.value.map(message => ({ message })))
|
||||
|
||||
const daysCount = computed(() => {
|
||||
if (!form.value.fromDate || !form.value.toDate) return 0
|
||||
const from = new Date(form.value.fromDate)
|
||||
@@ -164,9 +104,9 @@ const estimatedRows = computed(() => {
|
||||
// KRX: ~2000 stocks × days
|
||||
// OpenDart: ~200 quarterly filings
|
||||
if (form.value.dataSource === 'KRX') {
|
||||
return (daysCount.value * 2000).toLocaleString()
|
||||
return formatQuantity(daysCount.value * 2000, 0)
|
||||
} else if (form.value.dataSource === 'OpenDart') {
|
||||
return (Math.ceil(daysCount.value / 90) * 200).toLocaleString()
|
||||
return formatQuantity(Math.ceil(daysCount.value / 90) * 200, 0)
|
||||
}
|
||||
return '0'
|
||||
})
|
||||
@@ -219,7 +159,7 @@ const triggerIngestion = async () => {
|
||||
|
||||
} catch (error) {
|
||||
console.error('Ingestion error:', error)
|
||||
alert(`Failed to trigger ingestion: ${error instanceof Error ? error.message : 'Unknown error'}`)
|
||||
alert(`수집 요청에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
@@ -236,226 +176,11 @@ const resetForm = () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.market-data-ingestion {
|
||||
padding: 2rem;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.config-card,
|
||||
.summary-card,
|
||||
.error-card,
|
||||
.success-card {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: var(--surface-elevated);
|
||||
}
|
||||
|
||||
.config-card h2,
|
||||
.summary-card h3,
|
||||
.error-card h3,
|
||||
.success-card h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
background: var(--surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.presets {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: var(--surface-secondary);
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background: var(--surface);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
border-color: #ef4444;
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.error-card h3 {
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.error-card ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
color: #7f1d1d;
|
||||
}
|
||||
|
||||
.error-card li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.success-card {
|
||||
border-color: #10b981;
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.success-card h3 {
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.job-info {
|
||||
background: var(--surface);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.job-info p {
|
||||
margin: 0.5rem 0;
|
||||
color: #065f46;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.job-info strong {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-link {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
background: #d1d5db;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: transparent;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.hint { font-size: var(--ks-font-caption); color: var(--ks-color-text-muted); margin: 0; }
|
||||
.presets { display: flex; gap: var(--ks-space-2); flex-wrap: wrap; margin-top: var(--ks-space-3); }
|
||||
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ks-space-3); margin: 0; }
|
||||
.summary-item { display: flex; justify-content: space-between; padding: var(--ks-space-2) var(--ks-space-3); background: var(--ks-color-canvas); border-radius: var(--ks-radius-sm); }
|
||||
.summary-item dt { font-weight: 600; color: var(--ks-color-text-muted); }
|
||||
.summary-item dd { margin: 0; font-weight: 600; }
|
||||
.mono { font-family: monospace; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user