Compare commits
	
		
			37 Commits
		
	
	
		
			8af0cf5a44
			...
			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 | ||
| 0f0317e356 | |||
| 5f1d1f5018 | 
							
								
								
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										4
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -91,6 +91,7 @@ out
 | 
			
		||||
# Nuxt.js build / generate output
 | 
			
		||||
.nuxt
 | 
			
		||||
dist
 | 
			
		||||
.output
 | 
			
		||||
 | 
			
		||||
# Gatsby files
 | 
			
		||||
.cache/
 | 
			
		||||
@@ -144,6 +145,3 @@ dist
 | 
			
		||||
# TODO: where does this rule come from?
 | 
			
		||||
docs/_book
 | 
			
		||||
 | 
			
		||||
# TODO: where does this rule come from?
 | 
			
		||||
test/
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										79
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										79
									
								
								README.md
									
									
									
									
									
								
							@@ -1,21 +1,76 @@
 | 
			
		||||
# bio_frontend
 | 
			
		||||
 | 
			
		||||
## NUXT 3 (VUE 3) 환경
 | 
			
		||||
 | 
			
		||||
- data-list 와 dataList는 동일 변수명으로 인식
 | 
			
		||||
- compnenets 아래의 .vue는 자동 인식(template 내부에서만, 별도 script에서 필요시 선언 필요)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# 구성 요소
 | 
			
		||||
 | 
			
		||||
## components 구성
 | 
			
		||||
 | 
			
		||||
### 폴더 구조
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
components
 | 
			
		||||
 |- base      // 기본 요소(button, input, grid, popup)
 | 
			
		||||
 |- layout    // 레이아웃 요소(header, footer, sidebar, wrapper)
 | 
			
		||||
 |- module    // 특정 기능 단위(card, form, list)
 | 
			
		||||
 |- pages     // 특정 페이지 전용
 | 
			
		||||
components/
 | 
			
		||||
├── base/                    // 기본 UI 요소 (접두사 없이 사용)
 | 
			
		||||
│   ├── button/
 | 
			
		||||
│   │   ├── CommonButton.vue
 | 
			
		||||
│   │   └── 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 구성
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
pages         // 단일 화면(비 탭 요소)
 | 
			
		||||
|- popup      // 팝업 요소
 | 
			
		||||
@@ -25,7 +80,9 @@ pages         // 단일 화면(비 탭 요소)
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
# page(페이지) 생성 요소
 | 
			
		||||
 | 
			
		||||
## 공통 페이지 구성
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
<template>
 | 
			
		||||
    <ContentsWrapper> <!-- wrapper(title) 추가 -->
 | 
			
		||||
@@ -46,12 +103,12 @@ pages         // 단일 화면(비 탭 요소)
 | 
			
		||||
</script>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## Toast(Tui) Grid 사용법
 | 
			
		||||
 | 
			
		||||
한글 설명: https://github.com/nhn/tui.grid/blob/master/packages/toast-ui.grid/docs/v4.0-migration-guide-kor.md
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### 기본 설정
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
<template>
 | 
			
		||||
    <button @click="onAddClick">추가</button>
 | 
			
		||||
