221 lines
		
	
	
		
			4.3 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
		
		
			
		
	
	
			221 lines
		
	
	
		
			4.3 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
| 
								 | 
							
								<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>
							 |