박정하 박정하 03-21
250321 박정하 메인화면 API 통신 추가
@67f611b6cdbda6df5fa43767ffe10e9c165b1ab0
 
client/resources/api/category.js (added)
+++ client/resources/api/category.js
@@ -0,0 +1,6 @@
+import apiClient from "./index";
+
+// 카테고리 목록 조회
+export const findAllCategoryProc = mber => {
+    return apiClient.post(`/category/findAllCategory.json`, mber);
+}(파일 끝에 줄바꿈 문자 없음)
client/resources/api/index.js
--- client/resources/api/index.js
+++ client/resources/api/index.js
@@ -2,73 +2,148 @@
 import store from "../../views/pages/AppStore";
 
 const apiClient = axios.create({
-    headers: {'Content-Type': 'application/json; charset=UTF-8'}
-})
+  headers: { 'Content-Type': 'application/json; charset=UTF-8' }
+});
+
+// 동시에 여러 요청이 401 에러가 발생했을 때 관리하기 위한 변수
+let isRefreshing = false;
+let failedQueue = [];
+
+const processQueue = (error, token = null) => {
+  failedQueue.forEach(prom => {
+    if (error) {
+      prom.reject(error);
+    } else {
+      prom.resolve(token);
+    }
+  });
+
+  failedQueue = [];
+};
 
 apiClient.interceptors.request.use(
-    config => {
-        config.headers.Authorization = store.state.authorization // 요청 시 AccessToken 추가
-        return config;
-    },
-    error => {
-        return Promise.reject(error);
+  config => {
+    // Bearer 접두사 추가 (API 요구사항에 따라 필요한 경우)
+    if (store.state.authorization) {
+      config.headers.Authorization = store.state.authorization;
     }
-)
+    return config;
+  },
+  error => {
+    return Promise.reject(error);
+  }
+);
 
 async function refreshAccessToken() {
-    try {
-        const refreshToken = store.state.authorization; // 스토어에서 리프레시 토큰 가져오기
-        
-        // 리프레시 토큰을 포함하여 재발급 요청
-        const res = await axios.post('/refresh/tknReissue.json', {}, {
-            withCredentials: true // 쿠키를 요청에 포함시키기 위한 옵션
-        });
-
-        if (res.headers.authorization) {
-            // 새로 발급받은 AccessToken 저장
-            store.commit('setAuthorization', res.headers.authorization);
-            // 필요한 경우 리프레시 토큰도 업데이트
-            // store.commit('setRefresh', res.headers.refresh);
-        }
-
-        return res; // 응답 반환
-    } catch (error) {
-        console.error('Error refreshing access token:', error);
-        alert('토큰 재발급에 실패했습니다. 다시 로그인 해주세요.');
-        store.commit("setStoreReset");
-        window.location = '/login.page'; // 로그인 페이지로 리다이렉트
+  try {
+    // 리프레시 토큰 존재 여부 확인
+    if (!store.state.refreshToken) {
+      throw new Error('리프레시 토큰이 없습니다.');
     }
+
+    console.log('토큰 재발급 시도:', new Date().toISOString());
+
+    // 서버의 구현에 맞게 요청 형식 수정
+    // 백엔드는 요청 바디가 아닌 쿠키에서 refreshToken을 읽으므로
+    // 빈 객체로 보내고 withCredentials를 true로 설정
+    const res = await axios.post('/refresh/tknReissue.json', {}, {
+      withCredentials: true,
+      timeout: 10000 // 10초 타임아웃 설정
+    });
+
+    console.log('토큰 재발급 응답:', res.status);
+
+    // 백엔드는 응답 헤더에 Authorization으로 새 토큰을 보냄
+    const newToken = res.headers['authorization'];
+
+    if (newToken) {
+      // 새 토큰을 스토어에 저장
+      store.commit('setAuthorization', newToken);
+      return newToken;
+    } else {
+      console.error('응답 헤더에 토큰이 없습니다:', res.headers);
+      throw new Error('토큰이 포함되어 있지 않습니다.');
+    }
+  } catch (error) {
+    console.error('토큰 재발급 오류:', error);
+
+    // 네트워크나 서버 오류 구분
+    if (!navigator.onLine) {
+      alert('네트워크 연결을 확인해주세요.');
+    } else if (error.response && (error.response.status === 401 || error.response.status === 403)) {
+      alert('세션이 만료되었습니다. 다시 로그인해 주세요.');
+      store.commit("setStoreReset");
+      window.location = '/login.page';
+    } else {
+      alert('토큰 재발급에 실패했습니다. 다시 로그인해 주세요.');
+      store.commit("setStoreReset");
+      window.location = '/login.page';
+    }
+
+    throw error;
+  }
 }
 
 apiClient.interceptors.response.use(
-    response => {
-        return response;
-    },
-    async error => {
-        const originalReq = error.config;
+  response => {
+    return response;
+  },
+  async error => {
+    const originalReq = error.config;
 
-        // 403 오류 처리: 접근 권한 없음
-        if (error.response.status === 403 && error.response.data.message === '접근 권한이 없습니다.') {
-            window.history.back();
-            return Promise.reject(error);
-        }
-
-        // 401 오류 처리: 토큰 만료
-        if (error.response.status === 401 && !originalReq._retry) {
-            originalReq._retry = true; // 재요청 시도 플래그 설정
-            try {
-                // 액세스 토큰 재발급 요청
-                await refreshAccessToken(); // 리프레시 함수 호출
-
-                // 원래 요청 재시도
-                return apiClient(originalReq);
-            } catch (refreshError) {
-                return Promise.reject(refreshError);
-            }
-        }
-
-        return Promise.reject(error);
+    // 응답이 없는 경우(네트워크 오류) 처리
+    if (!error.response) {
+      console.error('네트워크 오류:', error);
+      return Promise.reject(error);
     }
-)
 
-export default apiClient;
+    // 403 오류 처리: 접근 권한 없음
+    if (error.response.status === 403 && error.response.data.message === '접근 권한이 없습니다.') {
+      window.history.back();
+      return Promise.reject(error);
+    }
+
+    // 401 오류 처리: 토큰 만료
+    if (error.response.status === 401 && !originalReq._retry) {
+      originalReq._retry = true;
+
+      // 이미 토큰 재발급 중인 경우 대기열에 추가
+      if (isRefreshing) {
+        return new Promise((resolve, reject) => {
+          failedQueue.push({ resolve, reject });
+        }).then(token => {
+          originalReq.headers['Authorization'] = token;
+          return apiClient(originalReq);
+        }).catch(err => {
+          return Promise.reject(err);
+        });
+      }
+
+      isRefreshing = true;
+
+      try {
+        // 액세스 토큰 재발급 요청
+        const newToken = await refreshAccessToken();
+
+        // 성공적으로 토큰을 재발급받은 경우
+        originalReq.headers['Authorization'] = newToken;
+
+        // 대기 중인 요청 처리
+        processQueue(null, newToken);
+
+        // 재요청
+        return apiClient(originalReq);
+      } catch (refreshError) {
+        // 대기 중인 모든 요청에 오류 전파
+        processQueue(refreshError, null);
+        return Promise.reject(refreshError);
+      } finally {
+        isRefreshing = false;
+      }
+    }
+
+    return Promise.reject(error);
+  }
+);
+
+export default apiClient;
(파일 끝에 줄바꿈 문자 없음)
 
client/resources/api/main.js (added)
+++ client/resources/api/main.js
@@ -0,0 +1,11 @@
+import apiClient from "./index";
+
+// 메인화면 정보 조회
+export const findAllSttusesProc = () => {
+  return apiClient.get(`/main/findAllSttuses.json`);
+}
+
+// 통합 검색
+export const findAllDatas = (searchReqDTO) => {
+  return apiClient.get(`/main/findAllDatas.json`, { params: { searchReqDTO: JSON.stringify(searchReqDTO) } });
+}(파일 끝에 줄바꿈 문자 없음)
 
client/resources/js/youtubeUtils.js (added)
+++ client/resources/js/youtubeUtils.js
@@ -0,0 +1,50 @@
+// youtubeUtils.js
+
+/**
+ * YouTube URL에서 비디오 ID를 추출합니다.
+ */
+export function extractYouTubeVideoId(url) {
+  if (!url) return null;
+
+  // youtu.be/ID 형식 (단축 URL)
+  const shortUrlRegex = /youtu\.be\/([a-zA-Z0-9_-]{11})/;
+  const shortMatch = url.match(shortUrlRegex);
+  if (shortMatch) {
+    return shortMatch[1];
+  }
+
+  // youtube.com/watch?v=ID 형식 (일반 URL)
+  const standardRegex = /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/;
+  const standardMatch = url.match(standardRegex);
+  if (standardMatch) {
+    return standardMatch[1];
+  }
+
+  // youtube.com/embed/ID 형식 (임베드 URL)
+  const embedRegex = /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/;
+  const embedMatch = url.match(embedRegex);
+  if (embedMatch) {
+    return embedMatch[1];
+  }
+
+  // 만약 위 정규식들이 실패하면 마지막 시도
+  const lastAttemptRegex = /([a-zA-Z0-9_-]{11})/;
+  const urlParts = url.split(/[\/\?&]/);
+  for (const part of urlParts) {
+    if (part.match(lastAttemptRegex) && part.length === 11) {
+      return part;
+    }
+  }
+
+  return null;
+}
+
+/**
+ * YouTube 썸네일 URL을 가져옵니다 (가장 안정적인 방법).
+ */
+export function getYouTubeThumbnail(url) {
+  const videoId = extractYouTubeVideoId(url);
+  if (!videoId) return '';
+  // return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`; // 기본 해상도
+  return `https://i.ytimg.com/vi/${videoId}/maxresdefault.jpg`; // 고해상도 (지원 안되는 영상 있음)
+}(파일 끝에 줄바꿈 문자 없음)
client/views/pages/main/Main.vue
--- client/views/pages/main/Main.vue
+++ client/views/pages/main/Main.vue
@@ -1,31 +1,27 @@
 <template>
   <div class="visual mb-50">
-    
-    <swiper 
-    ref="swiper"
-    :loop="true" 
-    :spaceBetween="30" 
-    :centeredSlides="true" 
-    :autoplay="{
+    <swiper ref="swiper" :loop="true" :spaceBetween="30" :centeredSlides="true" :autoplay="{
       delay: 2500,
       disableOnInteraction: false,
-    }"
-    :pagination="{
+    }" :pagination="{
       type: 'progressbar',
-    }" :navigation="true" :modules="modules"  @slideChange="onSlideChange" class="mySwiper ">
+    }" :navigation="true" :modules="modules" @slideChange="onSlideChange" class="mySwiper">
       <swiper-slide v-for="(item, index) in slides" :key="index">
         <img :src="item.img" :alt="item.alt" />
       </swiper-slide>
       <div class="pagination">