@@ -101,6 +158,7 @@ function onUpdateClick() {
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## tree data
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
<template>
 | 
			
		||||
  <ToastGrid
 | 
			
		||||
@@ -227,8 +285,8 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
 | 
			
		||||
2. 사용할 페이지에서 생성한 팝업 추가
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### 팝업 생성
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
// examplePopup.vue
 | 
			
		||||
<template>
 | 
			
		||||
@@ -258,6 +316,7 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### 팝업 사용
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
<template>
 | 
			
		||||
  <button @click="popupShow = true">팝업 실행</button>
 | 
			
		||||
@@ -269,8 +328,8 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
 | 
			
		||||
</script>
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
## 탭 구성
 | 
			
		||||
 | 
			
		||||
```
 | 
			
		||||
// layouts/default.vue
 | 
			
		||||
// stores/tab.ts
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										3
									
								
								app.vue
									
									
									
									
									
								
							
							
						
						
									
										3
									
								
								app.vue
									
									
									
									
									
								
							@@ -2,6 +2,9 @@
 | 
			
		||||
  <NuxtLayout>
 | 
			
		||||
    <NuxtPage :keepalive="true" />
 | 
			
		||||
  </NuxtLayout>
 | 
			
		||||
 | 
			
		||||
  <!-- 전역 로딩 오버레이 -->
 | 
			
		||||
  <GlobalLoading :show-details="true" />
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<!-- <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,14 +1,13 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div v-if="show" class="popup-overlay" @click.self="show = false">
 | 
			
		||||
    <div class="popup-container">
 | 
			
		||||
      <slot></slot>
 | 
			
		||||
      <slot />
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
const show = defineModel('show', {type: Boolean, default:false});
 | 
			
		||||
 | 
			
		||||
const show = defineModel("show", { type: Boolean, default: false });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
@@ -15,7 +15,7 @@
 | 
			
		||||
</template>
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
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 currentTab = ref(0);
 | 
			
		||||
</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"
 | 
			
		||||
  >
 | 
			
		||||
    <nav class="flex justify-center space-x-4">
 | 
			
		||||
      <!-- HOME 메뉴 -->
 | 
			
		||||
      <!-- 권한 기반 메뉴 -->
 | 
			
		||||
      <button
 | 
			
		||||
        v-for="menu in availableMenus"
 | 
			
		||||
        :key="menu.code"
 | 
			
		||||
        class="menu-btn"
 | 
			
		||||
        :class="{ active: modelValue === 'home' }"
 | 
			
		||||
        @click="$emit('update:modelValue', 'home')"
 | 
			
		||||
        :class="{ active: modelValue === menu.code }"
 | 
			
		||||
        @click="onMenuClick(menu.code)"
 | 
			
		||||
      >
 | 
			
		||||
        HOME
 | 
			
		||||
      </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')"
 | 
			
		||||
      >
 | 
			
		||||
        관리자 메뉴
 | 
			
		||||
        {{ menu.name }}
 | 
			
		||||
      </button>
 | 
			
		||||
    </nav>
 | 
			
		||||
 | 
			
		||||
    <!-- 사용자 정보 및 드롭다운 -->
 | 
			
		||||
    <div class="user-menu-wrapper">
 | 
			
		||||
      <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">
 | 
			
		||||
          <svg
 | 
			
		||||
            width="24"
 | 
			
		||||
@@ -51,10 +36,6 @@
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <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>
 | 
			
		||||
        <button class="logout-btn" @click="logout">
 | 
			
		||||
          <svg
 | 
			
		||||
@@ -76,39 +57,42 @@
 | 
			
		||||
  </header>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted, onBeforeUnmount } from "vue";
 | 
			
		||||
import { useRouter } from "vue-router";
 | 
			
		||||
import { useUserStore } from "~/stores/user";
 | 
			
		||||
 | 
			
		||||
defineProps({
 | 
			
		||||
  modelValue: {
 | 
			
		||||
    type: String,
 | 
			
		||||
    required: true,
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
defineEmits(["update:modelValue"]);
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
const modelValue = defineModel({ type: String, required: true });
 | 
			
		||||
 | 
			
		||||
const showDropdown = ref(false);
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
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() {
 | 
			
		||||
  showDropdown.value = !showDropdown.value;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function handleClickOutside(event) {
 | 
			
		||||
function handleClickOutside(event: MouseEvent) {
 | 
			
		||||
  const menu = document.querySelector(".user-menu-wrapper");
 | 
			
		||||
  if (menu && !menu.contains(event.target)) {
 | 
			
		||||
  if (menu && !menu.contains(event.target as Node)) {
 | 
			
		||||
    showDropdown.value = false;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function logout() {
 | 
			
		||||
  showDropdown.value = false;
 | 
			
		||||
  await userStore.logout();
 | 
			
		||||
  router.push("/login");
 | 
			
		||||
  userStore.logout();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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>
 | 
			
		||||
  <customPopup v-model:show="show">
 | 
			
		||||
  <CommonPopup v-model:show="show">
 | 
			
		||||
    <div class="popup-content" :style="{ width, height }">
 | 
			
		||||
      <!-- Top -->
 | 
			
		||||
      <div class="popup-top">
 | 
			
		||||
@@ -17,22 +17,19 @@
 | 
			
		||||
        <slot name="bottom"></slot>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </customPopup>
 | 
			
		||||
  </CommonPopup>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
 | 
			
		||||
defineProps<{
 | 
			
		||||
  width?: string
 | 
			
		||||
  height?: string
 | 
			
		||||
}>()
 | 
			
		||||
  width?: string;
 | 
			
		||||
  height?: string;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
// defineModel + 기본값 지정
 | 
			
		||||
const show = defineModel('show', {type: Boolean, default:false});
 | 
			
		||||
 | 
			
		||||
const show = defineModel("show", { type: Boolean, default: false });
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.popup-content {
 | 
			
		||||
  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 }
 | 
			
		||||
}
 | 
			
		||||
@@ -3,34 +3,132 @@
 | 
			
		||||
 *
 | 
			
		||||
 * @template T - 응답 데이터의 타입
 | 
			
		||||
 * @param path - API 엔드포인트 경로 (예: '/users', '/users/1')
 | 
			
		||||
 * @param opts - 요청 옵션 (method, body, headers 등)
 | 
			
		||||
 * @param options - 요청 및 에러 처리 옵션
 | 
			
		||||
 * @returns Promise<T> - API 응답 데이터
 | 
			
		||||
 *
 | 
			
		||||
 * @example
 | 
			
		||||
 * // GET 요청
 | 
			
		||||
 * // GET 요청 (기본 - 전역 로딩 자동 적용)
 | 
			
		||||
 * const users = await useApi<User[]>('/users')
 | 
			
		||||
 *
 | 
			
		||||
 * // POST 요청
 | 
			
		||||
 * // POST 요청 (커스텀 로딩 메시지)
 | 
			
		||||
 * const newUser = await useApi<User>('/users', {
 | 
			
		||||
 *   method: 'POST',
 | 
			
		||||
 *   body: { name: 'John', email: 'john@example.com' }
 | 
			
		||||
 *   body: { name: 'John', email: 'john@example.com' },
 | 
			
		||||
 *   loadingMessage: '사용자를 생성하는 중...'
 | 
			
		||||
 * })
 | 
			
		||||
 *
 | 
			
		||||
 * // PUT 요청
 | 
			
		||||
 * const updatedUser = await useApi<User>('/users/1', {
 | 
			
		||||
 *   method: 'PUT',
 | 
			
		||||
 *   body: { name: 'John Updated' }
 | 
			
		||||
 * // 전역 로딩 없이 API 호출
 | 
			
		||||
 * const data = await useApi<User[]>('/users', {
 | 
			
		||||
 *   useGlobalLoading: false
 | 
			
		||||
 * })
 | 
			
		||||
 *
 | 
			
		||||
 * // DELETE 요청
 | 
			
		||||
 * await useApi('/users/1', { method: 'DELETE' })
 | 
			
		||||
 * // 에러를 직접 처리
 | 
			
		||||
 * try {
 | 
			
		||||
 *   const data = await useApi<User[]>('/users', { handleError: false })
 | 
			
		||||
 * } catch (error) {
 | 
			
		||||
 *   // 직접 에러 처리
 | 
			
		||||
 * }
 | 
			
		||||
 *
 | 
			
		||||
 * // FormData 업로드
 | 
			
		||||
 * const formData = new FormData()
 | 
			
		||||
 * formData.append('file', file)
 | 
			
		||||
 * await useApi('/upload', { method: 'POST', body: formData })
 | 
			
		||||
 * await useApi('/upload', {
 | 
			
		||||
 *   method: 'POST',
 | 
			
		||||
 *   body: formData,
 | 
			
		||||
 *   loadingMessage: '파일을 업로드하는 중...'
 | 
			
		||||
 * })
 | 
			
		||||
 */
 | 
			
		||||
export const useApi = <T>(path: string, opts?: any): Promise<T> => {
 | 
			
		||||
export const useApi = async <T>(
 | 
			
		||||
  path: string,
 | 
			
		||||
  options?: {
 | 
			
		||||
    // API 요청 옵션
 | 
			
		||||
    method?: string;
 | 
			
		||||
    body?: any;
 | 
			
		||||
    headers?: Record<string, string>;
 | 
			
		||||
    // 에러 처리 옵션
 | 
			
		||||
    handleError?: boolean; // true: 에러를 null로 반환, false: 에러를 다시 던짐
 | 
			
		||||
    showAlert?: boolean; // true: 에러 시 alert 표시
 | 
			
		||||
    // 로딩 옵션
 | 
			
		||||
    loadingMessage?: string; // 로딩 메시지
 | 
			
		||||
    useGlobalLoading?: boolean; // 전역 로딩 사용 여부 (기본값: true)
 | 
			
		||||
  }
 | 
			
		||||
): Promise<T> => {
 | 
			
		||||
  const { withLoading } = useLoading();
 | 
			
		||||
 | 
			
		||||
  // API 호출 로직을 별도 함수로 분리
 | 
			
		||||
  const apiCall = async (): Promise<T> => {
 | 
			
		||||
    const { $api } = useNuxtApp();
 | 
			
		||||
  return ($api as any)(path, opts);
 | 
			
		||||
 | 
			
		||||
    // 기본값 설정
 | 
			
		||||
    const {
 | 
			
		||||
      method = "GET",
 | 
			
		||||
      body,
 | 
			
		||||
      headers,
 | 
			
		||||
      handleError = true,
 | 
			
		||||
      showAlert = true,
 | 
			
		||||
    } = options || {};
 | 
			
		||||
 | 
			
		||||
    // API 요청 옵션 구성
 | 
			
		||||
    const apiOpts = {
 | 
			
		||||
      method,
 | 
			
		||||
      ...(body && { body }),
 | 
			
		||||
      ...(headers && { headers }),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    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) {
 | 
			
		||||
        let description =
 | 
			
		||||
          status === 404
 | 
			
		||||
            ? "요청한 리소스를 찾을 수 없습니다."
 | 
			
		||||
            : status === 500
 | 
			
		||||
              ? "서버 오류가 발생했습니다."
 | 
			
		||||
              : "요청 처리 중 오류가 발생했습니다.";
 | 
			
		||||
 | 
			
		||||
        // 서버에서 온 에러 메시지가 있으면 우선 사용
 | 
			
		||||
        if (error.response?._data?.description) {
 | 
			
		||||
          description = error.response._data.description;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        alert(description);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 에러 처리 방식에 따라 반환
 | 
			
		||||
      if (handleError) {
 | 
			
		||||
        return null as T; // 에러를 null로 반환
 | 
			
		||||
      } else {
 | 
			
		||||
        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() {
 | 
			
		||||
  if (import.meta.server) {
 | 
			
		||||
    // SSR에서는 cytoscape-overlays를 사용하지 않음
 | 
			
		||||
    return null
 | 
			
		||||
  }
 | 
			
		||||
  // 전체 export 객체를 반환
 | 
			
		||||
  return await import('cytoscape-overlays')
 | 
			
		||||
  // SPA 모드에서는 항상 클라이언트에서만 실행됨
 | 
			
		||||
  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>
 | 
			
		||||
  <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>
 | 
			
		||||
      <button
 | 
			
		||||
        v-for="sub in subMenus"
 | 
			
		||||
        :key="sub.key"
 | 
			
		||||
        class="submenu-btn"
 | 
			
		||||
        @click="onSubMenuClick({ ...sub, componentName: sub.key })"
 | 
			
		||||
      >
 | 
			
		||||
        {{ sub.label }}
 | 
			
		||||
      </button>
 | 
			
		||||
    </nav>
 | 
			
		||||
    <br><br>
 | 
			
		||||
    <SubMenuBar
 | 
			
		||||
      :show-submenu-bar="showSubmenuBar"
 | 
			
		||||
      :active-menu="activeMenu"
 | 
			
		||||
      :sub-menus="subMenus"
 | 
			
		||||
      @submenu-click="onSubMenuClick"
 | 
			
		||||
    />
 | 
			
		||||
    <!-- 탭 바 -->
 | 
			
		||||
    <div class="tab-bar">
 | 
			
		||||
      <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>
 | 
			
		||||
    <TabBar />
 | 
			
		||||
 | 
			
		||||
    <main class="main">
 | 
			
		||||
      <slot />
 | 
			
		||||
@@ -152,6 +18,59 @@ function addNewTab() {
 | 
			
		||||
  </div>
 | 
			
		||||
</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>
 | 
			
		||||
.layout {
 | 
			
		||||
@@ -171,62 +90,4 @@ function addNewTab() {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  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>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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
 | 
			
		||||
export default defineNuxtConfig({
 | 
			
		||||
  compatibilityDate: "2025-05-15",
 | 
			
		||||
  ssr: false,
 | 
			
		||||
  devtools: { enabled: true },
 | 
			
		||||
  modules: [
 | 
			
		||||
    "@nuxt/eslint",
 | 
			
		||||
@@ -10,16 +10,8 @@ export default defineNuxtConfig({
 | 
			
		||||
    "pinia-plugin-persistedstate/nuxt",
 | 
			
		||||
    "@nuxtjs/tailwindcss",
 | 
			
		||||
  ],
 | 
			
		||||
  app: {
 | 
			
		||||
    head: {
 | 
			
		||||
      link: [
 | 
			
		||||
        {
 | 
			
		||||
          rel: "stylesheet",
 | 
			
		||||
          href: "https://fonts.googleapis.com/icon?family=Material+Icons",
 | 
			
		||||
        },
 | 
			
		||||
      ],
 | 
			
		||||
      //script: [{ src: "/dist/igv.js", defer: true }],
 | 
			
		||||
    },
 | 
			
		||||
  piniaPluginPersistedstate: {
 | 
			
		||||
    storage: "localStorage",
 | 
			
		||||
  },
 | 
			
		||||
  vite: {
 | 
			
		||||
    optimizeDeps: {
 | 
			
		||||
@@ -44,8 +36,10 @@ export default defineNuxtConfig({
 | 
			
		||||
    shim: false,
 | 
			
		||||
    strict: true,
 | 
			
		||||
  },
 | 
			
		||||
  plugins: ["~/plugins/vue3-tui-grid.client.ts"],
 | 
			
		||||
  plugins: ["~/plugins/vue3-tui-grid.ts"],
 | 
			
		||||
  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/icon": "^1.14.0",
 | 
			
		||||
    "@nuxt/image": "^1.10.0",
 | 
			
		||||
    "@nuxtjs/tailwindcss": "^7.0.0-beta.0",
 | 
			
		||||
    "@pinia/nuxt": "^0.11.2",
 | 
			
		||||
    "ag-grid-community": "^34.0.0",
 | 
			
		||||
    "ag-grid-vue3": "^34.0.0",
 | 
			
		||||
@@ -25,6 +24,8 @@
 | 
			
		||||
    "cytoscape-overlays": "^2.0.0",
 | 
			
		||||
    "echarts": "^5.6.0",
 | 
			
		||||
    "eslint": "^9.29.0",
 | 
			
		||||
    "ipx": "^3.1.1",
 | 
			
		||||
    "molstar": "^5.1.2",
 | 
			
		||||
    "nuxt": "^3.17.5",
 | 
			
		||||
    "pinia": "^3.0.3",
 | 
			
		||||
    "pinia-plugin-persistedstate": "^4.5.0",
 | 
			
		||||
@@ -35,11 +36,12 @@
 | 
			
		||||
    "vue3-tui-grid": "^0.1.51"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@nuxtjs/tailwindcss": "^6.12.0",
 | 
			
		||||
    "autoprefixer": "^10.4.21",
 | 
			
		||||
    "patch-package": "^8.0.0",
 | 
			
		||||
    "postcss": "^8.5.6",
 | 
			
		||||
    "postinstall-postinstall": "^2.1.0",
 | 
			
		||||
    "prettier": "^3.6.2",
 | 
			
		||||
    "tailwindcss": "^4.1.11"
 | 
			
		||||
    "tailwindcss": "^3.4.0"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,30 +4,26 @@
 | 
			
		||||
      <button @click="onAddClick">추가</button>
 | 
			
		||||
      <button @click="onUpdateClick">저장</button>
 | 
			
		||||
    </template>
 | 
			
		||||
        <input type="text" >
 | 
			
		||||
        <ToastGrid  
 | 
			
		||||
            ref="grid1Ref"
 | 
			
		||||
            :data="data"
 | 
			
		||||
            :columns="colDefs"
 | 
			
		||||
        />  
 | 
			
		||||
    <input type="text" />
 | 
			
		||||
    <ToastGrid ref="grid1Ref" :data="data" :columns="colDefs" />
 | 
			
		||||
  </ContentsWrapper>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import {colDefs} from '../../../composables/grids/resourceGrid'
 | 
			
		||||
import { colDefs } from "../../../constants/resourceGrid";
 | 
			
		||||
 | 
			
		||||
definePageMeta({
 | 
			
		||||
    title: '리소스 관리'
 | 
			
		||||
})
 | 
			
		||||
  title: "리소스 관리",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const data = [{}]
 | 
			
		||||
const data = [{}];
 | 
			
		||||
 | 
			
		||||
const grid1Ref = ref();
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  await nextTick() // DOM 및 컴포넌트 렌더링 완료 대기
 | 
			
		||||
  grid1Ref.value?.api()?.setBodyHeight('700')
 | 
			
		||||
})
 | 
			
		||||
  await nextTick(); // DOM 및 컴포넌트 렌더링 완료 대기
 | 
			
		||||
  grid1Ref.value?.api()?.setBodyHeight("700");
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function onAddClick() {
 | 
			
		||||
  grid1Ref.value?.api()?.appendRow({});
 | 
			
		||||
@@ -38,4 +34,3 @@ function onUpdateClick() {
 | 
			
		||||
  console.log(grid1Ref.value?.api()?.getModifiedRows());
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
  
 | 
			
		||||
@@ -5,7 +5,7 @@
 | 
			
		||||
    <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
 | 
			
		||||
          Integrated Bio Foundry Platform(pages/[tabId]/index.vue)
 | 
			
		||||
        </h1>
 | 
			
		||||
        <p class="text-xl text-gray-600">
 | 
			
		||||
          통합 바이오 파운드리 플랫폼에 오신 것을 환영합니다
 | 
			
		||||
@@ -23,19 +23,18 @@
 | 
			
		||||
            }}</span
 | 
			
		||||
            >님!
 | 
			
		||||
          </p>
 | 
			
		||||
          <p class="text-sm text-gray-600">
 | 
			
		||||
            {{ userStore.isAdmin ? "관리자" : "사용자" }} 권한으로
 | 
			
		||||
            로그인되었습니다.
 | 
			
		||||
          </p>
 | 
			
		||||
          <p class="text-sm text-gray-600">
 | 
			
		||||
            <button
 | 
			
		||||
              @click="
 | 
			
		||||
                useApi<ApiResponse<{}>>('/files/download/1756167537354001', {
 | 
			
		||||
                  method: 'get',
 | 
			
		||||
                })
 | 
			
		||||
              "
 | 
			
		||||
              class="mr-2 bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded"
 | 
			
		||||
              @click="apiTest"
 | 
			
		||||
            >
 | 
			
		||||
              Test
 | 
			
		||||
              자동 에러 처리
 | 
			
		||||
            </button>
 | 
			
		||||
            <button
 | 
			
		||||
              class="bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded"
 | 
			
		||||
              @click="apiTestWithCustomError"
 | 
			
		||||
            >
 | 
			
		||||
              직접 에러 처리
 | 
			
		||||
            </button>
 | 
			
		||||
          </p>
 | 
			
		||||
        </div>
 | 
			
		||||
@@ -110,15 +109,53 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { useUserStore } from "~/stores/user";
 | 
			
		||||
 | 
			
		||||
// 페이지 메타데이터 설정
 | 
			
		||||
definePageMeta({
 | 
			
		||||
  title: "Home",
 | 
			
		||||
  description: "Welcome to our Nuxt.js application",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore();
 | 
			
		||||
 | 
			
		||||
// 테스트 다운로드 함수 (자동 에러 처리)
 | 
			
		||||
const apiTest = async () => {
 | 
			
		||||
  const response = await useApi<ApiResponse<object>>(
 | 
			
		||||
    "/admin/common-codes/USER_STATUS_ACTIVE222"
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  if (response) {
 | 
			
		||||
    console.log("response:", response);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 직접 에러 처리하는 함수 예시
 | 
			
		||||
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("[errorCustomHandler]요청한 코드를 찾을 수 없습니다.");
 | 
			
		||||
    } else if (error.response?.status === 403) {
 | 
			
		||||
      alert("[errorCustomHandler]접근 권한이 없습니다.");
 | 
			
		||||
    } else if (error.response?.status >= 500) {
 | 
			
		||||
      alert(
 | 
			
		||||
        "[errorCustomHandler]서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      alert("[errorCustomHandler]알 수 없는 오류가 발생했습니다.");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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>
 | 
			
		||||
							
								
								
									
										413
									
								
								pages/[tabId]/test/culture-graph-multi.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										413
									
								
								pages/[tabId]/test/culture-graph-multi.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,413 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <PageDescription>
 | 
			
		||||
      <h1>배양 그래프 (멀티)</h1>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        <h2>1. 그래프 구성 및 기능</h2>
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>총 30개의 그래프를 한 화면에 동시에 볼 수 있습니다.</li>
 | 
			
		||||
          <li>렌더링 최적화를 위해 최소한의 기능만 제공됩니다.</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        <h2>2. 출력되는 데이터 개수</h2>
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>
 | 
			
		||||
            그래프 30개 × 시리즈 12개 × 10분 간격(100시간, 601포인트) =
 | 
			
		||||
            <span class="highlight">216,360개</span>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li>화면 렌더링 시간은 약 600ms ~ 800ms 정도 소요됩니다.</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        <h2>3. 그래프 복사</h2>
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>
 | 
			
		||||
            <span class="highlight">그래프 복사</span> 버튼을 클릭하면 그래프가
 | 
			
		||||
            클립보드에 복사됩니다.
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
    </PageDescription>
 | 
			
		||||
    <div class="multi-graph-list">
 | 
			
		||||
      <div v-for="series in seriesList" :key="series.name" class="single-graph">
 | 
			
		||||
        <div class="graph-title">
 | 
			
		||||
          {{ series.name }}<span v-if="series.unit"> {{ series.unit }}</span>
 | 
			
		||||
          <button class="copy-btn" @click="copyChartImage(series.name)">
 | 
			
		||||
            그래프 복사
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div
 | 
			
		||||
          :ref="el => setGraphRef(series.name, el as Element | null)"
 | 
			
		||||
          class="echarts-graph"
 | 
			
		||||
        ></div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted, nextTick } from "vue";
 | 
			
		||||
import * as echarts from "echarts";
 | 
			
		||||
 | 
			
		||||
// 파스텔 컬러 15개 반복
 | 
			
		||||
const pastelColors = [
 | 
			
		||||
  "#A3D8F4",
 | 
			
		||||
  "#F7B7A3",
 | 
			
		||||
  "#B5EAD7",
 | 
			
		||||
  "#FFDAC1",
 | 
			
		||||
  "#C7CEEA",
 | 
			
		||||
  "#FFF1BA",
 | 
			
		||||
  "#FFB7B2",
 | 
			
		||||
  "#B4A7D6",
 | 
			
		||||
  "#AED9E0",
 | 
			
		||||
  "#FFC3A0",
 | 
			
		||||
  "#E2F0CB",
 | 
			
		||||
  "#FFB347",
 | 
			
		||||
  "#C1C8E4",
 | 
			
		||||
  "#FFFACD",
 | 
			
		||||
  "#FFD1DC",
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
// 그래프 개수 변수로 분리
 | 
			
		||||
const NUM_GRAPHS = 30;
 | 
			
		||||
 | 
			
		||||
// 30개 y축 정보 (culture-graph.vue와 동일하게 수정)
 | 
			
		||||
const yAxisList = [
 | 
			
		||||
  { name: "ORP", unit: "", color: pastelColors[0], min: 0, max: 1000 },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Air flow",
 | 
			
		||||
    unit: "(L/min)",
 | 
			
		||||
    color: pastelColors[1],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 30,
 | 
			
		||||
  },
 | 
			
		||||
  { name: "DO", unit: "", color: pastelColors[2], min: 0, max: 200 },
 | 
			
		||||
  { name: "Feed TK1", unit: "(L)", color: pastelColors[3], min: 0, max: 10 },
 | 
			
		||||
  { name: "Feed TK2", unit: "(L)", color: pastelColors[4], min: 0, max: 10 },
 | 
			
		||||
  { name: "pH", unit: "", color: pastelColors[5], min: 6.0, max: 8.0 },
 | 
			
		||||
  { name: "Pressure", unit: "(bar)", color: pastelColors[6], min: 0, max: 2 },
 | 
			
		||||
  { name: "RPM", unit: "", color: pastelColors[7], min: 0, max: 3000 },
 | 
			
		||||
  { name: "CO2", unit: "(%)", color: pastelColors[8], min: 0, max: 10 },
 | 
			
		||||
  { name: "JAR Vol", unit: "(L)", color: pastelColors[9], min: 0, max: 20 },
 | 
			
		||||
  { name: "WEIGHT", unit: "(kg)", color: pastelColors[10], min: 0, max: 100 },
 | 
			
		||||
  { name: "O2", unit: "(%)", color: pastelColors[11], min: 0, max: 100 },
 | 
			
		||||
  { name: "NV", unit: "", color: pastelColors[12], min: 0, max: 10 },
 | 
			
		||||
  { name: "NIR", unit: "", color: pastelColors[13], min: 0, max: 10 },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Temperature",
 | 
			
		||||
    unit: "(℃)",
 | 
			
		||||
    color: pastelColors[14],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 50,
 | 
			
		||||
  },
 | 
			
		||||
  { name: "Humidity", unit: "(%)", color: pastelColors[0], min: 0, max: 100 },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Flow Rate",
 | 
			
		||||
    unit: "(L/min)",
 | 
			
		||||
    color: pastelColors[1],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 60,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Conductivity",
 | 
			
		||||
    unit: "(μS/cm)",
 | 
			
		||||
    color: pastelColors[2],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 600,
 | 
			
		||||
  },
 | 
			
		||||
  { name: "Turbidity", unit: "(NTU)", color: pastelColors[3], min: 0, max: 12 },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Dissolved Solids",
 | 
			
		||||
    unit: "(mg/L)",
 | 
			
		||||
    color: pastelColors[4],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 250,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Alkalinity",
 | 
			
		||||
    unit: "(mg/L)",
 | 
			
		||||
    color: pastelColors[5],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 120,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Hardness",
 | 
			
		||||
    unit: "(mg/L)",
 | 
			
		||||
    color: pastelColors[6],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 100,
 | 
			
		||||
  },
 | 
			
		||||
  { name: "Chlorine", unit: "(mg/L)", color: pastelColors[7], min: 0, max: 6 },
 | 
			
		||||
  { name: "Nitrate", unit: "(mg/L)", color: pastelColors[8], min: 0, max: 25 },
 | 
			
		||||
  {
 | 
			
		||||
    name: "Phosphate",
 | 
			
		||||
    unit: "(mg/L)",
 | 
			
		||||
    color: pastelColors[9],
 | 
			
		||||
    min: 0,
 | 
			
		||||
    max: 12,
 | 
			
		||||
  },
 | 
			
		||||
  { name: "Sulfate", unit: "(mg/L)", color: pastelColors[10], min: 0, max: 60 },
 | 
			
		||||
  { name: "Ammonia", unit: "(mg/L)", color: pastelColors[11], min: 0, max: 18 },
 | 
			
		||||
  { name: "Nitrite", unit: "(mg/L)", color: pastelColors[12], min: 0, max: 6 },
 | 
			
		||||
  { name: "BOD", unit: "(mg/L)", color: pastelColors[13], min: 0, max: 35 },
 | 
			
		||||
  { name: "COD", unit: "(mg/L)", color: pastelColors[14], min: 0, max: 120 },
 | 
			
		||||
];
 | 
			
		||||
// NUM_GRAPHS까지 자동 생성
 | 
			
		||||
if (yAxisList.length < NUM_GRAPHS) {
 | 
			
		||||
  for (let i = yAxisList.length; i < NUM_GRAPHS; i++) {
 | 
			
		||||
    yAxisList.push({
 | 
			
		||||
      name: `Graph ${i + 1}`,
 | 
			
		||||
      unit: "",
 | 
			
		||||
      color: pastelColors[i % pastelColors.length],
 | 
			
		||||
      min: 0,
 | 
			
		||||
      max: 100 + (i % 10) * 100, // 100, 200, ... 1000 반복
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// x축 간격(초) - 5초 또는 1분(60초) 등으로 변경 가능
 | 
			
		||||
// 예시: const X_INTERVAL = 5;   // 5초 간격
 | 
			
		||||
//      const X_INTERVAL = 60;  // 1분 간격
 | 
			
		||||
const X_INTERVAL = 600; // 필요시 60으로 변경
 | 
			
		||||
// 100시간치, X_INTERVAL 간격
 | 
			
		||||
const xLabels = Array.from(
 | 
			
		||||
  { length: (100 * 60 * 60) / X_INTERVAL + 1 },
 | 
			
		||||
  (_, i) => i * X_INTERVAL
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
// 부드러운 곡선형 + 구간별 변화 가데이터 생성 함수 (xLabels를 파라미터로 받도록 변경)
 | 
			
		||||
function smoothData(
 | 
			
		||||
  min: number,
 | 
			
		||||
  max: number,
 | 
			
		||||
  xLabels: number[],
 | 
			
		||||
  phase = 0,
 | 
			
		||||
  _amp = 1, // amp는 사용하지 않으므로 _amp로 변경
 | 
			
		||||
  offset = 0,
 | 
			
		||||
  seriesIndex: number
 | 
			
		||||
) {
 | 
			
		||||
  let _prevValue = 0.5;
 | 
			
		||||
  const values = [];
 | 
			
		||||
 | 
			
		||||
  // 데이터 범위 축소: min+10% ~ max-10%
 | 
			
		||||
  const rangeMin = min + (max - min) * 0.1;
 | 
			
		||||
  const rangeMax = max - (max - min) * 0.1;
 | 
			
		||||
 | 
			
		||||
  // 시리즈별 패턴 다양화: 증가/감소/진동/트렌드 섞기
 | 
			
		||||
  // 패턴 결정 (시리즈 인덱스에 따라)
 | 
			
		||||
  const trendType = seriesIndex % 4; // 0:증가, 1:감소, 2:진동, 3:랜덤
 | 
			
		||||
 | 
			
		||||
  // 진동폭(ampVar, randomFactor)은 원복 (더 낮게)
 | 
			
		||||
  const ampVar = 0.2 + (seriesIndex % 5) * 0.1; // 0.2~0.6
 | 
			
		||||
  const randomFactor = 0.01 + 0.01 * (seriesIndex % 4); // 0.01~0.04
 | 
			
		||||
 | 
			
		||||
  for (let i = 0; i < xLabels.length; i++) {
 | 
			
		||||
    const t = i / (xLabels.length - 1);
 | 
			
		||||
    let base;
 | 
			
		||||
 | 
			
		||||
    // 오르내림(파동 주기/계수)을 키움 (파동이 더 자주, 더 크게)
 | 
			
		||||
    if (trendType === 0) {
 | 
			
		||||
      base = 0.2 + 0.6 * t + 0.13 * Math.sin(phase + t * Math.PI * ampVar * 8);
 | 
			
		||||
    } else if (trendType === 1) {
 | 
			
		||||
      base = 0.8 - 0.6 * t + 0.13 * Math.cos(phase + t * Math.PI * ampVar * 8);
 | 
			
		||||
    } else if (trendType === 2) {
 | 
			
		||||
      base = 0.5 + 0.22 * Math.sin(phase + t * Math.PI * ampVar * 12);
 | 
			
		||||
    } else {
 | 
			
		||||
      base = 0.5 + 0.08 * Math.sin(phase + t * Math.PI * ampVar * 6) + 0.2 * t;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 노이즈는 낮게
 | 
			
		||||
    const noise = (Math.random() - 0.5) * randomFactor;
 | 
			
		||||
    base += noise;
 | 
			
		||||
 | 
			
		||||
    base = Math.max(0, Math.min(1, base));
 | 
			
		||||
    const value = +(rangeMin + (rangeMax - rangeMin) * base + offset).toFixed(
 | 
			
		||||
      2
 | 
			
		||||
    );
 | 
			
		||||
    _prevValue = base;
 | 
			
		||||
 | 
			
		||||
    values.push([xLabels[i], value]);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return values;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 시리즈 데이터 30개 생성 → 각 그래프마다 12개 라인(시리즈)로 확장
 | 
			
		||||
const NUM_LINES_PER_GRAPH = 12;
 | 
			
		||||
 | 
			
		||||
const seriesList = yAxisList.map((y, idx) => {
 | 
			
		||||
  // 12개 라인(시리즈) 생성
 | 
			
		||||
  const lines = Array.from({ length: NUM_LINES_PER_GRAPH }, (_, lineIdx) => ({
 | 
			
		||||
    name: String(lineIdx + 1), // "1", "2", ... "12"
 | 
			
		||||
    color: pastelColors[lineIdx % pastelColors.length],
 | 
			
		||||
    data: smoothData(
 | 
			
		||||
      y.min,
 | 
			
		||||
      y.max,
 | 
			
		||||
      xLabels,
 | 
			
		||||
      Math.PI / (idx + 1 + lineIdx), // phase를 다르게
 | 
			
		||||
      1,
 | 
			
		||||
      0,
 | 
			
		||||
      idx + lineIdx * 2 // 패턴 다양화
 | 
			
		||||
    ),
 | 
			
		||||
  }));
 | 
			
		||||
  return {
 | 
			
		||||
    name: y.name,
 | 
			
		||||
    unit: y.unit,
 | 
			
		||||
    color: y.color,
 | 
			
		||||
    min: y.min,
 | 
			
		||||
    max: y.max,
 | 
			
		||||
    lines, // 12개 시리즈 배열
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// 차트 렌더링
 | 
			
		||||
const graphRefs = ref<Record<string, HTMLDivElement | null>>({});
 | 
			
		||||
const chartInstances = ref<Record<string, echarts.ECharts | null>>({});
 | 
			
		||||
function setGraphRef(name: string, el: Element | null) {
 | 
			
		||||
  if (el && el instanceof HTMLDivElement) {
 | 
			
		||||
    graphRefs.value[name] = el;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function renderAllCharts() {
 | 
			
		||||
  nextTick(() => {
 | 
			
		||||
    seriesList.forEach(series => {
 | 
			
		||||
      const el = graphRefs.value[series.name];
 | 
			
		||||
      if (!el) return;
 | 
			
		||||
      if (chartInstances.value[series.name]) {
 | 
			
		||||
        chartInstances.value[series.name]?.dispose();
 | 
			
		||||
      }
 | 
			
		||||
      const chart = echarts.init(el);
 | 
			
		||||
      chart.setOption({
 | 
			
		||||
        grid: { left: 50, right: 20, top: 30, bottom: 40, containLabel: true },
 | 
			
		||||
        xAxis: {
 | 
			
		||||
          type: "value",
 | 
			
		||||
          axisLabel: {
 | 
			
		||||
            fontSize: 11,
 | 
			
		||||
            color: "#666",
 | 
			
		||||
            formatter: (v: number) =>
 | 
			
		||||
              `${Math.floor(v / 3600)}:${String(Math.floor((v % 3600) / 60)).padStart(2, "0")}`,
 | 
			
		||||
          },
 | 
			
		||||
        },
 | 
			
		||||
        yAxis: {
 | 
			
		||||
          type: "value",
 | 
			
		||||
          min: series.min,
 | 
			
		||||
          max: series.max,
 | 
			
		||||
          name: series.unit ? `${series.name} ${series.unit}` : series.name,
 | 
			
		||||
          nameTextStyle: {
 | 
			
		||||
            color: series.color,
 | 
			
		||||
            fontWeight: "bold",
 | 
			
		||||
            fontSize: 12,
 | 
			
		||||
          },
 | 
			
		||||
          axisLabel: { color: series.color, fontWeight: "bold", fontSize: 12 },
 | 
			
		||||
          axisLine: { lineStyle: { color: series.color, width: 2 } },
 | 
			
		||||
        },
 | 
			
		||||
        series: series.lines.map(line => ({
 | 
			
		||||
          name: line.name,
 | 
			
		||||
          data: line.data,
 | 
			
		||||
          type: "line",
 | 
			
		||||
          lineStyle: { color: line.color, width: 1 },
 | 
			
		||||
          symbol: "none",
 | 
			
		||||
        })),
 | 
			
		||||
        animation: false,
 | 
			
		||||
        legend: {
 | 
			
		||||
          show: true,
 | 
			
		||||
          bottom: 8, // 하단에 위치
 | 
			
		||||
          left: "center", // 중앙 정렬
 | 
			
		||||
          orient: "horizontal",
 | 
			
		||||
          itemWidth: 18,
 | 
			
		||||
          itemHeight: 10,
 | 
			
		||||
          icon: "circle",
 | 
			
		||||
          textStyle: { fontSize: 12, padding: [0, 4, 0, 0] },
 | 
			
		||||
        },
 | 
			
		||||
        tooltip: { show: true, trigger: "axis" },
 | 
			
		||||
      });
 | 
			
		||||
      chartInstances.value[series.name] = chart;
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 이미지 복사 함수 추가
 | 
			
		||||
async function copyImageToClipboard(dataUrl: string) {
 | 
			
		||||
  const res = await fetch(dataUrl);
 | 
			
		||||
  const blob = await res.blob();
 | 
			
		||||
  await navigator.clipboard.write([
 | 
			
		||||
    new window.ClipboardItem({ [blob.type]: blob }),
 | 
			
		||||
  ]);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function copyChartImage(name: string) {
 | 
			
		||||
  const chart = chartInstances.value[name];
 | 
			
		||||
  if (!chart) return;
 | 
			
		||||
  const dataUrl = chart.getDataURL({
 | 
			
		||||
    type: "png",
 | 
			
		||||
    pixelRatio: 2,
 | 
			
		||||
    backgroundColor: "#fff",
 | 
			
		||||
  });
 | 
			
		||||
  try {
 | 
			
		||||
    await copyImageToClipboard(dataUrl);
 | 
			
		||||
    alert("그래프가 클립보드에 복사되었습니다!");
 | 
			
		||||
  } catch {
 | 
			
		||||
    alert("클립보드 복사에 실패했습니다. 브라우저 권한을 확인하세요.");
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  renderAllCharts();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.multi-graph-list {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(4, 1fr);
 | 
			
		||||
  gap: 20px 1.5%;
 | 
			
		||||
  padding: 16px 0;
 | 
			
		||||
  align-content: flex-start;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  background: #fff;
 | 
			
		||||
}
 | 
			
		||||
.single-graph {
 | 
			
		||||
  min-height: 260px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  flex: 0 0 100%;
 | 
			
		||||
  max-width: 100%;
 | 
			
		||||
  background: #fff;
 | 
			
		||||
  border: 1px solid #e0e0e0;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
 | 
			
		||||
  padding: 10px 16px 10px 16px;
 | 
			
		||||
  position: relative;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
  align-items: flex-start;
 | 
			
		||||
}
 | 
			
		||||
.graph-title {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  font-size: 1.05rem;
 | 
			
		||||
  color: #1976d2;
 | 
			
		||||
  margin-bottom: 6px;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
.echarts-graph {
 | 
			
		||||
  min-height: 200px;
 | 
			
		||||
  height: 200px !important;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
}
 | 
			
		||||
.copy-btn {
 | 
			
		||||
  margin-left: 10px;
 | 
			
		||||
  font-size: 0.9rem;
 | 
			
		||||
  padding: 2px 8px;
 | 
			
		||||
  border: 1px solid #1976d2;
 | 
			
		||||
  background: #e3f2fd;
 | 
			
		||||
  color: #1976d2;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background 0.2s;
 | 
			
		||||
  float: right;
 | 
			
		||||
}
 | 
			
		||||
.copy-btn:hover {
 | 
			
		||||
  background: #bbdefb;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										32
									
								
								pages/[tabId]/test/culture-graph-tab.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								pages/[tabId]/test/culture-graph-tab.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,32 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <PageDescription>
 | 
			
		||||
      <h1>배양 그래프 (탭)</h1>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        <h2>1. 그래프 구성 및 기능</h2>
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>여러 개의 배양 그래프를 탭으로 전환하여 볼 수 있습니다.</li>
 | 
			
		||||
          <li>각 탭은 독립적인 그래프를 표시합니다.</li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="box">
 | 
			
		||||
        <h2>2. 출력되는 데이터 개수</h2>
 | 
			
		||||
        <ul>
 | 
			
		||||
          <li>
 | 
			
		||||
            탭 4개 × 시리즈 30개 × 1분 간격(100시간, 6,001포인트) =
 | 
			
		||||
            <span class="highlight">각 탭당 180,030개</span>
 | 
			
		||||
          </li>
 | 
			
		||||
          <li>전체(4개 탭 합산): <span class="highlight">720,120개</span></li>
 | 
			
		||||
          <li>
 | 
			
		||||
            화면 초기 렌더링 시간은 약 300ms ~ 400ms 정도 소요되며, 탭
 | 
			
		||||
            이동시에는 거의 딜레이 없이 화면이 출력되고 있습니다.
 | 
			
		||||
          </li>
 | 
			
		||||
        </ul>
 | 
			
		||||
      </div>
 | 
			
		||||
    </PageDescription>
 | 
			
		||||
    <BatchTabs />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import BatchTabs from "~/components/domain/culture-graph/BatchTabs.vue";
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										1648
									
								
								pages/[tabId]/test/culture-graph.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1648
									
								
								pages/[tabId]/test/culture-graph.vue
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										955
									
								
								pages/[tabId]/test/igv2.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										955
									
								
								pages/[tabId]/test/igv2.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,955 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div id="app" style="position: relative;">
 | 
			
		||||
    <main role="main" class="container-fluid">
 | 
			
		||||
      <div style="padding-top: 24px; position: relative;">
 | 
			
		||||
        <!-- IGV 위 컨트롤 바 -->
 | 
			
		||||
        <div style="display: flex; gap: 16px; align-items: center; margin-bottom: 8px; flex-wrap: wrap;">
 | 
			
		||||
          <!-- 파란 핸들 위치 input -->
 | 
			
		||||
          <label>시작 위치
 | 
			
		||||
            <input type="number" :value="startPos" @input="onInputPosChange('start', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
 | 
			
		||||
          </label>
 | 
			
		||||
          <label>끝 위치
 | 
			
		||||
            <input type="number" :value="endPos" @input="onInputPosChange('end', $event.target.value)" class="igv-control-input" style="width:110px; margin-left:4px;" />
 | 
			
		||||
          </label>
 | 
			
		||||
          <button @click="reverseSequence" :class="['igv-control-btn', { 'reverse-active': isReverse }]" style="margin-bottom: 0; margin-left: 8px;">리버스(상보서열) 변환</button>
 | 
			
		||||
          
 | 
			
		||||
          <!-- 서버 파일 목록 드롭다운 -->
 | 
			
		||||
          <div style="margin-left: 8px; position: relative;">
 | 
			
		||||
            <button @click="toggleServerFilesDropdown" class="igv-control-btn">
 | 
			
		||||
              서버 파일 목록
 | 
			
		||||
              <span v-if="isServerFilesDropdownOpen">▼</span>
 | 
			
		||||
              <span v-else>▶</span>
 | 
			
		||||
            </button>
 | 
			
		||||
            <div v-if="isServerFilesDropdownOpen" class="server-files-dropdown">
 | 
			
		||||
              <div v-if="loadingFiles" class="dropdown-item">로딩 중...</div>
 | 
			
		||||
              <div v-else-if="serverFiles.length === 0" class="dropdown-item">업로드된 파일이 없습니다.</div>
 | 
			
		||||
              <div v-else>
 | 
			
		||||
                <div 
 | 
			
		||||
                  v-for="file in serverFiles" 
 | 
			
		||||
                  :key="file.fileName"
 | 
			
		||||
                  @click="selectServerFile(file)"
 | 
			
		||||
                  class="dropdown-item file-item"
 | 
			
		||||
                  :class="{ 'selected': selectedServerFile && selectedServerFile.fileName === file.fileName }"
 | 
			
		||||
                >
 | 
			
		||||
                  <div class="file-name">{{ file.fileName }}</div>
 | 
			
		||||
                  <div class="file-info">
 | 
			
		||||
                    크기: {{ formatFileSize(file.fileSize) }} | 
 | 
			
		||||
                    업로드: {{ formatDate(file.uploadTime) }}
 | 
			
		||||
                  </div>
 | 
			
		||||
                </div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
          
 | 
			
		||||
          <!-- FASTA 업로드 -->
 | 
			
		||||
          <label style="margin-left: 8px;">
 | 
			
		||||
            <input type="file" accept=".fa,.fasta" @change="onFastaUpload" style="display:none;" ref="fastaInput" />
 | 
			
		||||
            <button type="button" class="igv-control-btn" @click="$refs.fastaInput.click()">FASTA 업로드</button>
 | 
			
		||||
          </label>
 | 
			
		||||
          <!-- 대용량 파일 분할 -->
 | 
			
		||||
          <button v-if="showSplitOption" @click="splitLargeFile" class="igv-control-btn" style="margin-left:4px;">파일 분할</button>
 | 
			
		||||
          <!-- 범위 내 염기서열 변경 -->
 | 
			
		||||
          <input type="text" v-model="editSequence" placeholder="새 염기서열 입력" class="igv-control-input" style="width:180px; margin-left:8px;" />
 | 
			
		||||
          <button @click="replaceSequenceInRange" class="igv-control-btn" style="margin-left:4px;">범위 염기서열 변경</button>
 | 
			
		||||
          <!-- Tracks 드롭다운 -->
 | 
			
		||||
          <ul
 | 
			
		||||
            class="navbar-nav mr-auto"
 | 
			
		||||
            style="list-style:none; padding-left:0; margin:0;"
 | 
			
		||||
          >
 | 
			
		||||
            <li
 | 
			
		||||
              class="nav-item dropdown"
 | 
			
		||||
              style="position: relative; display: inline-block;"
 | 
			
		||||
            >
 | 
			
		||||
              <a
 | 
			
		||||
                href="#"
 | 
			
		||||
                id="igv-example-api-dropdown"
 | 
			
		||||
                @click.prevent="toggleDropdown"
 | 
			
		||||
                aria-haspopup="true"
 | 
			
		||||
                :aria-expanded="isDropdownOpen.toString()"
 | 
			
		||||
                style="color: black; cursor: pointer; user-select: none;"
 | 
			
		||||
                >Tracks</a
 | 
			
		||||
              >
 | 
			
		||||
              <ul
 | 
			
		||||
                v-show="isDropdownOpen"
 | 
			
		||||
                class="dropdown-menu"
 | 
			
		||||
                style="width:350px; position: absolute; top: 100%; left: 0; background: white; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.15); padding: 5px 0; margin: 0; list-style:none; z-index: 1000;"
 | 
			
		||||
              >
 | 
			
		||||
                <li>
 | 
			
		||||
                  <a
 | 
			
		||||
                    href="#"
 | 
			
		||||
                    @click.prevent="loadCopyNumberTrack"
 | 
			
		||||
                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                    >Copy Number</a
 | 
			
		||||
                  >
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                  <a
 | 
			
		||||
                    href="#"
 | 
			
		||||
                    @click.prevent="loadDbSnpTrack"
 | 
			
		||||
                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                    >dbSNP 137 (bed tabix)</a
 | 
			
		||||
                  >
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                  <a
 | 
			
		||||
                    href="#"
 | 
			
		||||
                    @click.prevent="loadBigWigTrack"
 | 
			
		||||
                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                    >Encode bigwig</a
 | 
			
		||||
                  >
 | 
			
		||||
                </li>
 | 
			
		||||
                <li>
 | 
			
		||||
                  <a
 | 
			
		||||
                    href="#"
 | 
			
		||||
                    @click.prevent="loadBamTrack"
 | 
			
		||||
                    style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                    >1KG Bam (HG02450)</a
 | 
			
		||||
                  >
 | 
			
		||||
                </li>
 | 
			
		||||
              </ul>
 | 
			
		||||
            </li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
        <!-- IGV 뷰어 영역 -->
 | 
			
		||||
        <div id="igvDiv" ref="igvDiv" style="padding-top: 20px; min-height: 500px; position: relative;"></div>
 | 
			
		||||
        <!-- 염기서열 결과 -->
 | 
			
		||||
        <div style="margin-top: 12px;">
 | 
			
		||||
          <strong :class="{ 'reverse-active': isReverse }">염기서열:</strong>
 | 
			
		||||
          <pre :class="{ 'reverse-active': isReverse }" style="white-space: pre-wrap; word-break: break-all;">{{ displaySequence }}</pre>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </main>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
// igv.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
 | 
			
		||||
if (typeof window !== 'undefined') {
 | 
			
		||||
  function loadScriptOnce(src, globalCheck, callback) {
 | 
			
		||||
    if (globalCheck()) {
 | 
			
		||||
      if (callback) callback();
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 이미 로딩 중인지 확인
 | 
			
		||||
    if (document.querySelector(`script[src="${src}"]`)) {
 | 
			
		||||
      const checkInterval = setInterval(() => {
 | 
			
		||||
        if (globalCheck()) {
 | 
			
		||||
          clearInterval(checkInterval);
 | 
			
		||||
          if (callback) callback();
 | 
			
		||||
        }
 | 
			
		||||
      }, 100);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const script = document.createElement('script');
 | 
			
		||||
    script.src = src;
 | 
			
		||||
    script.onload = function() { 
 | 
			
		||||
      if (callback) callback(); 
 | 
			
		||||
    };
 | 
			
		||||
    script.onerror = function() {
 | 
			
		||||
      console.error(`Failed to load script: ${src}`);
 | 
			
		||||
    };
 | 
			
		||||
    document.head.appendChild(script);
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // igv.js 먼저 로드
 | 
			
		||||
  loadScriptOnce('/dist/igv.js', function() { 
 | 
			
		||||
    return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function'; 
 | 
			
		||||
  }, function() {
 | 
			
		||||
    console.log('IGV.js loaded successfully');
 | 
			
		||||
    // igv_custom.js는 igv.js가 로드된 후에만 로드
 | 
			
		||||
    loadScriptOnce('/dist/igv_custom.js', function() { 
 | 
			
		||||
      return !!window.igvCustomLoaded; 
 | 
			
		||||
    }, function() { 
 | 
			
		||||
      window.igvCustomLoaded = true; 
 | 
			
		||||
      console.log('IGV Custom loaded successfully');
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  name: "App",
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      browser: null,
 | 
			
		||||
      locus: null, // ex: "chr1:10000-10200"
 | 
			
		||||
      overlayWidth: 900,
 | 
			
		||||
      overlayHeight: 500,
 | 
			
		||||
      sequence: '',
 | 
			
		||||
      displaySequence: '',
 | 
			
		||||
      isReverse: false,
 | 
			
		||||
      isDropdownOpen: false,
 | 
			
		||||
      startPos: null,
 | 
			
		||||
      endPos: null,
 | 
			
		||||
      prevStartPos: null,
 | 
			
		||||
      prevEndPos: null,
 | 
			
		||||
      fastaFile: null, // 업로드된 FASTA 파일 Blob
 | 
			
		||||
      fastaName: '', // 업로드된 FASTA 파일명
 | 
			
		||||
      editSequence: '', // 범위 내 교체할 염기서열
 | 
			
		||||
      showSplitOption: false, // 파일 분할 옵션 표시
 | 
			
		||||
      largeFile: null, // 대용량 파일 저장
 | 
			
		||||
      isServerFilesDropdownOpen: false, // 서버 파일 목록 드롭다운 상태
 | 
			
		||||
      loadingFiles: false, // 서버 파일 목록 로딩 상태
 | 
			
		||||
      serverFiles: [], // 서버에 업로드된 파일 목록
 | 
			
		||||
      selectedServerFile: null, // 선택된 서버 파일
 | 
			
		||||
    };
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    locus: 'fetchSequence',
 | 
			
		||||
  },
 | 
			
		||||
  async mounted() {
 | 
			
		||||
    // IGV.js와 igv_custom.js를 script 태그로 동적 로드(SSR 방지)
 | 
			
		||||
    if (typeof window !== 'undefined') {
 | 
			
		||||
      function loadScriptOnce(src, globalCheck, callback) {
 | 
			
		||||
        if (globalCheck()) {
 | 
			
		||||
          if (callback) callback();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        // 이미 로딩 중인지 확인
 | 
			
		||||
        if (document.querySelector(`script[src="${src}"]`)) {
 | 
			
		||||
          const checkInterval = setInterval(() => {
 | 
			
		||||
            if (globalCheck()) {
 | 
			
		||||
              clearInterval(checkInterval);
 | 
			
		||||
              if (callback) callback();
 | 
			
		||||
            }
 | 
			
		||||
          }, 100);
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        const script = document.createElement('script');
 | 
			
		||||
        script.src = src;
 | 
			
		||||
        script.onload = function() { 
 | 
			
		||||
          if (callback) callback(); 
 | 
			
		||||
        };
 | 
			
		||||
        script.onerror = function() {
 | 
			
		||||
          console.error(`Failed to load script: ${src}`);
 | 
			
		||||
        };
 | 
			
		||||
        document.head.appendChild(script);
 | 
			
		||||
      }
 | 
			
		||||
      // igv.js 먼저 로드
 | 
			
		||||
      loadScriptOnce('/dist/igv.js', function() { 
 | 
			
		||||
        return typeof window.igv !== 'undefined' && typeof window.igv.createBrowser === 'function'; 
 | 
			
		||||
      }, function() {
 | 
			
		||||
        console.log('IGV.js loaded successfully');
 | 
			
		||||
        // igv_custom.js는 igv.js가 로드된 후에만 로드
 | 
			
		||||
        loadScriptOnce('/dist/igv_custom.js', function() { 
 | 
			
		||||
          return !!window.igvCustomLoaded; 
 | 
			
		||||
        }, function() { 
 | 
			
		||||
          window.igvCustomLoaded = true; 
 | 
			
		||||
          console.log('IGV Custom loaded successfully');
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
    await this.initIGV();
 | 
			
		||||
    // IGV가 완전히 생성될 때까지 대기
 | 
			
		||||
    const registerListener = () => {
 | 
			
		||||
      if (window.igvCustomLoaded && this.browser) {
 | 
			
		||||
        window.addEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
 | 
			
		||||
      } else {
 | 
			
		||||
        setTimeout(registerListener, 200);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    registerListener();
 | 
			
		||||
  },
 | 
			
		||||
  beforeUnmount() {
 | 
			
		||||
    window.removeEventListener('igv-blue-lines-changed', this.onBlueLinesChanged);
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    async initIGV() {
 | 
			
		||||
      try {
 | 
			
		||||
        await this.waitForIGV();
 | 
			
		||||
        await this.$nextTick();
 | 
			
		||||
        const igvDiv = this.$refs.igvDiv;
 | 
			
		||||
        if (!igvDiv) {
 | 
			
		||||
          console.error("❌ #igvDiv가 존재하지 않습니다.");
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        // FASTA 업로드 시 genome 옵션 변경
 | 
			
		||||
        const genomeOpt = this.fastaFile
 | 
			
		||||
          ? {
 | 
			
		||||
              id: this.fastaName || 'custom',
 | 
			
		||||
              fastaURL: this.fastaFile,
 | 
			
		||||
              indexURL: undefined,
 | 
			
		||||
            }
 | 
			
		||||
          : 'hg19';
 | 
			
		||||
        console.log('IGV 옵션:', { locus: this.locus || "chr1:10000-10200", genome: genomeOpt });
 | 
			
		||||
        const options = {
 | 
			
		||||
          locus: this.locus || "chr1:10000-10200",
 | 
			
		||||
          genome: genomeOpt,
 | 
			
		||||
        };
 | 
			
		||||
        try {
 | 
			
		||||
          console.log('IGV 브라우저 생성 시작');
 | 
			
		||||
          this.browser = await window.igv.createBrowser(igvDiv, options);
 | 
			
		||||
          console.log('IGV 브라우저 생성 완료:', this.browser);
 | 
			
		||||
          // 이전 좌표 저장용
 | 
			
		||||
          // prevStartPos, prevEndPos는 data에 저장된 this.prevStartPos, this.prevEndPos 사용
 | 
			
		||||
          this.browser.on("locuschange", (locus) => {
 | 
			
		||||
            // locus 업데이트
 | 
			
		||||
            this.locus = locus;
 | 
			
		||||
            // locus 파싱
 | 
			
		||||
            let locusStart, locusEnd;
 | 
			
		||||
            if (typeof locus === 'string') {
 | 
			
		||||
              const match = locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
			
		||||
              if (!match) return;
 | 
			
		||||
              locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
			
		||||
              locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
			
		||||
            } else if (typeof locus === 'object' && locus.chr && locus.start && locus.end) {
 | 
			
		||||
              locusStart = parseInt(String(locus.start).replace(/,/g, ''), 10);
 | 
			
		||||
              locusEnd = parseInt(String(locus.end).replace(/,/g, ''), 10);
 | 
			
		||||
            } else {
 | 
			
		||||
              return;
 | 
			
		||||
            }
 | 
			
		||||
            // input 박스의 startPos, endPos 기준으로 ratio 계산
 | 
			
		||||
            if (this.startPos !== null && this.endPos !== null) {
 | 
			
		||||
              const startRatio = (this.startPos - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
              const endRatio = (this.endPos - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
              if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
			
		||||
                window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          this.locus = options.locus;
 | 
			
		||||
          // IGV, igvCustom 모두 준비된 후 최초 막대/염기서열 동기화
 | 
			
		||||
          const syncInitialBlueLines = async () => {
 | 
			
		||||
            if (window.igvCustom && typeof window.igvCustom.getLineRatios === 'function') {
 | 
			
		||||
              // locus 파싱
 | 
			
		||||
              let chrom, locusStart, locusEnd;
 | 
			
		||||
              if (typeof this.locus === 'string') {
 | 
			
		||||
                const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
			
		||||
                if (!match) return;
 | 
			
		||||
                chrom = match[1];
 | 
			
		||||
                locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
			
		||||
                locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
			
		||||
              } else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
 | 
			
		||||
                chrom = this.locus.chr;
 | 
			
		||||
                locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
 | 
			
		||||
                locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
 | 
			
		||||
              } else {
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              const { startRatio, endRatio } = window.igvCustom.getLineRatios();
 | 
			
		||||
              const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
 | 
			
		||||
              const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
 | 
			
		||||
              const start = Math.min(pos1, pos2);
 | 
			
		||||
              const end = Math.max(pos1, pos2);
 | 
			
		||||
              this.startPos = start;
 | 
			
		||||
              this.endPos = end;
 | 
			
		||||
              if (end - start < 1) {
 | 
			
		||||
                this.sequence = '';
 | 
			
		||||
                this.displaySequence = '';
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              // 염기서열 fetch
 | 
			
		||||
              if (
 | 
			
		||||
                this.browser.genome &&
 | 
			
		||||
                this.browser.genome.sequence &&
 | 
			
		||||
                typeof this.browser.genome.sequence.getSequence === 'function'
 | 
			
		||||
              ) {
 | 
			
		||||
                const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
 | 
			
		||||
                this.sequence = seq || '';
 | 
			
		||||
                this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
 | 
			
		||||
              }
 | 
			
		||||
            } else {
 | 
			
		||||
              setTimeout(syncInitialBlueLines, 200);
 | 
			
		||||
            }
 | 
			
		||||
          };
 | 
			
		||||
          syncInitialBlueLines();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error("IGV 브라우저 생성 중 오류:", error);
 | 
			
		||||
        }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("IGV 초기화 중 오류:", error);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    // 파란 라인 위치 변경 이벤트 핸들러
 | 
			
		||||
    async onBlueLinesChanged(e) {
 | 
			
		||||
      if (!this.locus || !this.browser) {
 | 
			
		||||
        this.sequence = 'locus/browser 없음';
 | 
			
		||||
        this.displaySequence = 'locus/browser 없음';
 | 
			
		||||
        this.startPos = null;
 | 
			
		||||
        this.endPos = null;
 | 
			
		||||
        if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
			
		||||
          window.igvCustom.setMiniMapInfo(null, null, null);
 | 
			
		||||
        }
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      let chrom, locusStart, locusEnd;
 | 
			
		||||
      if (typeof this.locus === 'string') {
 | 
			
		||||
        const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
			
		||||
        if (!match) {
 | 
			
		||||
          this.sequence = 'locus 파싱 실패';
 | 
			
		||||
          this.displaySequence = 'locus 파싱 실패';
 | 
			
		||||
          this.startPos = null;
 | 
			
		||||
          this.endPos = null;
 | 
			
		||||
          if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
			
		||||
            window.igvCustom.setMiniMapInfo(null, null, null);
 | 
			
		||||
          }
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        chrom = match[1];
 | 
			
		||||
        locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
			
		||||
        locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
			
		||||
      } else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
 | 
			
		||||
        chrom = this.locus.chr;
 | 
			
		||||
        locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
 | 
			
		||||
        locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
 | 
			
		||||
      } else {
 | 
			
		||||
        this.sequence = 'locus 파싱 실패';
 | 
			
		||||
        this.displaySequence = 'locus 파싱 실패';
 | 
			
		||||
        this.startPos = null;
 | 
			
		||||
        this.endPos = null;
 | 
			
		||||
        if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
			
		||||
          window.igvCustom.setMiniMapInfo(null, null, null);
 | 
			
		||||
        }
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      const { startRatio, endRatio } = e.detail;
 | 
			
		||||
      const pos1 = Math.round(locusStart + (locusEnd - locusStart) * startRatio);
 | 
			
		||||
      const pos2 = Math.round(locusStart + (locusEnd - locusStart) * endRatio);
 | 
			
		||||
      const start = Math.min(pos1, pos2);
 | 
			
		||||
      const end = Math.max(pos1, pos2);
 | 
			
		||||
      this.startPos = start;
 | 
			
		||||
      this.endPos = end;
 | 
			
		||||
      if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
			
		||||
        window.igvCustom.setMiniMapInfo(chrom, start, end);
 | 
			
		||||
      }
 | 
			
		||||
      if (end - start < 1) {
 | 
			
		||||
        this.sequence = '';
 | 
			
		||||
        this.displaySequence = '';
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      try {
 | 
			
		||||
        if (
 | 
			
		||||
          this.browser.genome &&
 | 
			
		||||
          this.browser.genome.sequence &&
 | 
			
		||||
          typeof this.browser.genome.sequence.getSequence === 'function'
 | 
			
		||||
        ) {
 | 
			
		||||
          const seq = await this.browser.genome.sequence.getSequence(chrom, start, end);
 | 
			
		||||
          this.sequence = seq || '';
 | 
			
		||||
          this.displaySequence = this.isReverse ? this.getComplement(seq) : seq;
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
        this.sequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
 | 
			
		||||
        this.displaySequence = 'getSequence 없음(IGV 내부 구조 확인 필요)';
 | 
			
		||||
      } catch {
 | 
			
		||||
        this.sequence = '불러오기 실패';
 | 
			
		||||
        this.displaySequence = '불러오기 실패';
 | 
			
		||||
      }
 | 
			
		||||
      // locus 이동 전 좌표 저장
 | 
			
		||||
      this.prevStartPos = this.startPos;
 | 
			
		||||
      this.prevEndPos = this.endPos;
 | 
			
		||||
    },
 | 
			
		||||
    reverseSequence() {
 | 
			
		||||
      this.isReverse = !this.isReverse;
 | 
			
		||||
      this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
 | 
			
		||||
    },
 | 
			
		||||
    getComplement(seq) {
 | 
			
		||||
      if (!seq) return '';
 | 
			
		||||
      return seq.replace(/[ATCG]/gi, c => {
 | 
			
		||||
        switch (c.toUpperCase()) {
 | 
			
		||||
          case 'A': return 'T';
 | 
			
		||||
          case 'T': return 'A';
 | 
			
		||||
          case 'C': return 'G';
 | 
			
		||||
          case 'G': return 'C';
 | 
			
		||||
          default: return c;
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    toggleDropdown() {
 | 
			
		||||
      this.isDropdownOpen = !this.isDropdownOpen;
 | 
			
		||||
    },
 | 
			
		||||
    closeDropdown() {
 | 
			
		||||
      this.isDropdownOpen = false;
 | 
			
		||||
    },
 | 
			
		||||
    handleClickOutside(event) {
 | 
			
		||||
      const dropdown = this.$el.querySelector("#igv-example-api-dropdown");
 | 
			
		||||
      const menu = this.$el.querySelector(".dropdown-menu");
 | 
			
		||||
      if (
 | 
			
		||||
        dropdown &&
 | 
			
		||||
        menu &&
 | 
			
		||||
        !dropdown.contains(event.target) &&
 | 
			
		||||
        !menu.contains(event.target)
 | 
			
		||||
      ) {
 | 
			
		||||
        this.closeDropdown();
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    waitForIGV() {
 | 
			
		||||
      return new Promise((resolve, reject) => {
 | 
			
		||||
        let attempts = 0;
 | 
			
		||||
        const maxAttempts = 100; // 10초 타임아웃
 | 
			
		||||
        
 | 
			
		||||
        const checkIGV = () => {
 | 
			
		||||
          attempts++;
 | 
			
		||||
          
 | 
			
		||||
          if (typeof window.igv !== "undefined" && typeof window.igv.createBrowser === "function") {
 | 
			
		||||
            console.log('IGV is ready');
 | 
			
		||||
            resolve();
 | 
			
		||||
          } else if (attempts >= maxAttempts) {
 | 
			
		||||
            console.error('IGV loading timeout');
 | 
			
		||||
            reject(new Error('IGV loading timeout'));
 | 
			
		||||
          } else {
 | 
			
		||||
            setTimeout(checkIGV, 100);
 | 
			
		||||
          }
 | 
			
		||||
        };
 | 
			
		||||
        checkIGV();
 | 
			
		||||
      });
 | 
			
		||||
    },
 | 
			
		||||
    loadCopyNumberTrack() {
 | 
			
		||||
      if (this.browser) {
 | 
			
		||||
        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
        this.browser.loadTrackList([
 | 
			
		||||
          {
 | 
			
		||||
            url: "https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz",
 | 
			
		||||
            indexed: false,
 | 
			
		||||
            isLog: true,
 | 
			
		||||
            name: "GBM Copy # (TCGA Broad GDAC)",
 | 
			
		||||
          },
 | 
			
		||||
        ]);
 | 
			
		||||
      }
 | 
			
		||||
      this.closeDropdown();
 | 
			
		||||
    },
 | 
			
		||||
    loadDbSnpTrack() {
 | 
			
		||||
      if (this.browser) {
 | 
			
		||||
        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
        this.browser.loadTrackList([
 | 
			
		||||
          {
 | 
			
		||||
            type: "annotation",
 | 
			
		||||
            format: "bed",
 | 
			
		||||
            url: "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz",
 | 
			
		||||
            indexURL:
 | 
			
		||||
              "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz.tbi",
 | 
			
		||||
            visibilityWindow: 200000,
 | 
			
		||||
            name: "dbSNP 137",
 | 
			
		||||
          },
 | 
			
		||||
        ]);
 | 
			
		||||
      }
 | 
			
		||||
      this.closeDropdown();
 | 
			
		||||
    },
 | 
			
		||||
    loadBigWigTrack() {
 | 
			
		||||
      if (this.browser) {
 | 
			
		||||
        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
        this.browser.loadTrackList([
 | 
			
		||||
          {
 | 
			
		||||
            type: "wig",
 | 
			
		||||
            format: "bigwig",
 | 
			
		||||
            url: "https://s3.amazonaws.com/igv.broadinstitute.org/data/hg19/encode/wgEncodeBroadHistoneGm12878H3k4me3StdSig.bigWig",
 | 
			
		||||
            name: "Gm12878H3k4me3",
 | 
			
		||||
          },
 | 
			
		||||
        ]);
 | 
			
		||||
      }
 | 
			
		||||
      this.closeDropdown();
 | 
			
		||||
    },
 | 
			
		||||
    loadBamTrack() {
 | 
			
		||||
      if (this.browser) {
 | 
			
		||||
        this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
        this.browser.loadTrackList([
 | 
			
		||||
          {
 | 
			
		||||
            type: "alignment",
 | 
			
		||||
            format: "bam",
 | 
			
		||||
            url: "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam",
 | 
			
		||||
            indexURL:
 | 
			
		||||
              "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam.bai",
 | 
			
		||||
            name: "HG02450",
 | 
			
		||||
          },
 | 
			
		||||
        ]);
 | 
			
		||||
      }
 | 
			
		||||
      this.closeDropdown();
 | 
			
		||||
    },
 | 
			
		||||
    // input 박스에서 직접 좌표 입력 시 호출
 | 
			
		||||
    onInputPosChange(which, val) {
 | 
			
		||||
      let locusStart, locusEnd, chrom;
 | 
			
		||||
      if (typeof this.locus === 'string') {
 | 
			
		||||
        const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
			
		||||
        if (!match) return;
 | 
			
		||||
        chrom = match[1];
 | 
			
		||||
        locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
			
		||||
        locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
			
		||||
      } else if (typeof this.locus === 'object' && this.locus.chr && this.locus.start && this.locus.end) {
 | 
			
		||||
        chrom = this.locus.chr;
 | 
			
		||||
        locusStart = parseInt(String(this.locus.start).replace(/,/g, ''), 10);
 | 
			
		||||
        locusEnd = parseInt(String(this.locus.end).replace(/,/g, ''), 10);
 | 
			
		||||
      } else {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      let start = this.startPos, end = this.endPos;
 | 
			
		||||
      if (which === 'start') {
 | 
			
		||||
        start = Number(val);
 | 
			
		||||
      } else {
 | 
			
		||||
        end = Number(val);
 | 
			
		||||
      }
 | 
			
		||||
      // 범위 보정
 | 
			
		||||
      if (start >= end) return;
 | 
			
		||||
      // ratio 계산
 | 
			
		||||
      const startRatio = (start - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
      const endRatio = (end - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
      if (window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
			
		||||
        window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
			
		||||
      }
 | 
			
		||||
      if (window.igvCustom && typeof window.igvCustom.setMiniMapInfo === 'function') {
 | 
			
		||||
        window.igvCustom.setMiniMapInfo(chrom, start, end);
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    // FASTA 파일 업로드 핸들러
 | 
			
		||||
    async onFastaUpload(e) {
 | 
			
		||||
      const file = e.target.files[0];
 | 
			
		||||
      if (!file) return;
 | 
			
		||||
      
 | 
			
		||||
      console.log('FASTA 파일 업로드:', file.name, file.size);
 | 
			
		||||
      this.fastaName = file.name.replace(/\.[^/.]+$/, "");
 | 
			
		||||
      // 파란 범위 임시 저장
 | 
			
		||||
      const prevStartPos = this.startPos;
 | 
			
		||||
      const prevEndPos = this.endPos;
 | 
			
		||||
      try {
 | 
			
		||||
        // 서버에 파일 업로드
 | 
			
		||||
        const formData = new FormData();
 | 
			
		||||
        formData.append('file', file);
 | 
			
		||||
        
 | 
			
		||||
        console.log('서버에 파일 업로드 중...');
 | 
			
		||||
        if (typeof window !== 'undefined') {
 | 
			
		||||
          const { data: _data, error: _error } = await useApi('/api/fasta/upload', {
 | 
			
		||||
            method: 'post',
 | 
			
		||||
            body: formData,
 | 
			
		||||
            headers: {
 | 
			
		||||
              // 'Content-Type'은 브라우저가 자동으로 설정하므로 명시하지 않음
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        if (!response.ok) {
 | 
			
		||||
          throw new Error(`서버 업로드 실패: ${response.status}`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        
 | 
			
		||||
        const result = await response.json();
 | 
			
		||||
        console.log('서버 업로드 완료:', result);
 | 
			
		||||
        
 | 
			
		||||
        // 원격 URL 사용
 | 
			
		||||
        this.fastaFile = result.remoteUrl;
 | 
			
		||||
        
 | 
			
		||||
        // IGV 브라우저 완전 재생성
 | 
			
		||||
        if (this.browser) {
 | 
			
		||||
          if (typeof this.browser.removeAllTracks === 'function') {
 | 
			
		||||
            this.browser.removeAllTracks();
 | 
			
		||||
          }
 | 
			
		||||
          this.browser = null;
 | 
			
		||||
        }
 | 
			
		||||
        // igvDiv 완전히 비우기
 | 
			
		||||
        const igvDiv = this.$refs.igvDiv;
 | 
			
		||||
        if (igvDiv) {
 | 
			
		||||
          igvDiv.innerHTML = '';
 | 
			
		||||
        }
 | 
			
		||||
        // locus, 시퀀스 등 초기화
 | 
			
		||||
        this.locus = "chr1:1-100";
 | 
			
		||||
        this.sequence = '';
 | 
			
		||||
        this.displaySequence = '';
 | 
			
		||||
        this.startPos = null;
 | 
			
		||||
        this.endPos = null;
 | 
			
		||||
        console.log('IGV 재초기화 시작');
 | 
			
		||||
        await this.initIGV();
 | 
			
		||||
        // IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
			
		||||
            // locus 파싱
 | 
			
		||||
            let locusStart = 1, locusEnd = 100;
 | 
			
		||||
            if (typeof this.locus === 'string') {
 | 
			
		||||
              const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
			
		||||
              if (match) {
 | 
			
		||||
                locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
			
		||||
                locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
            const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
            const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
            window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
			
		||||
            if (typeof window.waitForIGVAndInit === 'function') {
 | 
			
		||||
              window.waitForIGVAndInit();
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }, 500);
 | 
			
		||||
      }
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('파일 업로드 오류:', error);
 | 
			
		||||
        alert(`파일 업로드 실패: ${error.message}\n\n서버 API가 설정되지 않았거나, 파일이 너무 클 수 있습니다.`);
 | 
			
		||||
        
 | 
			
		||||
        // 서버 업로드 실패 시 기존 방식으로 폴백
 | 
			
		||||
        if (file.size <= 100 * 1024 * 1024) { // 100MB 이하만
 | 
			
		||||
          this.largeFile = file;
 | 
			
		||||
          this.showSplitOption = true;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    // 대용량 파일 분할
 | 
			
		||||
    async splitLargeFile() {
 | 
			
		||||
      if (!this.largeFile) return;
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        const chunkSize = 50 * 1024 * 1024; // 50MB 청크
 | 
			
		||||
        const totalChunks = Math.ceil(this.largeFile.size / chunkSize);
 | 
			
		||||
        
 | 
			
		||||
        alert(`파일을 ${totalChunks}개 청크로 분할합니다. 각 청크는 약 50MB입니다.`);
 | 
			
		||||
        
 | 
			
		||||
        for (let i = 0; i < totalChunks; i++) {
 | 
			
		||||
          const start = i * chunkSize;
 | 
			
		||||
          const end = Math.min(start + chunkSize, this.largeFile.size);
 | 
			
		||||
          const chunk = this.largeFile.slice(start, end);
 | 
			
		||||
          
 | 
			
		||||
          // 청크를 파일로 다운로드
 | 
			
		||||
          const url = URL.createObjectURL(chunk);
 | 
			
		||||
          const a = document.createElement('a');
 | 
			
		||||
          a.href = url;
 | 
			
		||||
          a.download = `${this.largeFile.name.replace(/\.[^/.]+$/, "")}_part${i + 1}.fasta`;
 | 
			
		||||
          document.body.appendChild(a);
 | 
			
		||||
          a.click();
 | 
			
		||||
          document.body.removeChild(a);
 | 
			
		||||
          URL.revokeObjectURL(url);
 | 
			
		||||
          
 | 
			
		||||
          // 진행률 표시
 | 
			
		||||
          const progress = ((i + 1) / totalChunks * 100).toFixed(1);
 | 
			
		||||
          console.log(`분할 진행률: ${progress}%`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        alert(`파일 분할 완료! ${totalChunks}개의 파일이 다운로드되었습니다.\n각 파일을 개별적으로 업로드하여 사용하세요.`);
 | 
			
		||||
        this.showSplitOption = false;
 | 
			
		||||
        this.largeFile = null;
 | 
			
		||||
        
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('파일 분할 중 오류:', error);
 | 
			
		||||
        alert('파일 분할 중 오류가 발생했습니다.');
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    // 범위 내 염기서열 교체
 | 
			
		||||
    replaceSequenceInRange() {
 | 
			
		||||
      if (!this.sequence || !this.editSequence) {
 | 
			
		||||
        alert('염기서열 또는 입력값이 없습니다.');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      if (this.editSequence.length !== (this.endPos - this.startPos)) {
 | 
			
		||||
        alert('입력한 염기서열 길이가 범위와 일치해야 합니다.');
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      // 기존 염기서열을 교체
 | 
			
		||||
      // 실제 FASTA 파일은 수정하지 않고, 화면에만 반영
 | 
			
		||||
      this.sequence = this.editSequence;
 | 
			
		||||
      this.displaySequence = this.isReverse ? this.getComplement(this.sequence) : this.sequence;
 | 
			
		||||
      alert('범위 내 염기서열이 변경되었습니다. (화면에만 반영)');
 | 
			
		||||
    },
 | 
			
		||||
    // 서버 파일 목록 토글
 | 
			
		||||
    toggleServerFilesDropdown() {
 | 
			
		||||
      this.isServerFilesDropdownOpen = !this.isServerFilesDropdownOpen;
 | 
			
		||||
      if (this.isServerFilesDropdownOpen) {
 | 
			
		||||
        this.loadServerFiles();
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
         // 서버 파일 목록 로드
 | 
			
		||||
     async loadServerFiles() {
 | 
			
		||||
       this.loadingFiles = true;
 | 
			
		||||
       try {
 | 
			
		||||
         const response = await fetch('http://localhost/api/fasta/files');
 | 
			
		||||
         if (!response.ok) {
 | 
			
		||||
           throw new Error(`서버 파일 목록 로드 실패: ${response.status}`);
 | 
			
		||||
         }
 | 
			
		||||
         const result = await response.json();
 | 
			
		||||
         this.serverFiles = Array.isArray(result) && Array.isArray(result[0]) ? result[0] : (Array.isArray(result) ? result : []);
 | 
			
		||||
       } catch (error) {
 | 
			
		||||
         console.error('서버 파일 목록 로드 중 오류:', error);
 | 
			
		||||
         this.serverFiles = [];
 | 
			
		||||
         alert(`서버 파일 목록을 불러오는데 실패했습니다: ${error.message}`);
 | 
			
		||||
       } finally {
 | 
			
		||||
         this.loadingFiles = false;
 | 
			
		||||
       }
 | 
			
		||||
     },
 | 
			
		||||
         // 서버 파일 선택
 | 
			
		||||
     selectServerFile(file) {
 | 
			
		||||
       const prevStartPos = this.startPos;
 | 
			
		||||
       const prevEndPos = this.endPos;
 | 
			
		||||
       this.selectedServerFile = file;
 | 
			
		||||
       this.fastaFile = file.fileUrl; // 선택된 파일의 URL을 fastaFile에 저장
 | 
			
		||||
       this.fastaName = file.fileName; // 파일명 업데이트
 | 
			
		||||
      
 | 
			
		||||
      // IGV 브라우저 완전 재생성
 | 
			
		||||
      if (this.browser) {
 | 
			
		||||
        if (typeof this.browser.removeAllTracks === 'function') {
 | 
			
		||||
          this.browser.removeAllTracks();
 | 
			
		||||
        }
 | 
			
		||||
        this.browser = null;
 | 
			
		||||
      }
 | 
			
		||||
      // igvDiv 완전히 비우기
 | 
			
		||||
      const igvDiv = this.$refs.igvDiv;
 | 
			
		||||
      if (igvDiv) {
 | 
			
		||||
        igvDiv.innerHTML = '';
 | 
			
		||||
      }
 | 
			
		||||
      // locus, 시퀀스 등 초기화
 | 
			
		||||
      this.locus = "chr1:1-100";
 | 
			
		||||
      this.sequence = '';
 | 
			
		||||
      this.displaySequence = '';
 | 
			
		||||
      this.startPos = null;
 | 
			
		||||
      this.endPos = null;
 | 
			
		||||
      console.log('IGV 재초기화 시작');
 | 
			
		||||
      this.initIGV().then(() => {
 | 
			
		||||
        // IGV 재초기화 후 파란 범위 복원 (딜레이 추가)
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          if (prevStartPos !== null && prevEndPos !== null && window.igvCustom && typeof window.igvCustom.setLineRatios === 'function') {
 | 
			
		||||
            // locus 파싱
 | 
			
		||||
            let locusStart = 1, locusEnd = 100;
 | 
			
		||||
            if (typeof this.locus === 'string') {
 | 
			
		||||
              const match = this.locus.match(/(chr[\w\d]+):(\d+)-(\d+)/);
 | 
			
		||||
              if (match) {
 | 
			
		||||
                locusStart = parseInt(match[2].replace(/,/g, ''), 10);
 | 
			
		||||
                locusEnd = parseInt(match[3].replace(/,/g, ''), 10);
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
            const startRatio = (prevStartPos - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
            const endRatio = (prevEndPos - locusStart) / (locusEnd - locusStart);
 | 
			
		||||
            window.igvCustom.setLineRatios(startRatio, endRatio);
 | 
			
		||||
            if (typeof window.igvCustom.setupLines === 'function') {
 | 
			
		||||
              window.igvCustom.setupLines();
 | 
			
		||||
            }
 | 
			
		||||
            if (typeof window.waitForIGVAndInit === 'function') {
 | 
			
		||||
              window.waitForIGVAndInit();
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        }, 500);
 | 
			
		||||
      });
 | 
			
		||||
      this.isServerFilesDropdownOpen = false; // 드롭다운 닫기
 | 
			
		||||
    },
 | 
			
		||||
    // 파일 크기 포맷
 | 
			
		||||
    formatFileSize(bytes) {
 | 
			
		||||
      if (bytes === 0) return '0 Bytes';
 | 
			
		||||
      const k = 1024;
 | 
			
		||||
      const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
 | 
			
		||||
      const i = Math.floor(Math.log(bytes) / Math.log(k));
 | 
			
		||||
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
 | 
			
		||||
    },
 | 
			
		||||
    // 날짜 포맷
 | 
			
		||||
    formatDate(timestamp) {
 | 
			
		||||
      const date = new Date(timestamp);
 | 
			
		||||
      return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
#app {
 | 
			
		||||
  font-family: Avenir, Helvetica, Arial, sans-serif;
 | 
			
		||||
  -webkit-font-smoothing: antialiased;
 | 
			
		||||
  -moz-osx-font-smoothing: grayscale;
 | 
			
		||||
  color: #2c3e50;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
pre {
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.reverse-active {
 | 
			
		||||
  background: #e6f0ff !important;
 | 
			
		||||
  color: #0056b3 !important;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  border: 2px solid #0056b3 !important;
 | 
			
		||||
  box-shadow: 0 0 4px #b3d1ff;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.igv-control-btn.reverse-active {
 | 
			
		||||
  background: #0056b3 !important;
 | 
			
		||||
  color: #fff !important;
 | 
			
		||||
  border: 2px solid #003366 !important;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.nav-tabs {
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-content {
 | 
			
		||||
  padding-top: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.igv-control-input {
 | 
			
		||||
  border: 1.5px solid #007bff;
 | 
			
		||||
  padding: 4px 8px;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  font-size: 15px;
 | 
			
		||||
  outline: none;
 | 
			
		||||
  transition: border 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.igv-control-input:focus {
 | 
			
		||||
  border: 2px solid #0056b3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.igv-control-btn {
 | 
			
		||||
  border: 1.5px solid #007bff;
 | 
			
		||||
  background: #f5faff;
 | 
			
		||||
  color: #007bff;
 | 
			
		||||
  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: #007bff;
 | 
			
		||||
  color: #fff;
 | 
			
		||||
  border: 2px solid #0056b3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* 서버 파일 목록 드롭다운 스타일 */
 | 
			
		||||
.server-files-dropdown {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 100%;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  background-color: #fff;
 | 
			
		||||
  border: 1px solid #ccc;
 | 
			
		||||
  box-shadow: 0 2px 5px rgba(0,0,0,0.15);
 | 
			
		||||
  padding: 5px 0;
 | 
			
		||||
  margin-top: 5px;
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
  max-height: 300px; /* 스크롤 가능하도록 높이 제한 */
 | 
			
		||||
  overflow-y: auto;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .dropdown-item {
 | 
			
		||||
  padding: 8px 20px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background-color 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .dropdown-item:hover {
 | 
			
		||||
  background-color: #f0f0f0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .dropdown-item.selected {
 | 
			
		||||
  background-color: #e6f0ff;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  color: #0056b3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .file-item {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  padding: 8px 20px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: background-color 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .file-item:hover {
 | 
			
		||||
  background-color: #f0f0f0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .file-item.selected {
 | 
			
		||||
  background-color: #e6f0ff;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  color: #0056b3;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .file-name {
 | 
			
		||||
  flex-grow: 1;
 | 
			
		||||
  margin-right: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.server-files-dropdown .file-info {
 | 
			
		||||
  font-size: 0.8em;
 | 
			
		||||
  color: #666;
 | 
			
		||||
}
 | 
			
		||||
</style> 
 | 
			
		||||
							
								
								
									
										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>
 | 
			
		||||
							
								
								
									
										247
									
								
								pages/[tabId]/test/pathway.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										247
									
								
								pages/[tabId]/test/pathway.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,247 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div style="position: relative;">
 | 
			
		||||
    <h2>KEGG Pathway Viewer (Compact Overlay)</h2>
 | 
			
		||||
    <div ref="cyContainer" style="width: 100%; height: 800px; border: 1px solid #ccc; position: relative;"></div>
 | 
			
		||||
    <div ref="overlayContainer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1000;"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted, nextTick } from 'vue'
 | 
			
		||||
import cytoscape from 'cytoscape'
 | 
			
		||||
import { Chart, registerables } from 'chart.js'
 | 
			
		||||
 | 
			
		||||
Chart.register(...registerables)
 | 
			
		||||
 | 
			
		||||
const cyContainer = ref(null)
 | 
			
		||||
const overlayContainer = ref(null)
 | 
			
		||||
const chartMap = new Map()
 | 
			
		||||
 | 
			
		||||
function toRenderedPosition(pos, cy) {
 | 
			
		||||
  const zoom = cy.zoom()
 | 
			
		||||
  const pan = cy.pan()
 | 
			
		||||
  return {
 | 
			
		||||
    x: pos.x * zoom + pan.x,
 | 
			
		||||
    y: pos.y * zoom + pan.y
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  await nextTick()
 | 
			
		||||
 | 
			
		||||
  const resizeOverlay = () => {
 | 
			
		||||
    if (!cyContainer.value || !overlayContainer.value) return
 | 
			
		||||
    overlayContainer.value.style.width = cyContainer.value.offsetWidth + 'px'
 | 
			
		||||
    overlayContainer.value.style.height = cyContainer.value.offsetHeight + 'px'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  resizeOverlay()
 | 
			
		||||
  window.addEventListener('resize', resizeOverlay)
 | 
			
		||||
 | 
			
		||||
  const res = await fetch('/pon00061.xml')
 | 
			
		||||
  const xmlText = await res.text()
 | 
			
		||||
  const parser = new DOMParser()
 | 
			
		||||
  const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
 | 
			
		||||
 | 
			
		||||
  const scale = 5
 | 
			
		||||
  const entryMap = new Map()
 | 
			
		||||
  const entryDataMap = new Map()
 | 
			
		||||
  const parentMap = new Map()
 | 
			
		||||
 | 
			
		||||
  const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
 | 
			
		||||
  for (const entry of entries) {
 | 
			
		||||
    const id = entry.getAttribute('id')
 | 
			
		||||
    entryDataMap.set(id, entry)
 | 
			
		||||
    if (entry.getAttribute('type') === 'group') {
 | 
			
		||||
      const components = entry.getElementsByTagName('component')
 | 
			
		||||
      for (const comp of components) {
 | 
			
		||||
        const childId = comp.getAttribute('id')
 | 
			
		||||
        parentMap.set(childId, id)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nodes = entries.map(entry => {
 | 
			
		||||
    const id = entry.getAttribute('id')
 | 
			
		||||
    const graphics = entry.getElementsByTagName('graphics')[0]
 | 
			
		||||
    const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
 | 
			
		||||
    const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
 | 
			
		||||
    const label = graphics?.getAttribute('name') || id
 | 
			
		||||
    const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
 | 
			
		||||
    const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
 | 
			
		||||
    const entryType = entry.getAttribute('type')
 | 
			
		||||
    const shapeType = entryType === 'compound' ? 'compound' : entryType === 'group' ? 'group' : entryType === 'ortholog' ? 'ortholog' : 'gene'
 | 
			
		||||
    const parent = parentMap.get(id)
 | 
			
		||||
 | 
			
		||||
    const node = {
 | 
			
		||||
      data: {
 | 
			
		||||
        id,
 | 
			
		||||
        label,
 | 
			
		||||
        link: entry.getAttribute('link') || null,
 | 
			
		||||
        reaction: entry.getAttribute('reaction') || null
 | 
			
		||||
      },
 | 
			
		||||
      position: { x, y },
 | 
			
		||||
      classes: shapeType,
 | 
			
		||||
      style: {
 | 
			
		||||
        color: fgColor,
 | 
			
		||||
        'background-color': bgColor
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    if (parent) node.data.parent = parent
 | 
			
		||||
    entryMap.set(id, true)
 | 
			
		||||
    return node
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  function resolveToRealNode(id) {
 | 
			
		||||
    if (!entryMap.has(id)) return null
 | 
			
		||||
    const entry = entryDataMap.get(id)
 | 
			
		||||
    if (entry?.getAttribute('type') === 'group') {
 | 
			
		||||
      const components = Array.from(entry.getElementsByTagName('component'))
 | 
			
		||||
      if (components.length > 0) return components[0].getAttribute('id')
 | 
			
		||||
    }
 | 
			
		||||
    return id
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const edges = []
 | 
			
		||||
  const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
 | 
			
		||||
  relations.forEach((rel, i) => {
 | 
			
		||||
    let source = resolveToRealNode(rel.getAttribute('entry1'))
 | 
			
		||||
    let target = resolveToRealNode(rel.getAttribute('entry2'))
 | 
			
		||||
    const type = rel.getAttribute('type')
 | 
			
		||||
    const subtypes = Array.from(rel.getElementsByTagName('subtype'))
 | 
			
		||||
    const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
 | 
			
		||||
    if (compoundSubtype) {
 | 
			
		||||
      const compoundId = compoundSubtype.getAttribute('value')
 | 
			
		||||
      if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
 | 
			
		||||
        edges.push(
 | 
			
		||||
          { data: { id: `edge${i}-1`, source, target: compoundId, label: 'via compound' } },
 | 
			
		||||
          { data: { id: `edge${i}-2`, source: compoundId, target, label: 'via compound' } }
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      if (entryMap.has(source) && entryMap.has(target)) {
 | 
			
		||||
        edges.push({ data: { id: `edge${i}`, source, target, label: type } })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
 | 
			
		||||
  reactions.forEach((reaction, i) => {
 | 
			
		||||
    const reactionType = reaction.getAttribute('type')
 | 
			
		||||
    const reactionLabel = reaction.getAttribute('name') || `reaction${i}`
 | 
			
		||||
    const substrates = Array.from(reaction.getElementsByTagName('substrate'))
 | 
			
		||||
    const products = Array.from(reaction.getElementsByTagName('product'))
 | 
			
		||||
    substrates.forEach(substrate => {
 | 
			
		||||
      const sid = resolveToRealNode(substrate.getAttribute('id'))
 | 
			
		||||
      products.forEach(product => {
 | 
			
		||||
        const pid = resolveToRealNode(product.getAttribute('id'))
 | 
			
		||||
        if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
			
		||||
          edges.push({ data: { id: `reaction-${i}-${sid}-${pid}`, source: sid, target: pid, label: `${reactionLabel} (${reactionType})` } })
 | 
			
		||||
          if (reactionType === 'reversible') {
 | 
			
		||||
            edges.push({ data: { id: `reaction-${i}-${pid}-${sid}`, source: pid, target: sid, label: `${reactionLabel} (reversible)` } })
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  const cy = cytoscape({
 | 
			
		||||
    container: cyContainer.value,
 | 
			
		||||
    elements: { nodes, edges },
 | 
			
		||||
    style: [
 | 
			
		||||
      { selector: 'node', style: { label: 'data(label)', 'text-valign': 'center', 'text-halign': 'center', shape: 'rectangle', 'font-size': 10, 'border-width': 1, 'border-color': '#333' } },
 | 
			
		||||
      { selector: '.ortholog', style: { 'background-color': '#ccffcc', shape: 'round-rectangle', 'border-width': 2, 'border-color': '#339933' } },
 | 
			
		||||
      { selector: '$node > node', style: { 'background-color': '#f3f3f3', 'border-width': 2, 'border-color': '#666', shape: 'roundrectangle' } },
 | 
			
		||||
      { selector: '.compound', style: { 'background-color': '#ffe135', shape: 'ellipse' } },
 | 
			
		||||
      { selector: 'edge', style: { width: 2, 'line-color': '#888', 'target-arrow-shape': 'triangle', 'label': 'data(label)', 'font-size': 8 } }
 | 
			
		||||
    ],
 | 
			
		||||
    layout: { name: 'preset' }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  cy.ready(() => {
 | 
			
		||||
    cy.fit(cy.elements(), 100)
 | 
			
		||||
    createNodeOverlays(cy)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  cy.on('render', () => {
 | 
			
		||||
    requestAnimationFrame(() => updateOverlayPositions(cy))
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function createNodeOverlays(cy) {
 | 
			
		||||
  cy.nodes().forEach(node => {
 | 
			
		||||
    const pos = node.renderedPosition()
 | 
			
		||||
    const id = node.id()
 | 
			
		||||
 | 
			
		||||
    const wrapper = document.createElement('div')
 | 
			
		||||
    wrapper.style.position = 'absolute'
 | 
			
		||||
    wrapper.style.left = `${pos.x + 30}px`
 | 
			
		||||
    wrapper.style.top = `${pos.y - 40}px`
 | 
			
		||||
    wrapper.style.width = '20px'
 | 
			
		||||
    wrapper.style.height = '80px'
 | 
			
		||||
    wrapper.style.background = 'rgba(255,255,255,0.9)'
 | 
			
		||||
    wrapper.style.fontSize = '6px'
 | 
			
		||||
    wrapper.style.border = '1px solid #ccc'
 | 
			
		||||
    wrapper.style.borderRadius = '2px'
 | 
			
		||||
    wrapper.style.overflow = 'hidden'
 | 
			
		||||
    wrapper.style.pointerEvents = 'none'
 | 
			
		||||
    wrapper.style.zIndex = '9999' // 보장
 | 
			
		||||
 | 
			
		||||
    const table = document.createElement('table')
 | 
			
		||||
    table.style.borderCollapse = 'collapse'
 | 
			
		||||
    table.style.width = '100%'
 | 
			
		||||
    for (let i = 0; i < 7; i++) {
 | 
			
		||||
      const tr = document.createElement('tr')
 | 
			
		||||
      for (let j = 0; j < 2; j++) {
 | 
			
		||||
        const td = document.createElement('td')
 | 
			
		||||
        td.textContent = '·'
 | 
			
		||||
        td.style.padding = '0'
 | 
			
		||||
        td.style.fontSize = '6px'
 | 
			
		||||
        td.style.textAlign = 'center'
 | 
			
		||||
        tr.appendChild(td)
 | 
			
		||||
      }
 | 
			
		||||
      table.appendChild(tr)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const canvas = document.createElement('canvas')
 | 
			
		||||
    canvas.width = 16
 | 
			
		||||
    canvas.height = 16
 | 
			
		||||
    canvas.style.margin = '2px auto 0'
 | 
			
		||||
    canvas.style.display = 'block'
 | 
			
		||||
 | 
			
		||||
    wrapper.appendChild(table)
 | 
			
		||||
    wrapper.appendChild(canvas)
 | 
			
		||||
    overlayContainer.value.appendChild(wrapper)
 | 
			
		||||
 | 
			
		||||
    const ctx = canvas.getContext('2d')
 | 
			
		||||
    const chart = new Chart(ctx, {
 | 
			
		||||
      type: 'doughnut',
 | 
			
		||||
      data: {
 | 
			
		||||
        labels: ['A', 'B'],
 | 
			
		||||
        datasets: [{ data: [30, 70], backgroundColor: ['#ff6384', '#36a2eb'] }]
 | 
			
		||||
      },
 | 
			
		||||
      options: {
 | 
			
		||||
        responsive: false,
 | 
			
		||||
        plugins: { legend: { display: false }, tooltip: { enabled: false } },
 | 
			
		||||
        cutout: '60%',
 | 
			
		||||
        animation: false
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    chartMap.set(id, { wrapper, chart })
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function updateOverlayPositions(cy) {
 | 
			
		||||
  cy.nodes().forEach(node => {
 | 
			
		||||
    const entry = chartMap.get(node.id())
 | 
			
		||||
    if (entry) {
 | 
			
		||||
      const pos = node.renderedPosition()
 | 
			
		||||
      entry.wrapper.style.left = `${pos.x + 30}px`
 | 
			
		||||
      entry.wrapper.style.top = `${pos.y - 40}px`
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										263
									
								
								pages/[tabId]/test/pathway2.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										263
									
								
								pages/[tabId]/test/pathway2.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,263 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div>
 | 
			
		||||
    <h2>KEGG Pathway Viewer (Cytoscape.js)</h2>
 | 
			
		||||
    <div ref="cyContainer" style="width: 100%; height: 800px; border: 1px solid #ccc;"></div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted, nextTick } from 'vue'
 | 
			
		||||
import cytoscape from 'cytoscape'
 | 
			
		||||
 | 
			
		||||
const cyContainer = ref(null)
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  await nextTick()
 | 
			
		||||
 | 
			
		||||
  const res = await fetch('/pon00061.xml')
 | 
			
		||||
  const xmlText = await res.text()
 | 
			
		||||
  const parser = new DOMParser()
 | 
			
		||||
  const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
 | 
			
		||||
 | 
			
		||||
  const scale = 3
 | 
			
		||||
 | 
			
		||||
  const entryMap = new Map()
 | 
			
		||||
  const entryDataMap = new Map()
 | 
			
		||||
  const parentMap = new Map()
 | 
			
		||||
 | 
			
		||||
  const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
 | 
			
		||||
 | 
			
		||||
  // 1. 부모-자식 관계 정리
 | 
			
		||||
  for (const entry of entries) {
 | 
			
		||||
    const id = entry.getAttribute('id')
 | 
			
		||||
    entryDataMap.set(id, entry)
 | 
			
		||||
 | 
			
		||||
    if (entry.getAttribute('type') === 'group') {
 | 
			
		||||
      const components = entry.getElementsByTagName('component')
 | 
			
		||||
      for (const comp of components) {
 | 
			
		||||
        const childId = comp.getAttribute('id')
 | 
			
		||||
        parentMap.set(childId, id)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // 2. 노드 생성
 | 
			
		||||
  const nodes = entries.map(entry => {
 | 
			
		||||
    const id = entry.getAttribute('id')
 | 
			
		||||
    const graphics = entry.getElementsByTagName('graphics')[0]
 | 
			
		||||
    const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
 | 
			
		||||
    const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
 | 
			
		||||
    const label = graphics?.getAttribute('name') || id
 | 
			
		||||
    const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
 | 
			
		||||
    const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
 | 
			
		||||
    const shapeType = graphics?.getAttribute('type') === 'circle' ? 'compound' : 'gene'
 | 
			
		||||
    const parent = parentMap.get(id)
 | 
			
		||||
 | 
			
		||||
    const node = {
 | 
			
		||||
      data: {
 | 
			
		||||
        id,
 | 
			
		||||
        label,
 | 
			
		||||
        link: entry.getAttribute('link') || null,
 | 
			
		||||
        reaction: entry.getAttribute('reaction') || null
 | 
			
		||||
      },
 | 
			
		||||
      position: { x, y },
 | 
			
		||||
      classes: shapeType,
 | 
			
		||||
      style: {
 | 
			
		||||
        'color': fgColor,
 | 
			
		||||
        'background-color': bgColor
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // parent 설정
 | 
			
		||||
    if (parent) node.data.parent = parent
 | 
			
		||||
 | 
			
		||||
    // ortholog 스타일 구분
 | 
			
		||||
    if (entry.getAttribute('type') === 'ortholog') {
 | 
			
		||||
      node.classes += ' ortholog'
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    entryMap.set(id, true)
 | 
			
		||||
    return node
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // 3. group 노드가 edge에 등장할 경우 첫 자식으로 대체
 | 
			
		||||
  function resolveToRealNode(id) {
 | 
			
		||||
    if (!entryMap.has(id)) return null
 | 
			
		||||
    const entry = entryDataMap.get(id)
 | 
			
		||||
    if (entry?.getAttribute('type') === 'group') {
 | 
			
		||||
      const components = Array.from(entry.getElementsByTagName('component'))
 | 
			
		||||
      if (components.length > 0) return components[0].getAttribute('id')
 | 
			
		||||
    }
 | 
			
		||||
    return id
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const edges = []
 | 
			
		||||
 | 
			
		||||
  // 4. relation 기반 edge 처리
 | 
			
		||||
  const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
 | 
			
		||||
 | 
			
		||||
  relations.forEach((rel, i) => {
 | 
			
		||||
    let source = resolveToRealNode(rel.getAttribute('entry1'))
 | 
			
		||||
    let target = resolveToRealNode(rel.getAttribute('entry2'))
 | 
			
		||||
    const type = rel.getAttribute('type')
 | 
			
		||||
 | 
			
		||||
    const subtypes = Array.from(rel.getElementsByTagName('subtype'))
 | 
			
		||||
    const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
 | 
			
		||||
 | 
			
		||||
    if (compoundSubtype) {
 | 
			
		||||
      const compoundId = compoundSubtype.getAttribute('value')
 | 
			
		||||
      if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
 | 
			
		||||
        edges.push(
 | 
			
		||||
          {
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `edge${i}-1`,
 | 
			
		||||
              source,
 | 
			
		||||
              target: compoundId,
 | 
			
		||||
              label: 'via compound'
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `edge${i}-2`,
 | 
			
		||||
              source: compoundId,
 | 
			
		||||
              target,
 | 
			
		||||
              label: 'via compound'
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      if (entryMap.has(source) && entryMap.has(target)) {
 | 
			
		||||
        edges.push({
 | 
			
		||||
          data: {
 | 
			
		||||
            id: `edge${i}`,
 | 
			
		||||
            source,
 | 
			
		||||
            target,
 | 
			
		||||
            label: type
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // 5. reaction 기반 edge 처리
 | 
			
		||||
  const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
 | 
			
		||||
 | 
			
		||||
  reactions.forEach((reaction, i) => {
 | 
			
		||||
    const reactionType = reaction.getAttribute('type')
 | 
			
		||||
    const reactionLabel = reaction.getAttribute('name') || `reaction${i}`
 | 
			
		||||
 | 
			
		||||
    const substrates = Array.from(reaction.getElementsByTagName('substrate'))
 | 
			
		||||
    const products = Array.from(reaction.getElementsByTagName('product'))
 | 
			
		||||
 | 
			
		||||
    substrates.forEach(substrate => {
 | 
			
		||||
      const sid = resolveToRealNode(substrate.getAttribute('id'))
 | 
			
		||||
      products.forEach(product => {
 | 
			
		||||
        const pid = resolveToRealNode(product.getAttribute('id'))
 | 
			
		||||
        if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
			
		||||
          edges.push({
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `reaction-${i}-${sid}-${pid}`,
 | 
			
		||||
              source: sid,
 | 
			
		||||
              target: pid,
 | 
			
		||||
              label: `${reactionLabel} (${reactionType})`
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
          if (reactionType === 'reversible') {
 | 
			
		||||
            edges.push({
 | 
			
		||||
              data: {
 | 
			
		||||
                id: `reaction-${i}-${pid}-${sid}`,
 | 
			
		||||
                source: pid,
 | 
			
		||||
                target: sid,
 | 
			
		||||
                label: `${reactionLabel} (reversible)`
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // 6. Cytoscape 초기화
 | 
			
		||||
  const cy = cytoscape({
 | 
			
		||||
    container: cyContainer.value,
 | 
			
		||||
    elements: { nodes, edges },
 | 
			
		||||
    style: [
 | 
			
		||||
      {
 | 
			
		||||
        selector: 'node',
 | 
			
		||||
        style: {
 | 
			
		||||
          label: 'data(label)',
 | 
			
		||||
          'text-valign': 'center',
 | 
			
		||||
          'text-halign': 'center',
 | 
			
		||||
          'shape': 'rectangle',
 | 
			
		||||
          'padding': '6px',
 | 
			
		||||
          'text-wrap': 'wrap',
 | 
			
		||||
          'color': '#000',
 | 
			
		||||
          'font-size': 10,
 | 
			
		||||
          'border-width': 1,
 | 
			
		||||
          'border-color': '#333'
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        selector: '.ortholog',
 | 
			
		||||
        style: {
 | 
			
		||||
          'background-color': '#ccffcc',
 | 
			
		||||
          'shape': 'round-rectangle',
 | 
			
		||||
          'border-width': 2,
 | 
			
		||||
          'border-color': '#339933'
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        selector: '$node > node',
 | 
			
		||||
        style: {
 | 
			
		||||
          'background-color': '#f3f3f3',
 | 
			
		||||
          'border-width': 2,
 | 
			
		||||
          'border-color': '#666',
 | 
			
		||||
          'shape': 'roundrectangle',
 | 
			
		||||
          'text-valign': 'top',
 | 
			
		||||
          'text-halign': 'center',
 | 
			
		||||
          'font-weight': 'bold',
 | 
			
		||||
          'padding': '20px'
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        selector: '.compound',
 | 
			
		||||
        style: {
 | 
			
		||||
          'background-color': '#ffe135',
 | 
			
		||||
          'shape': 'ellipse'
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        selector: 'edge',
 | 
			
		||||
        style: {
 | 
			
		||||
          width: 2,
 | 
			
		||||
          'line-color': '#888',
 | 
			
		||||
          'target-arrow-color': '#888',
 | 
			
		||||
          'target-arrow-shape': 'triangle',
 | 
			
		||||
          'curve-style': 'bezier',
 | 
			
		||||
          'label': 'data(label)',
 | 
			
		||||
          'font-size': 8,
 | 
			
		||||
          'text-background-opacity': 1,
 | 
			
		||||
          'text-background-color': '#fff',
 | 
			
		||||
          'text-background-shape': 'roundrectangle',
 | 
			
		||||
          'text-rotation': 'autorotate'
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    ],
 | 
			
		||||
    layout: { name: 'preset' }
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  cy.ready(() => {
 | 
			
		||||
    cy.fit(cy.elements(), 100)
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  // 7. 노드 클릭 시 KEGG 링크 열기
 | 
			
		||||
  cy.on('tap', 'node', (evt) => {
 | 
			
		||||
    const node = evt.target
 | 
			
		||||
    const link = node.data('link')
 | 
			
		||||
    if (link) {
 | 
			
		||||
      window.open(link, '_blank')
 | 
			
		||||
    }
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										254
									
								
								pages/[tabId]/test/pathway3.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										254
									
								
								pages/[tabId]/test/pathway3.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,254 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div ref="cyContainer" class="cy-container"></div>
 | 
			
		||||
  </template>
 | 
			
		||||
  
 | 
			
		||||
  <script setup>
 | 
			
		||||
  import { ref, onMounted, nextTick } from 'vue'
 | 
			
		||||
  import cytoscape from 'cytoscape'
 | 
			
		||||
  
 | 
			
		||||
  if (typeof document !== 'undefined') {
 | 
			
		||||
    const script = document.createElement('script');
 | 
			
		||||
    script.src = '/dist/cy_custom.js';
 | 
			
		||||
    script.onload = () => {
 | 
			
		||||
      window.igvCustomLoaded = true;
 | 
			
		||||
    };
 | 
			
		||||
    document.head.appendChild(script);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let CytoscapeOverlays = null
 | 
			
		||||
 | 
			
		||||
    if (import.meta.client) {
 | 
			
		||||
    const useOverlay = (await import('@/composables/useOverlay')).default
 | 
			
		||||
    CytoscapeOverlays = await useOverlay()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
  const cyContainer = ref(null)
 | 
			
		||||
  
 | 
			
		||||
  onMounted(async () => {
 | 
			
		||||
    await nextTick()
 | 
			
		||||
 | 
			
		||||
    cytoscape.use(CytoscapeOverlays.default)
 | 
			
		||||
  
 | 
			
		||||
    const res = await fetch('/pon00061.xml')
 | 
			
		||||
    const xmlText = await res.text()
 | 
			
		||||
    const parser = new DOMParser()
 | 
			
		||||
    const xmlDoc = parser.parseFromString(xmlText, 'application/xml')
 | 
			
		||||
  
 | 
			
		||||
    const scale = 3
 | 
			
		||||
    const entryMap = new Map()
 | 
			
		||||
    const entryDataMap = new Map()
 | 
			
		||||
    const parentMap = new Map()
 | 
			
		||||
  
 | 
			
		||||
    const entries = Array.from(xmlDoc.getElementsByTagName('entry'))
 | 
			
		||||
  
 | 
			
		||||
    for (const entry of entries) {
 | 
			
		||||
      const id = entry.getAttribute('id')
 | 
			
		||||
      entryDataMap.set(id, entry)
 | 
			
		||||
      if (entry.getAttribute('type') === 'group') {
 | 
			
		||||
        const components = entry.getElementsByTagName('component')
 | 
			
		||||
        for (const comp of components) {
 | 
			
		||||
          parentMap.set(comp.getAttribute('id'), id)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    const nodes = entries.map(entry => {
 | 
			
		||||
      const id = entry.getAttribute('id')
 | 
			
		||||
      const graphics = entry.getElementsByTagName('graphics')[0]
 | 
			
		||||
      const x = parseFloat(graphics?.getAttribute('x') || '0') * scale
 | 
			
		||||
      const y = parseFloat(graphics?.getAttribute('y') || '0') * scale
 | 
			
		||||
      const label = graphics?.getAttribute('name') || id
 | 
			
		||||
      const fgColor = graphics?.getAttribute('fgcolor') || '#000000'
 | 
			
		||||
      const bgColor = graphics?.getAttribute('bgcolor') || '#ffffff'
 | 
			
		||||
      const parent = parentMap.get(id)
 | 
			
		||||
  
 | 
			
		||||
      const valueA = Math.floor(Math.random() * 50)
 | 
			
		||||
      const valueB = 100 - valueA
 | 
			
		||||
  
 | 
			
		||||
      const node = {
 | 
			
		||||
        data: {
 | 
			
		||||
          id,
 | 
			
		||||
          label,
 | 
			
		||||
          link: entry.getAttribute('link') || null,
 | 
			
		||||
          reaction: entry.getAttribute('reaction') || null,
 | 
			
		||||
          chartData: [valueA, valueB]
 | 
			
		||||
        },
 | 
			
		||||
        position: { x, y },
 | 
			
		||||
        classes: entry.getAttribute('type'),
 | 
			
		||||
        style: {
 | 
			
		||||
          color: fgColor,
 | 
			
		||||
          'background-color': bgColor
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
  
 | 
			
		||||
      if (parent) node.data.parent = parent
 | 
			
		||||
      entryMap.set(id, true)
 | 
			
		||||
      return node
 | 
			
		||||
    })
 | 
			
		||||
  
 | 
			
		||||
    function resolveToRealNode(id) {
 | 
			
		||||
      if (!entryMap.has(id)) return null
 | 
			
		||||
      const entry = entryDataMap.get(id)
 | 
			
		||||
      if (entry?.getAttribute('type') === 'group') {
 | 
			
		||||
        const components = Array.from(entry.getElementsByTagName('component'))
 | 
			
		||||
        if (components.length > 0) return components[0].getAttribute('id')
 | 
			
		||||
      }
 | 
			
		||||
      return id
 | 
			
		||||
    }
 | 
			
		||||
  
 | 
			
		||||
    const edges = []
 | 
			
		||||
    const relations = Array.from(xmlDoc.getElementsByTagName('relation'))
 | 
			
		||||
    relations.forEach((rel, i) => {
 | 
			
		||||
      const source = resolveToRealNode(rel.getAttribute('entry1'))
 | 
			
		||||
      const target = resolveToRealNode(rel.getAttribute('entry2'))
 | 
			
		||||
      const type = rel.getAttribute('type')
 | 
			
		||||
      const subtypes = Array.from(rel.getElementsByTagName('subtype'))
 | 
			
		||||
      const compoundSubtype = subtypes.find(s => s.getAttribute('name') === 'compound')
 | 
			
		||||
 | 
			
		||||
      if (compoundSubtype) {
 | 
			
		||||
        const compoundId = compoundSubtype.getAttribute('value')
 | 
			
		||||
        if (entryMap.has(source) && entryMap.has(target) && entryMap.has(compoundId)) {
 | 
			
		||||
          const sourceConst = source;
 | 
			
		||||
          const targetConst = target;
 | 
			
		||||
          edges.push(
 | 
			
		||||
            {
 | 
			
		||||
              data: {
 | 
			
		||||
                id: `edge${i}-1`,
 | 
			
		||||
                source: sourceConst,
 | 
			
		||||
                target: compoundId,
 | 
			
		||||
                label: 'via compound'
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              data: {
 | 
			
		||||
                id: `edge${i}-2`,
 | 
			
		||||
                source: compoundId,
 | 
			
		||||
                target: targetConst,
 | 
			
		||||
                label: 'via compound'
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      } else {
 | 
			
		||||
        if (entryMap.has(source) && entryMap.has(target)) {
 | 
			
		||||
          const sourceConst = source;
 | 
			
		||||
          const targetConst = target;
 | 
			
		||||
          edges.push({
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `edge${i}`,
 | 
			
		||||
              source: sourceConst,
 | 
			
		||||
              target: targetConst,
 | 
			
		||||
              label: type
 | 
			
		||||
            }
 | 
			
		||||
          })
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
  
 | 
			
		||||
    const reactions = Array.from(xmlDoc.getElementsByTagName('reaction'))
 | 
			
		||||
    reactions.forEach((reaction, i) => {
 | 
			
		||||
      const reactionType = reaction.getAttribute('type')
 | 
			
		||||
      const substrates = Array.from(reaction.getElementsByTagName('substrate'))
 | 
			
		||||
      const products = Array.from(reaction.getElementsByTagName('product'))
 | 
			
		||||
      substrates.forEach(substrate => {
 | 
			
		||||
        const sid = resolveToRealNode(substrate.getAttribute('id'))
 | 
			
		||||
        products.forEach(product => {
 | 
			
		||||
          const pid = resolveToRealNode(product.getAttribute('id'))
 | 
			
		||||
          if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
			
		||||
            edges.push({
 | 
			
		||||
              data: {
 | 
			
		||||
                id: `reaction-${i}-${sid}-${pid}`,
 | 
			
		||||
                source: sid,
 | 
			
		||||
                target: pid,
 | 
			
		||||
                label: `${reactionType}`
 | 
			
		||||
              }
 | 
			
		||||
            })
 | 
			
		||||
          }
 | 
			
		||||
        })
 | 
			
		||||
      })
 | 
			
		||||
    })
 | 
			
		||||
  
 | 
			
		||||
    // 원형(도넛/파이) 차트 overlay 생성 (흰색 라인 없이)
 | 
			
		||||
    const pieOverlay = CytoscapeOverlays.renderSymbol({
 | 
			
		||||
      symbol: node => {
 | 
			
		||||
        const data = node.data('chartData') || [50, 50]
 | 
			
		||||
        return {
 | 
			
		||||
          draw: (ctx, size) => {
 | 
			
		||||
            const total = data[0] + data[1]
 | 
			
		||||
            const r = Math.sqrt(size / Math.PI)
 | 
			
		||||
            const innerR = r * 0.5
 | 
			
		||||
            const startAngle = 0
 | 
			
		||||
            const angleA = (data[0] / total) * 2 * Math.PI
 | 
			
		||||
 | 
			
		||||
            // A 영역
 | 
			
		||||
            ctx.beginPath()
 | 
			
		||||
            ctx.arc(0, 0, r, startAngle, startAngle + angleA)
 | 
			
		||||
            ctx.arc(0, 0, innerR, startAngle + angleA, startAngle, true)
 | 
			
		||||
            ctx.closePath()
 | 
			
		||||
            ctx.fillStyle = '#36a2eb'
 | 
			
		||||
            ctx.fill()
 | 
			
		||||
 | 
			
		||||
            // B 영역
 | 
			
		||||
            ctx.beginPath()
 | 
			
		||||
            ctx.arc(0, 0, r, startAngle + angleA, startAngle + 2 * Math.PI)
 | 
			
		||||
            ctx.arc(0, 0, innerR, startAngle + 2 * Math.PI, startAngle + angleA, true)
 | 
			
		||||
            ctx.closePath()
 | 
			
		||||
            ctx.fillStyle = '#ff6384'
 | 
			
		||||
            ctx.fill()
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      color: '',
 | 
			
		||||
      width: 32,
 | 
			
		||||
      height: 32,
 | 
			
		||||
      borderColor: '#333'
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    const cy = cytoscape({
 | 
			
		||||
      container: cyContainer.value,
 | 
			
		||||
      elements: { nodes, edges },
 | 
			
		||||
      style: [
 | 
			
		||||
        { selector: 'node', style: {
 | 
			
		||||
            label: 'data(label)',
 | 
			
		||||
            'background-color': '#eee',
 | 
			
		||||
            'text-valign': 'center',
 | 
			
		||||
            'text-halign': 'center',
 | 
			
		||||
            'border-width': 2,
 | 
			
		||||
            'border-color': '#333',
 | 
			
		||||
            'font-size': 8,
 | 
			
		||||
            'padding': '6px',
 | 
			
		||||
            'width': 32,
 | 
			
		||||
            'height': 32,
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        { selector: 'edge', style: { 'curve-style': 'bezier', 'target-arrow-shape': 'triangle', 'line-color': '#888' } },
 | 
			
		||||
      ],
 | 
			
		||||
      layout: { name: 'preset', padding: 100 }
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    cy.overlays(
 | 
			
		||||
      [
 | 
			
		||||
        { position: 'right', vis: pieOverlay }
 | 
			
		||||
      ],
 | 
			
		||||
      {
 | 
			
		||||
        updateOn: 'render',
 | 
			
		||||
        backgroundColor: 'white'
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    applyCustomZoomHandling(cy);
 | 
			
		||||
 | 
			
		||||
});
 | 
			
		||||
  </script>
 | 
			
		||||
  
 | 
			
		||||
  <style>
 | 
			
		||||
  .cy-container {
 | 
			
		||||
    width: 100vw !important;
 | 
			
		||||
    max-width: 100vw !important;
 | 
			
		||||
    min-width: 0 !important;
 | 
			
		||||
    height: 800px !important;
 | 
			
		||||
    overflow: hidden !important;
 | 
			
		||||
    position: relative;
 | 
			
		||||
  }
 | 
			
		||||
  </style>
 | 
			
		||||
  
 | 
			
		||||
							
								
								
									
										260
									
								
								pages/[tabId]/test/pathway4.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										260
									
								
								pages/[tabId]/test/pathway4.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,260 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div ref="cyContainer" class="cy-container"></div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup>
 | 
			
		||||
import { ref, onMounted, nextTick } from "vue";
 | 
			
		||||
import cytoscape from "cytoscape";
 | 
			
		||||
 | 
			
		||||
let CytoscapeOverlays = null;
 | 
			
		||||
 | 
			
		||||
if (import.meta.client) {
 | 
			
		||||
  const useOverlay = (await import("@/composables/useOverlay")).default;
 | 
			
		||||
  CytoscapeOverlays = await useOverlay();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const cyContainer = ref(null);
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  await nextTick();
 | 
			
		||||
 | 
			
		||||
  cytoscape.use(CytoscapeOverlays.default);
 | 
			
		||||
 | 
			
		||||
  const res = await fetch("/expanded_pathway21600.xml");
 | 
			
		||||
  const xmlText = await res.text();
 | 
			
		||||
  const parser = new DOMParser();
 | 
			
		||||
  const xmlDoc = parser.parseFromString(xmlText, "application/xml");
 | 
			
		||||
 | 
			
		||||
  const scale = 3;
 | 
			
		||||
  const entryMap = new Map();
 | 
			
		||||
  const entryDataMap = new Map();
 | 
			
		||||
  const parentMap = new Map();
 | 
			
		||||
 | 
			
		||||
  const entries = Array.from(xmlDoc.getElementsByTagName("entry"));
 | 
			
		||||
 | 
			
		||||
  for (const entry of entries) {
 | 
			
		||||
    const id = entry.getAttribute("id");
 | 
			
		||||
    entryDataMap.set(id, entry);
 | 
			
		||||
    if (entry.getAttribute("type") === "group") {
 | 
			
		||||
      const components = entry.getElementsByTagName("component");
 | 
			
		||||
      for (const comp of components) {
 | 
			
		||||
        parentMap.set(comp.getAttribute("id"), id);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const nodes = entries.map(entry => {
 | 
			
		||||
    const id = entry.getAttribute("id");
 | 
			
		||||
    const graphics = entry.getElementsByTagName("graphics")[0];
 | 
			
		||||
    const x = parseFloat(graphics?.getAttribute("x") || "0") * scale;
 | 
			
		||||
    const y = parseFloat(graphics?.getAttribute("y") || "0") * scale;
 | 
			
		||||
    const label = graphics?.getAttribute("name") || id;
 | 
			
		||||
    const fgColor = graphics?.getAttribute("fgcolor") || "#000000";
 | 
			
		||||
    const bgColor = graphics?.getAttribute("bgcolor") || "#ffffff";
 | 
			
		||||
    const parent = parentMap.get(id);
 | 
			
		||||
 | 
			
		||||
    const valueA = Math.floor(Math.random() * 50);
 | 
			
		||||
    const valueB = 100 - valueA;
 | 
			
		||||
 | 
			
		||||
    const node = {
 | 
			
		||||
      data: {
 | 
			
		||||
        id,
 | 
			
		||||
        label,
 | 
			
		||||
        link: entry.getAttribute("link") || null,
 | 
			
		||||
        reaction: entry.getAttribute("reaction") || null,
 | 
			
		||||
        chartData: [valueA, valueB],
 | 
			
		||||
      },
 | 
			
		||||
      position: { x, y },
 | 
			
		||||
      classes: entry.getAttribute("type"),
 | 
			
		||||
      style: {
 | 
			
		||||
        color: fgColor,
 | 
			
		||||
        "background-color": bgColor,
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    if (parent) node.data.parent = parent;
 | 
			
		||||
    entryMap.set(id, true);
 | 
			
		||||
    return node;
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  function resolveToRealNode(id) {
 | 
			
		||||
    if (!entryMap.has(id)) return null;
 | 
			
		||||
    const entry = entryDataMap.get(id);
 | 
			
		||||
    if (entry?.getAttribute("type") === "group") {
 | 
			
		||||
      const components = Array.from(entry.getElementsByTagName("component"));
 | 
			
		||||
      if (components.length > 0) return components[0].getAttribute("id");
 | 
			
		||||
    }
 | 
			
		||||
    return id;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const edges = [];
 | 
			
		||||
  const relations = Array.from(xmlDoc.getElementsByTagName("relation"));
 | 
			
		||||
  relations.forEach((rel, i) => {
 | 
			
		||||
    const source = resolveToRealNode(rel.getAttribute("entry1"));
 | 
			
		||||
    const target = resolveToRealNode(rel.getAttribute("entry2"));
 | 
			
		||||
    const type = rel.getAttribute("type");
 | 
			
		||||
    const subtypes = Array.from(rel.getElementsByTagName("subtype"));
 | 
			
		||||
    const compoundSubtype = subtypes.find(
 | 
			
		||||
      s => s.getAttribute("name") === "compound"
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (compoundSubtype) {
 | 
			
		||||
      const compoundId = compoundSubtype.getAttribute("value");
 | 
			
		||||
      if (
 | 
			
		||||
        entryMap.has(source) &&
 | 
			
		||||
        entryMap.has(target) &&
 | 
			
		||||
        entryMap.has(compoundId)
 | 
			
		||||
      ) {
 | 
			
		||||
        const sourceConst = source;
 | 
			
		||||
        const targetConst = target;
 | 
			
		||||
        edges.push(
 | 
			
		||||
          {
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `edge${i}-1`,
 | 
			
		||||
              source: sourceConst,
 | 
			
		||||
              target: compoundId,
 | 
			
		||||
              label: "via compound",
 | 
			
		||||
            },
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `edge${i}-2`,
 | 
			
		||||
              source: compoundId,
 | 
			
		||||
              target: targetConst,
 | 
			
		||||
              label: "via compound",
 | 
			
		||||
            },
 | 
			
		||||
          }
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      if (entryMap.has(source) && entryMap.has(target)) {
 | 
			
		||||
        const sourceConst = source;
 | 
			
		||||
        const targetConst = target;
 | 
			
		||||
        edges.push({
 | 
			
		||||
          data: {
 | 
			
		||||
            id: `edge${i}`,
 | 
			
		||||
            source: sourceConst,
 | 
			
		||||
            target: targetConst,
 | 
			
		||||
            label: type,
 | 
			
		||||
          },
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const reactions = Array.from(xmlDoc.getElementsByTagName("reaction"));
 | 
			
		||||
  reactions.forEach((reaction, i) => {
 | 
			
		||||
    const reactionType = reaction.getAttribute("type");
 | 
			
		||||
    const substrates = Array.from(reaction.getElementsByTagName("substrate"));
 | 
			
		||||
    const products = Array.from(reaction.getElementsByTagName("product"));
 | 
			
		||||
    substrates.forEach(substrate => {
 | 
			
		||||
      const sid = resolveToRealNode(substrate.getAttribute("id"));
 | 
			
		||||
      products.forEach(product => {
 | 
			
		||||
        const pid = resolveToRealNode(product.getAttribute("id"));
 | 
			
		||||
        if (entryMap.has(sid) && entryMap.has(pid)) {
 | 
			
		||||
          edges.push({
 | 
			
		||||
            data: {
 | 
			
		||||
              id: `reaction-${i}-${sid}-${pid}`,
 | 
			
		||||
              source: sid,
 | 
			
		||||
              target: pid,
 | 
			
		||||
              label: `${reactionType}`,
 | 
			
		||||
            },
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  // 원형(도넛/파이) 차트 overlay 생성 (흰색 라인 없이)
 | 
			
		||||
  const pieOverlay = CytoscapeOverlays.renderSymbol({
 | 
			
		||||
    symbol: node => {
 | 
			
		||||
      const data = node.data("chartData") || [50, 50];
 | 
			
		||||
      return {
 | 
			
		||||
        draw: (ctx, size) => {
 | 
			
		||||
          const total = data[0] + data[1];
 | 
			
		||||
          const r = Math.sqrt(size / Math.PI);
 | 
			
		||||
          const innerR = r * 0.5;
 | 
			
		||||
          const startAngle = 0;
 | 
			
		||||
          const angleA = (data[0] / total) * 2 * Math.PI;
 | 
			
		||||
 | 
			
		||||
          // A 영역
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(0, 0, r, startAngle, startAngle + angleA);
 | 
			
		||||
          ctx.arc(0, 0, innerR, startAngle + angleA, startAngle, true);
 | 
			
		||||
          ctx.closePath();
 | 
			
		||||
          ctx.fillStyle = "#36a2eb";
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
 | 
			
		||||
          // B 영역
 | 
			
		||||
          ctx.beginPath();
 | 
			
		||||
          ctx.arc(0, 0, r, startAngle + angleA, startAngle + 2 * Math.PI);
 | 
			
		||||
          ctx.arc(
 | 
			
		||||
            0,
 | 
			
		||||
            0,
 | 
			
		||||
            innerR,
 | 
			
		||||
            startAngle + 2 * Math.PI,
 | 
			
		||||
            startAngle + angleA,
 | 
			
		||||
            true
 | 
			
		||||
          );
 | 
			
		||||
          ctx.closePath();
 | 
			
		||||
          ctx.fillStyle = "#ff6384";
 | 
			
		||||
          ctx.fill();
 | 
			
		||||
        },
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    color: "",
 | 
			
		||||
    width: 32,
 | 
			
		||||
    height: 32,
 | 
			
		||||
    borderColor: "#333",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const cy = cytoscape({
 | 
			
		||||
    container: cyContainer.value,
 | 
			
		||||
    elements: { nodes, edges },
 | 
			
		||||
    style: [
 | 
			
		||||
      {
 | 
			
		||||
        selector: "node",
 | 
			
		||||
        style: {
 | 
			
		||||
          label: "data(label)",
 | 
			
		||||
          "background-color": "#eee",
 | 
			
		||||
          "text-valign": "center",
 | 
			
		||||
          "text-halign": "center",
 | 
			
		||||
          "border-width": 2,
 | 
			
		||||
          "border-color": "#333",
 | 
			
		||||
          "font-size": 8,
 | 
			
		||||
          padding: "6px",
 | 
			
		||||
          width: 32,
 | 
			
		||||
          height: 32,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        selector: "edge",
 | 
			
		||||
        style: {
 | 
			
		||||
          "curve-style": "bezier",
 | 
			
		||||
          "target-arrow-shape": "triangle",
 | 
			
		||||
          "line-color": "#888",
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
    ],
 | 
			
		||||
    layout: { name: "preset", padding: 100 },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  cy.overlays([{ position: "right", vis: pieOverlay }], {
 | 
			
		||||
    updateOn: "render",
 | 
			
		||||
    backgroundColor: "white",
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  applyCustomZoomHandling(cy);
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.cy-container {
 | 
			
		||||
  width: 100vw !important;
 | 
			
		||||
  max-width: 100vw !important;
 | 
			
		||||
  min-width: 0 !important;
 | 
			
		||||
  height: 800px !important;
 | 
			
		||||
  overflow: hidden !important;
 | 
			
		||||
  position: relative;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										130
									
								
								pages/[tabId]/test/pathwayJson.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								pages/[tabId]/test/pathwayJson.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,130 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div>
 | 
			
		||||
      <select v-model="selectedGroup" @change="focusGroup">
 | 
			
		||||
        <option v-for="g in groups" :key="g" :value="g">
 | 
			
		||||
          Group {{ g }}
 | 
			
		||||
        </option>
 | 
			
		||||
      </select>
 | 
			
		||||
  
 | 
			
		||||
      <div ref="cyEl" style="width: 100%; height: 800px;"></div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </template>
 | 
			
		||||
  
 | 
			
		||||
  <script setup>
 | 
			
		||||
  import { ref, onMounted } from 'vue';
 | 
			
		||||
  import cytoscape from "cytoscape";
 | 
			
		||||
  
 | 
			
		||||
  let CytoscapeOverlays = null
 | 
			
		||||
  
 | 
			
		||||
  if (import.meta.client) {
 | 
			
		||||
    const useOverlay = (await import('@/composables/useOverlay')).default
 | 
			
		||||
    CytoscapeOverlays = await useOverlay()
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  useHead({
 | 
			
		||||
    script: [
 | 
			
		||||
      {
 | 
			
		||||
        src: '/dist/cy_custom.js',
 | 
			
		||||
        defer: true
 | 
			
		||||
      }
 | 
			
		||||
    ]
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  const cyEl = ref(null);
 | 
			
		||||
  const cy = ref(null);
 | 
			
		||||
  const selectedGroup = ref(null);
 | 
			
		||||
  const groups = ref([]);
 | 
			
		||||
  
 | 
			
		||||
  onMounted(() => {
 | 
			
		||||
    fetch("/group43200.json")
 | 
			
		||||
      .then(res => res.json())
 | 
			
		||||
      .then(data => {
 | 
			
		||||
        const nodes = data.entries.map(e => ({
 | 
			
		||||
          data: { id: `n${e.id}`, label: e.graphics.name, group: e.group },
 | 
			
		||||
          position: { x: e.graphics.x, y: e.graphics.y },
 | 
			
		||||
        }));
 | 
			
		||||
        const edges = data.relations.map(r => ({
 | 
			
		||||
          data: {
 | 
			
		||||
            source: `n${r.entry1}`,
 | 
			
		||||
            target: `n${r.entry2}`,
 | 
			
		||||
          }
 | 
			
		||||
        }));
 | 
			
		||||
        groups.value = [...new Set(data.entries.map(e => e.group))];
 | 
			
		||||
  
 | 
			
		||||
        const colorPalette = ['#e6194b', '#3cb44b', '#ffe119', '#4363d8', '#f58231', '#911eb4', '#46f0f0', '#f032e6', '#bcf60c', '#fabebe', '#008080', '#e6beff', '#9a6324', '#fffac8', '#800000', '#aaffc3', '#808000', '#ffd8b1', '#000075', '#808080'];
 | 
			
		||||
        const groupColorMap = {};
 | 
			
		||||
        groups.value.forEach((group, index) => {
 | 
			
		||||
          groupColorMap[group] = colorPalette[index % colorPalette.length];
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        cy.value = cytoscape({
 | 
			
		||||
          container: cyEl.value,
 | 
			
		||||
          elements: [...nodes, ...edges],
 | 
			
		||||
          style: [
 | 
			
		||||
            {
 | 
			
		||||
              selector: 'node',
 | 
			
		||||
              style: {
 | 
			
		||||
                label: 'data(label)',
 | 
			
		||||
                'background-color': (node) => groupColorMap[node.data('group')] || '#ccc',
 | 
			
		||||
                'text-valign': 'center',
 | 
			
		||||
                'text-halign': 'center',
 | 
			
		||||
                'width': 30,
 | 
			
		||||
                'height': 30
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              selector: 'edge',
 | 
			
		||||
              style: {
 | 
			
		||||
                'line-color': '#ccc',
 | 
			
		||||
                'target-arrow-shape': 'triangle'
 | 
			
		||||
              }
 | 
			
		||||
            }
 | 
			
		||||
          ],
 | 
			
		||||
          layout: { name: 'preset' },
 | 
			
		||||
          zoomingEnabled: true,
 | 
			
		||||
          userZoomingEnabled: true
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          if (typeof applyCustomZoomHandling === 'function') {
 | 
			
		||||
            applyCustomZoomHandling(cy.value);
 | 
			
		||||
          }
 | 
			
		||||
        }, 100);
 | 
			
		||||
      });
 | 
			
		||||
  });
 | 
			
		||||
  
 | 
			
		||||
  function focusGroup() {
 | 
			
		||||
    if (!cy.value || !selectedGroup.value) return;
 | 
			
		||||
  
 | 
			
		||||
    const groupNodes = cy.value.nodes().filter(ele => ele.data('group') === selectedGroup.value);
 | 
			
		||||
    if (groupNodes.length === 0) return;
 | 
			
		||||
  
 | 
			
		||||
    const bb = groupNodes.boundingBox();
 | 
			
		||||
    const container = cy.value.container();
 | 
			
		||||
    const viewportWidth = container.clientWidth;
 | 
			
		||||
    const viewportHeight = container.clientHeight;
 | 
			
		||||
    const padding = 40;
 | 
			
		||||
    const zoomX = (viewportWidth - 2 * padding) / bb.w;
 | 
			
		||||
    const zoomY = (viewportHeight - 2 * padding) / bb.h;
 | 
			
		||||
    const zoom = Math.min(zoomX, zoomY, 5);
 | 
			
		||||
  
 | 
			
		||||
    cy.value.animate({
 | 
			
		||||
        zoom: zoom,
 | 
			
		||||
        center: { eles: groupNodes }
 | 
			
		||||
    }, {
 | 
			
		||||
        duration: 800
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  </script>
 | 
			
		||||
  
 | 
			
		||||
  <style>
 | 
			
		||||
  .cy-container {
 | 
			
		||||
    width: 100vw !important;
 | 
			
		||||
    max-width: 100vw !important;
 | 
			
		||||
    min-width: 0 !important;
 | 
			
		||||
    height: 800px !important;
 | 
			
		||||
    overflow: hidden !important;
 | 
			
		||||
    position: relative;
 | 
			
		||||
  }
 | 
			
		||||
  </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>
 | 
			
		||||
							
								
								
									
										299
									
								
								pages/[tabId]/test/test01.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										299
									
								
								pages/[tabId]/test/test01.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,299 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="test-page">
 | 
			
		||||
    <div class="container">
 | 
			
		||||
      <h1 class="title">테스트 페이지 01</h1>
 | 
			
		||||
 | 
			
		||||
      <div class="test-section">
 | 
			
		||||
        <h2>기본 기능 테스트</h2>
 | 
			
		||||
 | 
			
		||||
        <div class="test-card">
 | 
			
		||||
          <h3>사용자 정보 테스트</h3>
 | 
			
		||||
          <p>
 | 
			
		||||
            로그인 상태: {{ userStore.isLoggedIn ? "로그인됨" : "로그아웃됨" }}
 | 
			
		||||
          </p>
 | 
			
		||||
          <p v-if="userStore.user">사용자: {{ userStore.user.name }}</p>
 | 
			
		||||
          <p v-if="userStore.user">역할: {{ userStore.user.role }}</p>
 | 
			
		||||
          <div class="button-group">
 | 
			
		||||
            <button class="btn btn-success" @click="loginTest">
 | 
			
		||||
              테스트 로그인
 | 
			
		||||
            </button>
 | 
			
		||||
            <button class="btn btn-info" @click="adminLoginTest">
 | 
			
		||||
              관리자 로그인
 | 
			
		||||
            </button>
 | 
			
		||||
            <button class="btn btn-warning" @click="logoutTest">
 | 
			
		||||
              테스트 로그아웃
 | 
			
		||||
            </button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <div class="test-section">
 | 
			
		||||
        <h2>API 테스트</h2>
 | 
			
		||||
 | 
			
		||||
        <div class="test-card">
 | 
			
		||||
          <h3>데이터 로딩 테스트</h3>
 | 
			
		||||
          <button
 | 
			
		||||
            class="btn btn-primary"
 | 
			
		||||
            :disabled="loading"
 | 
			
		||||
            @click="loadTestData"
 | 
			
		||||
          >
 | 
			
		||||
            {{ loading ? "로딩 중..." : "테스트 데이터 로드" }}
 | 
			
		||||
          </button>
 | 
			
		||||
          <div v-if="testData" class="data-display">
 | 
			
		||||
            <pre>{{ JSON.stringify(testData, null, 2) }}</pre>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted } from "vue";
 | 
			
		||||
import { useUserStore } from "~/stores/user";
 | 
			
		||||
 | 
			
		||||
// 페이지 메타데이터
 | 
			
		||||
definePageMeta({
 | 
			
		||||
  title: "테스트 페이지 01",
 | 
			
		||||
  description: "기능 테스트를 위한 페이지",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore();
 | 
			
		||||
 | 
			
		||||
// 반응형 데이터
 | 
			
		||||
const loading = ref(false);
 | 
			
		||||
const testData = ref<{
 | 
			
		||||
  id: number;
 | 
			
		||||
  name: string;
 | 
			
		||||
  timestamp: string;
 | 
			
		||||
  items: string[];
 | 
			
		||||
} | null>(null);
 | 
			
		||||
 | 
			
		||||
// 메서드
 | 
			
		||||
const loginTest = () => {
 | 
			
		||||
  // 테스트용 로그인 (실제로는 API 호출이 필요)
 | 
			
		||||
  userStore.user = {
 | 
			
		||||
    id: "1",
 | 
			
		||||
    userId: "test",
 | 
			
		||||
    email: "test@example.com",
 | 
			
		||||
    name: "테스트 사용자",
 | 
			
		||||
    role: "user",
 | 
			
		||||
  };
 | 
			
		||||
  userStore.isLoggedIn = true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const adminLoginTest = () => {
 | 
			
		||||
  // 관리자 로그인 로직 구현
 | 
			
		||||
  userStore.user = {
 | 
			
		||||
    id: "2",
 | 
			
		||||
    userId: "admin",
 | 
			
		||||
    email: "admin@example.com",
 | 
			
		||||
    name: "관리자",
 | 
			
		||||
    role: "admin",
 | 
			
		||||
  };
 | 
			
		||||
  userStore.isLoggedIn = true;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const logoutTest = () => {
 | 
			
		||||
  userStore.logout();
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const loadTestData = async () => {
 | 
			
		||||
  loading.value = true;
 | 
			
		||||
  try {
 | 
			
		||||
    // 실제 API 호출 대신 시뮬레이션
 | 
			
		||||
    await new Promise((resolve) => setTimeout(resolve, 1000));
 | 
			
		||||
    testData.value = {
 | 
			
		||||
      id: 1,
 | 
			
		||||
      name: "테스트 데이터",
 | 
			
		||||
      timestamp: new Date().toISOString(),
 | 
			
		||||
      items: ["항목 1", "항목 2", "항목 3"],
 | 
			
		||||
    };
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error("데이터 로딩 실패:", error);
 | 
			
		||||
  } finally {
 | 
			
		||||
    loading.value = false;
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// 라이프사이클
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  console.log("테스트 페이지 01이 마운트되었습니다.");
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.test-page {
 | 
			
		||||
  min-height: 100vh;
 | 
			
		||||
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
 | 
			
		||||
  padding: 2rem 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.container {
 | 
			
		||||
  max-width: 1200px;
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  padding: 0 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.title {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: white;
 | 
			
		||||
  font-size: 2.5rem;
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
  margin-bottom: 2rem;
 | 
			
		||||
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-section {
 | 
			
		||||
  background: white;
 | 
			
		||||
  border-radius: 12px;
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
  margin-bottom: 2rem;
 | 
			
		||||
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-section h2 {
 | 
			
		||||
  color: #333;
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
  font-size: 1.5rem;
 | 
			
		||||
  border-bottom: 2px solid #667eea;
 | 
			
		||||
  padding-bottom: 0.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-card {
 | 
			
		||||
  background: #f8f9fa;
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  padding: 1.5rem;
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
  border-left: 4px solid #667eea;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-card h3 {
 | 
			
		||||
  color: #495057;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  font-size: 1.2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.test-card p {
 | 
			
		||||
  color: #6c757d;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
  font-size: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.button-group {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 0.5rem;
 | 
			
		||||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn {
 | 
			
		||||
  padding: 0.5rem 1rem;
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  font-size: 0.9rem;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  transition: all 0.2s ease;
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
  display: inline-block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn:hover {
 | 
			
		||||
  transform: translateY(-1px);
 | 
			
		||||
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn:disabled {
 | 
			
		||||
  opacity: 0.6;
 | 
			
		||||
  cursor: not-allowed;
 | 
			
		||||
  transform: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary {
 | 
			
		||||
  background: #667eea;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-primary:hover {
 | 
			
		||||
  background: #5a6fd8;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-secondary {
 | 
			
		||||
  background: #6c757d;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-secondary:hover {
 | 
			
		||||
  background: #5a6268;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-danger {
 | 
			
		||||
  background: #dc3545;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-danger:hover {
 | 
			
		||||
  background: #c82333;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-success {
 | 
			
		||||
  background: #28a745;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-success:hover {
 | 
			
		||||
  background: #218838;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-warning {
 | 
			
		||||
  background: #ffc107;
 | 
			
		||||
  color: #212529;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-warning:hover {
 | 
			
		||||
  background: #e0a800;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-info {
 | 
			
		||||
  background: #17a2b8;
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.btn-info:hover {
 | 
			
		||||
  background: #138496;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.data-display {
 | 
			
		||||
  margin-top: 1rem;
 | 
			
		||||
  background: #f1f3f4;
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
  border: 1px solid #dee2e6;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.data-display pre {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  font-size: 0.85rem;
 | 
			
		||||
  color: #495057;
 | 
			
		||||
  white-space: pre-wrap;
 | 
			
		||||
  word-break: break-word;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  .container {
 | 
			
		||||
    padding: 0 0.5rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .test-section {
 | 
			
		||||
    padding: 1rem;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .button-group {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .btn {
 | 
			
		||||
    width: 100%;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										282
									
								
								pages/[tabId]/test/test02.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										282
									
								
								pages/[tabId]/test/test02.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,282 @@
 | 
			
		||||
<template>
 | 
			
		||||
    <div id="app">
 | 
			
		||||
      <main role="main" class="container-fluid">
 | 
			
		||||
        <div style="padding-top: 64px">
 | 
			
		||||
          <div>
 | 
			
		||||
            <ul
 | 
			
		||||
              class="navbar-nav mr-auto"
 | 
			
		||||
              style="list-style:none; padding-left:0; margin:0;"
 | 
			
		||||
            >
 | 
			
		||||
              <li
 | 
			
		||||
                class="nav-item dropdown"
 | 
			
		||||
                style="position: relative; display: inline-block;"
 | 
			
		||||
              >
 | 
			
		||||
                <a
 | 
			
		||||
                  href="#"
 | 
			
		||||
                  id="igv-example-api-dropdown"
 | 
			
		||||
                  @click.prevent="toggleDropdown"
 | 
			
		||||
                  aria-haspopup="true"
 | 
			
		||||
                  :aria-expanded="isDropdownOpen.toString()"
 | 
			
		||||
                  style="color: black; cursor: pointer; user-select: none;"
 | 
			
		||||
                  >Tracks</a
 | 
			
		||||
                >
 | 
			
		||||
                <ul
 | 
			
		||||
                  v-show="isDropdownOpen"
 | 
			
		||||
                  class="dropdown-menu"
 | 
			
		||||
                  style="width:350px; position: absolute; top: 100%; left: 0; background: white; border: 1px solid #ccc; box-shadow: 0 2px 5px rgba(0,0,0,0.15); padding: 5px 0; margin: 0; list-style:none; z-index: 1000;"
 | 
			
		||||
                >
 | 
			
		||||
                  <li>
 | 
			
		||||
                    <a
 | 
			
		||||
                      href="#"
 | 
			
		||||
                      @click.prevent="loadCopyNumberTrack"
 | 
			
		||||
                      style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                      >Copy Number</a
 | 
			
		||||
                    >
 | 
			
		||||
                  </li>
 | 
			
		||||
                  <li>
 | 
			
		||||
                    <a
 | 
			
		||||
                      href="#"
 | 
			
		||||
                      @click.prevent="loadDbSnpTrack"
 | 
			
		||||
                      style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                      >dbSNP 137 (bed tabix)</a
 | 
			
		||||
                    >
 | 
			
		||||
                  </li>
 | 
			
		||||
                  <li>
 | 
			
		||||
                    <a
 | 
			
		||||
                      href="#"
 | 
			
		||||
                      @click.prevent="loadBigWigTrack"
 | 
			
		||||
                      style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                      >Encode bigwig</a
 | 
			
		||||
                    >
 | 
			
		||||
                  </li>
 | 
			
		||||
                  <li>
 | 
			
		||||
                    <a
 | 
			
		||||
                      href="#"
 | 
			
		||||
                      @click.prevent="loadBamTrack"
 | 
			
		||||
                      style="display:block; padding: 8px 20px; color: black; text-decoration: none;"
 | 
			
		||||
                      >1KG Bam (HG02450)</a
 | 
			
		||||
                    >
 | 
			
		||||
                  </li>
 | 
			
		||||
                </ul>
 | 
			
		||||
              </li>
 | 
			
		||||
            </ul>
 | 
			
		||||
          </div>
 | 
			
		||||
          <!-- 탭 콘텐츠 -->
 | 
			
		||||
          <div class="tab-content" id="viewerTabContent">
 | 
			
		||||
            <div
 | 
			
		||||
              class="tab-pane fade show active"
 | 
			
		||||
              id="igv-viewer"
 | 
			
		||||
              role="tabpanel"
 | 
			
		||||
            >
 | 
			
		||||
              <div style="padding-top: 20px">
 | 
			
		||||
                이 예제는 드롭다운 메뉴에서 동적으로 트랙을 추가하는 igv.js API의
 | 
			
		||||
                사용을 보여줍니다.
 | 
			
		||||
                위의 메뉴에서 'CopyNumber'를 선택하면 다음과 같은 호출이 실행됩니다.
 | 
			
		||||
                <p>
 | 
			
		||||
                  <pre>
 | 
			
		||||
                  igv.browser.loadTrack({
 | 
			
		||||
                      url: 'https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz',
 | 
			
		||||
                      name: 'GBM Copy # (TCGA Broad GDAC)'});
 | 
			
		||||
                  </pre>
 | 
			
		||||
                </p>
 | 
			
		||||
                자세한 내용은
 | 
			
		||||
                <a href="https://github.com/igvteam/igv.js/wiki">개발자 위키</a>를
 | 
			
		||||
                참조하세요.
 | 
			
		||||
              </div>
 | 
			
		||||
              <div id="igvDiv" style="padding-top: 20px"></div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </main>
 | 
			
		||||
    </div>
 | 
			
		||||
  </template>
 | 
			
		||||
  
 | 
			
		||||
  <script>
 | 
			
		||||
  export default {
 | 
			
		||||
    name: "App",
 | 
			
		||||
    data() {
 | 
			
		||||
      return {
 | 
			
		||||
        browser: null,
 | 
			
		||||
        isDropdownOpen: false,
 | 
			
		||||
      };
 | 
			
		||||
    },
 | 
			
		||||
    mounted() {
 | 
			
		||||
      // 외부 클릭시 드롭다운 닫기
 | 
			
		||||
      document.addEventListener("click", this.handleClickOutside);
 | 
			
		||||
      this.initializeIGV();
 | 
			
		||||
    },
 | 
			
		||||
    beforeUnmount() {
 | 
			
		||||
      document.removeEventListener("click", this.handleClickOutside);
 | 
			
		||||
    },
 | 
			
		||||
    methods: {
 | 
			
		||||
      toggleDropdown() {
 | 
			
		||||
        this.isDropdownOpen = !this.isDropdownOpen;
 | 
			
		||||
      },
 | 
			
		||||
      closeDropdown() {
 | 
			
		||||
        this.isDropdownOpen = false;
 | 
			
		||||
      },
 | 
			
		||||
      handleClickOutside(event) {
 | 
			
		||||
        const dropdown = this.$el.querySelector("#igv-example-api-dropdown");
 | 
			
		||||
        const menu = this.$el.querySelector(".dropdown-menu");
 | 
			
		||||
        if (
 | 
			
		||||
          dropdown &&
 | 
			
		||||
          menu &&
 | 
			
		||||
          !dropdown.contains(event.target) &&
 | 
			
		||||
          !menu.contains(event.target)
 | 
			
		||||
        ) {
 | 
			
		||||
          this.closeDropdown();
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
  
 | 
			
		||||
      async initializeIGV() {
 | 
			
		||||
        await this.waitForIGV();
 | 
			
		||||
        await this.$nextTick();
 | 
			
		||||
  
 | 
			
		||||
        const igvDiv = document.getElementById("igvDiv");
 | 
			
		||||
        if (!igvDiv) {
 | 
			
		||||
          console.error("❌ #igvDiv가 존재하지 않습니다.");
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
  
 | 
			
		||||
        const options = {
 | 
			
		||||
          locus: "chr1:155,160,475-155,184,282",
 | 
			
		||||
          genome: "hg19",
 | 
			
		||||
        };
 | 
			
		||||
  
 | 
			
		||||
        try {
 | 
			
		||||
          this.browser = await igv.createBrowser(igvDiv, options);
 | 
			
		||||
          window.igv = { browser: this.browser };
 | 
			
		||||
          this.addBaseClickEvent();
 | 
			
		||||
          this.browser.on("locuschange", this.addBaseClickEvent);
 | 
			
		||||
          this.browser.on("trackclick", this.addBaseClickEvent);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
          console.error("IGV 브라우저 생성 중 오류:", error);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      waitForIGV() {
 | 
			
		||||
        return new Promise((resolve) => {
 | 
			
		||||
          const checkIGV = () => {
 | 
			
		||||
            if (typeof igv !== "undefined") {
 | 
			
		||||
              resolve();
 | 
			
		||||
            } else {
 | 
			
		||||
              setTimeout(checkIGV, 100);
 | 
			
		||||
            }
 | 
			
		||||
          };
 | 
			
		||||
          checkIGV();
 | 
			
		||||
        });
 | 
			
		||||
      },
 | 
			
		||||
      addBaseClickEvent() {
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
          const texts = document.querySelectorAll("#igvDiv text");
 | 
			
		||||
          texts.forEach((text) => {
 | 
			
		||||
            const base = text.textContent;
 | 
			
		||||
            if (["A", "T", "C", "G"].includes(base)) {
 | 
			
		||||
              text.style.cursor = "pointer";
 | 
			
		||||
              text.onclick = () => {
 | 
			
		||||
                text.textContent = this.getComplement(text.textContent);
 | 
			
		||||
              };
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
        }, 300);
 | 
			
		||||
      },
 | 
			
		||||
      getComplement(base) {
 | 
			
		||||
        switch (base) {
 | 
			
		||||
          case "A":
 | 
			
		||||
            return "T";
 | 
			
		||||
          case "T":
 | 
			
		||||
            return "A";
 | 
			
		||||
          case "C":
 | 
			
		||||
            return "G";
 | 
			
		||||
          case "G":
 | 
			
		||||
            return "C";
 | 
			
		||||
          default:
 | 
			
		||||
            return base;
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      loadCopyNumberTrack() {
 | 
			
		||||
        if (this.browser) {
 | 
			
		||||
          this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
          this.browser.loadTrackList([
 | 
			
		||||
            {
 | 
			
		||||
              url: "https://s3.amazonaws.com/igv.org.demo/GBM-TP.seg.gz",
 | 
			
		||||
              indexed: false,
 | 
			
		||||
              isLog: true,
 | 
			
		||||
              name: "GBM Copy # (TCGA Broad GDAC)",
 | 
			
		||||
            },
 | 
			
		||||
          ]);
 | 
			
		||||
        }
 | 
			
		||||
        this.closeDropdown();
 | 
			
		||||
      },
 | 
			
		||||
      loadDbSnpTrack() {
 | 
			
		||||
        if (this.browser) {
 | 
			
		||||
          this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
          this.browser.loadTrackList([
 | 
			
		||||
            {
 | 
			
		||||
              type: "annotation",
 | 
			
		||||
              format: "bed",
 | 
			
		||||
              url: "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz",
 | 
			
		||||
              indexURL:
 | 
			
		||||
                "https://data.broadinstitute.org/igvdata/annotations/hg19/dbSnp/snp137.hg19.bed.gz.tbi",
 | 
			
		||||
              visibilityWindow: 200000,
 | 
			
		||||
              name: "dbSNP 137",
 | 
			
		||||
            },
 | 
			
		||||
          ]);
 | 
			
		||||
        }
 | 
			
		||||
        this.closeDropdown();
 | 
			
		||||
      },
 | 
			
		||||
      loadBigWigTrack() {
 | 
			
		||||
        if (this.browser) {
 | 
			
		||||
          this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
          this.browser.loadTrackList([
 | 
			
		||||
            {
 | 
			
		||||
              type: "wig",
 | 
			
		||||
              format: "bigwig",
 | 
			
		||||
              url: "https://s3.amazonaws.com/igv.broadinstitute.org/data/hg19/encode/wgEncodeBroadHistoneGm12878H3k4me3StdSig.bigWig",
 | 
			
		||||
              name: "Gm12878H3k4me3",
 | 
			
		||||
            },
 | 
			
		||||
          ]);
 | 
			
		||||
        }
 | 
			
		||||
        this.closeDropdown();
 | 
			
		||||
      },
 | 
			
		||||
      loadBamTrack() {
 | 
			
		||||
        if (this.browser) {
 | 
			
		||||
          this.browser.loadTrackList = this.browser.loadTrackList.bind(this.browser);
 | 
			
		||||
          this.browser.loadTrackList([
 | 
			
		||||
            {
 | 
			
		||||
              type: "alignment",
 | 
			
		||||
              format: "bam",
 | 
			
		||||
              url: "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam",
 | 
			
		||||
              indexURL:
 | 
			
		||||
                "https://1000genomes.s3.amazonaws.com/phase3/data/HG02450/alignment/HG02450.mapped.ILLUMINA.bwa.ACB.low_coverage.20120522.bam.bai",
 | 
			
		||||
              name: "HG02450",
 | 
			
		||||
            },
 | 
			
		||||
          ]);
 | 
			
		||||
        }
 | 
			
		||||
        this.closeDropdown();
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  };
 | 
			
		||||
  </script>
 | 
			
		||||
  
 | 
			
		||||
  <style>
 | 
			
		||||
  #app {
 | 
			
		||||
    font-family: Avenir, Helvetica, Arial, sans-serif;
 | 
			
		||||
    -webkit-font-smoothing: antialiased;
 | 
			
		||||
    -moz-osx-font-smoothing: grayscale;
 | 
			
		||||
    color: #2c3e50;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  pre {
 | 
			
		||||
    padding: 10px;
 | 
			
		||||
    border-radius: 4px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .nav-tabs {
 | 
			
		||||
    margin-bottom: 20px;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .tab-content {
 | 
			
		||||
    padding-top: 20px;
 | 
			
		||||
  }
 | 
			
		||||
  </style>
 | 
			
		||||
  
 | 
			
		||||
@@ -1,17 +1,29 @@
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import ToastGrid from '@/components/base/ToastGrid.vue';
 | 
			
		||||
<template>
 | 
			
		||||
  <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 = [
 | 
			
		||||
  {
 | 
			
		||||
    id: 549731,
 | 
			
		||||
    name: 'Beautiful Lies',
 | 
			
		||||
    artist: 'Birdy',
 | 
			
		||||
    release: '2016.03.26',
 | 
			
		||||
    type: 'Deluxe',
 | 
			
		||||
    typeCode: '1',
 | 
			
		||||
    genre: 'Pop',
 | 
			
		||||
    genreCode: '1',
 | 
			
		||||
    grade: '4',
 | 
			
		||||
    name: "Beautiful Lies",
 | 
			
		||||
    artist: "Birdy",
 | 
			
		||||
    release: "2016.03.26",
 | 
			
		||||
    type: "Deluxe",
 | 
			
		||||
    typeCode: "1",
 | 
			
		||||
    genre: "Pop",
 | 
			
		||||
    genreCode: "1",
 | 
			
		||||
    grade: "4",
 | 
			
		||||
    price: 10000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
@@ -21,14 +33,14 @@ const data = [
 | 
			
		||||
    _children: [
 | 
			
		||||
      {
 | 
			
		||||
        id: 491379,
 | 
			
		||||
        name: 'Chaos And The Calm',
 | 
			
		||||
        artist: 'James Bay',
 | 
			
		||||
        release: '2015.03.23',
 | 
			
		||||
        type: 'EP',
 | 
			
		||||
        typeCode: '2',
 | 
			
		||||
        genre: 'Pop,Rock',
 | 
			
		||||
        genreCode: '1,2',
 | 
			
		||||
        grade: '5',
 | 
			
		||||
        name: "Chaos And The Calm",
 | 
			
		||||
        artist: "James Bay",
 | 
			
		||||
        release: "2015.03.23",
 | 
			
		||||
        type: "EP",
 | 
			
		||||
        typeCode: "2",
 | 
			
		||||
        genre: "Pop,Rock",
 | 
			
		||||
        genreCode: "1,2",
 | 
			
		||||
        grade: "5",
 | 
			
		||||
        price: 12000,
 | 
			
		||||
        downloadCount: 1000,
 | 
			
		||||
        listenCount: 5000,
 | 
			
		||||
@@ -36,14 +48,14 @@ const data = [
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 498896,
 | 
			
		||||
        name: 'The Magic Whip',
 | 
			
		||||
        artist: 'Blur',
 | 
			
		||||
        release: '2015.04.27',
 | 
			
		||||
        type: 'EP',
 | 
			
		||||
        typeCode: '2',
 | 
			
		||||
        genre: 'Rock',
 | 
			
		||||
        genreCode: '2',
 | 
			
		||||
        grade: '3',
 | 
			
		||||
        name: "The Magic Whip",
 | 
			
		||||
        artist: "Blur",
 | 
			
		||||
        release: "2015.04.27",
 | 
			
		||||
        type: "EP",
 | 
			
		||||
        typeCode: "2",
 | 
			
		||||
        genre: "Rock",
 | 
			
		||||
        genreCode: "2",
 | 
			
		||||
        grade: "3",
 | 
			
		||||
        price: 15000,
 | 
			
		||||
        downloadCount: 1000,
 | 
			
		||||
        listenCount: 5000,
 | 
			
		||||
@@ -54,13 +66,13 @@ const data = [
 | 
			
		||||
      {
 | 
			
		||||
        id: 450720,
 | 
			
		||||
        name: "I'm Not The Only One",
 | 
			
		||||
        artist: 'Sam Smith',
 | 
			
		||||
        release: '2014.09.15',
 | 
			
		||||
        type: 'Single',
 | 
			
		||||
        typeCode: '3',
 | 
			
		||||
        genre: 'Pop,R&B',
 | 
			
		||||
        genreCode: '1,3',
 | 
			
		||||
        grade: '4',
 | 
			
		||||
        artist: "Sam Smith",
 | 
			
		||||
        release: "2014.09.15",
 | 
			
		||||
        type: "Single",
 | 
			
		||||
        typeCode: "3",
 | 
			
		||||
        genre: "Pop,R&B",
 | 
			
		||||
        genreCode: "1,3",
 | 
			
		||||
        grade: "4",
 | 
			
		||||
        price: 8000,
 | 
			
		||||
        downloadCount: 1000,
 | 
			
		||||
        listenCount: 5000,
 | 
			
		||||
@@ -70,14 +82,14 @@ const data = [
 | 
			
		||||
        _children: [
 | 
			
		||||
          {
 | 
			
		||||
            id: 587871,
 | 
			
		||||
            name: 'This Is Acting',
 | 
			
		||||
            artist: 'Sia',
 | 
			
		||||
            release: '2016.10.22',
 | 
			
		||||
            type: 'EP',
 | 
			
		||||
            typeCode: '2',
 | 
			
		||||
            genre: 'Pop',
 | 
			
		||||
            genreCode: '1',
 | 
			
		||||
            grade: '3',
 | 
			
		||||
            name: "This Is Acting",
 | 
			
		||||
            artist: "Sia",
 | 
			
		||||
            release: "2016.10.22",
 | 
			
		||||
            type: "EP",
 | 
			
		||||
            typeCode: "2",
 | 
			
		||||
            genre: "Pop",
 | 
			
		||||
            genreCode: "1",
 | 
			
		||||
            grade: "3",
 | 
			
		||||
            price: 20000,
 | 
			
		||||
            downloadCount: 1000,
 | 
			
		||||
            listenCount: 5000,
 | 
			
		||||
@@ -87,14 +99,14 @@ const data = [
 | 
			
		||||
            _children: [
 | 
			
		||||
              {
 | 
			
		||||
                id: 490500,
 | 
			
		||||
                name: 'Blue Skies',
 | 
			
		||||
                release: '2015.03.18',
 | 
			
		||||
                artist: 'Lenka',
 | 
			
		||||
                type: 'Single',
 | 
			
		||||
                typeCode: '3',
 | 
			
		||||
                genre: 'Pop,Rock',
 | 
			
		||||
                genreCode: '1,2',
 | 
			
		||||
                grade: '5',
 | 
			
		||||
                name: "Blue Skies",
 | 
			
		||||
                release: "2015.03.18",
 | 
			
		||||
                artist: "Lenka",
 | 
			
		||||
                type: "Single",
 | 
			
		||||
                typeCode: "3",
 | 
			
		||||
                genre: "Pop,Rock",
 | 
			
		||||
                genreCode: "1,2",
 | 
			
		||||
                grade: "5",
 | 
			
		||||
                price: 6000,
 | 
			
		||||
                downloadCount: 1000,
 | 
			
		||||
                listenCount: 5000,
 | 
			
		||||
@@ -102,27 +114,27 @@ const data = [
 | 
			
		||||
              {
 | 
			
		||||
                id: 317659,
 | 
			
		||||
                name: "I Won't Give Up",
 | 
			
		||||
                artist: 'Jason Mraz',
 | 
			
		||||
                release: '2012.01.03',
 | 
			
		||||
                type: 'Single',
 | 
			
		||||
                typeCode: '3',
 | 
			
		||||
                genre: 'Pop',
 | 
			
		||||
                genreCode: '1',
 | 
			
		||||
                grade: '2',
 | 
			
		||||
                artist: "Jason Mraz",
 | 
			
		||||
                release: "2012.01.03",
 | 
			
		||||
                type: "Single",
 | 
			
		||||
                typeCode: "3",
 | 
			
		||||
                genre: "Pop",
 | 
			
		||||
                genreCode: "1",
 | 
			
		||||
                grade: "2",
 | 
			
		||||
                price: 7000,
 | 
			
		||||
                downloadCount: 1000,
 | 
			
		||||
                listenCount: 5000,
 | 
			
		||||
              },
 | 
			
		||||
              {
 | 
			
		||||
                id: 583551,
 | 
			
		||||
                name: 'Following My Intuition',
 | 
			
		||||
                artist: 'Craig David',
 | 
			
		||||
                release: '2016.10.01',
 | 
			
		||||
                type: 'Deluxe',
 | 
			
		||||
                typeCode: '1',
 | 
			
		||||
                genre: 'R&B,Electronic',
 | 
			
		||||
                genreCode: '3,4',
 | 
			
		||||
                grade: '5',
 | 
			
		||||
                name: "Following My Intuition",
 | 
			
		||||
                artist: "Craig David",
 | 
			
		||||
                release: "2016.10.01",
 | 
			
		||||
                type: "Deluxe",
 | 
			
		||||
                typeCode: "1",
 | 
			
		||||
                genre: "R&B,Electronic",
 | 
			
		||||
                genreCode: "3,4",
 | 
			
		||||
                grade: "5",
 | 
			
		||||
                price: 15000,
 | 
			
		||||
                downloadCount: 1000,
 | 
			
		||||
                listenCount: 5000,
 | 
			
		||||
@@ -135,42 +147,42 @@ const data = [
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 436461,
 | 
			
		||||
    name: 'X',
 | 
			
		||||
    artist: 'Ed Sheeran',
 | 
			
		||||
    release: '2014.06.24',
 | 
			
		||||
    type: 'Deluxe',
 | 
			
		||||
    typeCode: '1',
 | 
			
		||||
    genre: 'Pop',
 | 
			
		||||
    genreCode: '1',
 | 
			
		||||
    grade: '5',
 | 
			
		||||
    name: "X",
 | 
			
		||||
    artist: "Ed Sheeran",
 | 
			
		||||
    release: "2014.06.24",
 | 
			
		||||
    type: "Deluxe",
 | 
			
		||||
    typeCode: "1",
 | 
			
		||||
    genre: "Pop",
 | 
			
		||||
    genreCode: "1",
 | 
			
		||||
    grade: "5",
 | 
			
		||||
    price: 20000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 295651,
 | 
			
		||||
    name: 'Moves Like Jagger',
 | 
			
		||||
    release: '2011.08.08',
 | 
			
		||||
    artist: 'Maroon5',
 | 
			
		||||
    type: 'Single',
 | 
			
		||||
    typeCode: '3',
 | 
			
		||||
    genre: 'Pop,Rock',
 | 
			
		||||
    genreCode: '1,2',
 | 
			
		||||
    grade: '2',
 | 
			
		||||
    name: "Moves Like Jagger",
 | 
			
		||||
    release: "2011.08.08",
 | 
			
		||||
    artist: "Maroon5",
 | 
			
		||||
    type: "Single",
 | 
			
		||||
    typeCode: "3",
 | 
			
		||||
    genre: "Pop,Rock",
 | 
			
		||||
    genreCode: "1,2",
 | 
			
		||||
    grade: "2",
 | 
			
		||||
    price: 7000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 541713,
 | 
			
		||||
    name: 'A Head Full Of Dreams',
 | 
			
		||||
    artist: 'Coldplay',
 | 
			
		||||
    release: '2015.12.04',
 | 
			
		||||
    type: 'Deluxe',
 | 
			
		||||
    typeCode: '1',
 | 
			
		||||
    genre: 'Rock',
 | 
			
		||||
    genreCode: '2',
 | 
			
		||||
    grade: '3',
 | 
			
		||||
    name: "A Head Full Of Dreams",
 | 
			
		||||
    artist: "Coldplay",
 | 
			
		||||
    release: "2015.12.04",
 | 
			
		||||
    type: "Deluxe",
 | 
			
		||||
    typeCode: "1",
 | 
			
		||||
    genre: "Rock",
 | 
			
		||||
    genreCode: "2",
 | 
			
		||||
    grade: "3",
 | 
			
		||||
    price: 25000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
@@ -180,28 +192,28 @@ const data = [
 | 
			
		||||
    _children: [
 | 
			
		||||
      {
 | 
			
		||||
        id: 294574,
 | 
			
		||||
        name: '4',
 | 
			
		||||
        artist: 'Beyoncé',
 | 
			
		||||
        release: '2011.07.26',
 | 
			
		||||
        type: 'Deluxe',
 | 
			
		||||
        typeCode: '1',
 | 
			
		||||
        genre: 'Pop',
 | 
			
		||||
        genreCode: '1',
 | 
			
		||||
        grade: '3',
 | 
			
		||||
        name: "4",
 | 
			
		||||
        artist: "Beyoncé",
 | 
			
		||||
        release: "2011.07.26",
 | 
			
		||||
        type: "Deluxe",
 | 
			
		||||
        typeCode: "1",
 | 
			
		||||
        genre: "Pop",
 | 
			
		||||
        genreCode: "1",
 | 
			
		||||
        grade: "3",
 | 
			
		||||
        price: 12000,
 | 
			
		||||
        downloadCount: 1000,
 | 
			
		||||
        listenCount: 5000,
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 265289,
 | 
			
		||||
        name: '21',
 | 
			
		||||
        artist: 'Adele',
 | 
			
		||||
        release: '2011.01.21',
 | 
			
		||||
        type: 'Deluxe',
 | 
			
		||||
        typeCode: '1',
 | 
			
		||||
        genre: 'Pop,R&B',
 | 
			
		||||
        genreCode: '1,3',
 | 
			
		||||
        grade: '5',
 | 
			
		||||
        name: "21",
 | 
			
		||||
        artist: "Adele",
 | 
			
		||||
        release: "2011.01.21",
 | 
			
		||||
        type: "Deluxe",
 | 
			
		||||
        typeCode: "1",
 | 
			
		||||
        genre: "Pop,R&B",
 | 
			
		||||
        genreCode: "1,3",
 | 
			
		||||
        grade: "5",
 | 
			
		||||
        price: 15000,
 | 
			
		||||
        downloadCount: 1000,
 | 
			
		||||
        listenCount: 5000,
 | 
			
		||||
@@ -210,70 +222,70 @@ const data = [
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 555871,
 | 
			
		||||
    name: 'Warm On A Cold Night',
 | 
			
		||||
    artist: 'HONNE',
 | 
			
		||||
    release: '2016.07.22',
 | 
			
		||||
    type: 'EP',
 | 
			
		||||
    typeCode: '1',
 | 
			
		||||
    genre: 'R&B,Electronic',
 | 
			
		||||
    genreCode: '3,4',
 | 
			
		||||
    grade: '4',
 | 
			
		||||
    name: "Warm On A Cold Night",
 | 
			
		||||
    artist: "HONNE",
 | 
			
		||||
    release: "2016.07.22",
 | 
			
		||||
    type: "EP",
 | 
			
		||||
    typeCode: "1",
 | 
			
		||||
    genre: "R&B,Electronic",
 | 
			
		||||
    genreCode: "3,4",
 | 
			
		||||
    grade: "4",
 | 
			
		||||
    price: 11000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 550571,
 | 
			
		||||
    name: 'Take Me To The Alley',
 | 
			
		||||
    artist: 'Gregory Porter',
 | 
			
		||||
    release: '2016.09.02',
 | 
			
		||||
    type: 'Deluxe',
 | 
			
		||||
    typeCode: '1',
 | 
			
		||||
    genre: 'Jazz',
 | 
			
		||||
    genreCode: '5',
 | 
			
		||||
    grade: '3',
 | 
			
		||||
    name: "Take Me To The Alley",
 | 
			
		||||
    artist: "Gregory Porter",
 | 
			
		||||
    release: "2016.09.02",
 | 
			
		||||
    type: "Deluxe",
 | 
			
		||||
    typeCode: "1",
 | 
			
		||||
    genre: "Jazz",
 | 
			
		||||
    genreCode: "5",
 | 
			
		||||
    grade: "3",
 | 
			
		||||
    price: 30000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 544128,
 | 
			
		||||
    name: 'Make Out',
 | 
			
		||||
    artist: 'LANY',
 | 
			
		||||
    release: '2015.12.11',
 | 
			
		||||
    type: 'EP',
 | 
			
		||||
    typeCode: '2',
 | 
			
		||||
    genre: 'Electronic',
 | 
			
		||||
    genreCode: '4',
 | 
			
		||||
    grade: '2',
 | 
			
		||||
    name: "Make Out",
 | 
			
		||||
    artist: "LANY",
 | 
			
		||||
    release: "2015.12.11",
 | 
			
		||||
    type: "EP",
 | 
			
		||||
    typeCode: "2",
 | 
			
		||||
    genre: "Electronic",
 | 
			
		||||
    genreCode: "4",
 | 
			
		||||
    grade: "2",
 | 
			
		||||
    price: 12000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 366374,
 | 
			
		||||
    name: 'Get Lucky',
 | 
			
		||||
    artist: 'Daft Punk',
 | 
			
		||||
    release: '2013.04.23',
 | 
			
		||||
    type: 'Single',
 | 
			
		||||
    typeCode: '3',
 | 
			
		||||
    genre: 'Pop,Funk',
 | 
			
		||||
    genreCode: '1,5',
 | 
			
		||||
    grade: '3',
 | 
			
		||||
    name: "Get Lucky",
 | 
			
		||||
    artist: "Daft Punk",
 | 
			
		||||
    release: "2013.04.23",
 | 
			
		||||
    type: "Single",
 | 
			
		||||
    typeCode: "3",
 | 
			
		||||
    genre: "Pop,Funk",
 | 
			
		||||
    genreCode: "1,5",
 | 
			
		||||
    grade: "3",
 | 
			
		||||
    price: 9000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    id: 8012747,
 | 
			
		||||
    name: 'Valtari',
 | 
			
		||||
    artist: 'Sigur Rós',
 | 
			
		||||
    release: '2012.05.31',
 | 
			
		||||
    type: 'EP',
 | 
			
		||||
    typeCode: '3',
 | 
			
		||||
    genre: 'Rock',
 | 
			
		||||
    genreCode: '2',
 | 
			
		||||
    grade: '5',
 | 
			
		||||
    name: "Valtari",
 | 
			
		||||
    artist: "Sigur Rós",
 | 
			
		||||
    release: "2012.05.31",
 | 
			
		||||
    type: "EP",
 | 
			
		||||
    typeCode: "3",
 | 
			
		||||
    genre: "Rock",
 | 
			
		||||
    genreCode: "2",
 | 
			
		||||
    grade: "5",
 | 
			
		||||
    price: 10000,
 | 
			
		||||
    downloadCount: 1000,
 | 
			
		||||
    listenCount: 5000,
 | 
			
		||||
@@ -281,18 +293,23 @@ const data = [
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
const columns = [
 | 
			
		||||
  { header: 'Name', name: 'name', width: 300 },
 | 
			
		||||
  { header: 'Artist', name: 'artist' },
 | 
			
		||||
  { header: 'Type', name: 'type' },
 | 
			
		||||
  { header: 'Release', name: 'release' },
 | 
			
		||||
  { header: 'Genre', name: 'genre' },
 | 
			
		||||
  { header: 'checkbox', name: 'checkbox', editor:{ type: 'checkbox', options: {
 | 
			
		||||
          listItems: [
 | 
			
		||||
            { text: 'true', value: true },
 | 
			
		||||
          ]
 | 
			
		||||
  }}}
 | 
			
		||||
  { header: "Name", name: "name", width: 300 },
 | 
			
		||||
  { header: "Artist", name: "artist" },
 | 
			
		||||
  { header: "Type", name: "type" },
 | 
			
		||||
  { header: "Release", name: "release" },
 | 
			
		||||
  { header: "Genre", name: "genre" },
 | 
			
		||||
  {
 | 
			
		||||
    header: "checkbox",
 | 
			
		||||
    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();
 | 
			
		||||
 | 
			
		||||
@@ -305,19 +322,4 @@ function onUpdateClick() {
 | 
			
		||||
  //grid1Ref.value?.clearGrid();
 | 
			
		||||
  console.log(grid1Ref.value?.api()?.getModifiedRows());
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
</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">
 | 
			
		||||
import { useUserStore } from "~/stores/user";
 | 
			
		||||
import { useRouter } from 'vue-router'
 | 
			
		||||
const router = useRouter()
 | 
			
		||||
 | 
			
		||||
onMounted(()=> {
 | 
			
		||||
  router.push('/1/') 
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// 페이지 메타데이터 설정
 | 
			
		||||
definePageMeta({
 | 
			
		||||
  title: "Home",
 | 
			
		||||
  description: "Welcome to our Nuxt.js application",
 | 
			
		||||
  redirect: "/1/",
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const userStore = useUserStore();
 | 
			
		||||
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
.home {
 | 
			
		||||
  padding: 2rem;
 | 
			
		||||
  text-align: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
h1 {
 | 
			
		||||
  color: #00dc82;
 | 
			
		||||
  margin-bottom: 1rem;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
 
 | 
			
		||||
@@ -54,11 +54,6 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted } from "vue";
 | 
			
		||||
import { useRouter } from "vue-router";
 | 
			
		||||
import { useUserStore } from "~/stores/user";
 | 
			
		||||
 | 
			
		||||
// auth 레이아웃 사용
 | 
			
		||||
definePageMeta({
 | 
			
		||||
  layout: "auth",
 | 
			
		||||
});
 | 
			
		||||
@@ -67,13 +62,12 @@ const userId = ref("");
 | 
			
		||||
const password = ref("");
 | 
			
		||||
const errorMessage = ref("");
 | 
			
		||||
const isLoading = ref(false);
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
const userStore = useUserStore();
 | 
			
		||||
 | 
			
		||||
// 이미 로그인된 경우 홈으로 리다이렉션
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  if (userStore.isLoggedIn) {
 | 
			
		||||
    router.push("/");
 | 
			
		||||
    await navigateTo("/");
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -86,22 +80,15 @@ async function signIn() {
 | 
			
		||||
  isLoading.value = true;
 | 
			
		||||
  errorMessage.value = "";
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
  const result = await userStore.login(userId.value, password.value);
 | 
			
		||||
 | 
			
		||||
  if (result.success) {
 | 
			
		||||
      // 로그인 성공 시 홈으로 이동
 | 
			
		||||
      await router.push("/");
 | 
			
		||||
    await navigateTo("/");
 | 
			
		||||
  } else {
 | 
			
		||||
      errorMessage.value = result.error || "로그인에 실패했습니다.";
 | 
			
		||||
    errorMessage.value = result.error;
 | 
			
		||||
  }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    errorMessage.value = "로그인 중 오류가 발생했습니다.";
 | 
			
		||||
    console.error("로그인 오류:", error);
 | 
			
		||||
  } finally {
 | 
			
		||||
  isLoading.value = false;
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
 
 | 
			
		||||
@@ -66,11 +66,6 @@
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, onMounted } from "vue";
 | 
			
		||||
import { useRouter } from "vue-router";
 | 
			
		||||
import { useUserStore } from "~/stores/user";
 | 
			
		||||
 | 
			
		||||
// auth 레이아웃 사용
 | 
			
		||||
definePageMeta({
 | 
			
		||||
  layout: "auth",
 | 
			
		||||
});
 | 
			
		||||
@@ -81,13 +76,12 @@ const confirmPassword = ref("");
 | 
			
		||||
const errorMessage = ref("");
 | 
			
		||||
const successMessage = ref("");
 | 
			
		||||
const isLoading = ref(false);
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
const userStore = useUserStore();
 | 
			
		||||
 | 
			
		||||
// 이미 로그인된 경우 홈으로 리다이렉션
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  if (userStore.isLoggedIn) {
 | 
			
		||||
    router.push("/");
 | 
			
		||||
    await navigateTo("/");
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -133,8 +127,8 @@ async function signUp() {
 | 
			
		||||
        "회원 가입이 완료되었습니다! 로그인 페이지로 이동합니다.";
 | 
			
		||||
 | 
			
		||||
      // 2초 후 로그인 페이지로 이동
 | 
			
		||||
      setTimeout(() => {
 | 
			
		||||
        router.push("/login");
 | 
			
		||||
      setTimeout(async () => {
 | 
			
		||||
        await navigateTo("/login");
 | 
			
		||||
      }, 2000);
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error: any) {
 | 
			
		||||
 
 | 
			
		||||
@@ -19,20 +19,6 @@ export default defineNuxtPlugin(() => {
 | 
			
		||||
        ...(isFormData ? {} : { "Content-Type": "application/json" }),
 | 
			
		||||
        ...(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 }) {
 | 
			
		||||
      // 공통 로깅
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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";
 | 
			
		||||
 | 
			
		||||
interface Tab {
 | 
			
		||||
export interface Tab {
 | 
			
		||||
  key: number; // 1~10
 | 
			
		||||
  label: string;
 | 
			
		||||
  to: string; // 페이지 라우트
 | 
			
		||||
@@ -15,38 +15,45 @@ export const useTabsStore = defineStore("tabs", {
 | 
			
		||||
    activeTab: 1,
 | 
			
		||||
  }),
 | 
			
		||||
  actions: {
 | 
			
		||||
    // ✅ 새 탭 추가 (기본 페이지는 "/")
 | 
			
		||||
    addTab() {
 | 
			
		||||
      const { $router } = useNuxtApp();
 | 
			
		||||
    async updateActiveTab(sub: {
 | 
			
		||||
      label: string;
 | 
			
		||||
      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개까지 열 수 있습니다.");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // 빈 key 찾기
 | 
			
		||||
      let key = 1;
 | 
			
		||||
      while (this.tabs.find(t => t.key === key)) key++;
 | 
			
		||||
      let newKey = 1;
 | 
			
		||||
      while (this.tabs.find(t => t.key === newKey)) newKey++;
 | 
			
		||||
 | 
			
		||||
      this.tabs.push({ ...defaultTab, key: key });
 | 
			
		||||
      this.activeTab = key;
 | 
			
		||||
      $router.push(defaultTab.to);
 | 
			
		||||
      return key;
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    // ✅ 활성 탭 내용 변경 (서브메뉴 클릭)
 | 
			
		||||
    updateActiveTab(sub: { label: string; to: string; componentName: string }) {
 | 
			
		||||
      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}`);
 | 
			
		||||
      this.tabs.push({
 | 
			
		||||
        key: newKey,
 | 
			
		||||
        label: sub.label,
 | 
			
		||||
        to: sub.to,
 | 
			
		||||
        componentName: sub.componentName,
 | 
			
		||||
      });
 | 
			
		||||
      this.activeTab = newKey;
 | 
			
		||||
      await navigateTo(`/${newKey}${sub.to}`);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    // 활성 탭 제거 (HOME 탭 보호)
 | 
			
		||||
    removeTab(key: number) {
 | 
			
		||||
      if (key === 1) return; // HOME 탭은 제거 금지
 | 
			
		||||
      this.tabs = this.tabs.filter(t => t.key !== key);
 | 
			
		||||
      if (this.activeTab === key) {
 | 
			
		||||
        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;
 | 
			
		||||
 | 
			
		||||
      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,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,5 @@
 | 
			
		||||
import { useTabsStore } from "./tab";
 | 
			
		||||
 | 
			
		||||
export const useUserStore = defineStore(
 | 
			
		||||
  "user",
 | 
			
		||||
  () => {
 | 
			
		||||
@@ -7,103 +9,68 @@ export const useUserStore = defineStore(
 | 
			
		||||
      userId?: string;
 | 
			
		||||
      name?: string;
 | 
			
		||||
    } | null>(null);
 | 
			
		||||
    const token = ref<string | null>(null);
 | 
			
		||||
 | 
			
		||||
    // 추후 제거 필요
 | 
			
		||||
    const isAdmin = true;
 | 
			
		||||
    // 권한 스토어 참조
 | 
			
		||||
    const permissionsStore = usePermissionsStore();
 | 
			
		||||
    // 탭 스토어 참조
 | 
			
		||||
    const tabsStore = useTabsStore();
 | 
			
		||||
 | 
			
		||||
    interface LoginData {
 | 
			
		||||
      userId: string;
 | 
			
		||||
      name: string;
 | 
			
		||||
    }
 | 
			
		||||
    // 액션
 | 
			
		||||
    const login = async (userId: string, password: string) => {
 | 
			
		||||
      try {
 | 
			
		||||
        // 실제 API 호출로 대체할 수 있습니다
 | 
			
		||||
 | 
			
		||||
        const { success, data } = await useApi<ApiResponse<LoginData>>(
 | 
			
		||||
          "/login",
 | 
			
		||||
          {
 | 
			
		||||
    const login = async (userId: string, password: string) => {
 | 
			
		||||
      const { success, data, description } = await useApi<
 | 
			
		||||
        ApiResponse<LoginData>
 | 
			
		||||
      >("/login", {
 | 
			
		||||
        method: "post",
 | 
			
		||||
        body: { userId, password },
 | 
			
		||||
          }
 | 
			
		||||
        );
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (success) {
 | 
			
		||||
        user.value = data;
 | 
			
		||||
        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 {
 | 
			
		||||
          success: false,
 | 
			
		||||
          error:
 | 
			
		||||
            error?.response?.status === 401
 | 
			
		||||
              ? "아이디 또는 비밀번호가 올바르지 않습니다."
 | 
			
		||||
              : error instanceof Error
 | 
			
		||||
                ? error.message
 | 
			
		||||
                : "로그인에 실패했습니다.",
 | 
			
		||||
          error: description,
 | 
			
		||||
        };
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const logout = async () => {
 | 
			
		||||
      try {
 | 
			
		||||
      await useApi("/members/logout", {
 | 
			
		||||
        method: "post",
 | 
			
		||||
        loadingMessage: "로그아웃 처리중...",
 | 
			
		||||
        showAlert: false,
 | 
			
		||||
      });
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error("로그아웃 요청 실패:", error);
 | 
			
		||||
      } finally {
 | 
			
		||||
        // 로컬 상태 정리
 | 
			
		||||
 | 
			
		||||
      user.value = null;
 | 
			
		||||
      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 {
 | 
			
		||||
      // 상태
 | 
			
		||||
      isLoggedIn,
 | 
			
		||||
      user,
 | 
			
		||||
      token,
 | 
			
		||||
 | 
			
		||||
      // 게터
 | 
			
		||||
      isAdmin,
 | 
			
		||||
 | 
			
		||||
      // 액션
 | 
			
		||||
      login,
 | 
			
		||||
      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