Compare commits

...

37 Commits

Author SHA1 Message Date
sohot8653
195941a402 Merge branch 'main' of https://demo.stam.kr/leejisun9/bio_frontend 2025-10-31 15:18:18 +09:00
sohot8653
f0017a8703 [mol*] 라이브러리 추가 및 테스트 페이지 추가가 2025-10-31 15:17:58 +09:00
dc0fc6e41f igv js add 2025-10-28 17:17:58 +09:00
sohot8653
f2a717df4f [SSR 비활성화] SPA 방식으로 변경 2025-10-21 15:54:01 +09:00
sohot8653
7bbbdbe977 Merge branch 'main' of https://demo.stam.kr/leejisun9/bio_frontend 2025-10-20 14:02:33 +09:00
268cf9e50f [의존성 업데이트] @nuxtjs/tailwindcss를 6.12.0으로 다운그레이드하고, tailwindcss를 3.4.0으로 업데이트함. .gitignore에 .output 추가. 2025-10-20 13:56:05 +09:00
ikju1223
76992e262d ikju1223@stam.kr
ipx 추가
2025-10-17 14:43:33 +09:00
14970701f7 [페이지 로딩 처리 추가] 페이지 이동 시 자동으로 로딩 표시 및 DOM 렌더링 완료 후 로딩 종료 기능을 구현하는 플러그인 추가. 2025-09-26 09:17:41 +09:00
8b7b516855 [파일 경로 수정] cultureGraph 관련 컴포넌트의 import 경로를 culture-graph로 변경하고, user.ts에서 주석 처리된 사용자 로그아웃 및 탭 초기화 로직을 활성화함. 2025-09-26 09:07:51 +09:00
5687db9f25 [인증 오류 처리 개선] API 호출 시 인증 오류 발생 시 사용자 로그아웃 처리 및 인증 오류 페이지 추가. auth.global.ts에서 공개 라우트에 인증 오류 페이지 포함, 사용자 세션 정리 로직 개선. 2025-09-26 09:02:47 +09:00
d60e60010b [홈 화면 수정] 첫번째 탭이 '홈'이므로 /1/ 경로로 단일화화 2025-09-25 16:29:33 +09:00
ffc2b4a24a [README.md 업데이트] 컴포넌트 폴더 구조 및 사용법 추가, 자동 임포트 설정 설명 보강, 페이지 구성 및 팝업 생성 방법에 대한 세부 정보 업데이트 2025-09-25 15:56:20 +09:00
ded762517e [폴더, 파일 구조 정리] 2025-09-25 15:33:11 +09:00
51019d7f5f [미들웨어 및 권한 시스템 개선] 기존 auth 미들웨어를 auth.global.ts로 통합하여 로그인 및 권한 체크 로직을 개선하고, 페이지 권한 확인 방식을 동적 라우팅 패턴으로 확장함. 불필요한 코드 제거 및 로그아웃 처리 로직 간소화. 2025-09-25 11:28:12 +09:00
sohot8653
cb22e3904a [상태 관리 개선] Pinia 플러그인에 로컬 스토리지 설정 추가 및 권한 스토어에서 permissions 반환 방식을 간소화함 2025-09-24 19:52:12 +09:00
c0a54bb64c [탭 및 권한 시스템 개선] TabBar에서 이미 활성화된 탭 클릭 시 동작 방지 로직 추가, 권한 리소스 코드로 검색 기능 추가, 권한 테스트 페이지에서 불필요한 로딩 메시지 제거, 탭 관리 스토어에서 중복 탭 체크 및 활성 탭 전환 로직 개선 2025-09-24 17:21:19 +09:00
41523a57b3 [UI 개선] AppHeader에서 HOME 메뉴 제거, TabBar에서 탭 추가 버튼 및 관련 로직 삭제, 탭 관리 스토어에서 HOME 탭 제거 방지 및 서브메뉴 클릭 시 새 탭 생성 로직 수정 2025-09-24 16:52:48 +09:00
19bda71444 [라우터 방식 변경] 2025-09-24 16:35:52 +09:00
f9dde4eb09 [UI 개선] 서브메뉴 및 탭 바 컴포넌트 추가, AppHeader 및 기본 레이아웃 수정, API 호출 로직 개선 2025-09-24 16:25:30 +09:00
f83782813d [API 개선] 요청 URL 처리 로직 간소화 및 SSR 쿠키 포워딩 조건 개선 2025-09-24 13:33:22 +09:00
b866242d43 [로딩 시스템 추가] 전역 로딩 오버레이 및 상태 관리 스토어 구현, 로딩 카운터 방식으로 비동기 작업 관리 기능 추가 2025-09-24 10:53:08 +09:00
1229faa777 [권한 시스템 정리] 불필요한 권한 체크 함수 및 관련 코드 제거, 리소스 구조 개선 2025-09-24 10:19:23 +09:00
d278b635e7 [UI 개선] PermissionButton 컴포넌트를 새로 추가하여 권한 체크 및 클릭 이벤트 처리를 간소화하고, 테스트 페이지에서 사용 예시를 추가함 2025-09-24 09:08:08 +09:00
7ee8a3005a [권한 시스템 개선] 권한 데이터 접근 방식을 computed로 변경하고, 리소스별 접근 함수 추가 2025-09-23 17:12:41 +09:00
4f02146d9f [권한 시스템 테스트 페이지 추가] 권한 시스템의 동작을 테스트할 수 있는 새로운 페이지를 추가하고, 관련 권한 데이터를 업데이트 2025-09-23 16:09:15 +09:00
24c0f4f5b6 [탭 기능 개선] 탭 초기화 및 활성 탭 변경 로직 추가, 주석 정리 2025-09-23 16:01:23 +09:00
9f66ebac8a [메뉴 권한 2차 작업] 페이지 그룹 필터링 로직 개선: 권한이 있는 메뉴만 표시하도록 수정 2025-09-23 15:42:54 +09:00
e5f5a926a3 [리소스 권한 작업중] 2025-09-23 15:21:00 +09:00
29fbda149b [메뉴 권한 1차 작업] 2025-09-23 14:15:32 +09:00
9bbc4f82b6 [로그인 기능 개선] 로그인 처리 로직 간소화 및 에러 메시지 개선, 로그아웃 기능 리팩토링 2025-09-22 14:22:28 +09:00
8282a4d037 [메인 페이지 작업중] 추후 1개로 통합하기 위해 내용 동기화 2025-09-22 13:47:28 +09:00
75e9831907 [UI 개선] API 테스트 페이지의 자동 및 직접 에러 처리 테스트 섹션 업데이트, 예제 소스 코드 수정 및 추가 API 테스트 기능 개선 2025-09-22 13:16:16 +09:00
221e250814 [UI 개선] 기본 레이아웃에서 서브메뉴 및 탭 기능 개선, 공용 기능 테스트 페이지 추가 2025-09-22 11:20:12 +09:00
leejisun9
30e2099d14 delete folder 2025-09-12 11:21:21 +09:00
leejisun9
2ec34ff321 mearge 2025-09-12 11:10:43 +09:00
0f0317e356 [페이지 원복] 기존 테스트 페이지 원복복 2025-09-03 17:18:25 +09:00
5f1d1f5018 [API 개선] useApi 함수에 에러 처리 옵션 추가 및 사용 예시 업데이트 2025-09-03 17:17:53 +09:00
69 changed files with 81988 additions and 2004 deletions