-      <div class="page-count">{{ currentSlide }} / {{ totalSlides }}</div>
-      <div class="btn-control">
-        <button @click="play"><CaretRightOutlined /></button>
-        <button @click="stop"><PauseOutlined /></button>
+        <div class="page-count">{{ currentSlide }} / {{ totalSlides }}</div>
+        <div class="btn-control">
+          <button @click="play">
+            <CaretRightOutlined />
+          </button>
+          <button @click="stop">
+            <PauseOutlined />
+          </button>
+        </div>
       </div>
-    </div>
     </swiper>
     <div class="search-wrap">
-
       <div class="search-area">
         <select name="" id="">
           <option value="all" selected>전체</option>
@@ -39,83 +35,50 @@
         <button class="search-btn"><img :src="search" alt=""></button>
       </div>
       <div class="total-search">
-        <button class="total-btn"><router-link :to="{path : '/TotalSearch.page'}" >상세검색</router-link></button>
+        <button class="total-btn" @click="fnMoveTo('TotalSearch')"><a href="">상세검색</a></button>
       </div>
     </div>
   </div>
   <div class="current-status mb-60">
     <div class="board w1500">
       <ul>
-        <li>
-          <div class="labeling"><img :src="icon1" alt=""><span>전체</span></div>
-          <div class="count all">520</div>
-        </li>
-        <li class="line"></li>
-        <li>
-          <div class="labeling"><img :src="icon2" alt=""><span>사진</span></div>
-          <div class="count">520</div>
-        </li>
-        <li class="line"></li>
-        <li>
-          <div class="labeling"><img :src="icon3" alt=""><span>영상</span></div>
-          <div class="count">520</div>
-        </li>
-        <li class="line"></li>
-        <li>
-          <div class="labeling"><img :src="icon4" alt=""><span>미디어</span></div>
-          <div class="count">520</div>
-        </li>
-        <li class="line"></li>
-        <li>
-          <div class="labeling"><img :src="icon5" alt=""><span>보도자료</span></div>
-          <div class="count">520</div>
-        </li>
+        <template v-for="(item, idx) of icons" :key="idx">
+          <li>
+            <div class="labeling"><img :src="item.url" :alt="item.name + '아이콘'"><span>{{ item.name }}</span></div>
+            <div :class="{ 'count': true, 'all': item.id === 'TOTAL' }">{{ item.rowCo }}</div>
+          </li>
+          <li class="line" v-if="idx < icons.length - 1"></li>
+        </template>
       </ul>
