first commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 최상위 레이아웃 — 라우터 뷰만 마운트, 도메인 별 레이아웃은 라우트 단위로 분리
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,93 @@
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
|
||||
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
|
||||
export interface StandardResponse<T = unknown> {
|
||||
success: boolean
|
||||
data?: T
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
|
||||
const ErrorCodeLexicon: Record<string, string> = {
|
||||
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
|
||||
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
|
||||
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
|
||||
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
|
||||
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
|
||||
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
|
||||
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
|
||||
}
|
||||
|
||||
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
|
||||
const showErrorUI = (message: string) => {
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(message)
|
||||
}
|
||||
|
||||
// 중앙 Axios 인스턴스
|
||||
const api: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 10000,
|
||||
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
|
||||
api.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => config,
|
||||
(error: unknown) => Promise.reject(error),
|
||||
)
|
||||
|
||||
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 에러코드 → 메시지 매핑
|
||||
api.interceptors.response.use(
|
||||
(response: AxiosResponse<StandardResponse>) => {
|
||||
const payload = response.data
|
||||
|
||||
if (payload && typeof payload.success === 'boolean') {
|
||||
if (payload.success) {
|
||||
// 성공 시 data 만 언래핑하여 반환
|
||||
return payload.data as never
|
||||
}
|
||||
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
|
||||
const errCode = payload.error?.code || 'BIZ_001'
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(payload.error)
|
||||
}
|
||||
return payload as never
|
||||
},
|
||||
(error) => {
|
||||
let errCode = 'SYS_001'
|
||||
|
||||
if (error?.response) {
|
||||
const status: number = error.response.status
|
||||
const payload = error.response.data as StandardResponse | undefined
|
||||
|
||||
if (payload?.error?.code) {
|
||||
errCode = payload.error.code
|
||||
} else if (status === 401) errCode = 'AUTH_001'
|
||||
else if (status === 403) errCode = 'AUTH_003'
|
||||
else if (status === 400 || status === 422) errCode = 'VAL_001'
|
||||
else if (status === 404) errCode = 'RES_001'
|
||||
}
|
||||
|
||||
showErrorUI(ErrorCodeLexicon[errCode] || ErrorCodeLexicon.SYS_001)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* useApi — 컴포넌트에서 axios 인스턴스를 가져올 때 사용하는 composable
|
||||
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
|
||||
*/
|
||||
export function useApi() {
|
||||
return api
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
// 도메인별 API Composable 샘플 — 실제 도메인이 추가되면 이 패턴을 복제
|
||||
export interface User {
|
||||
id: number
|
||||
email: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
const api = useApi()
|
||||
const loading = ref<boolean>(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function getUser(id: number): Promise<User | null> {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
// 인터셉터에서 success/data 언래핑 후 데이터만 반환
|
||||
return (await api.get<User>(`/users/${id}`)) as unknown as User
|
||||
} catch (e) {
|
||||
error.value = (e as Error)?.message ?? 'unknown error'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, getUser }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
// 홈 페이지 — 템플릿 기본 진입점
|
||||
import { ref } from 'vue'
|
||||
|
||||
const message = ref<string>('You did it!')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<h1>{{ message }}</h1>
|
||||
<p>
|
||||
Visit
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a>
|
||||
to read the documentation
|
||||
</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
padding: 2rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
// 404 페이지 — 라우팅 매칭 실패 시 노출
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="not-found" role="alert">
|
||||
<h1>404</h1>
|
||||
<p>요청하신 정보나 페이지를 찾을 수 없습니다.</p>
|
||||
<RouterLink to="/">홈으로 돌아가기</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/pages/HomePage.vue'),
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
component: () => import('@/pages/NotFoundPage.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 네비게이션 가드 — 인증 분기 (스토어 도입 후 useAuthStore로 교체)
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
// TODO: useAuthStore().isLoggedIn 검사 후 '/login' 으로 리다이렉트
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,20 @@
|
||||
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
|
||||
|
||||
/**
|
||||
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
|
||||
*/
|
||||
export function formatCurrency(value: number): string {
|
||||
return `${value.toLocaleString('ko-KR')}원`
|
||||
}
|
||||
|
||||
/**
|
||||
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
|
||||
*/
|
||||
export function formatDate(input: Date | string): string {
|
||||
const date = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { User } from '@/composables/useUser'
|
||||
|
||||
// 사용자 도메인 Pinia 스토어 샘플 — 도메인 단위로 파일을 분리하여 관리
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isLoggedIn = computed<boolean>(() => user.value !== null)
|
||||
|
||||
function setUser(payload: User | null) {
|
||||
user.value = payload
|
||||
}
|
||||
|
||||
function clear() {
|
||||
user.value = null
|
||||
}
|
||||
|
||||
return { user, isLoggedIn, setUser, clear }
|
||||
})
|
||||
Reference in New Issue
Block a user