4
.gitignore vendored
View File

@@ -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/

105
README.md
View File

@@ -1,21 +1,76 @@
# bio_frontend
## NUXT 3 (VUE 3) 환경
- data-list 와 dataList는 동일 변수명으로 인식
- compnenets 아래의 .vue는 자동 인식(template 내부에서만, 별도 script에서 필요시 선언 필요)
- 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,25 +103,25 @@ 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>
<button @click="onUpdateClick">저장</button>
<!-- toast Grid 필수값 ref, data, columns(header) -->
<ToastGrid
<ToastGrid
ref="grid1Ref"
:data="data"
:columns="colDefs"
/>
/>
</template>
<script setup lang="ts">
// 컬럼 항목 리스트
// composables폴더 아래에 생성
@@ -87,7 +144,7 @@ let no = 1;
// 항목 추가 버튼
function onAddClick() {
grid1Ref.value?.api()?.appendRow({'no': no}); // ref api를 통해서 항목 추가
grid1Ref.value?.api()?.appendRow({'no': no}); // ref api를 통해서 항목 추가
++no;
}
@@ -95,20 +152,21 @@ function onAddClick() {
function onUpdateClick() {
//grid1Ref.value?.clearGrid();
const chageList = grid1Ref.value?.api()?.getModifiedRows(); // ref api를 통해서 변경점 읽어오기
console.log(changeList);
console.log(changeList);
}
</script>
```
```
## tree data
```
<template>
<ToastGrid
<ToastGrid
ref="grid1Ref"
:data="data"
:columns="columns"
:treeColumnOptions="treeColumnOptions"
/>
/>
</template>
<script setup lang="ts">
@@ -227,8 +285,8 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
2. 사용할 페이지에서 생성한 팝업 추가
```
### 팝업 생성
```
// examplePopup.vue
<template>
@@ -238,11 +296,11 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
<template #top>
<h2>팝업 제목</h2>
</template>
<template #middle>
<!-- 팝업 본문 -->
</template>
<template #bottom>
<button>추가 버튼</button>
<!-- 닫기 버튼은 자동 생성 -->
@@ -250,7 +308,7 @@ poupWrapper.vue - top, middle, bottom으로 구성된 팝업 구조
</PopupWrapper>
</div>
</template>
<script setup lang="ts">
// 숨김 여부 지정
const show = defineModel('show', {type: Boolean, default:false});
@@ -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
@@ -298,4 +357,4 @@ tabsStore.setActiveTab(key);
{{ tab.label }}
<span v-show="tabsStore.activeTab !== tab.key" class="close-btn" @click.stop="tabsStore.removeTab(tab.key)"> × </span>
</div>
```
```

View File

@@ -2,6 +2,9 @@
<NuxtLayout>
<NuxtPage :keepalive="true" />
</NuxtLayout>
<!-- 전역 로딩 오버레이 -->
<GlobalLoading :show-details="true" />
</template>
<!-- <script setup lang="ts">

View 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>

View File

@@ -1,50 +1,50 @@
<script setup lang="ts">
import { ref, defineExpose } from 'vue';
import type { OptColumn, OptRow } from 'tui-grid/types/options';
import type { TuiGridElement } from 'vue3-tui-grid';
import type Grid from 'tui-grid';
interface TreeColumnOptions {
name: string;
useCascadingCheckbox?: boolean;
}
const tuiGridRef = ref<TuiGridElement>();
const selectionUnit = "row"
const props = defineProps<{
data: OptRow[];
// editor: https://github.com/nhn/tui.grid/blob/master/packages/toast-ui.grid/docs/v4.0-migration-guide-kor.md
columns: OptColumn[];
treeColumnOptions?: TreeColumnOptions;
rowHeaders?: string[];
rowKey?: string;
}>();
// grid api : https://nhn.github.io/tui.grid/latest/Grid
// const ref = ref<InstanceType<typeof ToastGrid>>();
// ref.value?.api()?.clear();
// ref.value?.api()?.getModifiedRows();
defineExpose({
api: (): Grid | undefined => tuiGridRef.value?.gridInstance,
clearGrid: () => clearGrid(),
});
function clearGrid() {
tuiGridRef.value?.gridInstance.clear();
}
</script>
<template>
<tui-grid
ref="tuiGridRef"
:data="props.data"
:columns="props.columns"
:treeColumnOptions="props.treeColumnOptions"
:rowHeaders="props.rowHeaders"
:rowKey="props.rowKey"
:selectionUnit="selectionUnit"
/>
</template>
<script setup lang="ts">
import { ref, defineExpose } from 'vue';
import type { OptColumn, OptRow } from 'tui-grid/types/options';
import type { TuiGridElement } from 'vue3-tui-grid';
import type Grid from 'tui-grid';
interface TreeColumnOptions {
name: string;
useCascadingCheckbox?: boolean;
}
const tuiGridRef = ref<TuiGridElement>();
const selectionUnit = "row"
const props = defineProps<{
data: OptRow[];
// editor: https://github.com/nhn/tui.grid/blob/master/packages/toast-ui.grid/docs/v4.0-migration-guide-kor.md
columns: OptColumn[];
treeColumnOptions?: TreeColumnOptions;
rowHeaders?: string[];
rowKey?: string;
}>();
// grid api : https://nhn.github.io/tui.grid/latest/Grid
// const ref = ref<InstanceType<typeof ToastGrid>>();
// ref.value?.api()?.clear();
// ref.value?.api()?.getModifiedRows();
defineExpose({
api: (): Grid | undefined => tuiGridRef.value?.gridInstance,
clearGrid: () => clearGrid(),
});
function clearGrid() {
tuiGridRef.value?.gridInstance.clear();
}
</script>
<template>
<tui-grid
ref="tuiGridRef"
:data="props.data"
:columns="props.columns"
:treeColumnOptions="props.treeColumnOptions"
:rowHeaders="props.rowHeaders"
:rowKey="props.rowKey"
:selectionUnit="selectionUnit"
/>
</template>

View 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>

View File

@@ -1,28 +1,27 @@
<template>
<div v-if="show" class="popup-overlay" @click.self="show = false">
<div class="popup-container">
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
const show = defineModel('show', {type: Boolean, default:false});
</script>
<style scoped>
.popup-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.popup-container {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
</style>
<template>
<div v-if="show" class="popup-overlay" @click.self="show = false">
<div class="popup-container">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
const show = defineModel("show", { type: Boolean, default: false });
</script>
<style scoped>
.popup-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
}
.popup-container {
background: #fff;
border-radius: 8px;
overflow: hidden;
}
</style>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(() => {

View 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>

View 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>

View 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>

View File

@@ -1,81 +1,78 @@
<template>
<customPopup v-model:show="show">
<div class="popup-content" :style="{ width, height }">
<!-- Top -->
<div class="popup-top">
<slot name="top"></slot>
</div>
<!-- Middle -->
<div class="popup-middle">
<slot name="middle"></slot>
</div>
<!-- Bottom -->
<div class="popup-bottom">
<button class="popup-close" @click="show = false">닫기</button>
<slot name="bottom"></slot>
</div>
</div>
</customPopup>
</template>
<script setup lang="ts">
defineProps<{
width?: string
height?: string
}>()
// defineModel +
const show = defineModel('show', {type: Boolean, default:false});
</script>
<style scoped>
.popup-content {
background: white;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
}
.popup-top {
padding: 10px 20px;
font-weight: bold;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
}
.popup-middle {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.popup-bottom {
padding: 10px 20px;
display: flex;
justify-content: center; /* 중앙 정렬 */
gap: 10px;
background: #f9f9f9;
border-top: 1px solid #ddd;
}
/* ⭐️ bottom 슬롯 버튼 공통 스타일 */
.popup-bottom ::v-deep(button) {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #007bff;
color: white;
cursor: pointer;
}
.popup-bottom ::v-deep(button:hover) {
background: #0056b3;
}
.popup-close {
background: #ddd !important;
color: black !important;
}
</style>
<template>
<CommonPopup v-model:show="show">
<div class="popup-content" :style="{ width, height }">
<!-- Top -->
<div class="popup-top">
<slot name="top"></slot>
</div>
<!-- Middle -->
<div class="popup-middle">
<slot name="middle"></slot>
</div>
<!-- Bottom -->
<div class="popup-bottom">
<button class="popup-close" @click="show = false">닫기</button>
<slot name="bottom"></slot>
</div>
</div>
</CommonPopup>
</template>
<script setup lang="ts">
defineProps<{
width?: string;
height?: string;
}>();
// defineModel +
const show = defineModel("show", { type: Boolean, default: false });
</script>
<style scoped>
.popup-content {
background: white;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
}
.popup-top {
padding: 10px 20px;
font-weight: bold;
background: #f0f0f0;
border-bottom: 1px solid #ddd;
}
.popup-middle {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.popup-bottom {
padding: 10px 20px;
display: flex;
justify-content: center; /* 중앙 정렬 */
gap: 10px;
background: #f9f9f9;
border-top: 1px solid #ddd;
}
/* ⭐️ bottom 슬롯 버튼 공통 스타일 */
.popup-bottom ::v-deep(button) {
padding: 8px 16px;
border: none;
border-radius: 6px;
background: #007bff;
color: white;
cursor: pointer;
}
.popup-bottom ::v-deep(button:hover) {
background: #0056b3;
}
.popup-close {
background: #ddd !important;
color: black !important;
}
</style>

View File

@@ -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 }
}

View File

@@ -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> => {
const { $api } = useNuxtApp();
return ($api as any)(path, opts);
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();
// 기본값 설정
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();
}
};

View File

@@ -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
View 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,
};
};

View File

@@ -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");
}

View 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);
},
};
};

View File

@@ -1,188 +1,188 @@
import type { OptColumn } from 'tui-grid/types/options';
export const colDefs: OptColumn[] = [
{
name: 'seq',
header: 'seq',
width: 50,
align: 'center',
hidden: true,
},
{
name: 'parentCode',
header: '부모 코드',
width: 200,
editor: 'text',
align: 'center',
filter: { type: 'text' },
},
{
name: 'level',
header: '레벨',
width: 100,
editor: 'text',
align: 'center',
filter: { type: 'number' },
},
{
name: 'code',
header: '코드',
minWidth: 250,
editor: 'text',
align: 'center',
},
{
name: 'name',
header: '이름',
minWidth: 250,
editor: 'text',
align: 'center',
},
{
name: 'useFlag',
header: '사용 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'menuFlag',
header: '메뉴 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'apiFlag',
header: 'API 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'authExceptionFlag',
header: '예외 허용 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'sortOrder',
header: '표시 순서',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'uri',
header: 'uri',
width: 300,
editor: 'text',
align: 'center',
},
{
name: 'field1',
header: '필드1',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field2',
header: '필드2',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field3',
header: '필드3',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field4',
header: '필드4',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field5',
header: '필드5',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton1',
header: '사용자 버튼1',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton2',
header: '사용자 버튼2',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton3',
header: '사용자 버튼3',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton4',
header: '사용자 버튼4',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton5',
header: '사용자 버튼5',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton6',
header: '사용자 버튼6',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton7',
header: '사용자 버튼7',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton8',
header: '사용자 버튼8',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton9',
header: '사용자 버튼9',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton10',
header: '사용자 버튼10',
width: 200,
editor: 'text',
align: 'center',
},
];
import type { OptColumn } from 'tui-grid/types/options';
export const colDefs: OptColumn[] = [
{
name: 'seq',
header: 'seq',
width: 50,
align: 'center',
hidden: true,
},
{
name: 'parentCode',
header: '부모 코드',
width: 200,
editor: 'text',
align: 'center',
filter: { type: 'text' },
},
{
name: 'level',
header: '레벨',
width: 100,
editor: 'text',
align: 'center',
filter: { type: 'number' },
},
{
name: 'code',
header: '코드',
minWidth: 250,
editor: 'text',
align: 'center',
},
{
name: 'name',
header: '이름',
minWidth: 250,
editor: 'text',
align: 'center',
},
{
name: 'useFlag',
header: '사용 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'menuFlag',
header: '메뉴 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'apiFlag',
header: 'API 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'authExceptionFlag',
header: '예외 허용 여부',
width: 150,
filter: { type: 'text' },
align: 'center',
},
{
name: 'sortOrder',
header: '표시 순서',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'uri',
header: 'uri',
width: 300,
editor: 'text',
align: 'center',
},
{
name: 'field1',
header: '필드1',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field2',
header: '필드2',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field3',
header: '필드3',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field4',
header: '필드4',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'field5',
header: '필드5',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton1',
header: '사용자 버튼1',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton2',
header: '사용자 버튼2',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton3',
header: '사용자 버튼3',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton4',
header: '사용자 버튼4',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton5',
header: '사용자 버튼5',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton6',
header: '사용자 버튼6',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton7',
header: '사용자 버튼7',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton8',
header: '사용자 버튼8',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton9',
header: '사용자 버튼9',
width: 200,
editor: 'text',
align: 'center',
},
{
name: 'userButton10',
header: '사용자 버튼10',
width: 200,
editor: 'text',
align: 'center',
},
];

View File

@@ -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
View 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("/");
}
}
});

View File

@@ -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("/");
}
}
});

View File

@@ -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/ 접두사 유지
],
});

6046
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -1,41 +1,36 @@
<template>
<ContentsWrapper>
<template #actions>
<button @click="onAddClick">추가</button>
<button @click="onUpdateClick">저장</button>
</template>
<input type="text" >
<ToastGrid
ref="grid1Ref"
:data="data"
:columns="colDefs"
/>
</ContentsWrapper>
<ContentsWrapper>
<template #actions>
<button @click="onAddClick">추가</button>
<button @click="onUpdateClick">저장</button>
</template>
<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({});
grid1Ref.value?.api()?.appendRow({});
}
function onUpdateClick() {
//grid1Ref.value?.clearGrid();
console.log(grid1Ref.value?.api()?.getModifiedRows());
//grid1Ref.value?.clearGrid();
console.log(grid1Ref.value?.api()?.getModifiedRows());
}
</script>

View File

@@ -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>

View 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&lt;ApiResponse&lt;object&gt;&gt;(
"/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&lt;ApiResponse&lt;object&gt;&gt;(
"/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&lt;ApiResponse&lt;object&gt;&gt;(
"/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&lt;ApiResponse&lt;object&gt;&gt;(
"/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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

955
pages/[tabId]/test/igv2.vue Normal file
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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,43 +293,33 @@ 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();
function onClearClick() {
//grid1Ref.value?.clearGrid();
grid1Ref.value?.api()?.clear();
//grid1Ref.value?.clearGrid();
grid1Ref.value?.api()?.clear();
}
function onUpdateClick() {
//grid1Ref.value?.clearGrid();
console.log(grid1Ref.value?.api()?.getModifiedRows());
//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>

View File

@@ -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
View 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>

View File

@@ -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>

View File

@@ -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,21 +80,14 @@ async function signIn() {
isLoading.value = true;
errorMessage.value = "";
try {
const result = await userStore.login(userId.value, password.value);
const result = await userStore.login(userId.value, password.value);
if (result.success) {
// 로그인 성공 시 홈으로 이동
await router.push("/");
} else {
errorMessage.value = result.error || "로그인에 실패했습니다.";
}
} catch (error) {
errorMessage.value = "로그인 중 오류가 발생했습니다.";
console.error("로그인 오류:", error);
} finally {
isLoading.value = false;
if (result.success) {
await navigateTo("/");
} else {
errorMessage.value = result.error;
}
isLoading.value = false;
}
</script>

View File

@@ -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) {

View File

@@ -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
View 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;
}
});
});

View File

@@ -1,7 +1,7 @@
import { defineNuxtPlugin } from '#app'
import TuiGrid from 'vue3-tui-grid'
import 'tui-grid/dist/tui-grid.css'
export default defineNuxtPlugin(nuxtApp => {
nuxtApp.vueApp.use(TuiGrid)
})
import { defineNuxtPlugin } from '#app'
import TuiGrid from 'vue3-tui-grid'
import 'tui-grid/dist/tui-grid.css'
export default defineNuxtPlugin(nuxtApp => {
nuxtApp.vueApp.use(TuiGrid)
})

65
public/cy_custom.js Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

222
public/igv_custom.js Normal file
View 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);
})();

View File

@@ -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
View 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
View 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,
}
);

View File

@@ -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,

View File

@@ -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, description } = await useApi<
ApiResponse<LoginData>
>("/login", {
method: "post",
body: { userId, password },
});
const { success, data } = await useApi<ApiResponse<LoginData>>(
"/login",
{
method: "post",
body: { userId, password },
}
);
if (success) {
user.value = data;
isLoggedIn.value = true;
if (success) {
user.value = data;
isLoggedIn.value = true;
} else {
throw new Error("아이디 또는 비밀번호가 올바르지 않습니다.");
}
// 로그인 성공 시
// 탭 초기화
tabsStore.resetTabs();
// 권한 데이터 가져오기
await permissionsStore.fetchPermissions();
return { success };
} catch (error: any) {
console.log(error);
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",
});
} catch (error) {
console.error("로그아웃 요청 실패:", error);
} finally {
// 로컬 상태 정리
user.value = null;
isLoggedIn.value = false;
}
await useApi("/members/logout", {
method: "post",
loadingMessage: "로그아웃 처리중...",
showAlert: false,
});
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
View 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: "테스트 상세보기 버튼",
},
],
},
};