-      <div class="current-btn"> <button><span>기록물 현황</span><img :src="direct" alt=""></button></div>
+      <div class="current-btn"><button><span>기록물 현황</span><img :src="direct" alt=""></button></div>
     </div>
   </div>
   <div class="new-update w1500 mb-50">
     <div class="tabs">
       <ul class="">
-        <li v-for="(tab, index) in tabs" :key="index" class="tab-title" :class="{ active: selectedTab === index }"
-          @click="selectTab(index)">
-          <img :src="selectedTab === index ? tab.activeImage : tab.inactiveImage" :alt="tab.title" class="tab-icon" />
+        <li v-for="(tab, index) in tabs" :key="index" class="tab-title" :class="{ active: selectedTab === tab.id }" @click="selectTab(tab.id)">
+          <img :src="selectedTab === tab.id ? tab.activeImage : tab.inactiveImage" :alt="tab.title" class="tab-icon" />
           <p><b>{{ tab.title }}</b>&nbsp;기록물</p>
         </li>
       </ul>
-
       <div class="tab-content">
-        <div v-if="selectedTab === 0" class="content-wrap">
-          <router-link :to="{ path: '/' }" class="gopage">더보기</router-link>
-          <div class="new-pic">
-            <div v-for="(contentItem, index) in tabs[0].contentArray" :key="index" class="box-wrap">
-              <div class="box">
-                <img :src="contentItem.image" :alt="tabs[0].title" class="tab-image" />
-                <div class="info">
-                  <p>{{ contentItem.content }}</p>
-                  <span>{{ contentItem.date }}</span>
+        <template v-for="(tabContent, idx1) of tabContents" :key="idx1">
+          <div v-show="tabContent.id === selectedTab" class="content-wrap">
+            <router-link :to="{ path: '/' }" class="gopage">더보기</router-link>
+            <div class="new-pic">
+              <div v-for="(item, idx2) in tabContent.list" :key="idx2" class="box-wrap">
+                <div class="box">
+                  <img :src="item.hasOwnProperty('files') && item.files.length > 0 ? item.files[0].filePath : null" :alt="item.sj" class="tab-image" />
+                  <div class="info">
+                    <p>{{ item.sj }}</p>
+                    <span>{{ item.rgsde.slice(0, -6) }}</span>
+                  </div>
                 </div>
               </div>
             </div>
           </div>
