feat(wbs): Vue 3 CRUD templates and OpenAPI Axios client refactoring
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
// Create Base Axios Instance targeting ASP.NET Core FastEndpoints / OpenAPI Swagger
|
||||
export const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
withCredentials: true // Support Cookie / CSRF Session
|
||||
})
|
||||
|
||||
// Request Interceptor: Attach CSRF Anti-Forgery Token if available
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const csrfToken = getCookie('XSRF-TOKEN') || getCookie('RequestVerificationToken')
|
||||
if (csrfToken && config.headers) {
|
||||
config.headers['X-XSRF-TOKEN'] = csrfToken
|
||||
config.headers['RequestVerificationToken'] = csrfToken
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
// Response Interceptor: Standardized OpenAPI Error Handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => response.data,
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message || 'API 통신 중 오류가 발생했습니다.'
|
||||
|
||||
if (status === 401) {
|
||||
console.warn('Unauthorized access: Redirecting to login')
|
||||
window.location.href = '/Account/Login'
|
||||
} else if (status === 403) {
|
||||
console.error('Forbidden action:', message)
|
||||
} else if (status >= 500) {
|
||||
console.error('Server error:', message)
|
||||
}
|
||||
|
||||
return Promise.reject({ status, message, rawError: error })
|
||||
}
|
||||
)
|
||||
|
||||
function getCookie(name: string): string | null {
|
||||
const value = `; ${document.cookie}`
|
||||
const parts = value.split(`; ${name}=`)
|
||||
if (parts.length === 2) return parts.pop()?.split(';').shift() || null
|
||||
return null
|
||||
}
|
||||
|
||||
// Standard OpenAPI Type Client Definitions
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean
|
||||
message?: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export const QuantApi = {
|
||||
// Collection Endpoints
|
||||
getCollectionRuns: (limit = 20) => apiClient.get<any, ApiResponse<any[]>>(`/collection/runs?limit=${limit}`),
|
||||
getCollectionDetail: (runId: string) => apiClient.get<any, ApiResponse<any>>(`/collection/runs/${runId}`),
|
||||
startCollectionRun: () => apiClient.post<any, ApiResponse<{ runId: string }>>('/collection/run'),
|
||||
|
||||
// History & Factor Scores
|
||||
getPriceHistorySummary: () => apiClient.get<any, ApiResponse<any[]>>('/collection/history-summary'),
|
||||
getFactorScores: () => apiClient.get<any, ApiResponse<any[]>>('/factors/scores'),
|
||||
|
||||
// Generic REST CRUD Helpers matching OpenAPI endpoints
|
||||
get: <T>(url: string, config?: AxiosRequestConfig) => apiClient.get<any, T>(url, config),
|
||||
post: <T>(url: string, data?: any, config?: AxiosRequestConfig) => apiClient.post<any, T>(url, data, config),
|
||||
put: <T>(url: string, data?: any, config?: AxiosRequestConfig) => apiClient.put<any, T>(url, data, config),
|
||||
delete: <T>(url: string, config?: AxiosRequestConfig) => apiClient.delete<any, T>(url, config),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
targetName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['confirm', 'close'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="modal d-block modal-blur" tabindex="-1" style="background: rgba(0,0,0,0.5);">
|
||||
<div class="modal-dialog modal-sm modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-status bg-danger"></div>
|
||||
<div class="modal-body text-center py-4">
|
||||
<i class="ti ti-alert-triangle text-danger fs-1 mb-2"></i>
|
||||
<h4 class="fw-bold">정말 삭제하시겠습니까?</h4>
|
||||
<p class="text-muted fs-7 mb-0">
|
||||
{{ targetName ? `'${targetName}' 항목이` : '선택한 항목이' }} 비활성화(Soft Delete) 처리됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary w-50" @click="emit('close')">취소</button>
|
||||
<button type="button" class="btn btn-danger w-50" @click="emit('confirm')">삭제 실행</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
initialData?: Record<string, any>
|
||||
fields: Array<{
|
||||
name: string
|
||||
label: string
|
||||
type?: 'text' | 'number' | 'select' | 'textarea' | 'checkbox' | 'date'
|
||||
required?: boolean
|
||||
options?: Array<{ label: string; value: any }>
|
||||
placeholder?: string
|
||||
}>
|
||||
isEditing?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['save', 'cancel', 'delete'])
|
||||
|
||||
const formData = ref<Record<string, any>>({ ...(props.initialData || {}) })
|
||||
|
||||
const handleSave = () => {
|
||||
emit('save', formData.value)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
if (confirm('해당 레코드를 삭제(Soft Delete)하시겠습니까?')) {
|
||||
emit('delete', formData.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card shadow-sm border">
|
||||
<div class="card-header bg-navy text-white d-flex justify-content-between align-items-center py-2 px-3">
|
||||
<h5 class="card-title m-0 font-weight-bold text-white fs-6">
|
||||
<i class="ti ti-edit me-1"></i> {{ title || (isEditing ? '데이터 수정' : '신규 데이터 등록') }}
|
||||
</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-success fw-bold px-3" @click="handleSave">
|
||||
<span class="hotkey-badge me-1">F4</span>{{ isEditing ? '수정 저장' : '신규 저장' }}
|
||||
</button>
|
||||
<button v-if="isEditing" type="button" class="btn btn-sm btn-danger fw-bold px-3" @click="handleDelete">
|
||||
<span class="hotkey-badge me-1">F5</span>삭제
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary fw-bold px-3" @click="emit('cancel')">
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3">
|
||||
<div class="row g-3">
|
||||
<div v-for="field in fields" :key="field.name" class="col-md-6 col-12">
|
||||
<label class="form-label fw-bold fs-7 mb-1">
|
||||
<span v-if="field.required" class="text-danger me-1">*</span>{{ field.label }}
|
||||
</label>
|
||||
|
||||
<template v-if="field.type === 'select'">
|
||||
<select v-model="formData[field.name]" class="form-select form-select-sm fw-bold">
|
||||
<option v-for="opt in field.options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<template v-else-if="field.type === 'textarea'">
|
||||
<textarea v-model="formData[field.name]" class="form-control form-control-sm fw-bold" rows="3" :placeholder="field.placeholder"></textarea>
|
||||
</template>
|
||||
|
||||
<template v-else-if="field.type === 'checkbox'">
|
||||
<div class="form-check mt-2">
|
||||
<input v-model="formData[field.name]" type="checkbox" class="form-check-input" :id="field.name" />
|
||||
<label class="form-check-label fs-7 fw-bold" :for="field.name">{{ field.label }}</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<input
|
||||
v-model="formData[field.name]"
|
||||
:type="field.type || 'text'"
|
||||
class="form-control form-control-sm fw-bold"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-navy {
|
||||
background-color: #1E293B;
|
||||
}
|
||||
.hotkey-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
headers: Array<{ key: string; label: string; width?: string; align?: 'left' | 'center' | 'right' }>
|
||||
items: any[]
|
||||
loading?: boolean
|
||||
selectedId?: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['selectRow', 'create', 'refresh'])
|
||||
|
||||
const onRowClick = (item: any) => {
|
||||
emit('selectRow', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card shadow-sm border h-100 d-flex flex-column">
|
||||
<div class="card-header bg-navy text-white d-flex justify-content-between align-items-center py-2 px-3">
|
||||
<h5 class="card-title m-0 font-weight-bold text-white fs-6">
|
||||
<i class="ti ti-list me-1"></i> {{ title || '데이터 그리드 목록' }}
|
||||
</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-primary fw-bold" @click="emit('create')">
|
||||
<i class="ti ti-plus me-1"></i> 신규 등록
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light fw-bold" @click="emit('refresh')">
|
||||
<i class="ti ti-refresh me-1"></i> 새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive flex-grow-1">
|
||||
<table class="table table-hover table-vcenter card-table text-nowrap mb-0">
|
||||
<thead class="bg-light">
|
||||
<tr>
|
||||
<th
|
||||
v-for="h in headers"
|
||||
:key="h.key"
|
||||
:style="{ width: h.width || 'auto', textAlign: h.align || 'left' }"
|
||||
class="fw-bold fs-7 text-uppercase"
|
||||
>
|
||||
{{ h.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-if="items && items.length > 0">
|
||||
<tr
|
||||
v-for="(item, idx) in items"
|
||||
:key="idx"
|
||||
:class="{ 'table-active fw-bold': selectedId && item.id === selectedId }"
|
||||
style="cursor: pointer;"
|
||||
@click="onRowClick(item)"
|
||||
>
|
||||
<td
|
||||
v-for="h in headers"
|
||||
:key="h.key"
|
||||
:style="{ textAlign: h.align || 'left' }"
|
||||
class="fs-7"
|
||||
>
|
||||
<slot :name="`cell-${h.key}`" :item="item" :value="item[h.key]">
|
||||
{{ item[h.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tr>
|
||||
<td :colspan="headers.length" class="text-center py-4 text-muted">
|
||||
<i class="ti ti-database-off fs-2 d-block mb-1"></i>
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-navy {
|
||||
background-color: #1E293B;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
title?: string
|
||||
totalRecords?: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['search', 'reset', 'excelDownload', 'saveAll'])
|
||||
|
||||
const searchKw = ref('')
|
||||
const filterStatus = ref('ALL')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
|
||||
const handleSearch = () => {
|
||||
emit('search', {
|
||||
keyword: searchKw.value,
|
||||
status: filterStatus.value,
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value
|
||||
})
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchKw.value = ''
|
||||
filterStatus.value = 'ALL'
|
||||
dateFrom.value = ''
|
||||
dateTo.value = ''
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card shadow-sm border mb-3">
|
||||
<!-- Douzone ERP Style Search Header Bar -->
|
||||
<div class="card-header bg-navy text-white d-flex justify-content-between align-items-center py-2 px-3">
|
||||
<h6 class="card-title m-0 font-weight-bold text-white fs-6">
|
||||
<i class="ti ti-search me-1"></i> {{ title || '조회 조건 설정 (Douzone ERP Accounting Standard)' }}
|
||||
</h6>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-primary fw-bold px-3" @click="handleSearch">
|
||||
<span class="hotkey-badge me-1">F3</span>조회
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success fw-bold px-3" @click="emit('saveAll')">
|
||||
<span class="hotkey-badge me-1">F4</span>일괄저장
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-light fw-bold px-3" @click="emit('excelDownload')">
|
||||
<span class="hotkey-badge me-1">F7</span>엑셀다운
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary fw-bold px-2" @click="handleReset">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Inputs Row -->
|
||||
<div class="card-body p-3 bg-light">
|
||||
<div class="row g-3 align-items-center">
|
||||
<!-- Keyword Filter -->
|
||||
<div class="col-md-4 col-12">
|
||||
<label class="form-label fs-7 fw-bold mb-1">검색 키워드 (코드/명칭)</label>
|
||||
<input
|
||||
v-model="searchKw"
|
||||
type="text"
|
||||
class="form-control form-control-sm fw-bold"
|
||||
placeholder="종목코드, 티커, 종목명 입력..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="col-md-3 col-12">
|
||||
<label class="form-label fs-7 fw-bold mb-1">상태 필터</label>
|
||||
<select v-model="filterStatus" class="form-select form-select-sm fw-bold" @change="handleSearch">
|
||||
<option value="ALL">전체 (ALL)</option>
|
||||
<option value="ACTIVE">정상 (ACTIVE)</option>
|
||||
<option value="PASS">통과 (PASS)</option>
|
||||
<option value="FAIL">차단 (FAIL)</option>
|
||||
<option value="LIMIT">제한 (LIMIT)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<div class="col-md-5 col-12">
|
||||
<label class="form-label fs-7 fw-bold mb-1">조회 기간</label>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input v-model="dateFrom" type="date" class="form-control form-control-sm fw-bold" />
|
||||
<span class="fw-bold fs-7">~</span>
|
||||
<input v-model="dateTo" type="date" class="form-control form-control-sm fw-bold" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-navy {
|
||||
background-color: #1E293B;
|
||||
}
|
||||
.hotkey-badge {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface TabItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: string
|
||||
badge?: string | number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tabs: TabItem[]
|
||||
activeTabId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['changeTab'])
|
||||
|
||||
const currentTab = ref(props.activeTabId || (props.tabs.length > 0 ? props.tabs[0].id : ''))
|
||||
|
||||
const selectTab = (tabId: string) => {
|
||||
currentTab.value = tabId
|
||||
emit('changeTab', tabId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card shadow-sm border w-100 h-100 d-flex flex-column">
|
||||
<!-- Header with Tab Controls -->
|
||||
<div class="card-header bg-navy text-white p-0 d-flex justify-content-between align-items-center">
|
||||
<ul class="nav nav-tabs card-header-tabs m-0 border-0">
|
||||
<li v-for="tab in tabs" :key="tab.id" class="nav-item">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-link px-3 py-2 border-0 fw-bold fs-7 rounded-0"
|
||||
:class="{ 'active bg-white text-navy': currentTab === tab.id, 'text-light': currentTab !== tab.id }"
|
||||
@click="selectTab(tab.id)"
|
||||
>
|
||||
<i v-if="tab.icon" :class="[tab.icon, 'me-1']"></i>
|
||||
{{ tab.label }}
|
||||
<span v-if="tab.badge" class="badge bg-primary ms-1 fs-8">{{ tab.badge }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="pe-3">
|
||||
<slot name="header-actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Body Area -->
|
||||
<div class="card-body p-3 flex-grow-1 overflow-auto bg-light">
|
||||
<template v-for="tab in tabs" :key="tab.id">
|
||||
<div v-show="currentTab === tab.id" class="h-100">
|
||||
<slot :name="`tab-${tab.id}`">
|
||||
<div class="text-muted p-3 text-center">
|
||||
[{{ tab.label }}] 탭 영역입니다.
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bg-navy {
|
||||
background-color: #1E293B;
|
||||
}
|
||||
.text-navy {
|
||||
color: #1E293B !important;
|
||||
}
|
||||
.nav-link.active {
|
||||
border-top: 3px solid #3B82F6 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const leftWidthPercent = ref(30)
|
||||
const isDragging = ref(false)
|
||||
@@ -31,6 +32,23 @@ const versions = ref([
|
||||
{ id: 'FACTOR-V4.1', date: '2026-07-10', status: 'ARCHIVED' },
|
||||
{ id: 'FACTOR-V4.0', date: '2026-06-25', status: 'ARCHIVED' }
|
||||
])
|
||||
|
||||
const fetchFactorVersions = async () => {
|
||||
try {
|
||||
const res = await axios.get('/api/factors/versions')
|
||||
if (res.data?.versions) {
|
||||
versions.value = res.data.versions.map((v: any) => ({
|
||||
id: v.versionId,
|
||||
date: v.createdAt,
|
||||
status: v.status
|
||||
}))
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep fallback list if offline
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchFactorVersions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user