Compare commits
35 Commits
0f0317e356
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195941a402 | ||
|
|
f0017a8703 | ||
| dc0fc6e41f | |||
|
|
f2a717df4f | ||
|
|
7bbbdbe977 | ||
| 268cf9e50f | |||
|
|
76992e262d | ||
| 14970701f7 | |||
| 8b7b516855 | |||
| 5687db9f25 | |||
| d60e60010b | |||
| ffc2b4a24a | |||
| ded762517e | |||
| 51019d7f5f | |||
|
|
cb22e3904a | ||
| c0a54bb64c | |||
| 41523a57b3 | |||
| 19bda71444 | |||
| f9dde4eb09 | |||
| f83782813d | |||
| b866242d43 | |||
| 1229faa777 | |||
| d278b635e7 | |||
| 7ee8a3005a | |||
| 4f02146d9f | |||
| 24c0f4f5b6 | |||
| 9f66ebac8a | |||
| e5f5a926a3 | |||
| 29fbda149b | |||
| 9bbc4f82b6 | |||
| 8282a4d037 | |||
| 75e9831907 | |||
| 221e250814 | |||
|
|
30e2099d14 | ||
|
|
2ec34ff321 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -91,6 +91,7 @@ out
|
|||||||
# Nuxt.js build / generate output
|
# Nuxt.js build / generate output
|
||||||
.nuxt
|
.nuxt
|
||||||
dist
|
dist
|
||||||
|
.output
|
||||||
|
|
||||||
# Gatsby files
|
# Gatsby files
|
||||||
.cache/
|
.cache/
|
||||||
|
|||||||
81
README.md
81
README.md
@@ -1,21 +1,76 @@
|
|||||||
# bio_frontend
|
# bio_frontend
|
||||||
|
|
||||||
## NUXT 3 (VUE 3) 환경
|
## NUXT 3 (VUE 3) 환경
|
||||||
- data-list 와 dataList는 동일 변수명으로 인식
|
|
||||||
- compnenets 아래의 .vue는 자동 인식(template 내부에서만, 별도 script에서 필요시 선언 필요)
|
|
||||||
|
|
||||||
|
- data-list 와 dataList는 동일 변수명으로 인식
|
||||||
|
- compnenets 아래의 .vue는 자동 인식(template 내부에서만, 별도 script에서 필요시 선언 필요)
|
||||||
|
|
||||||
# 구성 요소
|
# 구성 요소
|
||||||
|
|
||||||
## components 구성
|
## components 구성
|
||||||
|
|
||||||
|
### 폴더 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
components
|
components/
|
||||||
|- base // 기본 요소(button, input, grid, popup)
|
├── base/ // 기본 UI 요소 (접두사 없이 사용)
|
||||||
|- layout // 레이아웃 요소(header, footer, sidebar, wrapper)
|
│ ├── button/
|
||||||
|- module // 특정 기능 단위(card, form, list)
|
│ │ ├── CommonButton.vue
|
||||||
|- pages // 특정 페이지 전용
|
│ │ └── PermissionButton.vue
|
||||||
|
│ ├── grid/
|
||||||
|
│ │ └── ToastGrid.vue
|
||||||
|
│ ├── info/
|
||||||
|
│ │ └── PageDescription.vue
|
||||||
|
│ ├── loading/
|
||||||
|
│ │ └── GlobalLoading.vue
|
||||||
|
│ └── popup/
|
||||||
|
│ └── CommonPopup.vue
|
||||||
|
├── layout/ // 레이아웃 요소 (접두사 없이 사용)
|
||||||
|
│ ├── navigation/
|
||||||
|
│ │ ├── AppHeader.vue
|
||||||
|
│ │ ├── SubMenuBar.vue
|
||||||
|
│ │ └── TabBar.vue
|
||||||
|
│ └── wrapper/
|
||||||
|
│ ├── ContentsWrapper.vue
|
||||||
|
│ └── PopupWrapper.vue
|
||||||
|
└── domain/ // 도메인별 기능 컴포넌트 (@domain/ 접두사 사용)
|
||||||
|
└── culture-graph/
|
||||||
|
├── BatchGraph.vue
|
||||||
|
├── BatchTabs.vue
|
||||||
|
└── CustomContextMenu.vue
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 자동 임포트 설정 (nuxt.config.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
components: [
|
||||||
|
{ path: "~/components/base", pathPrefix: false }, // @base/ 접두사 제거
|
||||||
|
{ path: "~/components/layout", pathPrefix: false }, // @layout/ 접두사 제거
|
||||||
|
{ path: "~/components/domain", pathPrefix: true }, // @domain/ 접두사 유지
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴포넌트 사용법
|
||||||
|
|
||||||
|
- **base/**, **layout/** 폴더의 컴포넌트\*\*: 접두사 없이 직접 사용
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<CommonButton />
|
||||||
|
<ToastGrid />
|
||||||
|
<ContentsWrapper />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **domain/** 폴더의 컴포넌트: `@domain/` 접두사 사용
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<DomainCultureGraphBatchGraph />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
## page 구성
|
## page 구성
|
||||||
|
|
||||||
```
|
```
|
||||||
pages // 단일 화면(비 탭 요소)
|
pages // 단일 화면(비 탭 요소)
|
||||||
|- popup // 팝업 요소
|
|- popup // 팝업 요소
|
||||||
@@ -25,7 +80,9 @@ pages // 단일 화면(비 탭 요소)
|
|||||||
```
|
```
|
||||||
|
|
||||||
# page(페이지) 생성 요소
|
# page(페이지) 생성 요소
|
||||||
|
|
||||||
## 공통 페이지 구성
|
## 공통 페이지 구성
|
||||||
|
|
||||||
```
|
```
|
||||||
<template>
|
<template>
|
||||||
<ContentsWrapper> <!-- wrapper(title) 추가 -->
|
<ContentsWrapper> <!-- wrapper(title) 추가 -->
|
||||||
@@ -46,12 +103,12 @@ pages // 단일 화면(비 탭 요소)
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## Toast(Tui) Grid 사용법
|
## Toast(Tui) Grid 사용법
|
||||||
|
|
||||||
한글 설명: https://github.com/nhn/tui.grid/blob/master/packages/toast-ui.grid/docs/v4.0-migration-guide-kor.md
|
한글 설명: https://github.com/nhn/tui.grid/blob/master/packages/toast-ui.grid/docs/v4.0-migration-guide-kor.md
|
||||||
|
|
||||||
|
|
||||||
### 기본 설정
|
### 기본 설정
|
||||||
|
|
||||||
```
|
```
|
||||||
<template>
|
<template>
|
||||||
<button @click="onAddClick">추가</button>
|
<button @click="onAddClick">추가</button>
|
||||||
@@ -101,6 +158,7 @@ function onUpdateClick() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
## tree data
|
## tree data
|
||||||
|
|
||||||
```
|
```
|
||||||
<template>
|
<template>
|
||||||
<ToastGrid
|
<ToastGrid
|
||||||
@@ -227,8 +285,8 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
|
|||||||
2. 사용할 페이지에서 생성한 팝업 추가
|
2. 사용할 페이지에서 생성한 팝업 추가
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
### 팝업 생성
|
### 팝업 생성
|
||||||
|
|
||||||
```
|
```
|
||||||
// examplePopup.vue
|
// examplePopup.vue
|
||||||
<template>
|
<template>
|
||||||
@@ -258,6 +316,7 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 팝업 사용
|
### 팝업 사용
|
||||||
|
|
||||||
```
|
```
|
||||||
<template>
|
<template>
|
||||||
<button @click="popupShow = true">팝업 실행</button>
|
<button @click="popupShow = true">팝업 실행</button>
|
||||||
@@ -269,8 +328,8 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
## 탭 구성
|
## 탭 구성
|
||||||
|
|
||||||
```
|
```
|
||||||
// layouts/default.vue
|
// layouts/default.vue
|
||||||
// stores/tab.ts
|
// stores/tab.ts
|
||||||
|
|||||||
3
app.vue
3
app.vue
@@ -2,6 +2,9 @@
|
|||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage :keepalive="true" />
|
<NuxtPage :keepalive="true" />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
|
|
||||||
|
<!-- 전역 로딩 오버레이 -->
|
||||||
|
<GlobalLoading :show-details="true" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- <script setup lang="ts">
|
<!-- <script setup lang="ts">
|
||||||
|
|||||||
95
components/base/button/PermissionButton.vue
Normal file
95
components/base/button/PermissionButton.vue
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="hasPermission"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:class="buttonClass"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="handleClick"
|
||||||
|
>
|
||||||
|
<slot>{{ buttonText }}</slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
/** 버튼 코드 (권한 체크용) */
|
||||||
|
buttonCode: string;
|
||||||
|
/** 버튼 비활성화 여부 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 추가 CSS 클래스 */
|
||||||
|
class?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
/** 클릭 이벤트 */
|
||||||
|
(e: "click", event: MouseEvent): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false,
|
||||||
|
class: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
// 권한 체크 - usePermission composable만 사용
|
||||||
|
const permission = usePermission();
|
||||||
|
const hasPermission = computed(() =>
|
||||||
|
permission.hasComponentPermission(props.buttonCode)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 버튼 텍스트 가져오기 - usePermission composable 사용
|
||||||
|
const buttonText = computed(() => {
|
||||||
|
const component = permission.getResourceByCode(props.buttonCode);
|
||||||
|
return component?.name || props.buttonCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 버튼 클래스 계산
|
||||||
|
const buttonClass = computed(() => {
|
||||||
|
const baseClass = "permission-button";
|
||||||
|
const disabledClass = props.disabled ? "permission-button--disabled" : "";
|
||||||
|
const customClass = props.class || "";
|
||||||
|
|
||||||
|
return [baseClass, disabledClass, customClass].filter(Boolean).join(" ");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 클릭 이벤트 처리
|
||||||
|
const handleClick = (event: MouseEvent) => {
|
||||||
|
if (!props.disabled && hasPermission.value) {
|
||||||
|
emit("click", event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.permission-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-button:hover:not(.permission-button--disabled) {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-button:focus {
|
||||||
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-button--disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
220
components/base/loading/GlobalLoading.vue
Normal file
220
components/base/loading/GlobalLoading.vue
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
name="loading-fade"
|
||||||
|
enter-active-class="transition-opacity duration-300"
|
||||||
|
leave-active-class="transition-opacity duration-300"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isLoading"
|
||||||
|
class="global-loading-overlay"
|
||||||
|
@click.self="handleOverlayClick"
|
||||||
|
>
|
||||||
|
<div class="loading-container">
|
||||||
|
<!-- 스피너 -->
|
||||||
|
<div class="loading-spinner">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 로딩 메시지 -->
|
||||||
|
<div class="loading-message">
|
||||||
|
<p class="message-text">{{ currentMessage }}</p>
|
||||||
|
<p v-if="loadingCount > 1" class="count-text">
|
||||||
|
({{ loadingCount }}개 작업 진행 중)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 진행 중인 작업 목록 (개발 모드에서만) -->
|
||||||
|
<div
|
||||||
|
v-if="showDetails && loadingMessages.length > 1"
|
||||||
|
class="loading-details"
|
||||||
|
>
|
||||||
|
<details class="details">
|
||||||
|
<summary class="details-summary">진행 중인 작업 보기</summary>
|
||||||
|
<ul class="details-list">
|
||||||
|
<li
|
||||||
|
v-for="(message, index) in loadingMessages"
|
||||||
|
:key="index"
|
||||||
|
class="details-item"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
/** 오버레이 클릭 시 로딩 취소 허용 여부 */
|
||||||
|
allowCancel?: boolean;
|
||||||
|
/** 개발 모드에서 상세 정보 표시 여부 */
|
||||||
|
showDetails?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
allowCancel: false,
|
||||||
|
showDetails: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isLoading, currentMessage, loadingCount, loadingMessages, clearAll } =
|
||||||
|
useLoading();
|
||||||
|
|
||||||
|
// 오버레이 클릭 처리
|
||||||
|
const handleOverlayClick = () => {
|
||||||
|
if (props.allowCancel) {
|
||||||
|
clearAll();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.global-loading-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
text-align: center;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #f3f4f6;
|
||||||
|
border-top: 4px solid #3b82f6;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-message {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-text {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 500;
|
||||||
|
list-style: none;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-summary::before {
|
||||||
|
content: "▶";
|
||||||
|
display: inline-block;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details[open] .details-summary::before {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-list {
|
||||||
|
margin: 0.5rem 0 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-item {
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
color: #6b7280;
|
||||||
|
border-bottom: 1px solid #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 다크 모드 지원 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.loading-container {
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-text,
|
||||||
|
.details-summary,
|
||||||
|
.details-item {
|
||||||
|
color: #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-summary {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-item {
|
||||||
|
border-bottom-color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,21 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="show" class="popup-overlay" @click.self="show = false">
|
<div v-if="show" class="popup-overlay" @click.self="show = false">
|
||||||
<div class="popup-container">
|
<div class="popup-container">
|
||||||
<slot></slot>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const show = defineModel('show', {type: Boolean, default:false});
|
const show = defineModel("show", { type: Boolean, default: false });
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.popup-overlay {
|
.popup-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0,0,0,0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import BatchGraph from "~/components/BatchGraph.vue";
|
import BatchGraph from "~/components/domain/culture-graph/BatchGraph.vue";
|
||||||
const batchNames = ["배치 1", "배치 2", "배치 3", "배치 4"];
|
const batchNames = ["배치 1", "배치 2", "배치 3", "배치 4"];
|
||||||
const currentTab = ref(0);
|
const currentTab = ref(0);
|
||||||
</script>
|
</script>
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="wrapper">
|
|
||||||
<!-- 경로 -->
|
|
||||||
<nav class="breadcrumb">{{ breadcrumb }}</nav>
|
|
||||||
|
|
||||||
<!-- 화면 명 + 버튼 영역 -->
|
|
||||||
<header class="header">
|
|
||||||
<h1 class="title">{{ pageTitle }}</h1>
|
|
||||||
<div class="header-actions">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- 메인 콘텐츠 -->
|
|
||||||
<main class="content">
|
|
||||||
<slot />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { useRoute } from '#imports'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
// 경로(메뉴 경로)
|
|
||||||
const breadcrumb = computed(() => route.path)
|
|
||||||
|
|
||||||
// 화면명(meta.title 값)
|
|
||||||
const pageTitle = computed(() => route.meta.title || 'Untitled Page')
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.wrapper {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 16px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
min-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 화면명과 버튼을 좌우 끝으로 배치 */
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 버튼 공통 스타일 */
|
|
||||||
.header-actions ::v-deep button {
|
|
||||||
background-color: #4CAF50;
|
|
||||||
color: white;
|
|
||||||
font-size: 14px;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions ::v-deep button:hover {
|
|
||||||
background-color: #45a049;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-actions ::v-deep button:disabled {
|
|
||||||
background-color: #ccc;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
</style>
|
|
||||||
|
|
||||||
@@ -3,37 +3,22 @@
|
|||||||
class="w-full bg-white shadow flex items-center justify-center px-4 h-24 relative"
|
class="w-full bg-white shadow flex items-center justify-center px-4 h-24 relative"
|
||||||
>
|
>
|
||||||
<nav class="flex justify-center space-x-4">
|
<nav class="flex justify-center space-x-4">
|
||||||
<!-- HOME 메뉴 -->
|
<!-- 권한 기반 메뉴 -->
|
||||||
<button
|
<button
|
||||||
|
v-for="menu in availableMenus"
|
||||||
|
:key="menu.code"
|
||||||
class="menu-btn"
|
class="menu-btn"
|
||||||
:class="{ active: modelValue === 'home' }"
|
:class="{ active: modelValue === menu.code }"
|
||||||
@click="$emit('update:modelValue', 'home')"
|
@click="onMenuClick(menu.code)"
|
||||||
>
|
>
|
||||||
HOME
|
{{ menu.name }}
|
||||||
</button>
|
|
||||||
<!-- 테스트트 메뉴 -->
|
|
||||||
<button
|
|
||||||
class="menu-btn"
|
|
||||||
:class="{ active: modelValue === 'test' }"
|
|
||||||
@click="$emit('update:modelValue', 'test')"
|
|
||||||
>
|
|
||||||
테스트 메뉴
|
|
||||||
</button>
|
|
||||||
<!-- 관리자 메뉴 (관리자만 표시) -->
|
|
||||||
<button
|
|
||||||
v-if="userStore.isAdmin"
|
|
||||||
class="menu-btn"
|
|
||||||
:class="{ active: modelValue === 'admin' }"
|
|
||||||
@click="$emit('update:modelValue', 'admin')"
|
|
||||||
>
|
|
||||||
관리자 메뉴
|
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 사용자 정보 및 드롭다운 -->
|
<!-- 사용자 정보 및 드롭다운 -->
|
||||||
<div class="user-menu-wrapper">
|
<div class="user-menu-wrapper">
|
||||||
<div class="user-info" @click="toggleDropdown">
|
<div class="user-info" @click="toggleDropdown">
|
||||||
<span class="user-name">{{ userStore.name }}</span>
|
<span class="user-name">{{ userStore.user?.name }}</span>
|
||||||
<div class="user-icon">
|
<div class="user-icon">
|
||||||
<svg
|
<svg
|
||||||
width="24"
|
width="24"
|
||||||
@@ -51,10 +36,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-show="showDropdown" class="user-dropdown">
|
<div v-show="showDropdown" class="user-dropdown">
|
||||||
<div class="user-details">
|
|
||||||
<p class="user-email">{{ userStore.user?.email }}</p>
|
|
||||||
<p class="user-role">{{ userStore.isAdmin ? "관리자" : "사용자" }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<button class="logout-btn" @click="logout">
|
<button class="logout-btn" @click="logout">
|
||||||
<svg
|
<svg
|
||||||
@@ -76,39 +57,42 @@
|
|||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
const modelValue = defineModel({ type: String, required: true });
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useUserStore } from "~/stores/user";
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
defineEmits(["update:modelValue"]);
|
|
||||||
|
|
||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
const router = useRouter();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
const permissionStore = usePermissionsStore();
|
||||||
|
|
||||||
|
// 권한이 있고 메뉴에 표시할 페이지그룹들만 필터링
|
||||||
|
const availableMenus = computed(() => {
|
||||||
|
return permissionStore.permissions.resources.pageGroups.filter(pageGroup => {
|
||||||
|
return (
|
||||||
|
permissionStore.hasPageGroupPermission(pageGroup.code) &&
|
||||||
|
pageGroup.menuYn === "Y"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 메뉴 클릭 핸들러
|
||||||
|
function onMenuClick(menu: string) {
|
||||||
|
modelValue.value = menu;
|
||||||
|
}
|
||||||
|
|
||||||
function toggleDropdown() {
|
function toggleDropdown() {
|
||||||
showDropdown.value = !showDropdown.value;
|
showDropdown.value = !showDropdown.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
const menu = document.querySelector(".user-menu-wrapper");
|
const menu = document.querySelector(".user-menu-wrapper");
|
||||||
if (menu && !menu.contains(event.target)) {
|
if (menu && !menu.contains(event.target as Node)) {
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
await userStore.logout();
|
userStore.logout();
|
||||||
router.push("/login");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
66
components/layout/navigation/SubMenuBar.vue
Normal file
66
components/layout/navigation/SubMenuBar.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
v-if="showSubmenuBar && subMenus.length > 0"
|
||||||
|
class="w-full bg-gray-100 shadow-sm px-4 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center space-x-6">
|
||||||
|
<span class="text-sm font-medium text-gray-600 mr-4">
|
||||||
|
{{ activeMenu }}
|
||||||
|
</span>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<button
|
||||||
|
v-for="sub in subMenus"
|
||||||
|
:key="sub.key"
|
||||||
|
class="submenu-btn"
|
||||||
|
@click="onSubMenuClick(sub)"
|
||||||
|
>
|
||||||
|
{{ sub.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface SubMenu {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
componentName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
showSubmenuBar: boolean;
|
||||||
|
activeMenu: string;
|
||||||
|
subMenus: SubMenu[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "submenu-click", sub: SubMenu): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
function onSubMenuClick(sub: SubMenu) {
|
||||||
|
emit("submenu-click", sub);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.submenu-btn {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #374151;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-btn:hover {
|
||||||
|
color: #2563eb;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
116
components/layout/navigation/TabBar.vue
Normal file
116
components/layout/navigation/TabBar.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tab-bar">
|
||||||
|
<div
|
||||||
|
v-for="tab in tabsStore.tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: tabsStore.activeTab === tab.key }"
|
||||||
|
@click="handleTabClick(tab.key)"
|
||||||
|
>
|
||||||
|
<span class="tab-label">{{ tab.label }}</span>
|
||||||
|
<button
|
||||||
|
v-if="tab.key !== 1 && tabsStore.activeTab !== tab.key"
|
||||||
|
class="close-btn"
|
||||||
|
type="button"
|
||||||
|
@click.stop="handleTabClose(tab.key)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTabsStore } from "@/stores/tab";
|
||||||
|
|
||||||
|
const tabsStore = useTabsStore();
|
||||||
|
|
||||||
|
const handleTabClick = (tabKey: number) => {
|
||||||
|
// 이미 활성화된 탭이면 아무것도 하지 않음
|
||||||
|
if (tabsStore.activeTab === tabKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tabsStore.setActiveTab(tabKey);
|
||||||
|
};
|
||||||
|
const handleTabClose = (tabKey: number) => tabsStore.removeTab(tabKey);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #ffffff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: fit-content;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item:hover {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
background: #3b82f6;
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active:hover {
|
||||||
|
background: #2563eb;
|
||||||
|
border-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: inherit;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active .close-btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
components/layout/wrapper/ContentsWrapper.vue
Normal file
91
components/layout/wrapper/ContentsWrapper.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wrapper">
|
||||||
|
<!-- 경로 -->
|
||||||
|
<nav class="breadcrumb">{{ breadcrumb }}</nav>
|
||||||
|
|
||||||
|
<!-- 화면 명 + 버튼 영역 -->
|
||||||
|
<header class="header">
|
||||||
|
<h1 class="title">{{ pageTitle }}</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 메인 콘텐츠 -->
|
||||||
|
<main class="content">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
// 경로(메뉴 경로)
|
||||||
|
const breadcrumb = computed(() => route.path);
|
||||||
|
|
||||||
|
// 화면명(meta.title 값)
|
||||||
|
const pageTitle = computed(() => route.meta.title || "Untitled Page");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 화면명과 버튼을 좌우 끝으로 배치 */
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 공통 스타일 */
|
||||||
|
.header-actions ::v-deep button {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions ::v-deep button:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions ::v-deep button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<customPopup v-model:show="show">
|
<CommonPopup v-model:show="show">
|
||||||
<div class="popup-content" :style="{ width, height }">
|
<div class="popup-content" :style="{ width, height }">
|
||||||
<!-- Top -->
|
<!-- Top -->
|
||||||
<div class="popup-top">
|
<div class="popup-top">
|
||||||
@@ -17,22 +17,19 @@
|
|||||||
<slot name="bottom"></slot>
|
<slot name="bottom"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</customPopup>
|
</CommonPopup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
width?: string
|
width?: string;
|
||||||
height?: string
|
height?: string;
|
||||||
}>()
|
}>();
|
||||||
|
|
||||||
// defineModel + 기본값 지정
|
// defineModel + 기본값 지정
|
||||||
const show = defineModel('show', {type: Boolean, default:false});
|
const show = defineModel("show", { type: Boolean, default: false });
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.popup-content {
|
.popup-content {
|
||||||
background: white;
|
background: white;
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
let baseZIndex = 1000;
|
|
||||||
|
|
||||||
++baseZIndex;
|
|
||||||
|
|
||||||
const currentZ = ref(baseZIndex)
|
|
||||||
|
|
||||||
export function usePopupZIndex() {
|
|
||||||
function nextZIndex() {
|
|
||||||
currentZ.value += 1
|
|
||||||
return currentZ.value
|
|
||||||
}
|
|
||||||
return { nextZIndex }
|
|
||||||
}
|
|
||||||
@@ -7,17 +7,20 @@
|
|||||||
* @returns Promise<T> - API 응답 데이터
|
* @returns Promise<T> - API 응답 데이터
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // GET 요청 (기본)
|
* // GET 요청 (기본 - 전역 로딩 자동 적용)
|
||||||
* const users = await useApi<User[]>('/users')
|
* const users = await useApi<User[]>('/users')
|
||||||
*
|
*
|
||||||
* // POST 요청
|
* // POST 요청 (커스텀 로딩 메시지)
|
||||||
* const newUser = await useApi<User>('/users', {
|
* const newUser = await useApi<User>('/users', {
|
||||||
* method: 'POST',
|
* method: 'POST',
|
||||||
* body: { name: 'John', email: 'john@example.com' }
|
* body: { name: 'John', email: 'john@example.com' },
|
||||||
|
* loadingMessage: '사용자를 생성하는 중...'
|
||||||
* })
|
* })
|
||||||
*
|
*
|
||||||
* // 에러 시 alert 표시
|
* // 전역 로딩 없이 API 호출
|
||||||
* const data = await useApi<User[]>('/users', { showAlert: true })
|
* const data = await useApi<User[]>('/users', {
|
||||||
|
* useGlobalLoading: false
|
||||||
|
* })
|
||||||
*
|
*
|
||||||
* // 에러를 직접 처리
|
* // 에러를 직접 처리
|
||||||
* try {
|
* try {
|
||||||
@@ -29,7 +32,11 @@
|
|||||||
* // FormData 업로드
|
* // FormData 업로드
|
||||||
* const formData = new FormData()
|
* const formData = new FormData()
|
||||||
* formData.append('file', file)
|
* formData.append('file', file)
|
||||||
* await useApi('/upload', { method: 'POST', body: formData })
|
* await useApi('/upload', {
|
||||||
|
* method: 'POST',
|
||||||
|
* body: formData,
|
||||||
|
* loadingMessage: '파일을 업로드하는 중...'
|
||||||
|
* })
|
||||||
*/
|
*/
|
||||||
export const useApi = async <T>(
|
export const useApi = async <T>(
|
||||||
path: string,
|
path: string,
|
||||||
@@ -41,8 +48,15 @@ export const useApi = async <T>(
|
|||||||
// 에러 처리 옵션
|
// 에러 처리 옵션
|
||||||
handleError?: boolean; // true: 에러를 null로 반환, false: 에러를 다시 던짐
|
handleError?: boolean; // true: 에러를 null로 반환, false: 에러를 다시 던짐
|
||||||
showAlert?: boolean; // true: 에러 시 alert 표시
|
showAlert?: boolean; // true: 에러 시 alert 표시
|
||||||
|
// 로딩 옵션
|
||||||
|
loadingMessage?: string; // 로딩 메시지
|
||||||
|
useGlobalLoading?: boolean; // 전역 로딩 사용 여부 (기본값: true)
|
||||||
}
|
}
|
||||||
): Promise<T> => {
|
): Promise<T> => {
|
||||||
|
const { withLoading } = useLoading();
|
||||||
|
|
||||||
|
// API 호출 로직을 별도 함수로 분리
|
||||||
|
const apiCall = async (): Promise<T> => {
|
||||||
const { $api } = useNuxtApp();
|
const { $api } = useNuxtApp();
|
||||||
|
|
||||||
// 기본값 설정
|
// 기본값 설정
|
||||||
@@ -61,11 +75,26 @@ export const useApi = async <T>(
|
|||||||
...(headers && { headers }),
|
...(headers && { headers }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return ($api as any)(path, apiOpts).catch((error: any) => {
|
return ($api as any)(path, apiOpts).catch(async (error: any) => {
|
||||||
|
const status = error.response?.status;
|
||||||
|
const message = error.response._data.message;
|
||||||
|
|
||||||
|
if (
|
||||||
|
status === 401 &&
|
||||||
|
["JWT_TOKEN_EXPIRED", "INVALID_CLIENT_IP", "JWT_TOKEN_NULL"].includes(
|
||||||
|
message
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
userStore.user = null;
|
||||||
|
userStore.isLoggedIn = false;
|
||||||
|
await navigateTo(`/auth-error?type=${message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
// 사용자에게 알림 표시
|
// 사용자에게 알림 표시
|
||||||
if (showAlert) {
|
if (showAlert) {
|
||||||
const status = error.response?.status;
|
let description =
|
||||||
let message =
|
|
||||||
status === 404
|
status === 404
|
||||||
? "요청한 리소스를 찾을 수 없습니다."
|
? "요청한 리소스를 찾을 수 없습니다."
|
||||||
: status === 500
|
: status === 500
|
||||||
@@ -74,10 +103,10 @@ export const useApi = async <T>(
|
|||||||
|
|
||||||
// 서버에서 온 에러 메시지가 있으면 우선 사용
|
// 서버에서 온 에러 메시지가 있으면 우선 사용
|
||||||
if (error.response?._data?.description) {
|
if (error.response?._data?.description) {
|
||||||
message = error.response._data.description;
|
description = error.response._data.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(message);
|
alert(description);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 에러 처리 방식에 따라 반환
|
// 에러 처리 방식에 따라 반환
|
||||||
@@ -87,4 +116,19 @@ export const useApi = async <T>(
|
|||||||
throw error; // 에러를 다시 던짐
|
throw error; // 에러를 다시 던짐
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전역 로딩 사용 여부 확인 (기본값: true)
|
||||||
|
const shouldUseLoading = options?.useGlobalLoading !== false;
|
||||||
|
|
||||||
|
if (shouldUseLoading) {
|
||||||
|
// 전역 로딩과 함께 API 호출
|
||||||
|
return await withLoading(
|
||||||
|
apiCall,
|
||||||
|
options?.loadingMessage || "데이터를 불러오는 중..."
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 전역 로딩 없이 API 호출
|
||||||
|
return await apiCall();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
export const useCounter = () => {
|
|
||||||
const count = ref(0);
|
|
||||||
|
|
||||||
const increment = () => {
|
|
||||||
count.value++;
|
|
||||||
};
|
|
||||||
|
|
||||||
const decrement = () => {
|
|
||||||
count.value--;
|
|
||||||
};
|
|
||||||
|
|
||||||
const reset = () => {
|
|
||||||
count.value = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const double = computed(() => count.value * 2);
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: readonly(count),
|
|
||||||
increment,
|
|
||||||
decrement,
|
|
||||||
reset,
|
|
||||||
double,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
65
composables/useLoading.ts
Normal file
65
composables/useLoading.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* 로딩 상태를 쉽게 사용할 수 있는 컴포저블
|
||||||
|
* 로딩 카운터 방식으로 여러 비동기 작업을 안전하게 관리합니다.
|
||||||
|
*/
|
||||||
|
export const useLoading = () => {
|
||||||
|
const loadingStore = useLoadingStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로딩 상태와 함께 비동기 함수를 실행합니다.
|
||||||
|
* 함수 실행 중에는 자동으로 로딩 카운터가 증가하고, 완료되면 감소합니다.
|
||||||
|
*
|
||||||
|
* @param asyncFn 실행할 비동기 함수
|
||||||
|
* @param message 로딩 메시지 (선택사항)
|
||||||
|
* @returns Promise<T> 비동기 함수의 결과
|
||||||
|
*/
|
||||||
|
const withLoading = async <T>(
|
||||||
|
asyncFn: () => Promise<T>,
|
||||||
|
message?: string
|
||||||
|
): Promise<T> => {
|
||||||
|
loadingStore.startLoading(message);
|
||||||
|
try {
|
||||||
|
return await asyncFn();
|
||||||
|
} finally {
|
||||||
|
loadingStore.stopLoading(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 로딩 상태를 수동으로 관리할 때 사용합니다.
|
||||||
|
*
|
||||||
|
* @param message 로딩 메시지 (선택사항)
|
||||||
|
* @returns 로딩 종료 함수
|
||||||
|
*/
|
||||||
|
const startLoading = (message?: string) => {
|
||||||
|
loadingStore.startLoading(message);
|
||||||
|
return () => loadingStore.stopLoading(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 메시지로 로딩을 시작하고, 해당 메시지로 종료할 수 있는 함수를 반환합니다.
|
||||||
|
*
|
||||||
|
* @param message 로딩 메시지
|
||||||
|
* @returns 로딩 종료 함수
|
||||||
|
*/
|
||||||
|
const withMessage = (message: string) => {
|
||||||
|
loadingStore.startLoading(message);
|
||||||
|
return () => loadingStore.stopLoading(message);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 상태
|
||||||
|
isLoading: computed(() => loadingStore.isLoading),
|
||||||
|
loadingCount: computed(() => loadingStore.loadingCount),
|
||||||
|
currentMessage: computed(() => loadingStore.currentMessage),
|
||||||
|
loadingMessages: computed(() => loadingStore.loadingMessages),
|
||||||
|
|
||||||
|
// 액션
|
||||||
|
startLoading,
|
||||||
|
stopLoading: loadingStore.stopLoading,
|
||||||
|
withLoading,
|
||||||
|
withMessage,
|
||||||
|
clearAll: loadingStore.clearAllLoading,
|
||||||
|
reset: loadingStore.reset,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
export default async function useOverlay() {
|
export default async function useOverlay() {
|
||||||
if (import.meta.server) {
|
// SPA 모드에서는 항상 클라이언트에서만 실행됨
|
||||||
// SSR에서는 cytoscape-overlays를 사용하지 않음
|
return await import("cytoscape-overlays");
|
||||||
return null
|
|
||||||
}
|
|
||||||
// 전체 export 객체를 반환
|
|
||||||
return await import('cytoscape-overlays')
|
|
||||||
}
|
}
|
||||||
39
composables/usePermission.ts
Normal file
39
composables/usePermission.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 권한 체크를 위한 컴포저블
|
||||||
|
* 컴포넌트에서 권한을 쉽게 체크할 수 있도록 도와주는 유틸리티 함수들
|
||||||
|
*/
|
||||||
|
export const usePermission = () => {
|
||||||
|
const permissionsStore = usePermissionsStore();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 페이지 권한 체크
|
||||||
|
hasPagePermission: (page: string) =>
|
||||||
|
permissionsStore.hasPagePermission(page),
|
||||||
|
|
||||||
|
// 페이지그룹 권한 체크
|
||||||
|
hasPageGroupPermission: (pageGroup: string) =>
|
||||||
|
permissionsStore.hasPageGroupPermission(pageGroup),
|
||||||
|
|
||||||
|
// 컴포넌트 권한 체크
|
||||||
|
hasComponentPermission: (component: string) =>
|
||||||
|
permissionsStore.hasComponentPermission(component),
|
||||||
|
|
||||||
|
// 권한 데이터 직접 접근
|
||||||
|
permissions: computed(() => permissionsStore.permissions),
|
||||||
|
resources: computed(() => permissionsStore.permissions?.resources),
|
||||||
|
|
||||||
|
// 리소스별 전체 접근 함수
|
||||||
|
getPageGroups: () =>
|
||||||
|
permissionsStore.permissions?.resources?.pageGroups || [],
|
||||||
|
getPages: () => permissionsStore.permissions?.resources?.pages || [],
|
||||||
|
getComponents: () =>
|
||||||
|
permissionsStore.permissions?.resources?.components || [],
|
||||||
|
|
||||||
|
// 코드로 리소스 찾기
|
||||||
|
getResourceByCode: (code: string) => {
|
||||||
|
const components =
|
||||||
|
permissionsStore.permissions?.resources?.components || [];
|
||||||
|
return components.find(component => component.code === code);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,150 +1,16 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import AppHeader from "../components/layout/AppHeader.vue";
|
|
||||||
import { ref, computed, watch } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useTabsStore } from "../stores/tab";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const activeMenu = ref("home");
|
|
||||||
const showSubmenuBar = ref(false);
|
|
||||||
|
|
||||||
const tabsStore = useTabsStore();
|
|
||||||
|
|
||||||
// 메뉴 클릭 시 홈 이동
|
|
||||||
watch(activeMenu, (newValue) => {
|
|
||||||
if (newValue === "home") router.push("/");
|
|
||||||
});
|
|
||||||
|
|
||||||
// 서브메뉴 정의
|
|
||||||
const subMenus = computed(() => {
|
|
||||||
if (activeMenu.value === "test") {
|
|
||||||
return [{
|
|
||||||
key: "test",
|
|
||||||
label: "테스트",
|
|
||||||
to: "/test/test01"
|
|
||||||
}, {
|
|
||||||
key: "igv",
|
|
||||||
label: "ivg",
|
|
||||||
to: "/test/test02"
|
|
||||||
}, {
|
|
||||||
key: "igv2",
|
|
||||||
label: "ivg2",
|
|
||||||
to: "/test/igv2"
|
|
||||||
}, {
|
|
||||||
key: "pathway",
|
|
||||||
label: "pathway",
|
|
||||||
to: "/test/pathway"
|
|
||||||
}, {
|
|
||||||
key: "pathway2",
|
|
||||||
label: "pathway2",
|
|
||||||
to: "/test/pathway2"
|
|
||||||
}, {
|
|
||||||
key: "pathway3",
|
|
||||||
label: "pathway3",
|
|
||||||
to: "/test/pathway3"
|
|
||||||
}, {
|
|
||||||
key: "pathway4",
|
|
||||||
label: "pathway4",
|
|
||||||
to: "/cultureGraph/pathway4"
|
|
||||||
}, {
|
|
||||||
key: "pathwayjson",
|
|
||||||
label: "pathwayjson",
|
|
||||||
to: "/test/pathwayjson"
|
|
||||||
}, {
|
|
||||||
key: "cultureGraph",
|
|
||||||
label: "배양그래프",
|
|
||||||
to: "/test/culture-graph"
|
|
||||||
}, {
|
|
||||||
key: "cultureGraphMulti",
|
|
||||||
label: "배양그래프 멀티",
|
|
||||||
to: "/test/culture-graph-multi"
|
|
||||||
}, {
|
|
||||||
key: "cultureGraphTab",
|
|
||||||
label: "배양그래프 탭",
|
|
||||||
to: "/test/culture-graph-tab"
|
|
||||||
}, {
|
|
||||||
key: "tui-grid",
|
|
||||||
label: "tui-grid",
|
|
||||||
to: "/tui"
|
|
||||||
}, {
|
|
||||||
key: "리소스",
|
|
||||||
label: "리소스",
|
|
||||||
to: "/admin/resource"
|
|
||||||
}, {
|
|
||||||
key: "sample",
|
|
||||||
label: "sample",
|
|
||||||
to: "/sampleList"
|
|
||||||
}, ];
|
|
||||||
} else if (activeMenu.value === "ADMIN") {
|
|
||||||
return [{
|
|
||||||
key: "logs",
|
|
||||||
label: "접속기록",
|
|
||||||
to: "/admin/logs"
|
|
||||||
}, {
|
|
||||||
key: "codes",
|
|
||||||
label: "공통코드",
|
|
||||||
to: "/admin/codes"
|
|
||||||
}, {
|
|
||||||
key: "programs",
|
|
||||||
label: "프로그램",
|
|
||||||
to: "/admin/programs"
|
|
||||||
}, ];
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
|
|
||||||
function onMenuClick(menu: string) {
|
|
||||||
activeMenu.value = menu;
|
|
||||||
showSubmenuBar.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 서브메뉴 클릭 → 현재 활성 탭 내용만 변경
|
|
||||||
function onSubMenuClick(sub: { key: string; label: string; to: string; componentName: string }) {
|
|
||||||
tabsStore.updateActiveTab(sub);
|
|
||||||
// const activeKey = tabsStore.activeTab;
|
|
||||||
// router.push(`/${activeKey}${sub.to}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ 새 탭 추가 버튼
|
|
||||||
function addNewTab() {
|
|
||||||
tabsStore.addTab();
|
|
||||||
// router.push(`/${key}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<AppHeader v-model="activeMenu" @update:model-value="onMenuClick" />
|
<AppHeader v-model="activeMenu" @update:model-value="handleMenuChange" />
|
||||||
|
|
||||||
<!-- 서브메뉴 바 -->
|
<!-- 서브메뉴 바 -->
|
||||||
<nav v-if="subMenus && subMenus.length && showSubmenuBar" class="submenu-bar" @click.stop>
|
<SubMenuBar
|
||||||
<button
|
:show-submenu-bar="showSubmenuBar"
|
||||||
v-for="sub in subMenus"
|
:active-menu="activeMenu"
|
||||||
:key="sub.key"
|
:sub-menus="subMenus"
|
||||||
class="submenu-btn"
|
@submenu-click="onSubMenuClick"
|
||||||
@click="onSubMenuClick({ ...sub, componentName: sub.key })"
|
/>
|
||||||
>
|
|
||||||
{{ sub.label }}
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
<br><br>
|
|
||||||
<!-- 탭 바 -->
|
<!-- 탭 바 -->
|
||||||
<div class="tab-bar">
|
<TabBar />
|
||||||
<div
|
|
||||||
v-for="tab in tabsStore.tabs"
|
|
||||||
:key="tab.key"
|
|
||||||
class="tab-item"
|
|
||||||
:class="{ active: tabsStore.activeTab === tab.key }"
|
|
||||||
@click="tabsStore.setActiveTab(tab.key);"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
<span v-show="tabsStore.activeTab !== tab.key" class="close-btn" @click.stop="tabsStore.removeTab(tab.key)"> × </span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ✅ 새 탭 추가 버튼 -->
|
|
||||||
<button class="add-tab-btn" @click="addNewTab">+</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<slot />
|
<slot />
|
||||||
@@ -152,6 +18,59 @@ function addNewTab() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import AppHeader from "../components/layout/navigation/AppHeader.vue";
|
||||||
|
import SubMenuBar from "../components/layout/navigation/SubMenuBar.vue";
|
||||||
|
import TabBar from "../components/layout/navigation/TabBar.vue";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useTabsStore } from "../stores/tab";
|
||||||
|
import { usePermissionsStore } from "~/stores/permissions";
|
||||||
|
|
||||||
|
const activeMenu = ref("HOME");
|
||||||
|
const showSubmenuBar = ref(false);
|
||||||
|
|
||||||
|
const tabsStore = useTabsStore();
|
||||||
|
const permissionStore = usePermissionsStore();
|
||||||
|
|
||||||
|
// 권한 기반 서브메뉴 생성
|
||||||
|
const subMenus = computed(() => {
|
||||||
|
if (activeMenu.value === "HOME") return [];
|
||||||
|
|
||||||
|
// 활성 메뉴의 코드 찾기 (PG01, PG02 등)
|
||||||
|
const activeMenuCode = activeMenu.value;
|
||||||
|
|
||||||
|
// 해당 페이지그룹의 하위 페이지들 필터링 (menu_yn이 "Y"인 것만)
|
||||||
|
return permissionStore.permissions.resources.pages
|
||||||
|
.filter(page => page.parentCode === activeMenuCode)
|
||||||
|
.filter(page => page.menuYn === "Y") // 메뉴에 표시할 페이지만
|
||||||
|
.filter(page => permissionStore.hasPagePermission(page.path || ""))
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map(page => ({
|
||||||
|
key: page.code,
|
||||||
|
label: page.name,
|
||||||
|
to: page.path || "",
|
||||||
|
componentName: page.name,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleMenuChange(_menuCode: string) {
|
||||||
|
if (activeMenu.value === "HOME") {
|
||||||
|
showSubmenuBar.value = false;
|
||||||
|
await navigateTo("/");
|
||||||
|
} else {
|
||||||
|
showSubmenuBar.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSubMenuClick(sub: {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
componentName: string;
|
||||||
|
}) {
|
||||||
|
tabsStore.updateActiveTab(sub);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.layout {
|
.layout {
|
||||||
@@ -171,62 +90,4 @@ function addNewTab() {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
border-top: 1px solid #e9ecef;
|
border-top: 1px solid #e9ecef;
|
||||||
}
|
}
|
||||||
.submenu-bar {
|
|
||||||
background: #f4f6fa;
|
|
||||||
border-bottom: 1px solid #e0e7ef;
|
|
||||||
padding: 0.5rem 2rem;
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
position: absolute;
|
|
||||||
top: 80px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.submenu-btn {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #222;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1.2rem;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition:
|
|
||||||
background 0.15s,
|
|
||||||
color 0.15s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.submenu-btn.active {
|
|
||||||
background: none;
|
|
||||||
color: #1976d2;
|
|
||||||
}
|
|
||||||
.submenu-btn:hover {
|
|
||||||
background: #e6f0fa;
|
|
||||||
color: #1976d2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 탭바 스타일 */
|
|
||||||
.tab-bar {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 0.4rem 0.8rem;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
}
|
|
||||||
.tab-item {
|
|
||||||
padding: 0.3rem 0.8rem;
|
|
||||||
background: #f2f2f2;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.tab-item.active {
|
|
||||||
background: #1976d2;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.close-btn {
|
|
||||||
margin-left: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
30
middleware/auth.global.ts
Normal file
30
middleware/auth.global.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(async (to, _from) => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { hasPagePermission } = usePermission();
|
||||||
|
|
||||||
|
// 공개 라우트 목록 (로그인 없이 접근 가능)
|
||||||
|
const publicRoutes = ["/login", "/register", "/auth-error"];
|
||||||
|
|
||||||
|
// 공개 라우트인지 확인
|
||||||
|
const isPublicRoute = publicRoutes.some(route => to.path === route);
|
||||||
|
|
||||||
|
// 공개 라우트가 아닌 경우 로그인 체크
|
||||||
|
if (!isPublicRoute && !userStore.isLoggedIn) {
|
||||||
|
return navigateTo("/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그인된 사용자의 경우 권한 체크
|
||||||
|
if (userStore.isLoggedIn) {
|
||||||
|
// 홈화면 경로는 항상 허용
|
||||||
|
if (to.path === "/" || to.path === "/1/") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 권한 체크
|
||||||
|
if (!hasPagePermission(to.path)) {
|
||||||
|
console.log(`페이지 권한이 없습니다.: ${to.path}`);
|
||||||
|
alert(`페이지 권한이 없습니다.`);
|
||||||
|
return navigateTo("/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export default defineNuxtRouteMiddleware((to, _from) => {
|
|
||||||
// 클라이언트 사이드에서만 실행
|
|
||||||
if (import.meta.client) {
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
// 보호된 라우트 목록(메뉴 확정되면 수정)
|
|
||||||
const protectedRoutes = ["/admin", "/profile", "/dashboard"];
|
|
||||||
|
|
||||||
// 현재 라우트가 보호된 라우트인지 확인
|
|
||||||
const isProtectedRoute = protectedRoutes.some((route) =>
|
|
||||||
to.path.startsWith(route)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 관리자 전용 라우트 확인
|
|
||||||
const isAdminRoute = to.path.startsWith("/admin");
|
|
||||||
|
|
||||||
if (isProtectedRoute && !userStore.isLoggedIn) {
|
|
||||||
// 인증되지 않은 사용자를 로그인 페이지로 리다이렉트
|
|
||||||
return navigateTo("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdminRoute && !userStore.isAdmin) {
|
|
||||||
// 관리자가 아닌 사용자를 홈 페이지로 리다이렉트
|
|
||||||
return navigateTo("/");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: "2025-05-15",
|
ssr: false,
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: [
|
modules: [
|
||||||
"@nuxt/eslint",
|
"@nuxt/eslint",
|
||||||
@@ -10,16 +10,8 @@ export default defineNuxtConfig({
|
|||||||
"pinia-plugin-persistedstate/nuxt",
|
"pinia-plugin-persistedstate/nuxt",
|
||||||
"@nuxtjs/tailwindcss",
|
"@nuxtjs/tailwindcss",
|
||||||
],
|
],
|
||||||
app: {
|
piniaPluginPersistedstate: {
|
||||||
head: {
|
storage: "localStorage",
|
||||||
link: [
|
|
||||||
{
|
|
||||||
rel: "stylesheet",
|
|
||||||
href: "https://fonts.googleapis.com/icon?family=Material+Icons",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
//script: [{ src: "/dist/igv.js", defer: true }],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
@@ -44,8 +36,10 @@ export default defineNuxtConfig({
|
|||||||
shim: false,
|
shim: false,
|
||||||
strict: true,
|
strict: true,
|
||||||
},
|
},
|
||||||
plugins: ["~/plugins/vue3-tui-grid.client.ts"],
|
plugins: ["~/plugins/vue3-tui-grid.ts"],
|
||||||
components: [
|
components: [
|
||||||
{ path: "~/components", pathPrefix: false }, // 경로 접두사 제거
|
{ path: "~/components/base", pathPrefix: false }, // @base/ 접두사 제거
|
||||||
|
{ path: "~/components/layout", pathPrefix: false }, // @layout/ 접두사 제거
|
||||||
|
{ path: "~/components/domain", pathPrefix: true }, // @domain/ 접두사 유지
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
6044
package-lock.json
generated
6044
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,6 @@
|
|||||||
"@nuxt/eslint": "^1.4.1",
|
"@nuxt/eslint": "^1.4.1",
|
||||||
"@nuxt/icon": "^1.14.0",
|
"@nuxt/icon": "^1.14.0",
|
||||||
"@nuxt/image": "^1.10.0",
|
"@nuxt/image": "^1.10.0",
|
||||||
"@nuxtjs/tailwindcss": "^7.0.0-beta.0",
|
|
||||||
"@pinia/nuxt": "^0.11.2",
|
"@pinia/nuxt": "^0.11.2",
|
||||||
"ag-grid-community": "^34.0.0",
|
"ag-grid-community": "^34.0.0",
|
||||||
"ag-grid-vue3": "^34.0.0",
|
"ag-grid-vue3": "^34.0.0",
|
||||||
@@ -25,6 +24,8 @@
|
|||||||
"cytoscape-overlays": "^2.0.0",
|
"cytoscape-overlays": "^2.0.0",
|
||||||
"echarts": "^5.6.0",
|
"echarts": "^5.6.0",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
|
"ipx": "^3.1.1",
|
||||||
|
"molstar": "^5.1.2",
|
||||||
"nuxt": "^3.17.5",
|
"nuxt": "^3.17.5",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.5.0",
|
"pinia-plugin-persistedstate": "^4.5.0",
|
||||||
@@ -35,11 +36,12 @@
|
|||||||
"vue3-tui-grid": "^0.1.51"
|
"vue3-tui-grid": "^0.1.51"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "^6.12.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"tailwindcss": "^4.1.11"
|
"tailwindcss": "^3.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,26 @@
|
|||||||
<button @click="onAddClick">추가</button>
|
<button @click="onAddClick">추가</button>
|
||||||
<button @click="onUpdateClick">저장</button>
|
<button @click="onUpdateClick">저장</button>
|
||||||
</template>
|
</template>
|
||||||
<input type="text" >
|
<input type="text" />
|
||||||
<ToastGrid
|
<ToastGrid ref="grid1Ref" :data="data" :columns="colDefs" />
|
||||||
ref="grid1Ref"
|
|
||||||
:data="data"
|
|
||||||
:columns="colDefs"
|
|
||||||
/>
|
|
||||||
</ContentsWrapper>
|
</ContentsWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {colDefs} from '../../../composables/grids/resourceGrid'
|
import { colDefs } from "../../../constants/resourceGrid";
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
title: '리소스 관리'
|
title: "리소스 관리",
|
||||||
})
|
});
|
||||||
|
|
||||||
const data = [{}]
|
const data = [{}];
|
||||||
|
|
||||||
const grid1Ref = ref();
|
const grid1Ref = ref();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick() // DOM 및 컴포넌트 렌더링 완료 대기
|
await nextTick(); // DOM 및 컴포넌트 렌더링 완료 대기
|
||||||
grid1Ref.value?.api()?.setBodyHeight('700')
|
grid1Ref.value?.api()?.setBodyHeight("700");
|
||||||
})
|
});
|
||||||
|
|
||||||
function onAddClick() {
|
function onAddClick() {
|
||||||
grid1Ref.value?.api()?.appendRow({});
|
grid1Ref.value?.api()?.appendRow({});
|
||||||
@@ -38,4 +34,3 @@ function onUpdateClick() {
|
|||||||
console.log(grid1Ref.value?.api()?.getModifiedRows());
|
console.log(grid1Ref.value?.api()?.getModifiedRows());
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="max-w-4xl mx-auto">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||||
Integrated Bio Foundry Platform
|
Integrated Bio Foundry Platform(pages/[tabId]/index.vue)
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl text-gray-600">
|
<p class="text-xl text-gray-600">
|
||||||
통합 바이오 파운드리 플랫폼에 오신 것을 환영합니다
|
통합 바이오 파운드리 플랫폼에 오신 것을 환영합니다
|
||||||
@@ -23,10 +23,6 @@
|
|||||||
}}</span
|
}}</span
|
||||||
>님!
|
>님!
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ userStore.isAdmin ? "관리자" : "사용자" }} 권한으로
|
|
||||||
로그인되었습니다.
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-600">
|
<p class="text-sm text-gray-600">
|
||||||
<button
|
<button
|
||||||
class="mr-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
|
class="mr-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
|
||||||
@@ -113,9 +109,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from "~/stores/user";
|
|
||||||
|
|
||||||
// 페이지 메타데이터 설정
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
title: "Home",
|
title: "Home",
|
||||||
description: "Welcome to our Nuxt.js application",
|
description: "Welcome to our Nuxt.js application",
|
||||||
|
|||||||
497
pages/[tabId]/test/common-test.vue
Normal file
497
pages/[tabId]/test/common-test.vue
Normal file
@@ -0,0 +1,497 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4"
|
||||||
|
>
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<!-- 페이지 헤더 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">공용 기능 테스트</h1>
|
||||||
|
<p class="text-xl text-gray-600">
|
||||||
|
API 및 공용 기능들의 동작을 테스트할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API 테스트 섹션 -->
|
||||||
|
<div class="mb-12">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 mb-2">API 테스트</h2>
|
||||||
|
<p class="text-lg text-gray-600">
|
||||||
|
useApi 함수의 다양한 기능을 테스트합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
<!-- 좌측: 자동 에러 처리 테스트 -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- 자동 에러 처리 테스트 -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
자동 에러 처리 테스트
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
useApi 함수의 자동 에러 처리 기능을 테스트합니다. 에러가
|
||||||
|
발생하면 자동으로 alert가 표시됩니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="apiTest"
|
||||||
|
>
|
||||||
|
{{ isLoading ? "테스트 중..." : "자동 에러 처리 테스트" }}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="autoErrorResult"
|
||||||
|
class="mt-3 p-3 bg-gray-50 rounded border"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2">결과:</h4>
|
||||||
|
<pre class="text-sm text-gray-700 whitespace-pre-wrap">{{
|
||||||
|
autoErrorResult
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 예제 소스 -->
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 rounded border">
|
||||||
|
<h5 class="font-medium text-blue-900 mb-2">예제 소스:</h5>
|
||||||
|
<pre class="text-xs text-blue-800 whitespace-pre-wrap">
|
||||||
|
{`// 자동 에러 처리 예제
|
||||||
|
const apiTest = async () => {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/admin/common-codes/USER_STATUS_ACTIVE222"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
console.log("response:", response);
|
||||||
|
}
|
||||||
|
};`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 직접 에러 처리 테스트 -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
직접 에러 처리 테스트
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
useApi 함수의 직접 에러 처리 기능을 테스트합니다. 에러 타입에
|
||||||
|
따른 세밀한 처리를 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoadingCustom"
|
||||||
|
@click="apiTestWithCustomError"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isLoadingCustom ? "테스트 중..." : "직접 에러 처리 테스트"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-if="customErrorResult"
|
||||||
|
class="mt-3 p-3 bg-gray-50 rounded border"
|
||||||
|
>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2">결과:</h4>
|
||||||
|
<pre class="text-sm text-gray-700 whitespace-pre-wrap">{{
|
||||||
|
customErrorResult
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 예제 소스 -->
|
||||||
|
<div class="mt-4 p-3 bg-green-50 rounded border">
|
||||||
|
<h5 class="font-medium text-green-900 mb-2">예제 소스:</h5>
|
||||||
|
<pre class="text-xs text-green-800 whitespace-pre-wrap">
|
||||||
|
{`// 직접 에러 처리 예제
|
||||||
|
const apiTestWithCustomError = async () => {
|
||||||
|
try {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/admin/common-codes/USER_STATUS_ACTIVE222",
|
||||||
|
{
|
||||||
|
handleError: false, // 에러를 직접 처리
|
||||||
|
showAlert: false, // alert 표시 안함
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
console.log("response:", response);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 에러 타입에 따른 세밀한 처리
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
alert("요청한 코드를 찾을 수 없습니다.");
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
alert("접근 권한이 없습니다.");
|
||||||
|
} else if (error.response?.status >= 500) {
|
||||||
|
alert("서버 오류가 발생했습니다.");
|
||||||
|
} else {
|
||||||
|
alert("알 수 없는 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 우측: 추가 API 테스트 -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-purple-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
추가 API 테스트
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
다양한 API 엔드포인트를 테스트할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-purple-500 hover:bg-purple-600 disabled:bg-gray-400 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoadingValid"
|
||||||
|
@click="testValidEndpoint"
|
||||||
|
>
|
||||||
|
{{ isLoadingValid ? "테스트 중..." : "유효한 엔드포인트" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full bg-red-500 hover:bg-red-600 disabled:bg-gray-400 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoadingNetwork"
|
||||||
|
@click="testNetworkError"
|
||||||
|
>
|
||||||
|
{{ isLoadingNetwork ? "테스트 중..." : "네트워크 에러" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full bg-gray-500 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
||||||
|
@click="clearResults"
|
||||||
|
>
|
||||||
|
결과 초기화
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="additionalResults.length > 0" class="mt-4 space-y-3">
|
||||||
|
<h4 class="font-medium text-gray-900">추가 테스트 결과:</h4>
|
||||||
|
<div
|
||||||
|
v-for="(result, index) in additionalResults"
|
||||||
|
:key="index"
|
||||||
|
class="p-3 bg-gray-50 rounded border"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<span class="font-medium text-gray-900 text-sm">{{
|
||||||
|
result.title
|
||||||
|
}}</span>
|
||||||
|
<span class="text-xs text-gray-500">{{
|
||||||
|
result.timestamp
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="text-xs text-gray-700 whitespace-pre-wrap">{{
|
||||||
|
result.data
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 예제 소스 -->
|
||||||
|
<div class="mt-4 p-3 bg-purple-50 rounded border">
|
||||||
|
<h5 class="font-medium text-purple-900 mb-2">예제 소스:</h5>
|
||||||
|
<pre class="text-xs text-purple-800 whitespace-pre-wrap">
|
||||||
|
{`// 추가 API 테스트 예제
|
||||||
|
const testValidEndpoint = async () => {
|
||||||
|
try {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/admin/common-codes/USER_STATUS_ACTIVE",
|
||||||
|
{
|
||||||
|
handleError: false,
|
||||||
|
showAlert: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("성공:", response);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log("에러:", error.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const testNetworkError = async () => {
|
||||||
|
try {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/non-existent-endpoint",
|
||||||
|
{
|
||||||
|
handleError: false,
|
||||||
|
showAlert: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log("네트워크 에러:", error.message);
|
||||||
|
}
|
||||||
|
};`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 권한 테스트 섹션 -->
|
||||||
|
<div class="mb-12">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
권한 시스템 테스트
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600">
|
||||||
|
API 권한, 메뉴 권한, 컴포넌트 권한의 동작을 테스트합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="w-16 h-16 bg-indigo-500 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-2xl">🔐</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-2xl font-semibold text-gray-900 mb-4">
|
||||||
|
권한 시스템 테스트
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
로그인 후 권한 데이터가 자동으로 로드되며, API 권한, 메뉴 권한,
|
||||||
|
컴포넌트 권한의 동작을 확인할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<NuxtLink
|
||||||
|
to="/admin/permission-test"
|
||||||
|
class="inline-flex items-center bg-indigo-500 hover:bg-indigo-600 text-white font-medium py-3 px-6 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span class="mr-2">권한 테스트 시작</span>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용법 가이드 -->
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-yellow-800 mb-3">
|
||||||
|
사용법 가이드
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-yellow-800 mb-2">API 테스트</h4>
|
||||||
|
<div class="text-yellow-700 space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>자동 에러 처리:</strong> useApi 함수가 에러를 자동으로
|
||||||
|
처리하고 사용자에게 알림을 표시합니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>직접 에러 처리:</strong> handleError: false 옵션을
|
||||||
|
사용하여 에러를 직접 처리할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>에러 타입:</strong> 404 (Not Found), 403 (Forbidden),
|
||||||
|
500+ (Server Error) 등 다양한 에러 상황을 테스트할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-semibold text-yellow-800 mb-2">
|
||||||
|
권한 시스템 테스트
|
||||||
|
</h4>
|
||||||
|
<div class="text-yellow-700 space-y-2 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>API 권한:</strong> 페이지 라우터 접근 권한을 제어합니다.
|
||||||
|
권한이 없으면 홈으로 리다이렉트됩니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>메뉴 권한:</strong> 메뉴 표시 여부를 제어합니다. 권한이
|
||||||
|
없으면 메뉴가 숨겨집니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>컴포넌트 권한:</strong> 버튼 등 UI 컴포넌트의 표시
|
||||||
|
여부를 제어합니다. 권한이 없으면 컴포넌트가 렌더링되지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// import { useUserStore } from "~/stores/user"; // 현재 사용하지 않음
|
||||||
|
|
||||||
|
// 페이지 메타데이터 설정
|
||||||
|
definePageMeta({
|
||||||
|
title: "공용 기능 테스트",
|
||||||
|
description: "API 및 공용 기능들의 동작을 테스트하는 페이지",
|
||||||
|
});
|
||||||
|
|
||||||
|
// const userStore = useUserStore(); // 현재 사용하지 않음
|
||||||
|
|
||||||
|
// 반응형 데이터
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isLoadingCustom = ref(false);
|
||||||
|
const isLoadingValid = ref(false);
|
||||||
|
const isLoadingNetwork = ref(false);
|
||||||
|
const autoErrorResult = ref("");
|
||||||
|
const customErrorResult = ref("");
|
||||||
|
const additionalResults = ref<
|
||||||
|
Array<{ title: string; data: string; timestamp: string }>
|
||||||
|
>([]);
|
||||||
|
|
||||||
|
// 테스트 다운로드 함수 (자동 에러 처리)
|
||||||
|
const apiTest = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
autoErrorResult.value = "";
|
||||||
|
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/admin/common-codes/USER_STATUS_ACTIVE222"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
autoErrorResult.value = `성공: ${JSON.stringify(response, null, 2)}`;
|
||||||
|
} else {
|
||||||
|
autoErrorResult.value = "응답이 없습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 직접 에러 처리하는 함수 예시
|
||||||
|
const apiTestWithCustomError = async () => {
|
||||||
|
isLoadingCustom.value = true;
|
||||||
|
customErrorResult.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/admin/common-codes/USER_STATUS_ACTIVE222",
|
||||||
|
{
|
||||||
|
handleError: false, // 에러를 직접 처리하겠다는 의미
|
||||||
|
showAlert: false, // alert는 표시하지 않음
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
customErrorResult.value = `성공: ${JSON.stringify(response, null, 2)}`;
|
||||||
|
} else {
|
||||||
|
customErrorResult.value = "응답이 없습니다.";
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// 에러 타입에 처리
|
||||||
|
let errorMessage = "";
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
errorMessage = "[errorCustomHandler]요청한 코드를 찾을 수 없습니다.";
|
||||||
|
} else if (error.response?.status === 403) {
|
||||||
|
errorMessage = "[errorCustomHandler]접근 권한이 없습니다.";
|
||||||
|
} else if (error.response?.status >= 500) {
|
||||||
|
errorMessage =
|
||||||
|
"[errorCustomHandler]서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.";
|
||||||
|
} else {
|
||||||
|
errorMessage = "[errorCustomHandler]알 수 없는 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
customErrorResult.value = `에러 처리됨: ${errorMessage}\n상세 정보: ${JSON.stringify(error.response?.data || error.message, null, 2)}`;
|
||||||
|
} finally {
|
||||||
|
isLoadingCustom.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 유효한 엔드포인트 테스트
|
||||||
|
const testValidEndpoint = async () => {
|
||||||
|
isLoadingValid.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/admin/common-codes/USER_STATUS_ACTIVE",
|
||||||
|
{
|
||||||
|
handleError: false,
|
||||||
|
showAlert: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
additionalResults.value.unshift({
|
||||||
|
title: "유효한 엔드포인트 테스트",
|
||||||
|
data: response ? JSON.stringify(response, null, 2) : "응답 없음",
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
additionalResults.value.unshift({
|
||||||
|
title: "유효한 엔드포인트 테스트 (에러)",
|
||||||
|
data: `에러: ${error.message || "알 수 없는 에러"}`,
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isLoadingValid.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 네트워크 에러 테스트
|
||||||
|
const testNetworkError = async () => {
|
||||||
|
isLoadingNetwork.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await useApi<ApiResponse<object>>(
|
||||||
|
"/non-existent-endpoint",
|
||||||
|
{
|
||||||
|
handleError: false,
|
||||||
|
showAlert: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
additionalResults.value.unshift({
|
||||||
|
title: "네트워크 에러 테스트",
|
||||||
|
data: response ? JSON.stringify(response, null, 2) : "응답 없음",
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
additionalResults.value.unshift({
|
||||||
|
title: "네트워크 에러 테스트 (에러)",
|
||||||
|
data: `에러: ${error.message || "알 수 없는 에러"}\n상태 코드: ${error.response?.status || "N/A"}`,
|
||||||
|
timestamp: new Date().toLocaleTimeString(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isLoadingNetwork.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 결과 초기화
|
||||||
|
const clearResults = () => {
|
||||||
|
autoErrorResult.value = "";
|
||||||
|
customErrorResult.value = "";
|
||||||
|
additionalResults.value = [];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 추가 스타일이 필요한 경우 여기에 작성 */
|
||||||
|
</style>
|
||||||
@@ -28,5 +28,5 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import BatchTabs from "~/components/BatchTabs.vue";
|
import BatchTabs from "~/components/domain/culture-graph/BatchTabs.vue";
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed, onUnmounted } from "vue";
|
import { ref, onMounted, watch, computed, onUnmounted } from "vue";
|
||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import CustomContextMenu from "~/components/CustomContextMenu.vue";
|
import CustomContextMenu from "~/components/domain/culture-graph/CustomContextMenu.vue";
|
||||||
|
|
||||||
// 타입 인터페이스 정의
|
// 타입 인터페이스 정의
|
||||||
interface YAxis {
|
interface YAxis {
|
||||||
|
|||||||
205
pages/[tabId]/test/loading-test.vue
Normal file
205
pages/[tabId]/test/loading-test.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4"
|
||||||
|
>
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<!-- 페이지 헤더 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
로딩 카운터 테스트
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600">
|
||||||
|
전역 로딩 시스템의 동작을 테스트할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 현재 상태 표시 -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg mb-8">
|
||||||
|
<h2 class="text-2xl font-semibold mb-4">현재 로딩 상태</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>로딩 중:</strong>
|
||||||
|
<span :class="isLoading ? 'text-green-600' : 'text-red-600'">
|
||||||
|
{{ isLoading ? "예" : "아니오" }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p><strong>로딩 카운트:</strong> {{ loadingCount }}</p>
|
||||||
|
<p><strong>현재 메시지:</strong> {{ currentMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p><strong>진행 중인 작업:</strong></p>
|
||||||
|
<ul
|
||||||
|
v-if="loadingMessages.length > 0"
|
||||||
|
class="list-disc list-inside space-y-1"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(message, index) in loadingMessages"
|
||||||
|
:key="index"
|
||||||
|
class="text-sm"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p v-else class="text-gray-500 text-sm">없음</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 테스트 버튼들 -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- 단일 로딩 테스트 -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">단일 로딩 테스트</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="testSingleLoading"
|
||||||
|
>
|
||||||
|
단일 작업 로딩 (2초)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 다중 로딩 테스트 -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">다중 로딩 테스트</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-purple-500 hover:bg-purple-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="testMultipleLoading"
|
||||||
|
>
|
||||||
|
동시 다중 작업 (1초, 2초, 3초)
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full bg-orange-500 hover:bg-orange-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="testSequentialLoading"
|
||||||
|
>
|
||||||
|
순차 다중 작업 (각 1초씩)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 권한 로딩 테스트 -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">권한 로딩 테스트</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-indigo-500 hover:bg-indigo-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="testPermissionLoading"
|
||||||
|
>
|
||||||
|
권한 데이터 로드
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full bg-pink-500 hover:bg-pink-600 disabled:bg-gray-400 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="testPermissionWithOtherLoading"
|
||||||
|
>
|
||||||
|
권한 + 다른 작업 동시 실행
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 제어 버튼들 -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg">
|
||||||
|
<h3 class="text-xl font-semibold mb-4">로딩 제어</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<button
|
||||||
|
class="w-full bg-red-500 hover:bg-red-600 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
@click="clearAllLoading"
|
||||||
|
>
|
||||||
|
모든 로딩 강제 종료
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="w-full bg-gray-500 hover:bg-gray-600 text-white font-medium py-3 px-4 rounded-lg transition-colors"
|
||||||
|
@click="resetLoading"
|
||||||
|
>
|
||||||
|
로딩 상태 리셋
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
loadingCount,
|
||||||
|
currentMessage,
|
||||||
|
loadingMessages,
|
||||||
|
withLoading,
|
||||||
|
clearAll,
|
||||||
|
reset,
|
||||||
|
} = useLoading();
|
||||||
|
|
||||||
|
// 단일 로딩 테스트
|
||||||
|
const testSingleLoading = async () => {
|
||||||
|
await withLoading(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
}, "단일 작업 실행 중...");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 동시 다중 작업 로딩 테스트
|
||||||
|
const testMultipleLoading = async () => {
|
||||||
|
const tasks = [
|
||||||
|
{ name: "작업 1", duration: 1000 },
|
||||||
|
{ name: "작업 2", duration: 2000 },
|
||||||
|
{ name: "작업 3", duration: 3000 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 모든 작업을 동시에 시작
|
||||||
|
const promises = tasks.map(task =>
|
||||||
|
withLoading(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, task.duration));
|
||||||
|
}, `${task.name} 실행 중...`)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 순차 다중 로딩 테스트
|
||||||
|
const testSequentialLoading = async () => {
|
||||||
|
const tasks = ["작업 A", "작업 B", "작업 C"];
|
||||||
|
|
||||||
|
for (const task of tasks) {
|
||||||
|
await withLoading(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}, `${task} 실행 중...`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 로딩 테스트
|
||||||
|
const testPermissionLoading = async () => {
|
||||||
|
const permissionsStore = usePermissionsStore();
|
||||||
|
await permissionsStore.fetchPermissions();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 + 다른 작업 동시 실행
|
||||||
|
const testPermissionWithOtherLoading = async () => {
|
||||||
|
// 권한 로딩과 다른 작업을 동시에 실행
|
||||||
|
await Promise.all([
|
||||||
|
usePermissionsStore().fetchPermissions(),
|
||||||
|
withLoading(async () => {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||||
|
}, "다른 작업 실행 중..."),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 제어 함수들
|
||||||
|
const clearAllLoading = () => {
|
||||||
|
clearAll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetLoading = () => {
|
||||||
|
reset();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
87
pages/[tabId]/test/molstar.vue
Normal file
87
pages/[tabId]/test/molstar.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<template>
|
||||||
|
<div class="molstar-page">
|
||||||
|
<PageDescription>
|
||||||
|
<h1>Mol* 뷰어 테스트</h1>
|
||||||
|
<div class="box">
|
||||||
|
<p>간단한 PDB 구조(1CRN)를 불러와 Mol*로 렌더링합니다.</p>
|
||||||
|
</div>
|
||||||
|
</PageDescription>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="viewer-wrap"
|
||||||
|
:style="{ height: containerHeight }"
|
||||||
|
>
|
||||||
|
<div ref="viewerRef" class="molstar-viewer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onBeforeUnmount, ref } from "vue";
|
||||||
|
import { Viewer } from "molstar/lib/apps/viewer/app";
|
||||||
|
import "molstar/build/viewer/molstar.css";
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
title: "Mol* 테스트",
|
||||||
|
description: "Mol* 뷰어 테스트 페이지",
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null);
|
||||||
|
const containerHeight = ref<string>("80vh");
|
||||||
|
let viewer: Viewer | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!viewerRef.value) return;
|
||||||
|
|
||||||
|
viewer = await Viewer.create(viewerRef.value, {
|
||||||
|
layoutIsExpanded: true,
|
||||||
|
layoutShowControls: true,
|
||||||
|
viewportShowExpand: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 예시 구조: 1CRN
|
||||||
|
await viewer.loadStructureFromUrl(
|
||||||
|
"https://files.rcsb.org/download/1CRN.pdb",
|
||||||
|
"pdb"
|
||||||
|
);
|
||||||
|
|
||||||
|
const resizeToAvailableHeight = () => {
|
||||||
|
if (!containerRef.value) return;
|
||||||
|
const rect = containerRef.value.getBoundingClientRect();
|
||||||
|
const bottomPadding = 16; // 여백
|
||||||
|
const available = window.innerHeight - rect.top - bottomPadding;
|
||||||
|
containerHeight.value = `${Math.max(300, available)}px`;
|
||||||
|
viewer?.handleResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
resizeToAvailableHeight();
|
||||||
|
window.addEventListener("resize", resizeToAvailableHeight);
|
||||||
|
// 정리 함수 저장
|
||||||
|
(containerRef as any)._off = () =>
|
||||||
|
window.removeEventListener("resize", resizeToAvailableHeight);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
viewer?.dispose?.();
|
||||||
|
viewer = null;
|
||||||
|
(containerRef as any)?._off?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.viewer-wrap {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden; /* 내부 Mol*에서 자체 스크롤 관리 */
|
||||||
|
}
|
||||||
|
.molstar-viewer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msp-plugin .msp-layout-expanded {
|
||||||
|
position: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
573
pages/[tabId]/test/permission-test.vue
Normal file
573
pages/[tabId]/test/permission-test.vue
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4"
|
||||||
|
>
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<!-- 페이지 헤더 -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
권한 시스템 테스트
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-600">
|
||||||
|
페이지 권한, 메뉴 권한, 컴포넌트 권한의 동작을 테스트할 수 있습니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 현재 상태 표시 -->
|
||||||
|
<div class="bg-gray-100 p-4 rounded-lg mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-2">현재 상태</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>로그인 상태:</strong>
|
||||||
|
<span
|
||||||
|
:class="userStore.isLoggedIn ? 'text-green-600' : 'text-red-600'"
|
||||||
|
>
|
||||||
|
{{ userStore.isLoggedIn ? "로그인됨" : "로그인 안됨" }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="userStore.user">
|
||||||
|
<strong>사용자:</strong> {{ userStore.user.name }} ({{
|
||||||
|
userStore.user.userId
|
||||||
|
}})
|
||||||
|
</p>
|
||||||
|
<p v-if="!userStore.isLoggedIn" class="text-orange-600 text-sm">
|
||||||
|
<strong>참고:</strong> 로그인이 필요합니다. 로그인 후 권한 데이터가
|
||||||
|
자동으로 로드됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 권한 테스트 섹션 -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||||
|
<!-- 좌측: 권한 상태 및 체크 -->
|
||||||
|
<div class="lg:col-span-2 space-y-6">
|
||||||
|
<!-- 현재 권한 상태 -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-blue-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">1</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
현재 권한 상태
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
현재 사용자의 권한 상태와 페이지 권한 체크를 확인합니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="p-3 bg-gray-50 rounded border">
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2">기본 정보:</h4>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>현재 경로:</strong> <code>{{ $route.path }}</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>로그인 상태:</strong>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
userStore.isLoggedIn ? 'text-green-600' : 'text-red-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ userStore.isLoggedIn ? "로그인됨" : "로그인 안됨" }}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="userStore.user">
|
||||||
|
<strong>사용자:</strong> {{ userStore.user.name }} ({{
|
||||||
|
userStore.user.userId
|
||||||
|
}})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-gray-50 rounded border">
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2">권한 체크 결과:</h4>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<p>
|
||||||
|
<strong>페이지 권한({{ $route.path }}):</strong>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
permission.hasPagePermission($route.path)
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
permission.hasPagePermission($route.path)
|
||||||
|
? "있음"
|
||||||
|
: "없음"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>테스트 페이지그룹 권한(PG01):</strong>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
permission.hasPageGroupPermission('PG01')
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
permission.hasPageGroupPermission("PG01")
|
||||||
|
? "있음"
|
||||||
|
: "없음"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 권한별 버튼 테스트 -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-green-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">2</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
컴포넌트 권한 테스트
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
각 컴포넌트 권한에 따라 버튼이 표시되거나 숨겨지는 것을
|
||||||
|
확인합니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010105"
|
||||||
|
@click="handleButtonClick('C010105')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010102"
|
||||||
|
@click="handleButtonClick('C010102')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010101"
|
||||||
|
@click="handleButtonClick('C010101')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010103"
|
||||||
|
@click="handleButtonClick('C010103')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010104"
|
||||||
|
@click="handleButtonClick('C010104')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010106"
|
||||||
|
@click="handleButtonClick('C010106')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010122"
|
||||||
|
@click="handleButtonClick('C010122')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 페이지그룹 권한 테스트 -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-orange-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">3</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
페이지그룹 권한 테스트
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
페이지그룹 권한에 따라 메뉴가 표시되거나 숨겨지는 것을 확인합니다.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
v-if="permission.hasPageGroupPermission('PG01')"
|
||||||
|
class="bg-blue-100 text-blue-800 px-3 py-1 rounded text-sm"
|
||||||
|
>
|
||||||
|
테스트 페이지그룹
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400 text-sm"
|
||||||
|
>테스트 페이지그룹 (권한 없음)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div
|
||||||
|
v-if="permission.hasPageGroupPermission('PG02')"
|
||||||
|
class="bg-green-100 text-green-800 px-3 py-1 rounded text-sm"
|
||||||
|
>
|
||||||
|
관리자 페이지그룹
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-gray-400 text-sm"
|
||||||
|
>관리자 페이지그룹 (권한 없음)</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 우측: 권한 체크 로직 테스트 -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-purple-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">4</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
권한 체크 로직
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
다양한 권한 체크 함수들의 동작을 테스트합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-3 bg-gray-50 rounded border">
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2 text-sm">
|
||||||
|
페이지 권한 체크:
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-600 mb-1">
|
||||||
|
<code>hasPagePermission('/admin/codes')</code>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
:class="
|
||||||
|
permission.hasPagePermission('/admin/codes')
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
permission.hasPagePermission("/admin/codes")
|
||||||
|
? "true"
|
||||||
|
: "false"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-gray-50 rounded border">
|
||||||
|
<h4 class="font-medium text-gray-900 mb-2 text-sm">
|
||||||
|
페이지그룹 권한 체크:
|
||||||
|
</h4>
|
||||||
|
<p class="text-xs text-gray-600 mb-1">
|
||||||
|
<code>hasPageGroupPermission('PG01')</code>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
:class="
|
||||||
|
permission.hasPageGroupPermission('PG01')
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
permission.hasPageGroupPermission("PG01") ? "true" : "false"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API 권한 테스트 -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<div class="flex items-center mb-4">
|
||||||
|
<div
|
||||||
|
class="w-10 h-10 bg-indigo-500 rounded-full flex items-center justify-center mr-3"
|
||||||
|
>
|
||||||
|
<span class="text-white font-bold text-sm">5</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
페이지 권한 테스트
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
다양한 페이지 경로에 대한 권한을 테스트합니다. 권한이 없으면 홈으로
|
||||||
|
리다이렉트됩니다.
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="pagePath in [
|
||||||
|
'/',
|
||||||
|
'/login',
|
||||||
|
'/register',
|
||||||
|
'/about',
|
||||||
|
'/sampleList',
|
||||||
|
'/admin/codes',
|
||||||
|
'/admin/logs',
|
||||||
|
'/admin/programs',
|
||||||
|
'/admin/resource',
|
||||||
|
'/test/permission-test',
|
||||||
|
'/test/culture-graph',
|
||||||
|
'/test/pathway',
|
||||||
|
'/test/test01',
|
||||||
|
'/test/common-test',
|
||||||
|
'/popup/addSamplePopup',
|
||||||
|
'/nonexistent',
|
||||||
|
]"
|
||||||
|
:key="pagePath"
|
||||||
|
class="p-3 rounded border text-center"
|
||||||
|
:class="
|
||||||
|
permission.hasPagePermission(pagePath)
|
||||||
|
? 'bg-green-50 border-green-200'
|
||||||
|
: 'bg-red-50 border-red-200'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium text-gray-900 mb-1">
|
||||||
|
{{ pagePath }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="text-xs"
|
||||||
|
:class="
|
||||||
|
permission.hasPagePermission(pagePath)
|
||||||
|
? 'text-green-600'
|
||||||
|
: 'text-red-600'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
permission.hasPagePermission(pagePath)
|
||||||
|
? "✓ 접근 가능"
|
||||||
|
: "✗ 접근 불가"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 rounded border">
|
||||||
|
<p class="text-sm text-blue-800">
|
||||||
|
<strong>참고:</strong> 실제로는 권한이 없는 경로에 접근하면 자동으로
|
||||||
|
홈으로 리다이렉트됩니다. 이 페이지는
|
||||||
|
<code>/test/permission-test</code> 경로로, 페이지 권한이 있어야
|
||||||
|
접근할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 권한 데이터 표시 -->
|
||||||
|
<div
|
||||||
|
v-if="userStore.isLoggedIn"
|
||||||
|
class="bg-white rounded-lg shadow-md p-6 mb-8"
|
||||||
|
>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">
|
||||||
|
현재 권한 데이터
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- 페이지 권한 -->
|
||||||
|
<div class="bg-blue-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-lg font-semibold mb-3 text-blue-800">
|
||||||
|
페이지 권한
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="page in pages"
|
||||||
|
:key="page.oid"
|
||||||
|
class="text-sm bg-white p-2 rounded border"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ page.name }} ({{ page.code }})</div>
|
||||||
|
<div class="text-gray-600">{{ page.path }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 페이지그룹 권한 -->
|
||||||
|
<div class="bg-green-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-lg font-semibold mb-3 text-green-800">
|
||||||
|
페이지그룹 권한
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="pageGroup in pageGroups"
|
||||||
|
:key="pageGroup.oid"
|
||||||
|
class="text-sm bg-white p-2 rounded border"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ pageGroup.name }} ({{ pageGroup.code }})
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600">{{ pageGroup.description }}</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 컴포넌트 권한 -->
|
||||||
|
<div class="bg-purple-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-lg font-semibold mb-3 text-purple-800">
|
||||||
|
컴포넌트 권한
|
||||||
|
</h4>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="component in components"
|
||||||
|
:key="component.oid"
|
||||||
|
class="text-sm bg-white p-2 rounded border"
|
||||||
|
>
|
||||||
|
<div class="font-medium">
|
||||||
|
{{ component.name }} ({{ component.code }})
|
||||||
|
</div>
|
||||||
|
<div class="text-gray-600">
|
||||||
|
{{ component.componentType }} - {{ component.description }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PermissionButton 컴포넌트 사용 예시 -->
|
||||||
|
<div class="bg-green-50 border border-green-200 rounded-lg p-6 mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-green-800 mb-4">
|
||||||
|
PermissionButton 컴포넌트 사용 예시
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-green-700 mb-2">기본 사용법</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010101"
|
||||||
|
@click="handleButtonClick('C010101')"
|
||||||
|
/>
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010102"
|
||||||
|
@click="handleButtonClick('C010102')"
|
||||||
|
/>
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010103"
|
||||||
|
@click="handleButtonClick('C010103')"
|
||||||
|
/>
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010104"
|
||||||
|
@click="handleButtonClick('C010104')"
|
||||||
|
/>
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010105"
|
||||||
|
@click="handleButtonClick('C010105')"
|
||||||
|
/>
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010106"
|
||||||
|
@click="handleButtonClick('C010106')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-green-600 mt-2">
|
||||||
|
버튼 텍스트는 권한 코드에서 자동으로 가져옵니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-green-700 mb-2">비활성화 상태</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C010104"
|
||||||
|
:disabled="true"
|
||||||
|
@click="handleButtonClick('C010104')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-green-700 mb-2">
|
||||||
|
권한이 없는 버튼 (표시되지 않음)
|
||||||
|
</h4>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<PermissionButton
|
||||||
|
button-code="C999999"
|
||||||
|
@click="handleButtonClick('C999999')"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-500 text-sm"
|
||||||
|
>↑ 이 버튼은 권한이 없어서 표시되지 않습니다</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 사용법 가이드 -->
|
||||||
|
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-yellow-800 mb-3">
|
||||||
|
권한 시스템 가이드
|
||||||
|
</h3>
|
||||||
|
<div class="text-yellow-700 space-y-2">
|
||||||
|
<p>
|
||||||
|
<strong>로그인 필요:</strong> 이 페이지를 사용하려면 먼저 로그인해야
|
||||||
|
합니다. 로그인 시 가데이터 권한이 자동으로 로드됩니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>페이지 권한:</strong> 페이지 라우터 접근 권한을 제어합니다.
|
||||||
|
권한이 없으면 홈으로 리다이렉트됩니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>페이지그룹 권한:</strong> 페이지그룹 표시 여부를 제어합니다.
|
||||||
|
권한이 없으면 페이지그룹이 숨겨집니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>컴포넌트 권한:</strong> 버튼 등 UI 컴포넌트의 표시 여부를
|
||||||
|
제어합니다. 권한이 없으면 컴포넌트가 렌더링되지 않습니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>PermissionButton 컴포넌트:</strong> 권한 체크와 클릭
|
||||||
|
이벤트를 자동으로 처리하는 재사용 가능한 버튼 컴포넌트입니다.
|
||||||
|
buttonCode prop으로 권한을 체크하고, 권한이 있을 때만 버튼이
|
||||||
|
표시됩니다.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>백엔드 연동:</strong> 나중에 백엔드 API가 준비되면
|
||||||
|
<code>stores/permissions.ts</code>의
|
||||||
|
<code>fetchPermissions</code> 함수를 수정하여 실제 API를 호출하도록
|
||||||
|
변경할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
title: "권한 시스템 테스트",
|
||||||
|
description:
|
||||||
|
"페이지 권한, 메뉴 권한, 컴포넌트 권한의 동작을 테스트하는 페이지",
|
||||||
|
});
|
||||||
|
|
||||||
|
const permission = usePermission();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
// 리소스 데이터를 computed로 선언
|
||||||
|
const pageGroups = computed(() => permission.getPageGroups());
|
||||||
|
const pages = computed(() => permission.getPages());
|
||||||
|
const components = computed(() => permission.getComponents());
|
||||||
|
|
||||||
|
// 이 페이지는 /test 경로이므로 페이지 권한이 필요합니다
|
||||||
|
// middleware/auth.ts에서 자동으로 권한을 체크합니다
|
||||||
|
// 로그인 시 권한 데이터가 자동으로 로드됩니다
|
||||||
|
|
||||||
|
// 버튼 클릭 핸들러
|
||||||
|
const handleButtonClick = (buttonCode: string) => {
|
||||||
|
console.log(`버튼 클릭됨: ${buttonCode}`);
|
||||||
|
alert(`${buttonCode} 버튼이 클릭되었습니다!`);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -1,17 +1,29 @@
|
|||||||
<script setup lang="ts">
|
<template>
|
||||||
import ToastGrid from '@/components/base/ToastGrid.vue';
|
<div>
|
||||||
|
<button @click="onClearClick">clear api</button>
|
||||||
|
<br />
|
||||||
|
<button @click="onUpdateClick">update list</button>
|
||||||
|
<ToastGrid
|
||||||
|
ref="grid1Ref"
|
||||||
|
:data="data"
|
||||||
|
:columns="columns"
|
||||||
|
:tree-column-options="treeColumnOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
id: 549731,
|
id: 549731,
|
||||||
name: 'Beautiful Lies',
|
name: "Beautiful Lies",
|
||||||
artist: 'Birdy',
|
artist: "Birdy",
|
||||||
release: '2016.03.26',
|
release: "2016.03.26",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'Pop',
|
genre: "Pop",
|
||||||
genreCode: '1',
|
genreCode: "1",
|
||||||
grade: '4',
|
grade: "4",
|
||||||
price: 10000,
|
price: 10000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -21,14 +33,14 @@ const data = [
|
|||||||
_children: [
|
_children: [
|
||||||
{
|
{
|
||||||
id: 491379,
|
id: 491379,
|
||||||
name: 'Chaos And The Calm',
|
name: "Chaos And The Calm",
|
||||||
artist: 'James Bay',
|
artist: "James Bay",
|
||||||
release: '2015.03.23',
|
release: "2015.03.23",
|
||||||
type: 'EP',
|
type: "EP",
|
||||||
typeCode: '2',
|
typeCode: "2",
|
||||||
genre: 'Pop,Rock',
|
genre: "Pop,Rock",
|
||||||
genreCode: '1,2',
|
genreCode: "1,2",
|
||||||
grade: '5',
|
grade: "5",
|
||||||
price: 12000,
|
price: 12000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -36,14 +48,14 @@ const data = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 498896,
|
id: 498896,
|
||||||
name: 'The Magic Whip',
|
name: "The Magic Whip",
|
||||||
artist: 'Blur',
|
artist: "Blur",
|
||||||
release: '2015.04.27',
|
release: "2015.04.27",
|
||||||
type: 'EP',
|
type: "EP",
|
||||||
typeCode: '2',
|
typeCode: "2",
|
||||||
genre: 'Rock',
|
genre: "Rock",
|
||||||
genreCode: '2',
|
genreCode: "2",
|
||||||
grade: '3',
|
grade: "3",
|
||||||
price: 15000,
|
price: 15000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -54,13 +66,13 @@ const data = [
|
|||||||
{
|
{
|
||||||
id: 450720,
|
id: 450720,
|
||||||
name: "I'm Not The Only One",
|
name: "I'm Not The Only One",
|
||||||
artist: 'Sam Smith',
|
artist: "Sam Smith",
|
||||||
release: '2014.09.15',
|
release: "2014.09.15",
|
||||||
type: 'Single',
|
type: "Single",
|
||||||
typeCode: '3',
|
typeCode: "3",
|
||||||
genre: 'Pop,R&B',
|
genre: "Pop,R&B",
|
||||||
genreCode: '1,3',
|
genreCode: "1,3",
|
||||||
grade: '4',
|
grade: "4",
|
||||||
price: 8000,
|
price: 8000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -70,14 +82,14 @@ const data = [
|
|||||||
_children: [
|
_children: [
|
||||||
{
|
{
|
||||||
id: 587871,
|
id: 587871,
|
||||||
name: 'This Is Acting',
|
name: "This Is Acting",
|
||||||
artist: 'Sia',
|
artist: "Sia",
|
||||||
release: '2016.10.22',
|
release: "2016.10.22",
|
||||||
type: 'EP',
|
type: "EP",
|
||||||
typeCode: '2',
|
typeCode: "2",
|
||||||
genre: 'Pop',
|
genre: "Pop",
|
||||||
genreCode: '1',
|
genreCode: "1",
|
||||||
grade: '3',
|
grade: "3",
|
||||||
price: 20000,
|
price: 20000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -87,14 +99,14 @@ const data = [
|
|||||||
_children: [
|
_children: [
|
||||||
{
|
{
|
||||||
id: 490500,
|
id: 490500,
|
||||||
name: 'Blue Skies',
|
name: "Blue Skies",
|
||||||
release: '2015.03.18',
|
release: "2015.03.18",
|
||||||
artist: 'Lenka',
|
artist: "Lenka",
|
||||||
type: 'Single',
|
type: "Single",
|
||||||
typeCode: '3',
|
typeCode: "3",
|
||||||
genre: 'Pop,Rock',
|
genre: "Pop,Rock",
|
||||||
genreCode: '1,2',
|
genreCode: "1,2",
|
||||||
grade: '5',
|
grade: "5",
|
||||||
price: 6000,
|
price: 6000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -102,27 +114,27 @@ const data = [
|
|||||||
{
|
{
|
||||||
id: 317659,
|
id: 317659,
|
||||||
name: "I Won't Give Up",
|
name: "I Won't Give Up",
|
||||||
artist: 'Jason Mraz',
|
artist: "Jason Mraz",
|
||||||
release: '2012.01.03',
|
release: "2012.01.03",
|
||||||
type: 'Single',
|
type: "Single",
|
||||||
typeCode: '3',
|
typeCode: "3",
|
||||||
genre: 'Pop',
|
genre: "Pop",
|
||||||
genreCode: '1',
|
genreCode: "1",
|
||||||
grade: '2',
|
grade: "2",
|
||||||
price: 7000,
|
price: 7000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 583551,
|
id: 583551,
|
||||||
name: 'Following My Intuition',
|
name: "Following My Intuition",
|
||||||
artist: 'Craig David',
|
artist: "Craig David",
|
||||||
release: '2016.10.01',
|
release: "2016.10.01",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'R&B,Electronic',
|
genre: "R&B,Electronic",
|
||||||
genreCode: '3,4',
|
genreCode: "3,4",
|
||||||
grade: '5',
|
grade: "5",
|
||||||
price: 15000,
|
price: 15000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -135,42 +147,42 @@ const data = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 436461,
|
id: 436461,
|
||||||
name: 'X',
|
name: "X",
|
||||||
artist: 'Ed Sheeran',
|
artist: "Ed Sheeran",
|
||||||
release: '2014.06.24',
|
release: "2014.06.24",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'Pop',
|
genre: "Pop",
|
||||||
genreCode: '1',
|
genreCode: "1",
|
||||||
grade: '5',
|
grade: "5",
|
||||||
price: 20000,
|
price: 20000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 295651,
|
id: 295651,
|
||||||
name: 'Moves Like Jagger',
|
name: "Moves Like Jagger",
|
||||||
release: '2011.08.08',
|
release: "2011.08.08",
|
||||||
artist: 'Maroon5',
|
artist: "Maroon5",
|
||||||
type: 'Single',
|
type: "Single",
|
||||||
typeCode: '3',
|
typeCode: "3",
|
||||||
genre: 'Pop,Rock',
|
genre: "Pop,Rock",
|
||||||
genreCode: '1,2',
|
genreCode: "1,2",
|
||||||
grade: '2',
|
grade: "2",
|
||||||
price: 7000,
|
price: 7000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 541713,
|
id: 541713,
|
||||||
name: 'A Head Full Of Dreams',
|
name: "A Head Full Of Dreams",
|
||||||
artist: 'Coldplay',
|
artist: "Coldplay",
|
||||||
release: '2015.12.04',
|
release: "2015.12.04",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'Rock',
|
genre: "Rock",
|
||||||
genreCode: '2',
|
genreCode: "2",
|
||||||
grade: '3',
|
grade: "3",
|
||||||
price: 25000,
|
price: 25000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -180,28 +192,28 @@ const data = [
|
|||||||
_children: [
|
_children: [
|
||||||
{
|
{
|
||||||
id: 294574,
|
id: 294574,
|
||||||
name: '4',
|
name: "4",
|
||||||
artist: 'Beyoncé',
|
artist: "Beyoncé",
|
||||||
release: '2011.07.26',
|
release: "2011.07.26",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'Pop',
|
genre: "Pop",
|
||||||
genreCode: '1',
|
genreCode: "1",
|
||||||
grade: '3',
|
grade: "3",
|
||||||
price: 12000,
|
price: 12000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 265289,
|
id: 265289,
|
||||||
name: '21',
|
name: "21",
|
||||||
artist: 'Adele',
|
artist: "Adele",
|
||||||
release: '2011.01.21',
|
release: "2011.01.21",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'Pop,R&B',
|
genre: "Pop,R&B",
|
||||||
genreCode: '1,3',
|
genreCode: "1,3",
|
||||||
grade: '5',
|
grade: "5",
|
||||||
price: 15000,
|
price: 15000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -210,70 +222,70 @@ const data = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 555871,
|
id: 555871,
|
||||||
name: 'Warm On A Cold Night',
|
name: "Warm On A Cold Night",
|
||||||
artist: 'HONNE',
|
artist: "HONNE",
|
||||||
release: '2016.07.22',
|
release: "2016.07.22",
|
||||||
type: 'EP',
|
type: "EP",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'R&B,Electronic',
|
genre: "R&B,Electronic",
|
||||||
genreCode: '3,4',
|
genreCode: "3,4",
|
||||||
grade: '4',
|
grade: "4",
|
||||||
price: 11000,
|
price: 11000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 550571,
|
id: 550571,
|
||||||
name: 'Take Me To The Alley',
|
name: "Take Me To The Alley",
|
||||||
artist: 'Gregory Porter',
|
artist: "Gregory Porter",
|
||||||
release: '2016.09.02',
|
release: "2016.09.02",
|
||||||
type: 'Deluxe',
|
type: "Deluxe",
|
||||||
typeCode: '1',
|
typeCode: "1",
|
||||||
genre: 'Jazz',
|
genre: "Jazz",
|
||||||
genreCode: '5',
|
genreCode: "5",
|
||||||
grade: '3',
|
grade: "3",
|
||||||
price: 30000,
|
price: 30000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 544128,
|
id: 544128,
|
||||||
name: 'Make Out',
|
name: "Make Out",
|
||||||
artist: 'LANY',
|
artist: "LANY",
|
||||||
release: '2015.12.11',
|
release: "2015.12.11",
|
||||||
type: 'EP',
|
type: "EP",
|
||||||
typeCode: '2',
|
typeCode: "2",
|
||||||
genre: 'Electronic',
|
genre: "Electronic",
|
||||||
genreCode: '4',
|
genreCode: "4",
|
||||||
grade: '2',
|
grade: "2",
|
||||||
price: 12000,
|
price: 12000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 366374,
|
id: 366374,
|
||||||
name: 'Get Lucky',
|
name: "Get Lucky",
|
||||||
artist: 'Daft Punk',
|
artist: "Daft Punk",
|
||||||
release: '2013.04.23',
|
release: "2013.04.23",
|
||||||
type: 'Single',
|
type: "Single",
|
||||||
typeCode: '3',
|
typeCode: "3",
|
||||||
genre: 'Pop,Funk',
|
genre: "Pop,Funk",
|
||||||
genreCode: '1,5',
|
genreCode: "1,5",
|
||||||
grade: '3',
|
grade: "3",
|
||||||
price: 9000,
|
price: 9000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 8012747,
|
id: 8012747,
|
||||||
name: 'Valtari',
|
name: "Valtari",
|
||||||
artist: 'Sigur Rós',
|
artist: "Sigur Rós",
|
||||||
release: '2012.05.31',
|
release: "2012.05.31",
|
||||||
type: 'EP',
|
type: "EP",
|
||||||
typeCode: '3',
|
typeCode: "3",
|
||||||
genre: 'Rock',
|
genre: "Rock",
|
||||||
genreCode: '2',
|
genreCode: "2",
|
||||||
grade: '5',
|
grade: "5",
|
||||||
price: 10000,
|
price: 10000,
|
||||||
downloadCount: 1000,
|
downloadCount: 1000,
|
||||||
listenCount: 5000,
|
listenCount: 5000,
|
||||||
@@ -281,18 +293,23 @@ const data = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ header: 'Name', name: 'name', width: 300 },
|
{ header: "Name", name: "name", width: 300 },
|
||||||
{ header: 'Artist', name: 'artist' },
|
{ header: "Artist", name: "artist" },
|
||||||
{ header: 'Type', name: 'type' },
|
{ header: "Type", name: "type" },
|
||||||
{ header: 'Release', name: 'release' },
|
{ header: "Release", name: "release" },
|
||||||
{ header: 'Genre', name: 'genre' },
|
{ header: "Genre", name: "genre" },
|
||||||
{ header: 'checkbox', name: 'checkbox', editor:{ type: 'checkbox', options: {
|
{
|
||||||
listItems: [
|
header: "checkbox",
|
||||||
{ text: 'true', value: true },
|
name: "checkbox",
|
||||||
]
|
editor: {
|
||||||
}}}
|
type: "checkbox",
|
||||||
|
options: {
|
||||||
|
listItems: [{ text: "true", value: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const treeColumnOptions = { name: 'name', useCascadingCheckbox: true };
|
const treeColumnOptions = { name: "name", useCascadingCheckbox: true };
|
||||||
|
|
||||||
const grid1Ref = ref();
|
const grid1Ref = ref();
|
||||||
|
|
||||||
@@ -305,19 +322,4 @@ function onUpdateClick() {
|
|||||||
//grid1Ref.value?.clearGrid();
|
//grid1Ref.value?.clearGrid();
|
||||||
console.log(grid1Ref.value?.api()?.getModifiedRows());
|
console.log(grid1Ref.value?.api()?.getModifiedRows());
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<button @click="onClearClick">clear api</button>
|
|
||||||
<br>
|
|
||||||
<button @click="onUpdateClick">update list</button>
|
|
||||||
<ToastGrid
|
|
||||||
ref="grid1Ref"
|
|
||||||
:data="data"
|
|
||||||
:columns="columns"
|
|
||||||
:treeColumnOptions="treeColumnOptions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
276
pages/about.vue
276
pages/about.vue
@@ -1,276 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="about">
|
|
||||||
<section class="hero">
|
|
||||||
<h1>About Us</h1>
|
|
||||||
<p class="subtitle">혁신적인 솔루션으로 미래를 만들어갑니다</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mission">
|
|
||||||
<h2>Our Mission</h2>
|
|
||||||
<p>
|
|
||||||
우리는 최신 기술을 활용하여 사용자 중심의 혁신적인 제품을 개발하고, 더
|
|
||||||
나은 디지털 경험을 제공하는 것을 목표로 합니다.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="values">
|
|
||||||
<h2>Our Values</h2>
|
|
||||||
<div class="values-grid">
|
|
||||||
<div class="value-card">
|
|
||||||
<h3>혁신</h3>
|
|
||||||
<p>끊임없는 혁신을 통해 새로운 가치를 창출합니다</p>
|
|
||||||
</div>
|
|
||||||
<div class="value-card">
|
|
||||||
<h3>품질</h3>
|
|
||||||
<p>최고의 품질을 위해 세심한 주의를 기울입니다</p>
|
|
||||||
</div>
|
|
||||||
<div class="value-card">
|
|
||||||
<h3>협력</h3>
|
|
||||||
<p>팀워크와 협력을 통해 더 큰 성과를 달성합니다</p>
|
|
||||||
</div>
|
|
||||||
<div class="value-card">
|
|
||||||
<h3>성장</h3>
|
|
||||||
<p>지속적인 학습과 성장을 추구합니다</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="team">
|
|
||||||
<h2>Our Team</h2>
|
|
||||||
<div class="team-grid">
|
|
||||||
<div class="team-member">
|
|
||||||
<div class="member-avatar">
|
|
||||||
<span>👨💻</span>
|
|
||||||
</div>
|
|
||||||
<h3>김개발</h3>
|
|
||||||
<p class="position">Frontend Developer</p>
|
|
||||||
<p>
|
|
||||||
Vue.js와 Nuxt.js 전문가로 사용자 경험에 중점을 둔 개발을 담당합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="team-member">
|
|
||||||
<div class="member-avatar">
|
|
||||||
<span>👩💻</span>
|
|
||||||
</div>
|
|
||||||
<h3>이디자인</h3>
|
|
||||||
<p class="position">UI/UX Designer</p>
|
|
||||||
<p>사용자 중심의 직관적이고 아름다운 인터페이스를 설계합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div class="team-member">
|
|
||||||
<div class="member-avatar">
|
|
||||||
<span>👨🔧</span>
|
|
||||||
</div>
|
|
||||||
<h3>박백엔드</h3>
|
|
||||||
<p class="position">Backend Developer</p>
|
|
||||||
<p>안정적이고 확장 가능한 서버 아키텍처를 구축합니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="contact">
|
|
||||||
<h2>Contact Us</h2>
|
|
||||||
<p>궁금한 점이 있으시면 언제든 연락주세요!</p>
|
|
||||||
<div class="contact-info">
|
|
||||||
<p>📧 Email: contact@example.com</p>
|
|
||||||
<p>📱 Phone: 02-1234-5678</p>
|
|
||||||
<p>📍 Address: 서울특별시 강남구 테헤란로 123</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
// 페이지 메타데이터 설정
|
|
||||||
definePageMeta({
|
|
||||||
title: "About",
|
|
||||||
description: "우리 팀과 미션에 대해 알아보세요",
|
|
||||||
});
|
|
||||||
|
|
||||||
// SEO 최적화
|
|
||||||
useHead({
|
|
||||||
title: "About Us - Nuxt.js App",
|
|
||||||
meta: [
|
|
||||||
{
|
|
||||||
name: "description",
|
|
||||||
content: "혁신적인 솔루션으로 미래를 만들어가는 우리 팀을 소개합니다.",
|
|
||||||
},
|
|
||||||
{ property: "og:title", content: "About Us - Nuxt.js App" },
|
|
||||||
{
|
|
||||||
property: "og:description",
|
|
||||||
content: "혁신적인 솔루션으로 미래를 만들어가는 우리 팀을 소개합니다.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.about {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 0;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border-radius: 12px;
|
|
||||||
margin-bottom: 3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero h1 {
|
|
||||||
font-size: 3rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 1.2rem;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
margin-bottom: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: #333;
|
|
||||||
font-size: 2rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mission {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mission p {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: #666;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.values-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 2rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-card {
|
|
||||||
background: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-card:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-card h3 {
|
|
||||||
color: #00dc82;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-size: 1.3rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.value-card p {
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 2rem;
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-member {
|
|
||||||
background: white;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|
||||||
text-align: center;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-member:hover {
|
|
||||||
transform: translateY(-5px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-avatar {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
background: linear-gradient(135deg, #00dc82, #00b894);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-member h3 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.position {
|
|
||||||
color: #00dc82;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-member p:last-child {
|
|
||||||
color: #666;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact {
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact p {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info {
|
|
||||||
margin-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.contact-info p {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.hero h1 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.values-grid,
|
|
||||||
.team-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero,
|
|
||||||
.mission,
|
|
||||||
.contact {
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
176
pages/auth-error.vue
Normal file
176
pages/auth-error.vue
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-error-container">
|
||||||
|
<div class="auth-error-card">
|
||||||
|
<div class="auth-error-icon">
|
||||||
|
<Icon name="mdi:alert-circle" size="64" color="#ef4444" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="auth-error-title">인증 오류</h1>
|
||||||
|
|
||||||
|
<div class="auth-error-message">
|
||||||
|
<p v-if="errorType === 'JWT_TOKEN_EXPIRED'">
|
||||||
|
세션이 만료되었습니다.<br />
|
||||||
|
보안을 위해 다시 로그인해 주세요.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="errorType === 'INVALID_CLIENT_IP'">
|
||||||
|
접근 권한이 없습니다.<br />
|
||||||
|
허용된 IP에서 접근해 주세요.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="errorType === 'JWT_TOKEN_NULL'">
|
||||||
|
인증 정보가 없습니다.<br />
|
||||||
|
다시 로그인해 주세요.
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
인증에 문제가 발생했습니다.<br />
|
||||||
|
다시 로그인해 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-error-actions">
|
||||||
|
<button
|
||||||
|
class="auth-error-btn primary"
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="goToLogin"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:login" size="20" />
|
||||||
|
로그인하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-error-help">
|
||||||
|
<p>문제가 지속되면 관리자에게 문의해 주세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// auth 레이아웃 사용
|
||||||
|
definePageMeta({
|
||||||
|
layout: "auth",
|
||||||
|
});
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
// 쿼리 파라미터에서 에러 타입 가져오기
|
||||||
|
const errorType = computed(() => (route.query.type as string) || "UNKNOWN");
|
||||||
|
|
||||||
|
const goToLogin = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
await navigateTo("/login");
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 진입 시 사용자 세션 정리
|
||||||
|
onMounted(() => {
|
||||||
|
const userStore = useUserStore();
|
||||||
|
userStore.user = null;
|
||||||
|
userStore.isLoggedIn = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.auth-error-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 48px;
|
||||||
|
box-shadow:
|
||||||
|
0 4px 6px -1px rgba(0, 0, 0, 0.1),
|
||||||
|
0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-icon {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-message {
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-message p {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-btn.primary {
|
||||||
|
background: #0f172a;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-btn.primary:hover:not(:disabled) {
|
||||||
|
background: #1e293b;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-help {
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-help p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.auth-error-card {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-error-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
128
pages/index.vue
128
pages/index.vue
@@ -1,131 +1,5 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4"
|
|
||||||
>
|
|
||||||
<div class="max-w-4xl mx-auto">
|
|
||||||
<div class="text-center mb-8">
|
|
||||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
|
||||||
Integrated Bio Foundry Platform
|
|
||||||
</h1>
|
|
||||||
<p class="text-xl text-gray-600">
|
|
||||||
통합 바이오 파운드리 플랫폼에 오신 것을 환영합니다
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- 사용자 환영 메시지 -->
|
|
||||||
<div
|
|
||||||
v-if="userStore.isLoggedIn"
|
|
||||||
class="mt-6 p-4 bg-white rounded-lg shadow-md inline-block"
|
|
||||||
>
|
|
||||||
<p class="text-lg text-gray-800 mb-2">
|
|
||||||
안녕하세요,
|
|
||||||
<span class="font-semibold text-blue-600">{{
|
|
||||||
userStore.userName
|
|
||||||
}}</span
|
|
||||||
>님!
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-600">
|
|
||||||
{{ userStore.isAdmin ? "관리자" : "사용자" }} 권한으로
|
|
||||||
로그인되었습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="mt-6 p-4 bg-yellow-50 rounded-lg shadow-md inline-block"
|
|
||||||
>
|
|
||||||
<p class="text-lg text-gray-800 mb-2">로그인이 필요합니다</p>
|
|
||||||
<NuxtLink
|
|
||||||
to="/login"
|
|
||||||
class="inline-block mt-2 bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
로그인하기
|
|
||||||
</NuxtLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tailwind CSS 테스트 섹션 -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mb-4 mx-auto"
|
|
||||||
>
|
|
||||||
<span class="text-white font-bold">1</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Feature 1</h3>
|
|
||||||
<p class="text-gray-600">Tailwind CSS가 정상 작동하고 있습니다!</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 bg-green-500 rounded-full flex items-center justify-center mb-4 mx-auto"
|
|
||||||
>
|
|
||||||
<span class="text-white font-bold">2</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Feature 2</h3>
|
|
||||||
<p class="text-gray-600">반응형 디자인이 적용되었습니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="bg-white rounded-lg shadow-md p-6 hover:shadow-lg transition-shadow"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-12 h-12 bg-purple-500 rounded-full flex items-center justify-center mb-4 mx-auto"
|
|
||||||
>
|
|
||||||
<span class="text-white font-bold">3</span>
|
|
||||||
</div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-2">Feature 3</h3>
|
|
||||||
<p class="text-gray-600">모던한 UI 컴포넌트를 사용할 수 있습니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 버튼 테스트 -->
|
|
||||||
<div class="text-center space-x-4">
|
|
||||||
<button
|
|
||||||
class="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Primary Button
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="bg-gray-500 hover:bg-gray-600 text-white font-medium py-2 px-4 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
Secondary Button
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from "~/stores/user";
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
onMounted(()=> {
|
|
||||||
router.push('/1/')
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
// 페이지 메타데이터 설정
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
title: "Home",
|
redirect: "/1/",
|
||||||
description: "Welcome to our Nuxt.js application",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.home {
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #00dc82;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -54,11 +54,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useUserStore } from "~/stores/user";
|
|
||||||
|
|
||||||
// auth 레이아웃 사용
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "auth",
|
layout: "auth",
|
||||||
});
|
});
|
||||||
@@ -67,13 +62,12 @@ const userId = ref("");
|
|||||||
const password = ref("");
|
const password = ref("");
|
||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const router = useRouter();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
// 이미 로그인된 경우 홈으로 리다이렉션
|
// 이미 로그인된 경우 홈으로 리다이렉션
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
router.push("/");
|
await navigateTo("/");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,21 +80,14 @@ async function signIn() {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = "";
|
errorMessage.value = "";
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await userStore.login(userId.value, password.value);
|
const result = await userStore.login(userId.value, password.value);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// 로그인 성공 시 홈으로 이동
|
await navigateTo("/");
|
||||||
await router.push("/");
|
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = result.error || "로그인에 실패했습니다.";
|
errorMessage.value = result.error;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
errorMessage.value = "로그인 중 오류가 발생했습니다.";
|
|
||||||
console.error("로그인 오류:", error);
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,6 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useUserStore } from "~/stores/user";
|
|
||||||
|
|
||||||
// auth 레이아웃 사용
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "auth",
|
layout: "auth",
|
||||||
});
|
});
|
||||||
@@ -81,13 +76,12 @@ const confirmPassword = ref("");
|
|||||||
const errorMessage = ref("");
|
const errorMessage = ref("");
|
||||||
const successMessage = ref("");
|
const successMessage = ref("");
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const router = useRouter();
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
// 이미 로그인된 경우 홈으로 리다이렉션
|
// 이미 로그인된 경우 홈으로 리다이렉션
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
if (userStore.isLoggedIn) {
|
if (userStore.isLoggedIn) {
|
||||||
router.push("/");
|
await navigateTo("/");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,8 +127,8 @@ async function signUp() {
|
|||||||
"회원 가입이 완료되었습니다! 로그인 페이지로 이동합니다.";
|
"회원 가입이 완료되었습니다! 로그인 페이지로 이동합니다.";
|
||||||
|
|
||||||
// 2초 후 로그인 페이지로 이동
|
// 2초 후 로그인 페이지로 이동
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
router.push("/login");
|
await navigateTo("/login");
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -19,20 +19,6 @@ export default defineNuxtPlugin(() => {
|
|||||||
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
...(isFormData ? {} : { "Content-Type": "application/json" }),
|
||||||
...(options.headers || {}),
|
...(options.headers || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3) SSR 쿠키 포워딩
|
|
||||||
if (import.meta.server) {
|
|
||||||
const cookie = useRequestHeaders(["cookie"])?.cookie;
|
|
||||||
// request가 절대 URL이면 호스트 비교
|
|
||||||
const reqUrl = typeof request === "string" ? request : String(request);
|
|
||||||
const isBackendApi =
|
|
||||||
!reqUrl.startsWith("http") || // 상대경로면 내 API
|
|
||||||
reqUrl.startsWith(baseURL); // 혹은 baseURL과 동일
|
|
||||||
|
|
||||||
if (cookie && isBackendApi) {
|
|
||||||
options.headers = { ...(options.headers || {}), cookie } as any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onResponseError({ response }) {
|
onResponseError({ response }) {
|
||||||
// 공통 로깅
|
// 공통 로깅
|
||||||
|
|||||||
53
plugins/page-loading.ts
Normal file
53
plugins/page-loading.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 페이지 이동 시 자동 로딩 처리 플러그인
|
||||||
|
* 모든 페이지 이동에서 GlobalLoading을 자동으로 표시하고
|
||||||
|
* DOM 렌더링 완료 후 자동으로 숨깁니다.
|
||||||
|
*/
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
const { startLoading } = useLoading();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
let loadingStopFn: (() => void) | null = null;
|
||||||
|
let isNavigating = false;
|
||||||
|
|
||||||
|
// 페이지 이동 시작 시 로딩 시작
|
||||||
|
router.beforeEach((to, from) => {
|
||||||
|
// 같은 페이지 내에서의 이동은 무시
|
||||||
|
if (to.path === from.path) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 로딩 중이면 무시
|
||||||
|
if (isNavigating) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNavigating = true;
|
||||||
|
loadingStopFn = startLoading("페이지를 불러오는 중...");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 페이지 이동 완료 후 로딩 종료
|
||||||
|
router.afterEach(() => {
|
||||||
|
if (!isNavigating || !loadingStopFn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM이 완전히 렌더링된 후 로딩 종료
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingStopFn?.();
|
||||||
|
loadingStopFn = null;
|
||||||
|
isNavigating = false;
|
||||||
|
}, 200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 발생 시 로딩 강제 종료
|
||||||
|
router.onError(() => {
|
||||||
|
if (loadingStopFn) {
|
||||||
|
loadingStopFn();
|
||||||
|
loadingStopFn = null;
|
||||||
|
isNavigating = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
65
public/cy_custom.js
Normal file
65
public/cy_custom.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
(function () {
|
||||||
|
const EXTREME_ZOOM_MIN = 1e-4;
|
||||||
|
const EXTREME_ZOOM_MAX = 1e4;
|
||||||
|
|
||||||
|
function applyCustomZoomHandling(cy) {
|
||||||
|
let zoomFramePending = false;
|
||||||
|
|
||||||
|
if (!cy || typeof cy.zoom !== 'function') {
|
||||||
|
console.warn('🚫 유효하지 않은 Cytoscape 인스턴스입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cy.userZoomingEnabled(false); // Cytoscape 기본 줌 비활성화
|
||||||
|
|
||||||
|
const container = cy.container();
|
||||||
|
if (!container) {
|
||||||
|
console.warn('🚫 Cytoscape container를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (zoomFramePending) return;
|
||||||
|
zoomFramePending = true;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const currentZoom = cy.zoom();
|
||||||
|
|
||||||
|
const clampedDelta = Math.max(-100, Math.min(100, event.deltaY));
|
||||||
|
const zoomFactor = 1 + (clampedDelta > 0 ? -0.05 : 0.05);
|
||||||
|
const nextZoom = currentZoom * zoomFactor;
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
isFinite(nextZoom) &&
|
||||||
|
nextZoom >= EXTREME_ZOOM_MIN &&
|
||||||
|
nextZoom <= EXTREME_ZOOM_MAX;
|
||||||
|
|
||||||
|
if (isValid) {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const renderedPosition = {
|
||||||
|
x: event.clientX - rect.left,
|
||||||
|
y: event.clientY - rect.top
|
||||||
|
};
|
||||||
|
cy.zoom({
|
||||||
|
level: nextZoom,
|
||||||
|
renderedPosition
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("🚫 휠 줌 비정상 값 차단:", nextZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomFramePending = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 전역(window)으로 함수 등록
|
||||||
|
window.applyCustomZoomHandling = applyCustomZoomHandling;
|
||||||
|
})();
|
||||||
|
|
||||||
28
public/igv.css
Normal file
28
public/igv.css
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/* IGV blue lines & UI custom style (기본 파란색: #3579f6) */
|
||||||
|
.igv-blue-line {
|
||||||
|
background: #3579f6 !important;
|
||||||
|
width: 2px !important;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.igv-blue-fill {
|
||||||
|
background: rgba(53, 121, 246, 0.18) !important;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.igv-control-btn {
|
||||||
|
background: #f5faff;
|
||||||
|
color: #3579f6;
|
||||||
|
border: 1.5px solid #3579f6;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 6px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
.igv-control-btn:hover {
|
||||||
|
background: #3579f6;
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid #2456a6;
|
||||||
|
}
|
||||||
|
.igv-blue-line.selected {
|
||||||
|
box-shadow: 0 0 8px #3579f6;
|
||||||
|
}
|
||||||
67298
public/igv.js
Normal file
67298
public/igv.js
Normal file
File diff suppressed because one or more lines are too long
222
public/igv_custom.js
Normal file
222
public/igv_custom.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
// igv_custom.js
|
||||||
|
// IGV 브라우저가 생성된 후, 파란색 라인 2개를 ideogram/viewport에 추가하고 드래그로 이동 가능하게 함
|
||||||
|
// 라인 위치 변경 시 window에 커스텀 이벤트('igv-blue-lines-changed')를 dispatch
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
// 설정값
|
||||||
|
const LINE_COLOR = '#007bff';
|
||||||
|
const LINE_WIDTH = 2;
|
||||||
|
const INIT_RATIO_1 = 0.3; // 30% 위치
|
||||||
|
const INIT_RATIO_2 = 0.7; // 70% 위치
|
||||||
|
|
||||||
|
// 내부 상태
|
||||||
|
let viewportRect = null;
|
||||||
|
let dragging = null; // 'start' or 'end' or null
|
||||||
|
let dragOffset = 0;
|
||||||
|
let startRatio = INIT_RATIO_1;
|
||||||
|
let endRatio = INIT_RATIO_2;
|
||||||
|
|
||||||
|
// 라인 DOM
|
||||||
|
let viewportLines = [];
|
||||||
|
let viewportFill = null;
|
||||||
|
let ideogramLines = [];
|
||||||
|
let ideogramFill = null;
|
||||||
|
|
||||||
|
// IGV가 준비되면 실행
|
||||||
|
function waitForIGVAndInit() {
|
||||||
|
const check = () => {
|
||||||
|
const viewport = document.querySelector('.igv-viewport-content');
|
||||||
|
if (viewport) {
|
||||||
|
setupLines();
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 300);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라인 생성 및 이벤트 바인딩
|
||||||
|
function setupLines() {
|
||||||
|
// 모든 파란 라인 삭제
|
||||||
|
document.querySelectorAll('.igv-blue-line').forEach(el => el.remove());
|
||||||
|
// 기존 fill 오버레이 삭제
|
||||||
|
document.querySelectorAll('.igv-blue-fill').forEach(el => el.remove());
|
||||||
|
|
||||||
|
// 모든 .igv-viewport-content 중, height가 16px 초과인 첫 번째를 메인 뷰포트로 간주
|
||||||
|
const viewports = Array.from(document.querySelectorAll('.igv-viewport-content'));
|
||||||
|
const mainViewport = viewports.find(vp => {
|
||||||
|
const h = vp.offsetHeight || parseInt(vp.style.height, 10);
|
||||||
|
return h > 16;
|
||||||
|
});
|
||||||
|
if (!mainViewport) return;
|
||||||
|
|
||||||
|
// 이미 있으면 중복 생성 방지
|
||||||
|
if (mainViewport.parentNode.querySelector('.igv-blue-line')) return;
|
||||||
|
|
||||||
|
viewportRect = mainViewport.getBoundingClientRect();
|
||||||
|
|
||||||
|
// 라인 2개 생성 (뷰포트)
|
||||||
|
viewportLines = [createLine('start', 'viewport', mainViewport), createLine('end', 'viewport', mainViewport)];
|
||||||
|
viewportLines.forEach(line => mainViewport.parentNode.appendChild(line));
|
||||||
|
// fill 오버레이 생성 및 추가 (뷰포트)
|
||||||
|
viewportFill = document.createElement('div');
|
||||||
|
viewportFill.className = 'igv-blue-fill';
|
||||||
|
Object.assign(viewportFill.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: (viewportRect.top - mainViewport.getBoundingClientRect().top) + 'px',
|
||||||
|
height: viewportRect.height + 'px',
|
||||||
|
background: 'rgba(0,123,255,0.18)',
|
||||||
|
zIndex: 999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
borderRadius: '2px',
|
||||||
|
transition: 'left 0.08s, width 0.08s',
|
||||||
|
});
|
||||||
|
mainViewport.parentNode.appendChild(viewportFill);
|
||||||
|
|
||||||
|
// === ideogram(미니맵)에도 라인/오버레이 추가 ===
|
||||||
|
const ideogram = document.querySelector('.igv-ideogram-content') || document.querySelector('.igv-ideogram');
|
||||||
|
if (ideogram) {
|
||||||
|
const ideogramRect = ideogram.getBoundingClientRect();
|
||||||
|
ideogramLines = [createLine('start', 'ideogram', ideogram), createLine('end', 'ideogram', ideogram)];
|
||||||
|
ideogramLines.forEach(line => ideogram.parentNode.appendChild(line));
|
||||||
|
ideogramFill = document.createElement('div');
|
||||||
|
ideogramFill.className = 'igv-blue-fill';
|
||||||
|
Object.assign(ideogramFill.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: (ideogramRect.top - ideogram.parentNode.getBoundingClientRect().top) + 'px',
|
||||||
|
height: ideogramRect.height + 'px',
|
||||||
|
background: 'rgba(0,123,255,0.18)',
|
||||||
|
zIndex: 999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
borderRadius: '2px',
|
||||||
|
transition: 'left 0.08s, width 0.08s',
|
||||||
|
});
|
||||||
|
ideogram.parentNode.appendChild(ideogramFill);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinePositions();
|
||||||
|
bindDragEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라인 DOM 생성
|
||||||
|
function createLine(which, area, parentEl) {
|
||||||
|
const line = document.createElement('div');
|
||||||
|
line.className = 'igv-blue-line';
|
||||||
|
line.dataset.which = which;
|
||||||
|
line.dataset.area = area;
|
||||||
|
line._parentEl = parentEl; // 커스텀 속성으로 부모 저장
|
||||||
|
Object.assign(line.style, {
|
||||||
|
position: 'absolute',
|
||||||
|
top: area === 'ideogram' ? (viewportRect.top - parentEl.getBoundingClientRect().top) + 'px' : (viewportRect.top - parentEl.getBoundingClientRect().top) + 'px',
|
||||||
|
width: LINE_WIDTH + 'px',
|
||||||
|
height: area === 'ideogram' ? viewportRect.height + 'px' : viewportRect.height + 'px',
|
||||||
|
background: LINE_COLOR,
|
||||||
|
zIndex: 1000,
|
||||||
|
cursor: 'ew-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
line.addEventListener('mousedown', (e) => startDrag(which, e));
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라인 위치 갱신
|
||||||
|
function updateLinePositions() {
|
||||||
|
viewportRect = document.querySelector('.igv-viewport-content').getBoundingClientRect();
|
||||||
|
let baseArea = document.querySelector('.igv-viewport-content canvas, .igv-viewport-content svg');
|
||||||
|
let baseRect = baseArea ? baseArea.getBoundingClientRect() : viewportRect;
|
||||||
|
// viewport 라인 위치 (실제 베이스 표시 영역 기준)
|
||||||
|
const baseWidth = baseRect.width;
|
||||||
|
const baseLeft = baseRect.left;
|
||||||
|
const startX_view = baseLeft + baseWidth * startRatio;
|
||||||
|
const endX_view = baseLeft + baseWidth * endRatio;
|
||||||
|
viewportLines[0].style.left = (startX_view - viewportLines[0]._parentEl.getBoundingClientRect().left) + 'px';
|
||||||
|
viewportLines[1].style.left = (endX_view - viewportLines[1]._parentEl.getBoundingClientRect().left) + 'px';
|
||||||
|
// fill 오버레이 위치/크기 갱신
|
||||||
|
if (viewportFill) {
|
||||||
|
const left = Math.min(startX_view, endX_view) - viewportFill.parentNode.getBoundingClientRect().left;
|
||||||
|
const width = Math.abs(endX_view - startX_view);
|
||||||
|
viewportFill.style.left = left + 'px';
|
||||||
|
viewportFill.style.width = width + 'px';
|
||||||
|
}
|
||||||
|
// === ideogram(미니맵) 라인/오버레이 위치 갱신 ===
|
||||||
|
const ideogram = document.querySelector('.igv-ideogram-content') || document.querySelector('.igv-ideogram');
|
||||||
|
if (ideogram && ideogramLines.length === 2 && ideogramFill) {
|
||||||
|
const ideogramRect = ideogram.getBoundingClientRect();
|
||||||
|
const ideogramWidth = ideogramRect.width;
|
||||||
|
const ideogramLeft = ideogramRect.left;
|
||||||
|
const startX_ideo = ideogramLeft + ideogramWidth * startRatio;
|
||||||
|
const endX_ideo = ideogramLeft + ideogramWidth * endRatio;
|
||||||
|
ideogramLines[0].style.left = (startX_ideo - ideogram.parentNode.getBoundingClientRect().left) + 'px';
|
||||||
|
ideogramLines[1].style.left = (endX_ideo - ideogram.parentNode.getBoundingClientRect().left) + 'px';
|
||||||
|
// fill
|
||||||
|
const left = Math.min(startX_ideo, endX_ideo) - ideogramFill.parentNode.getBoundingClientRect().left;
|
||||||
|
const width = Math.abs(endX_ideo - startX_ideo);
|
||||||
|
ideogramFill.style.left = left + 'px';
|
||||||
|
ideogramFill.style.width = width + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그 이벤트 바인딩
|
||||||
|
function bindDragEvents() {
|
||||||
|
document.addEventListener('mousemove', onDrag);
|
||||||
|
document.addEventListener('mouseup', stopDrag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(which, e) {
|
||||||
|
dragging = which;
|
||||||
|
dragOffset = e.clientX;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrag(e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
const areaWidth = viewportRect.width;
|
||||||
|
let x = e.clientX - viewportRect.left;
|
||||||
|
let ratio = x / areaWidth;
|
||||||
|
ratio = Math.max(0, Math.min(1, ratio));
|
||||||
|
if (dragging === 'start') {
|
||||||
|
startRatio = Math.min(ratio, endRatio - 0.01);
|
||||||
|
} else {
|
||||||
|
endRatio = Math.max(ratio, startRatio + 0.01);
|
||||||
|
}
|
||||||
|
updateLinePositions();
|
||||||
|
fireChangeEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDrag() {
|
||||||
|
dragging = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 라인 위치 변경 이벤트 발생
|
||||||
|
function fireChangeEvent() {
|
||||||
|
console.log('fireChangeEvent 호출됨', { startRatio, endRatio });
|
||||||
|
window.dispatchEvent(new CustomEvent('igv-blue-lines-changed', {
|
||||||
|
detail: {
|
||||||
|
startRatio,
|
||||||
|
endRatio
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 외부에서 라인 위치를 강제로 지정할 수 있도록 export
|
||||||
|
window.igvCustom = {
|
||||||
|
setLineRatios: (start, end) => {
|
||||||
|
startRatio = start;
|
||||||
|
endRatio = end;
|
||||||
|
updateLinePositions();
|
||||||
|
fireChangeEvent();
|
||||||
|
},
|
||||||
|
getLineRatios: () => ({ startRatio, endRatio }),
|
||||||
|
setupLines: setupLines
|
||||||
|
};
|
||||||
|
|
||||||
|
// 스타일 추가
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.innerHTML = `.igv-blue-line { pointer-events: auto !important; }
|
||||||
|
.igv-blue-fill { pointer-events: none !important; }`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// IGV가 준비되면 실행
|
||||||
|
setTimeout(waitForIGVAndInit, 1000);
|
||||||
|
})();
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
export const useCounterStore = defineStore("counter", () => {
|
|
||||||
const count = ref(0);
|
|
||||||
const name = ref("Counter Store");
|
|
||||||
|
|
||||||
const doubleCount = computed(() => count.value * 2);
|
|
||||||
|
|
||||||
function increment() {
|
|
||||||
count.value++;
|
|
||||||
}
|
|
||||||
|
|
||||||
function decrement() {
|
|
||||||
count.value--;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
count.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
count,
|
|
||||||
name,
|
|
||||||
doubleCount,
|
|
||||||
increment,
|
|
||||||
decrement,
|
|
||||||
reset,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
75
stores/loading.ts
Normal file
75
stores/loading.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 전역 로딩 상태 관리 스토어 (로딩 카운터 방식)
|
||||||
|
* 여러 비동기 작업이 동시에 진행되어도 안전하게 로딩 상태를 관리합니다.
|
||||||
|
*/
|
||||||
|
export const useLoadingStore = defineStore(
|
||||||
|
"loading",
|
||||||
|
() => {
|
||||||
|
// 로딩 카운터 - 0보다 크면 로딩 중
|
||||||
|
const loadingCount = ref(0);
|
||||||
|
|
||||||
|
// 현재 로딩 중인 작업들의 메시지
|
||||||
|
const loadingMessages = ref<string[]>([]);
|
||||||
|
|
||||||
|
// 로딩 상태 (카운터가 0보다 크면 true)
|
||||||
|
const isLoading = computed(() => loadingCount.value > 0);
|
||||||
|
|
||||||
|
// 현재 로딩 메시지 (가장 최근 메시지 또는 기본 메시지)
|
||||||
|
const currentMessage = computed(
|
||||||
|
() =>
|
||||||
|
loadingMessages.value[loadingMessages.value.length - 1] || "로딩 중..."
|
||||||
|
);
|
||||||
|
|
||||||
|
// 로딩 시작
|
||||||
|
const startLoading = (message?: string) => {
|
||||||
|
loadingCount.value++;
|
||||||
|
if (message) {
|
||||||
|
loadingMessages.value.push(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 종료
|
||||||
|
const stopLoading = (message?: string) => {
|
||||||
|
loadingCount.value = Math.max(0, loadingCount.value - 1);
|
||||||
|
|
||||||
|
if (message && loadingMessages.value.length > 0) {
|
||||||
|
const index = loadingMessages.value.lastIndexOf(message);
|
||||||
|
if (index > -1) {
|
||||||
|
loadingMessages.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
} else if (loadingMessages.value.length > 0) {
|
||||||
|
// 메시지가 지정되지 않으면 가장 최근 메시지 제거
|
||||||
|
loadingMessages.value.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모든 로딩 강제 종료
|
||||||
|
const clearAllLoading = () => {
|
||||||
|
loadingCount.value = 0;
|
||||||
|
loadingMessages.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 상태 리셋
|
||||||
|
const reset = () => {
|
||||||
|
loadingCount.value = 0;
|
||||||
|
loadingMessages.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 상태
|
||||||
|
loadingCount: readonly(loadingCount),
|
||||||
|
loadingMessages: readonly(loadingMessages),
|
||||||
|
isLoading,
|
||||||
|
currentMessage,
|
||||||
|
|
||||||
|
// 액션
|
||||||
|
startLoading,
|
||||||
|
stopLoading,
|
||||||
|
clearAllLoading,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: false, // 로딩 상태는 새로고침 시 초기화되어야 함
|
||||||
|
}
|
||||||
|
);
|
||||||
131
stores/permissions.ts
Normal file
131
stores/permissions.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { UserPermissions } from "~/types/permissions";
|
||||||
|
import { MOCK_PERMISSIONS } from "~/types/permissions";
|
||||||
|
|
||||||
|
export const usePermissionsStore = defineStore(
|
||||||
|
"permissions",
|
||||||
|
() => {
|
||||||
|
// 권한 데이터 상태
|
||||||
|
const permissions = ref<UserPermissions>({
|
||||||
|
resources: {
|
||||||
|
pageGroups: [],
|
||||||
|
pages: [],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 서버에서 권한 데이터 가져오기 (현재는 가데이터 사용)
|
||||||
|
const fetchPermissions = async (): Promise<boolean> => {
|
||||||
|
const { withLoading } = useLoading();
|
||||||
|
|
||||||
|
return await withLoading(async () => {
|
||||||
|
// 실제 API 호출 (백엔드 준비되면 주석 해제)
|
||||||
|
/*
|
||||||
|
const { success, data } = await useApi<UserPermissions>('/auth/permissions', {
|
||||||
|
method: 'GET',
|
||||||
|
handleError: false
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success && data) {
|
||||||
|
permissions.value = {
|
||||||
|
resources: {
|
||||||
|
pageGroups: data.resources?.pageGroups || [],
|
||||||
|
pages: data.resources?.pages || [],
|
||||||
|
components: data.resources?.components || []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 임시 가데이터 사용
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000)); // 로딩 시뮬레이션
|
||||||
|
|
||||||
|
permissions.value = {
|
||||||
|
resources: {
|
||||||
|
pageGroups: [...MOCK_PERMISSIONS.resources.pageGroups],
|
||||||
|
pages: [...MOCK_PERMISSIONS.resources.pages],
|
||||||
|
components: [...MOCK_PERMISSIONS.resources.components],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}, "권한 정보를 불러오는 중...");
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPagePaths = (): string[] => {
|
||||||
|
return permissions.value.resources.pages
|
||||||
|
.map(page => page.path)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPageGroupCodes = (): string[] => {
|
||||||
|
return permissions.value.resources.pageGroups.map(
|
||||||
|
pageGroup => pageGroup.code
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getComponentCodes = (): string[] => {
|
||||||
|
return permissions.value.resources.components.map(
|
||||||
|
component => component.code
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPagePermission = (page: string): boolean => {
|
||||||
|
const pagePaths = getPagePaths();
|
||||||
|
|
||||||
|
// 1. 정확한 경로 매치 먼저 확인
|
||||||
|
if (pagePaths.includes(page)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 동적 라우팅 패턴 체크
|
||||||
|
for (const allowedPath of pagePaths) {
|
||||||
|
// 동적 라우팅 패턴: /[tabId]/path 형태
|
||||||
|
const dynamicPattern = new RegExp(`^/\\d+${allowedPath}$`);
|
||||||
|
if (dynamicPattern.test(page)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasPageGroupPermission = (pageGroup: string): boolean => {
|
||||||
|
return getPageGroupCodes().includes(pageGroup);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasComponentPermission = (component: string): boolean => {
|
||||||
|
return getComponentCodes().includes(component);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 권한 초기화
|
||||||
|
const clearPermissions = () => {
|
||||||
|
permissions.value = {
|
||||||
|
resources: {
|
||||||
|
pageGroups: [],
|
||||||
|
pages: [],
|
||||||
|
components: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
permissions,
|
||||||
|
|
||||||
|
fetchPermissions,
|
||||||
|
clearPermissions,
|
||||||
|
|
||||||
|
hasPagePermission,
|
||||||
|
hasPageGroupPermission,
|
||||||
|
hasComponentPermission,
|
||||||
|
|
||||||
|
getPagePaths,
|
||||||
|
getPageGroupCodes,
|
||||||
|
getComponentCodes,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
|
||||||
interface Tab {
|
export interface Tab {
|
||||||
key: number; // 1~10
|
key: number; // 1~10
|
||||||
label: string;
|
label: string;
|
||||||
to: string; // 페이지 라우트
|
to: string; // 페이지 라우트
|
||||||
@@ -15,38 +15,45 @@ export const useTabsStore = defineStore("tabs", {
|
|||||||
activeTab: 1,
|
activeTab: 1,
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
// ✅ 새 탭 추가 (기본 페이지는 "/")
|
async updateActiveTab(sub: {
|
||||||
addTab() {
|
label: string;
|
||||||
const { $router } = useNuxtApp();
|
to: string;
|
||||||
|
componentName: string;
|
||||||
|
}) {
|
||||||
|
// 이미 동일한 페이지가 열려있는지 확인
|
||||||
|
const existingTab = this.tabs.find(
|
||||||
|
tab => tab.to === sub.to && tab.componentName === sub.componentName
|
||||||
|
);
|
||||||
|
|
||||||
if (this.tabs.length >= 10) {
|
if (existingTab) {
|
||||||
|
// 이미 동일한 페이지가 열려있으면 해당 탭으로 이동
|
||||||
|
this.activeTab = existingTab.key;
|
||||||
|
await navigateTo(`/${existingTab.key}${existingTab.to}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.tabs.length > 10) {
|
||||||
alert("탭은 최대 10개까지 열 수 있습니다.");
|
alert("탭은 최대 10개까지 열 수 있습니다.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 빈 key 찾기
|
// 빈 key 찾기
|
||||||
let key = 1;
|
let newKey = 1;
|
||||||
while (this.tabs.find(t => t.key === key)) key++;
|
while (this.tabs.find(t => t.key === newKey)) newKey++;
|
||||||
|
|
||||||
this.tabs.push({ ...defaultTab, key: key });
|
this.tabs.push({
|
||||||
this.activeTab = key;
|
key: newKey,
|
||||||
$router.push(defaultTab.to);
|
label: sub.label,
|
||||||
return key;
|
to: sub.to,
|
||||||
},
|
componentName: sub.componentName,
|
||||||
|
});
|
||||||
// ✅ 활성 탭 내용 변경 (서브메뉴 클릭)
|
this.activeTab = newKey;
|
||||||
updateActiveTab(sub: { label: string; to: string; componentName: string }) {
|
await navigateTo(`/${newKey}${sub.to}`);
|
||||||
const { $router } = useNuxtApp();
|
|
||||||
|
|
||||||
const tab = this.tabs.find(t => t.key === this.activeTab);
|
|
||||||
if (tab) {
|
|
||||||
tab.label = sub.label;
|
|
||||||
tab.to = sub.to;
|
|
||||||
tab.componentName = sub.componentName;
|
|
||||||
}
|
|
||||||
$router.push(`/${tab?.key}${tab?.to}`);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 활성 탭 제거 (HOME 탭 보호)
|
||||||
removeTab(key: number) {
|
removeTab(key: number) {
|
||||||
|
if (key === 1) return; // HOME 탭은 제거 금지
|
||||||
this.tabs = this.tabs.filter(t => t.key !== key);
|
this.tabs = this.tabs.filter(t => t.key !== key);
|
||||||
if (this.activeTab === key) {
|
if (this.activeTab === key) {
|
||||||
this.activeTab = this.tabs.length
|
this.activeTab = this.tabs.length
|
||||||
@@ -55,12 +62,18 @@ export const useTabsStore = defineStore("tabs", {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setActiveTab(key: number) {
|
// 활성 탭 변경
|
||||||
const { $router } = useNuxtApp();
|
async setActiveTab(key: number) {
|
||||||
this.activeTab = key;
|
this.activeTab = key;
|
||||||
|
|
||||||
const tab = this.tabs.find(t => t.key === this.activeTab);
|
const tab = this.tabs.find(t => t.key === this.activeTab);
|
||||||
$router.push(`/${tab?.key}${tab?.to}`);
|
await navigateTo(`/${tab?.key}${tab?.to}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 탭 초기화
|
||||||
|
resetTabs() {
|
||||||
|
this.tabs = [{ ...defaultTab }];
|
||||||
|
this.activeTab = 1;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
persist: true,
|
persist: true,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useTabsStore } from "./tab";
|
||||||
|
|
||||||
export const useUserStore = defineStore(
|
export const useUserStore = defineStore(
|
||||||
"user",
|
"user",
|
||||||
() => {
|
() => {
|
||||||
@@ -7,103 +9,68 @@ export const useUserStore = defineStore(
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const token = ref<string | null>(null);
|
|
||||||
|
|
||||||
// 추후 제거 필요
|
// 권한 스토어 참조
|
||||||
const isAdmin = true;
|
const permissionsStore = usePermissionsStore();
|
||||||
|
// 탭 스토어 참조
|
||||||
|
const tabsStore = useTabsStore();
|
||||||
|
|
||||||
interface LoginData {
|
interface LoginData {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
// 액션
|
|
||||||
const login = async (userId: string, password: string) => {
|
|
||||||
try {
|
|
||||||
// 실제 API 호출로 대체할 수 있습니다
|
|
||||||
|
|
||||||
const { success, data } = await useApi<ApiResponse<LoginData>>(
|
const login = async (userId: string, password: string) => {
|
||||||
"/login",
|
const { success, data, description } = await useApi<
|
||||||
{
|
ApiResponse<LoginData>
|
||||||
|
>("/login", {
|
||||||
method: "post",
|
method: "post",
|
||||||
body: { userId, password },
|
body: { userId, password },
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
user.value = data;
|
user.value = data;
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
} else {
|
|
||||||
throw new Error("아이디 또는 비밀번호가 올바르지 않습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success };
|
// 로그인 성공 시
|
||||||
} catch (error: any) {
|
// 탭 초기화
|
||||||
console.log(error);
|
tabsStore.resetTabs();
|
||||||
|
// 권한 데이터 가져오기
|
||||||
|
await permissionsStore.fetchPermissions();
|
||||||
|
|
||||||
|
return { success, data };
|
||||||
|
} else {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: description,
|
||||||
error?.response?.status === 401
|
|
||||||
? "아이디 또는 비밀번호가 올바르지 않습니다."
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "로그인에 실패했습니다.",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
|
||||||
await useApi("/members/logout", {
|
await useApi("/members/logout", {
|
||||||
method: "post",
|
method: "post",
|
||||||
|
loadingMessage: "로그아웃 처리중...",
|
||||||
|
showAlert: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error("로그아웃 요청 실패:", error);
|
|
||||||
} finally {
|
|
||||||
// 로컬 상태 정리
|
|
||||||
user.value = null;
|
user.value = null;
|
||||||
isLoggedIn.value = false;
|
isLoggedIn.value = false;
|
||||||
}
|
|
||||||
|
tabsStore.resetTabs();
|
||||||
|
permissionsStore.clearPermissions();
|
||||||
|
|
||||||
|
await navigateTo("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const checkAuth = () => {
|
|
||||||
// 페이지 로드 시 로컬 스토리지에서 사용자 정보 복원
|
|
||||||
const savedUser = localStorage.getItem("user");
|
|
||||||
const savedToken = localStorage.getItem("token");
|
|
||||||
|
|
||||||
if (savedUser && savedToken) {
|
|
||||||
user.value = JSON.parse(savedUser);
|
|
||||||
token.value = savedToken;
|
|
||||||
isLoggedIn.value = true;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const setToken = (accessToken: string) => {
|
|
||||||
token.value = accessToken;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getToken = () => {
|
|
||||||
return token;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 초기 인증 상태 확인
|
|
||||||
if (import.meta.client) {
|
|
||||||
checkAuth();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 상태
|
// 상태
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
user,
|
user,
|
||||||
token,
|
|
||||||
|
|
||||||
// 게터
|
|
||||||
isAdmin,
|
|
||||||
|
|
||||||
// 액션
|
// 액션
|
||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
checkAuth,
|
|
||||||
setToken,
|
|
||||||
getToken,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
434
types/permissions.ts
Normal file
434
types/permissions.ts
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
/**
|
||||||
|
* 권한 시스템 타입 정의
|
||||||
|
*
|
||||||
|
* 2자리 계층 코드 시스템을 사용하여 페이지그룹 > 페이지 > 컴포넌트 계층 구조를 표현합니다.
|
||||||
|
*
|
||||||
|
* 코드 규칙:
|
||||||
|
* - 페이지그룹: PG01, PG02, PG03, PG04...
|
||||||
|
* - 페이지: P0101 (PG01 하위), P0201 (PG02 하위), P0501 (독립 페이지)...
|
||||||
|
* - 컴포넌트: C010101 (P0101 하위), C010102 (P0101 하위)...
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 리소스 타입 정의 (페이지그룹, 페이지, 컴포넌트)
|
||||||
|
export type ResourceType = "PAGE_GROUP" | "PAGE" | "COMPONENT";
|
||||||
|
|
||||||
|
// 개별 리소스 객체 (계층 구조 지원)
|
||||||
|
export interface Resource {
|
||||||
|
oid: number; // OID (고유 식별자)
|
||||||
|
code: string; // 2자리 계층 코드 (PG01, P0101, C010101)
|
||||||
|
name: string; // 리소스 이름
|
||||||
|
parentCode?: string; // 부모 리소스 코드 (계층 구조)
|
||||||
|
type: ResourceType; // 리소스 타입
|
||||||
|
path?: string; // 페이지 경로 (PAGE 타입만)
|
||||||
|
sortOrder: number; // 정렬 순서
|
||||||
|
description?: string; // 리소스 설명
|
||||||
|
menuYn?: string; // 메뉴 여부 (char(1))
|
||||||
|
componentType?: string; // 컴포넌트 세부 타입 (버튼, 그리드 등)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 사용자 권한 구조 (계층적 리소스 관리)
|
||||||
|
export interface UserPermissions {
|
||||||
|
resources: {
|
||||||
|
pageGroups: Resource[]; // 페이지그룹 리소스들 (PG01, PG02...)
|
||||||
|
pages: Resource[]; // 페이지 리소스들 (P0101, P0201...)
|
||||||
|
components: Resource[]; // 컴포넌트 리소스들 (C010101, C010102...)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 체크 결과 타입 (권한 스토어에서 제공하는 함수들)
|
||||||
|
export interface PermissionCheckResult {
|
||||||
|
// 기존 호환성 함수들 (경로/코드 기반)
|
||||||
|
hasPagePermission: (page: string) => boolean; // 페이지 경로로 권한 체크
|
||||||
|
hasPageGroupPermission: (pageGroup: string) => boolean; // 페이지그룹 코드로 권한 체크
|
||||||
|
hasComponentPermission: (component: string) => boolean; // 컴포넌트 코드로 권한 체크
|
||||||
|
|
||||||
|
// 계층 구조를 고려한 권한 체크 함수들
|
||||||
|
hasResourcePermission: (resourceCode: string) => boolean; // 리소스 코드로 권한 체크
|
||||||
|
getResourceByCode: (code: string) => Resource | undefined; // 코드로 리소스 조회
|
||||||
|
getResourceByPath: (path: string) => Resource | undefined; // 경로로 리소스 조회
|
||||||
|
getChildResources: (parentCode: string) => Resource[]; // 부모의 자식 리소스들 조회
|
||||||
|
}
|
||||||
|
|
||||||
|
// 권한 종류별 상수 (기존 호환성 유지용)
|
||||||
|
export const PERMISSION_TYPES = {
|
||||||
|
PAGE: "pagePermissions",
|
||||||
|
PAGE_GROUP: "pageGroupPermissions",
|
||||||
|
COMPONENT: "componentPermissions",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가데이터용 권한 목록 (개발/테스트용)
|
||||||
|
*
|
||||||
|
* 2자리 계층 코드 시스템을 사용한 실제 권한 데이터 예시입니다.
|
||||||
|
*
|
||||||
|
* 계층 구조:
|
||||||
|
* - PG01 (테스트 페이지그룹) > P0101~P0115 (테스트 페이지들) > C010101~C010106 (컴포넌트들)
|
||||||
|
* - PG02 (관리자 페이지그룹) > P0201~P0203 (관리자 페이지들)
|
||||||
|
* - P0501~P0504 (독립 페이지들: 홈, 로그인, 회원가입, 소개)
|
||||||
|
* - P0601~P0602 (테스트 페이지들)
|
||||||
|
* - P0701 (팝업 페이지들)
|
||||||
|
*/
|
||||||
|
export const MOCK_PERMISSIONS: UserPermissions = {
|
||||||
|
resources: {
|
||||||
|
// 페이지그룹 리소스들 (최상위 레벨)
|
||||||
|
pageGroups: [
|
||||||
|
{
|
||||||
|
oid: 1,
|
||||||
|
code: "PG01",
|
||||||
|
name: "테스트",
|
||||||
|
type: "PAGE_GROUP",
|
||||||
|
sortOrder: 1,
|
||||||
|
description: "테스트 관련 페이지그룹",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 2,
|
||||||
|
code: "PG02",
|
||||||
|
name: "관리자",
|
||||||
|
type: "PAGE_GROUP",
|
||||||
|
sortOrder: 2,
|
||||||
|
description: "관리자 전용 페이지그룹",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 3,
|
||||||
|
code: "PG03",
|
||||||
|
name: "공통 팝업",
|
||||||
|
type: "PAGE_GROUP",
|
||||||
|
sortOrder: 2,
|
||||||
|
description: "공통 팝업 페이지그룹",
|
||||||
|
menuYn: "N",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// 페이지 리소스들 (화면 메뉴 구성에 맞춤)
|
||||||
|
pages: [
|
||||||
|
// 테스트 페이지그룹 하위 페이지들 (PG01 > P0101~P0115)
|
||||||
|
{
|
||||||
|
oid: 5,
|
||||||
|
code: "P0101",
|
||||||
|
name: "테스트",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 1,
|
||||||
|
description: "기본 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 50,
|
||||||
|
code: "P010101",
|
||||||
|
name: "테스트 등록 팝업",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/registerPopup",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 1,
|
||||||
|
description: "기본 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 6,
|
||||||
|
code: "P0102",
|
||||||
|
name: "ivg",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/igv",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 2,
|
||||||
|
description: "IGV 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 7,
|
||||||
|
code: "P0103",
|
||||||
|
name: "ivg2",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/igv2",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 3,
|
||||||
|
description: "IGV2 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 8,
|
||||||
|
code: "P0104",
|
||||||
|
name: "pathway",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/pathway",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 4,
|
||||||
|
description: "경로 분석 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 9,
|
||||||
|
code: "P0105",
|
||||||
|
name: "pathway2",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/pathway2",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 5,
|
||||||
|
description: "경로 분석 페이지 2",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 10,
|
||||||
|
code: "P0106",
|
||||||
|
name: "pathway3",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/pathway3",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 6,
|
||||||
|
description: "경로 분석 페이지 3",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 11,
|
||||||
|
code: "P0107",
|
||||||
|
name: "pathway4",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/pathway4",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 7,
|
||||||
|
description: "경로 분석 페이지 4",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 12,
|
||||||
|
code: "P0108",
|
||||||
|
name: "pathwayjson",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/pathwayJson",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 8,
|
||||||
|
description: "경로 분석 JSON 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 13,
|
||||||
|
code: "P0109",
|
||||||
|
name: "배양그래프",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/culture-graph",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 9,
|
||||||
|
description: "배양 그래프 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 14,
|
||||||
|
code: "P0110",
|
||||||
|
name: "배양그래프 멀티",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/culture-graph-multi",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 10,
|
||||||
|
description: "배양 그래프 멀티 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 15,
|
||||||
|
code: "P0111",
|
||||||
|
name: "배양그래프 탭",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/culture-graph-tab",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 11,
|
||||||
|
description: "배양 그래프 탭 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 16,
|
||||||
|
code: "P0112",
|
||||||
|
name: "tui-grid",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/tui",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 12,
|
||||||
|
description: "TUI 그리드 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 17,
|
||||||
|
code: "P0113",
|
||||||
|
name: "리소스",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/admin/resource",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 13,
|
||||||
|
description: "리소스 관리 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 18,
|
||||||
|
code: "P0114",
|
||||||
|
name: "sample",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/sampleList",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 14,
|
||||||
|
description: "샘플 목록 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 19,
|
||||||
|
code: "P0115",
|
||||||
|
name: "공용 기능 테스트",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/common-test",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 15,
|
||||||
|
description: "공용 기능 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 20,
|
||||||
|
code: "P0116",
|
||||||
|
name: "권한 시스템 테스트",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/permission-test",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 16,
|
||||||
|
description: "권한 시스템 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 25,
|
||||||
|
code: "P0117",
|
||||||
|
name: "등록",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/register",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 17,
|
||||||
|
description: "테스트 등록 페이지",
|
||||||
|
menuYn: "N",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 26,
|
||||||
|
code: "P0118",
|
||||||
|
name: "molstar",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/molstar",
|
||||||
|
parentCode: "PG01",
|
||||||
|
sortOrder: 18,
|
||||||
|
description: "Mol* 뷰어 테스트 페이지",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 관리자 페이지그룹 하위 페이지들 (PG02 > P0201~P0203)
|
||||||
|
{
|
||||||
|
oid: 21,
|
||||||
|
code: "P0201",
|
||||||
|
name: "접속기록",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/admin/logs",
|
||||||
|
parentCode: "PG02",
|
||||||
|
sortOrder: 1,
|
||||||
|
description: "사용자 접속 기록 관리",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 22,
|
||||||
|
code: "P0202",
|
||||||
|
name: "공통코드",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/admin/codes",
|
||||||
|
parentCode: "PG02",
|
||||||
|
sortOrder: 2,
|
||||||
|
description: "공통 코드 관리",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 23,
|
||||||
|
code: "P0203",
|
||||||
|
name: "프로그램",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/admin/programs",
|
||||||
|
parentCode: "PG02",
|
||||||
|
sortOrder: 3,
|
||||||
|
description: "프로그램 관리",
|
||||||
|
menuYn: "Y",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 24,
|
||||||
|
code: "P0204",
|
||||||
|
name: "등록",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/admin/register",
|
||||||
|
parentCode: "PG02",
|
||||||
|
sortOrder: 4,
|
||||||
|
description: "관리자 등록 페이지",
|
||||||
|
menuYn: "N",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 25,
|
||||||
|
code: "P0301",
|
||||||
|
name: "부서 조회 팝업",
|
||||||
|
type: "PAGE",
|
||||||
|
path: "/test/departmentPopup",
|
||||||
|
parentCode: "PG03",
|
||||||
|
sortOrder: 1,
|
||||||
|
description: "부서 조회 팝업",
|
||||||
|
menuYn: "N",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// 컴포넌트 리소스들 (페이지 하위)
|
||||||
|
components: [
|
||||||
|
// 테스트 페이지 하위 컴포넌트들 (P0101 > C010101~C010106)
|
||||||
|
{
|
||||||
|
oid: 19,
|
||||||
|
code: "C010101",
|
||||||
|
name: "삭제 버튼",
|
||||||
|
type: "COMPONENT",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 1,
|
||||||
|
description: "테스트 삭제 버튼",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 20,
|
||||||
|
code: "C010102",
|
||||||
|
name: "수정 버튼",
|
||||||
|
type: "COMPONENT",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 2,
|
||||||
|
description: "테스트 수정 버튼",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 21,
|
||||||
|
code: "C010103",
|
||||||
|
name: "내보내기 버튼",
|
||||||
|
type: "COMPONENT",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 3,
|
||||||
|
description: "테스트 내보내기 버튼",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 22,
|
||||||
|
code: "C010104",
|
||||||
|
name: "가져오기 버튼",
|
||||||
|
type: "COMPONENT",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 4,
|
||||||
|
description: "테스트 가져오기 버튼",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 23,
|
||||||
|
code: "C010105",
|
||||||
|
name: "생성 버튼",
|
||||||
|
type: "COMPONENT",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 5,
|
||||||
|
description: "테스트 생성 버튼",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
oid: 24,
|
||||||
|
code: "C010106",
|
||||||
|
name: "보기 버튼",
|
||||||
|
type: "COMPONENT",
|
||||||
|
parentCode: "P0101",
|
||||||
|
sortOrder: 6,
|
||||||
|
description: "테스트 상세보기 버튼",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user