-        </div>
-        <div v-if="selectedTab === 1" class="content-wrap">
-          <router-link :to="{ path: '/' }" class="gopage">더보기</router-link>
-          <div class="new-video">
-            <div v-for="(contentItem, index) in tabs[1].contentArray" :key="index" class="box-wrap">
-              <div class="box">
-                <img :src="contentItem.image" :alt="tabs[1].title" class="tab-image" />
-                <div class="info">
-                  <p>{{ contentItem.content }}</p>
-                  <span>{{ contentItem.date }}</span>
-                </div>
-              </div>
-            </div>
-          </div>
-        </div>
+        </template>
       </div>
-
-
     </div>
   </div>
   <div class="new-update w1500 mb-50 flex-sp-bw">
@@ -125,11 +88,11 @@
         <router-link :to="{ path: '/' }" class="gopage">더보기</router-link>
       </div>
       <div class="media-wrap">
-        <div v-for="(mediacontent, index) in mediacontent" :key="index" class="media-box">
-          <img :src="mediacontent.image" :alt="mediacontent.title" class="media-image" />
+        <div v-for="(mediaContent, index) in mediaContent" :key="index" class="media-box">
+          <img :src="mediaContent.url" :alt="mediaContent.title" class="media-image" />
           <div class="info">
-            <p>{{ mediacontent.content }}</p>
-            <span>{{ mediacontent.date }}</span>
+            <p>{{ mediaContent.content }}</p>
+            <span>{{ mediaContent.date }}</span>
           </div>
         </div>
       </div>
@@ -140,17 +103,17 @@
         <router-link :to="{ path: '/' }" class="gopage">더보기</router-link>
       </div>
       <ul>
-        <li v-for="(bodocontent, index) in bodocontent" :key="index" class="info">
-            <p>{{ bodocontent.content }}</p>
-            <span>{{ bodocontent.date }}</span>
+        <li v-for="(bodoContent, index) in bodoContent" :key="index" class="info">
+          <p>{{ bodoContent.content }}</p>
+          <span>{{ bodoContent.date }}</span>
         </li>
       </ul>
     </div>
   </div>
 </template>
-
 <script>
 import { ref } from 'vue';
+
 // Import Swiper Vue components
 import { Swiper, SwiperSlide } from 'swiper/vue';
 
@@ -161,9 +124,15 @@
 import 'swiper/css/navigation';
 import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
 
-
 // import required modules
 import { Autoplay, Pagination, Navigation } from 'swiper/modules';
+
+// 메인화면 조회
+import { findAllSttusesProc } from "../../../resources/api/main";
+
+// 유투브 유틸
+import { getYouTubeThumbnail } from '../../../resources/js/youtubeUtils';
+
 export default {
   components: {
     Swiper,
@@ -178,45 +147,53 @@
   },
   data() {
     return {
-      selectedTab: 0, // Set initial tab index to 0 (first tab)
+      selectedTab: "newPhoto", // Set initial tab index to 신규사진기록물 (first tab)
       tabs: [
         {
-          title: "신규 사진", activeImage: "client/resources/images/mCont_ico1_on.png", // Active tab image
+          id: "newPhoto",
+          title: "신규 사진",
+          activeImage: "client/resources/images/mCont_ico1_on.png", // Active tab image
           inactiveImage: "client/resources/images/mCont_ico1_off.png",
-          contentArray: [
-            { content: '문수사', date: '2025-03-18', image: 'client/resources/images/img1.png' },
-            { content: '신평벽화마을', date: '2025-03-19', image: 'client/resources/images/img2.png' },
-            { content: '박정희대통령역사자료관', date: '2025-03-20', image: 'client/resources/images/img3.png' }
-          ],
         },
         {
-          title: "신규 영상", activeImage: "client/resources/images/mCont_ico2_on.png", // Active tab image
+          id: "newVideo",
+          title: "신규 영상",
+          activeImage: "client/resources/images/mCont_ico2_on.png", // Active tab image
           inactiveImage: "client/resources/images/mCont_ico2_off.png",
-          contentArray: [
-            { content: '제목1', date: '2025-03-18', image: 'client/resources/images/img1.png' },
-            { content: '제목2', date: '2025-03-19', image: 'client/resources/images/img2.png' },
-            { content: '제목3', date: '2025-03-20', image: 'client/resources/images/img3.png' }
-          ],
         },
       ],
-      mediacontent: [
-        { content: '문수사', date: '2025-03-18', image: 'client/resources/images/img4.png' },
-        { content: '신평벽화마을', date: '2025-03-19', image: 'client/resources/images/img5.png' },
-      ],
-      bodocontent: [
-        { content: '구미시장 우리동네 온(溫)데이', date: '2025-03-18',  },
-        { content: "구미 청년 창업, 지난해 성과 '쏠쏠'…올해도 새 도전자", date: '2025-03-19', },
-        { content: "구미시여성단체협의회 신경은 회장 부부, 1,000만 원", date: '2025-03-19', },
-        { content: "초보 기술 공무원도 전문가로! 구미시, 실무 역량 향상", date: '2025-03-19', },
-        { content: "구미시, 청렴도 1등급 목표…반부패·청렴 시책 강화", date: '2025-03-19', },
-      ],
+      tabContents: [], // 신규 사진, 영상 기록물
+      mediaContent: [], // 신규 미디어 영상
+      bodoContent: [], // 신규 보도자료
       direct: 'client/resources/images/direct-btn.png',
       search: 'client/resources/images/icon/search.png',
-      icon1: 'client/resources/images/icon/icon1.png',
-      icon2: 'client/resources/images/icon/icon2.png',
-      icon3: 'client/resources/images/icon/icon3.png',
-      icon4: 'client/resources/images/icon/icon4.png',
-      icon5: 'client/resources/images/icon/icon5.png',
+      icons: [
+        {
+          id: "TOTAL",
+          name: "전체",
+          url: 'client/resources/images/icon/icon1.png',
+        },
+        {
+          id: "dcry_photo",
+          name: "사진",
+          url: 'client/resources/images/icon/icon2.png',
+        },
+        {
+          id: "dcry_vido",
+          name: "영상",
+          url: 'client/resources/images/icon/icon3.png',
+        },
+        {
+          id: "media_vido",
+          name: "미디어",
+          url: 'client/resources/images/icon/icon4.png',
+        },
+        {
+          id: "nes_dta",
+          name: "보도자료",
+          url: 'client/resources/images/icon/icon5.png',
+        },
+      ],
       slides: [
         { img: 'client/resources/images/visual.png', alt: 'Slide 1' },
         { img: 'client/resources/images/visual.png', alt: 'Slide 2' },
@@ -225,9 +202,12 @@
       ],
       currentSlide: 1,   // To track the current slide
       totalSlides: 3,
-      
+
       navigation: true, // Enable navigation buttons (prev/next)
     };
+  },
+  created() {
+    this.fnFindAllSttuses();
   },
   mounted() {
     // Store the swiper instance when it's ready
@@ -261,8 +241,68 @@
         this.currentSlide = swiper.realIndex + 1; // Get current slide (1-based index)
       }
     },
+    // 메인화면 정보 조회
+    async fnFindAllSttuses() {
+      try {
+        const response = await findAllSttusesProc();
+        if (response.data.status == 200) {
+          let dcrySttuses = response.data.data.dcrySttuses;
+          dcrySttuses.forEach(item => {
+            const matchingIcon = this.icons.find(icon => icon.id === item.iemNm);
+            if (matchingIcon) matchingIcon.rowCo = item.rowCo;
+          });
+
+          let photoDcrys = response.data.data.photoDcrys;
+          this.tabContents.push({ id: 'newPhoto', list: photoDcrys });
+
+          let vidoDcrys = response.data.data.vidoDcrys;
+          this.tabContents.push({ id: 'newVideo', list: vidoDcrys });
+
+          let mediaVidos = response.data.data.mediaVidos;
+          this.mediaContent = this.onChangeList(mediaVidos);
+
+          let nesDtas = response.data.data.nesDtas;
+          this.bodoContent = this.onChangeList(nesDtas);
+        } else {
+          alert(response.data.message);
+        }
+      } catch (error) {
+        alert("에러가 발생했습니다.\n시스템관리자에게 문의하세요.");
+      }
+    },
+    // 목록 변환
+    onChangeList(list) {
+      let resultArr = [];
+
+      for (let item of list) {
+        let url = null;
+
+        if (item.hasOwnProperty('files')) {
+          if (item.files.length > 0) {
+            url = item.files[0].filePath
+          }
+        } else {
+          url = getYouTubeThumbnail(item.link);
+        }
+
+        resultArr.push({
+          content: item.sj,
+          date: item.rgsde.slice(0, -6),
+          url: url
+        });
+      }
+
+      return resultArr;
+    },
+
+    // 페이지 이동
+    fnMoveTo(page) {
+      this.$router.push({
+        name: page,
+        query: {},
+      });
+    }
   },
 };
 </script>
-
 <style scoped></style>
server/modules/web/server.js
--- server/modules/web/server.js
+++ server/modules/web/server.js
@@ -140,4 +140,4 @@
   response.redirect('/notFound.page'); // 에러 페이지로 유도
   let message = `[Error:${errorCode}] ${request.url}/n ${error.stack}/n`;
   Logger.logging(message);
-});
+});
(파일 끝에 줄바꿈 문자 없음)
Add a comment
List