박정하 박정하 03-27
250327 박정하 사진기록물 등록, 수정, 상세 조회 수정
@8392289202d0c3bc1e8dea6bfe3655d91d41217e
client/resources/api/category.js
--- client/resources/api/category.js
+++ client/resources/api/category.js
@@ -18,4 +18,4 @@
 // 카테고리 정보 등록
 export const saveCategory = insertCtgryDTO => {
   return apiClient.post(`/category/saveCategory.json`, insertCtgryDTO);
-}
+}
(파일 끝에 줄바꿈 문자 없음)
client/resources/api/dcry.js
--- client/resources/api/dcry.js
+++ client/resources/api/dcry.js
@@ -1,14 +1,26 @@
-import {apiClient} from "./index";
+import { apiClient, fileClient } from "./index";
 
 // 기록물 목록 조회
 export const findAllDatas = (searchReqDTO) => {
-  return apiClient.get(`/main/findAllDatas.json`, { params: searchReqDTO });
+  return apiClient.get(`/dcry/findAllDatas.json`, { params: searchReqDTO });
 }
 
 // 기록물 상세 조회
+export const findDcryProc = (dcryId) => {
+  return apiClient.get(`/dcry/${dcryId}/findDcry.json`);
+}
 
 // 기록물 등록
+export const saveDcry = (formData) => {
+  return fileClient.post(`/dcry/saveDcry.file`, formData);
+}
 
 // 기록물 수정
+export const updateDcry = (formData) => {
+  return fileClient.put(`/dcry/updateDcry.file`, formData);
+}
 
-// 기록물 삭제
(파일 끝에 줄바꿈 문자 없음)
+// 기록물 삭제
+export const deleteDcryProc = (dcryId) => {
+  return apiClient.put(`/dcry/${dcryId}/deleteDcry.json`);
+}
(파일 끝에 줄바꿈 문자 없음)
 
client/resources/api/file.js (added)
+++ client/resources/api/file.js
@@ -0,0 +1,11 @@
+import { apiClient } from "./index";
+
+// 파일 다운로드
+export const fileDownloadProc = (fileId) => {
+  return apiClient.get(`/file/${fileId}/fileDownload.json`, { responseType: 'blob' });
+}
+
+// 파일 다운로드
+export const multiFileDownloadProc = (fileIds) => {
+  return apiClient.get(`/file/multiFileDownload.json`, { responseType: 'blob', params: { fileIds } });
+}(파일 끝에 줄바꿈 문자 없음)
 
client/resources/js/cmmnPlugin.js (added)
+++ client/resources/js/cmmnPlugin.js
@@ -0,0 +1,26 @@
+export default {
+  install(Vue) {
+    // 빈값체크
+    Vue.config.globalProperties.$isEmpty = function (data) {
+      if (data === undefined || data === null || data === "" || data.length === 0 || (data.constructor == Object && Object.keys(data).length === 0)) {
+        if ((typeof data) === "number") {
+          return false
+        } else {
+          return true;
+        }
+      } else {
+        return false;
+      }
+    }
+
+    /**
+     * FROM: 2025-03-25 13:32
+     * TO  : 2025.03.25
+     */
+    Vue.config.globalProperties.$dotFormatDate = (dateString) => {
+      if (!dateString) return '';
+      const dateOnly = dateString.split(' ')[0];
+      return dateOnly.replace(/-/g, '.');
+    }
+  }
+}(파일 끝에 줄바꿈 문자 없음)
client/views/component/EditorComponent.vue
--- client/views/component/EditorComponent.vue
+++ client/views/component/EditorComponent.vue
@@ -6,9 +6,7 @@
  * This configuration was generated using the CKEditor 5 Builder. You can modify it anytime using this link:
  * https://ckeditor.com/ckeditor-5/builder/#installation/NoNgNARATAdAzPCkCMAGKIAcmCsrdwghzICcUqqyyALESDnKXFKW6djTdkhANYB7JKjDBkYEZIlhkAXUggAZmxwBjVRFlA==
  */
-
 import { Ckeditor } from '@ckeditor/ckeditor5-vue';
-
 import {
   ClassicEditor,
   Alignment,
@@ -21,8 +19,6 @@
   FontFamily,
   FontSize,
   HorizontalLine,
-  ImageEditing,
-  ImageUtils,
   Indent,
   IndentBlock,
   Italic,
@@ -41,16 +37,12 @@
   TextPartLanguage,
   Underline,
 } from 'ckeditor5';
-
 import translations from 'ckeditor5/translations/ko.js';
-
 import 'ckeditor5/ckeditor5.css';
-
 /**
  * Create a free account with a trial: https://portal.ckeditor.com/checkout?plan=free
  */
 const LICENSE_KEY = 'GPL'; // or <YOUR_LICENSE_KEY>.
-
 export default {
   name: 'EditorComponent',
   components: {
@@ -78,7 +70,6 @@
       if (!this.isLayoutReady) {
         return null;
       }
-
       return {
         toolbar: {
           items: [
@@ -118,8 +109,6 @@
           FontFamily,
           FontSize,
           HorizontalLine,
-          ImageEditing,
-          ImageUtils,
           Indent,
           IndentBlock,
           Italic,
@@ -164,7 +153,6 @@
   },
   methods: {
     updateContents(data) {
-      // 에디터 내용이 변경될 때 부모 컴포넌트에 알림
       this.$emit('update:contents', data);
     }
   }
client/views/component/List/CardViewList.vue
--- client/views/component/List/CardViewList.vue
+++ client/views/component/List/CardViewList.vue
@@ -24,7 +24,7 @@
           </div>
           <div class="date">
             <ul>
-              <li>생산연도 <b>{{ item.prdctnYear }}</b></li>
+              <li>생산연도 <b>{{ item.prdctnYear ? item.prdctnYear : '-' }}</b></li>
               <li>|</li>
               <li>등록일 <b>{{ item.rgsde }}</b></li>
             </ul>
 
client/views/component/ViewerComponent.vue (added)
+++ client/views/component/ViewerComponent.vue
@@ -0,0 +1,15 @@
+<template>
+  <div class="ck-content" v-html="content"></div>
+</template>
+<script>
+import 'ckeditor5/ckeditor5.css';
+export default {
+  name: 'ViewerComponent',
+  props: {
+    content: {
+      type: String,
+      default: ''
+    }
+  }
+};
+</script>(파일 끝에 줄바꿈 문자 없음)
client/views/component/modal/CategorySelectModal.vue
--- client/views/component/modal/CategorySelectModal.vue
+++ client/views/component/modal/CategorySelectModal.vue
@@ -6,8 +6,9 @@
         <button @click="closeModal" class="closebtn">✕</button>
       </div>
       <div class="modal-search flex-center mb-20">
-        <input type="text" placeholder="카테고리명을 입력하세요.">
-        <button class="search-btn"><img :src="searchicon" alt="">
+        <input type="text" placeholder="카테고리명을 입력하세요." v-model="searchReqDTO.searchText" @keyup.enter="fnFindAllCategory">
+        <button type="button" class="search-btn" @click="fnFindAllCategory">
+          <img :src="searchicon" alt="">
           <p>검색</p>
         </button>
       </div>
@@ -19,27 +20,28 @@
           </tr>
         </thead>
         <tbody>
-          <tr v-for="item in items" :key="item.id">
-            <!-- Category 칼럼 -->
-            <td> {{ item.category }} </td>
-            <!-- Checkbox 칼럼 -->
-            <td>
-              <input type="checkbox" v-model="item.selected" />
-            </td>
+          <template v-if="list.length > 0">
+            <tr v-for="(item, idx) of list" :key="idx" :class="{ 'selected': item.isSelected }">
+              <td>{{ item.ctgryNm }}</td>
+              <td><input type="checkbox" :value="item" v-model="selectedList" /></td>
+            </tr>
+          </template>
+          <tr v-else>
+            <td colspan="2">조건에 맞는 카테고리가 존재하지 않습니다.</td>
           </tr>
         </tbody>
       </table>
-      <div class="flex-end mb-30"><button class="register-b " @click="registerCategories">등록</button></div>
+      <div class="flex-end mb-30"><button class="register-b" @click="fnAddCtgries">등록</button></div>
       <div class="pagination">
         <!-- Previous and Next Page Buttons -->
         <button>
           <DoubleLeftOutlined />
         </button>
-        <button @click="previousPage" :disabled="currentPage === 1">
+        <button @click="previousPage" :disabled="searchReqDTO.currentPage === 1">
           <LeftOutlined />
         </button>
         <button class="page-number clicked">1</button>
-        <button @click="nextPage" :disabled="currentPage === totalPages">
+        <button @click="nextPage" :disabled="searchReqDTO.currentPage === searchReqDTO.recordSize">
           <RightOutlined />
         </button>
         <button>
@@ -51,59 +53,109 @@
 </template>
 <script>
 import { DoubleLeftOutlined, LeftOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons-vue';
+// API
+import { findAllCategoryProc } from '@/resources/api/category';
 
 export default {
   name: 'CategorySelectModal',
+
   components: {
     DoubleLeftOutlined,
     LeftOutlined,
     RightOutlined,
     DoubleRightOutlined
   },
+
   props: {
     selectedCtgries: {
       type: Array,
       default: () => [],
     }
   },
+
   data() {
     return {
-      items: [
-        { id: 1, category: '카테고리 1', selected: false },
-        { id: 2, category: '카테고리 2', selected: false },
-        { id: 3, category: '카테고리 3', selected: false },
-      ],
+      // ICON
       searchicon: 'client/resources/images/icon/search.png',
-      currentPage: 1,
-      totalPages: 1
+
+      // 검색 객체
+      searchReqDTO: {
+        currentPage: 1,
+        recordSize: 10,
+        pageSize: 10,
+        totalRecordCount: 5,
+        totalPageCount: 1,
+        startPage: 1,
+        endPage: 1,
+        limitStart: 0,
+        existPrevPage: false,
+        existNextPage: false,
+        searchType: 'nm',
+        searchText: null,
+        useAt: 'Y',
+        selectedCtgryIds: null,
+      },
+
+      list: [], // 카테고리 목록
+      selectedList: [] // 선택한 카테고리 목록
     };
   },
+
+  created() {
+    this.fnFindAllCategory(); // 목록 조회
+  },
+
   methods: {
+    // 목록 조회
+    async fnFindAllCategory() {
+      try {
+        if (this.selectedCtgries.length > 0) {
+          this.searchReqDTO.selectedCtgryIds = this.selectedCtgries.map(item => item.ctgryId).join(',');
+        }
+        console.log('req: ', this.searchReqDTO);
+
+        const response = await findAllCategoryProc(this.searchReqDTO);
+        console.log('res: ', response.data.data.ctgry);
+
+        let ctgries = response.data.data.ctgry;
+        for (let item of ctgries) {
+          item.isSelected = false;
+          for (let ctgry of this.selectedCtgries) {
+            if (item.ctgryId === ctgry.ctgryId) {
+              item.isSelected = true;
+              break;
+            }
+          }
+        }
+
+        this.list = ctgries;
+      } catch (error) {
+        alert('조회중 오류가 발생했습니다.');
+
+        if (error.response) {
+          alert(error.response.data.message);
+        }
+        console.error(error.message);
+      }
+    },
+
     closeModal() {
       this.$emit('toggleModal');
     },
-    registerCategories() {
-      // 선택된 카테고리 목록
-      const selectedCategories = this.items
-        .filter(item => item.selected)
-        .map(item => item.category);
 
-      // 선택된 카테고리 ID 목록
-      const selectedIds = this.items
-        .filter(item => item.selected)
-        .map(item => item.id);
-
-      // 부모 컴포넌트로 전달하고 모달 닫기
-      this.$emit('toggleModal', selectedIds);
+    fnAddCtgries() {
+      this.$emit('addCtgries', this.selectedList);
     },
+
     previousPage() {
-      if (this.currentPage > 1) {
-        this.currentPage--;
+      if (this.searchReqDTO.currentPage > 1) {
+        this.searchReqDTO.currentPage--;
       }
     },
+
     nextPage() {
-      if (this.currentPage < this.totalPages) {
-        this.currentPage++;
+      if (this.searchReqDTO.currentPage < this.searchReqDTO.recordSize) {
+        this.searchReqDTO.currentPage++;
       }
     }
   }
client/views/index.js
--- client/views/index.js
+++ client/views/index.js
@@ -3,11 +3,13 @@
 import App from "./App.vue";
 import Store from "./pages/AppStore.js";
 import Router from "./pages/AppRouter.js";
+import cmmnPlugin from '@/resources/js/cmmnPlugin.js'
 
 async function initVueApp() {
   const vue = createApp(App)
     .use(Router)
     .use(Store)
+    .use(cmmnPlugin)
   vue.config.devtools = true;
   vue.mount("#root");
 }
client/views/pages/user/PicHistoryDetail.vue
--- client/views/pages/user/PicHistoryDetail.vue
+++ client/views/pages/user/PicHistoryDetail.vue
@@ -1,171 +1,253 @@
 <template>
-    <div class="content">
-        <div class="sub-title-area mb-30">
-            <h2>사진 기록물</h2>
-            <div class="breadcrumb-list">
-                <ul>
-                    <li><img :src="homeicon" alt="Home Icon">
-                        <p>기록물</p>
-                    </li>
-                    <li><img :src="righticon" alt=""></li>
-                    <li>사진 기록물</li>
-                </ul>
-            </div>
-        </div>
-        <form action="" class="gallery-form mb-40">
-            <dl class="mb-20">
-                <dd>
-                    <p>사진 기록물 제목1
-                    </p>
-                    <div class="date flex align-center">
-                        <img :src="calendaricon" alt="">
-                        <span>2025.02.28</span>
-                    </div>
-                </dd>
-
-            </dl>
-            <div>
-                <div class="gallery">
-                    <div class="main-swiper">
-                        <swiper :style="{
-                            '--swiper-navigation-color': '#fff',
-                            '--swiper-pagination-color': '#fff',
-                        }" :loop="true" :spaceBetween="10" :thumbs="{ swiper: thumbsSwiper }" :modules="modules"
-                            class="mySwiper2">
-                            <swiper-slide v-for="(item, index) in slides" :key="index">
-                                <img :src="item.img" :alt="item.alt" />
-                            </swiper-slide>
-                        </swiper>
-                    </div>
-                    <div class="thumbnail">
-                        <swiper @swiper="setThumbsSwiper" :spaceBetween="20" :slidesPerView="4" :freeMode="true"
-                            :watchSlidesProgress="true" :modules="modules" :navigation="true" class="mySwiper">
-                            <swiper-slide v-for="(item, index) in slides" :key="index">
-                                <img :src="item.img" :alt="item.alt" />
-                            </swiper-slide>
-                        </swiper>
-                    </div>
-                </div>
-                <div class="btn-group">
-                    <button class="select-down">선택 다운로드</button>
-                    <button class="all-down">전체 다운로드</button>
-                </div>
-            </div>
-        </form>
-
-        <h3>내용</h3>
-        <form action="" class=" info-form mb-50">
-            <dl>
-                <dd>
-                    <p> 대한민국 최대의 내륙 산업단지를 보유하고, 서울로부터 277km, 부산으로부터 167km 거리에 있으며, 면적은 615㎢로 경상북도 전체 면적의 3.2%에 달합니다. 인구는
-                        41만 명이고, 선산읍, 고아읍, 산동읍을 비롯한 3읍, 5면, 17개 동으로 구성되어 있습니다.</p>
-
-                </dd>
-            </dl>
-        </form>
-
-        <h3>기본정보</h3>
-        <form action="" class="info-form mb-50">
-            <dl>
-                <dd class="mb-20">
-                    <img :src="addressicon" alt="">
-                    <span>주소</span>
-                    <p>경상북도 구미시 송정대로 55</p>
-                </dd>
-                <dd class="mb-20">
-                    <img :src="yearicon" alt="">
-                    <span>생산연도</span>
-                    <p>2017</p>
-
-                </dd>
-                <dd>
-                    <img :src="categoryicon" alt="">
-                    <span>카테고리</span>
-                    <ul class="category">
-                        <li v-if="resultitem.category1" class="category1">카테고리1</li>
-                        <li v-if="resultitem.category2" class="category2">카테고리2</li>
-                    </ul>
-
-                </dd>
-
-            </dl>
-        </form>
-        <div class="btn-group flex-center">
-            <button class="red-line " type="button" @click="fnDeleteUser">삭제</button>
-            <button class="blue-line " type="button" @click="fnUpdateUser">수정</button>
-            <button class="gray-line-bg " type="button" @click="fnUpdateUser">목록</button>
-        </div>
+  <div class="content">
+    <div class="sub-title-area mb-30">
+      <h2>사진 기록물</h2>
+      <div class="breadcrumb-list">
+        <ul>
+          <li><img :src="homeicon" alt="Home Icon">
+            <p>기록물</p>
+          </li>
+          <li><img :src="righticon" alt=""></li>
+          <li>사진 기록물</li>
+        </ul>
+      </div>
     </div>
+    <div class="gallery-form mb-40">
+      <dl class="mb-20">
+        <dd>
+          <p>{{ dcry.sj }}</p>
+          <div class="date flex align-center">
+            <img :src="calendaricon" alt="">
+            <span>{{ $dotFormatDate(dcry.rgsde) }}</span>
+          </div>
+        </dd>
+      </dl>
+      <div>
+        <div class="gallery">
+          <div class="main-swiper">
+            <swiper :style="{ '--swiper-navigation-color': '#fff', '--swiper-pagination-color': '#fff' }" :loop="true" :spaceBetween="10" :thumbs="{ swiper: thumbsSwiper }" :modules="modules" class="mySwiper2">
+              <swiper-slide v-for="(item, idx) of dcry.files" :key="idx">
+                <img :src="item.filePath" :alt="item.fileNm" />
+              </swiper-slide>
+            </swiper>
+          </div>
+          <div class="thumbnail">
+            <swiper @swiper="setThumbsSwiper" :spaceBetween="20" :slidesPerView="4" :freeMode="true" :watchSlidesProgress="true" :modules="modules" :navigation="true" class="mySwiper">
+              <swiper-slide v-for="(item, idx) of dcry.files" :key="idx">
+                <input type="checkbox" :id="'photo_' + idx" :value="item.fileId" v-model="selectedFiles" />
+                <img :src="item.filePath" :alt="item.fileNm" />
+              </swiper-slide>
+            </swiper>
+          </div>
+        </div>
+        <div class="btn-group">
+          <button class="select-down" @click="fnDownload('selected')">선택 다운로드</button>
+          <button class="all-down" @click="fnDownload('all')">전체 다운로드</button>
+        </div>
+      </div>
+    </div>
+    <h3>내용</h3>
+    <div class="info-form mb-50">
+      <dl>
+        <dd>
+          <ViewerComponent :content="dcry.cn" />
+        </dd>
+      </dl>
+    </div>
+    <h3>기본정보</h3>
+    <div class="info-form mb-50">
+      <dl>
+        <dd class="mb-20">
+          <img :src="addressicon" alt="">
+          <span>주소</span>
+          <p>{{ dcry.adres }}</p>
+        </dd>
+        <dd class="mb-20">
+          <img :src="yearicon" alt="">
+          <span>생산연도</span>
+          <p>{{ $dotFormatDate(dcry.prdctnYear) }}</p>
+        </dd>
+        <dd>
+          <img :src="categoryicon" alt="">
+          <span>카테고리</span>
+          <ul class="category">
+            <li v-for="(item, idx) of dcry.ctgrys" :key="idx" class="category">{{ item.ctgryNm }}</li>
+          </ul>
+        </dd>
+      </dl>
+    </div>
+    <div class="btn-group flex-center">
+      <button class="red-line " type="button" @click="fnDelete">삭제</button>
+      <button class="blue-line " type="button" @click="fnMoveTo('PicHistoryInsert', pageId)">수정</button>
+      <button class="gray-line-bg " type="button" @click="fnMoveTo('PicHistorySearch')">목록</button>
+    </div>
+  </div>
 </template>
-
 <script>
-import axios from "axios";
 import { ref } from 'vue';
-import { updateUsers, logOutProc, updatePassword } from "../../../resources/api/user"
 // Import Swiper Vue components
 import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue';
 import { Swiper, SwiperSlide } from 'swiper/vue';
-
 // Import Swiper styles
 import 'swiper/css';
-
 import 'swiper/css/free-mode';
 import 'swiper/css/navigation';
 import 'swiper/css/thumbs';
-
 // import required modules
 import { FreeMode, Navigation, Thumbs } from 'swiper/modules';
+// COMPONENT
+import ViewerComponent from '../../component/ViewerComponent.vue';
+// API
+import { findDcryProc, deleteDcryProc } from '@/resources/api/dcry';
+import { fileDownloadProc, multiFileDownloadProc } from '@/resources/api/file';
 
 export default {
-    components: {
-        PauseOutlined,
-        CaretRightOutlined,
-        Swiper,
-        SwiperSlide,
-    },
-    setup() {
-        const thumbsSwiper = ref(null);
+  components: {
+    PauseOutlined,
+    CaretRightOutlined,
+    Swiper,
+    SwiperSlide,
+    ViewerComponent,
+  },
+  setup() {
+    const thumbsSwiper = ref(null);
 
-        const setThumbsSwiper = (swiper) => {
-            thumbsSwiper.value = swiper;
-        };
+    const setThumbsSwiper = (swiper) => {
+      thumbsSwiper.value = swiper;
+    };
 
-        return {
-            thumbsSwiper,
-            setThumbsSwiper,
-            modules: [FreeMode, Navigation, Thumbs],
-        };
-    },
-    data() {
-        return {
-            resultitem: {
-                category1: true,
-                category2: true,
-            },
-            calendaricon: 'client/resources/images/icon/calendaricon.png',
-            homeicon: 'client/resources/images/icon/home.png',
-            erroricon: 'client/resources/images/icon/error.png',
-            righticon: 'client/resources/images/icon/right.png',
-            addressicon: 'client/resources/images/icon/addressicon.png',
-            yearicon: 'client/resources/images/icon/yearicon.png',
-            categoryicon: 'client/resources/images/icon/categoryicon.png',
-            slides: [
-                { img: 'client/resources/images/visual.png', alt: 'Slide 1' },
-                { img: 'client/resources/images/visual.png', alt: 'Slide 2' },
-                { img: 'client/resources/images/visual.png', alt: 'Slide 3' },
-                { img: 'client/resources/images/visual.png', alt: 'Slide 3' },
-                { img: 'client/resources/images/visual.png', alt: 'Slide 3' },
-                { img: 'client/resources/images/visual.png', alt: 'Slide 3' },
-                // Add more slides as needed
-            ],
+    return {
+      thumbsSwiper,
+      setThumbsSwiper,
+      modules: [FreeMode, Navigation, Thumbs],
+    };
+  },
+  data() {
+    return {
+      // ICON
+      calendaricon: 'client/resources/images/icon/calendaricon.png',
+      homeicon: 'client/resources/images/icon/home.png',
+      erroricon: 'client/resources/images/icon/error.png',
+      righticon: 'client/resources/images/icon/right.png',
+      addressicon: 'client/resources/images/icon/addressicon.png',
+      yearicon: 'client/resources/images/icon/yearicon.png',
+      categoryicon: 'client/resources/images/icon/categoryicon.png',
 
-        };
+      pageId: null,
+      dcry: {},
+      selectedFiles: [],
+    };
+  },
+  methods: {},
+  watch: {},
+  computed: {},
+  created() {
+    this.pageId = this.$route.query.id;
+    if (this.pageId === null) {
+      alert("게시물 존재하지 않습니다.");
+      return;
+    }
+
+    this.fnFindDcry(); // 상세 조회
+  },
+  mounted() { },
+  methods: {
+    // 상세 조회
+    async fnFindDcry() {
+      try {
+        const response = await findDcryProc(this.pageId);
+        this.dcry = response.data.data.dcry;
+      } catch (error) {
+        alert('조회중 오류가 발생했습니다.');
+
+        if (error.response) {
+          alert(error.response.data.message);
+        }
+        console.error(error.message);
+      }
     },
-    methods: {
+
+    // 파일 다운로드
+    async fnDownload(type) {
+      // 유효성 검사
+      if (this.selectedFiles.length === 1) {
+        alert("파일을 1개 이상 선택하거나 전체 다운로드를 클릭해주세요.");
+        return;
+      }
+
+      let url = null;
+      let link = null;
+
+      try {
+        // 파일 ID 수집
+        let fileIds = null;
+        if (type === 'selected') {
+          fileIds = this.selectedFiles[0];
+        } else if (type === 'all') {
+          fileIds = this.dcry.files.map(file => file.fileId).join(',');
+        }
+
+        let isMultiple = fileIds.length > 1;
+        const response = isMultiple ? await multiFileDownloadProc(fileIds) : await fileDownloadProc(fileIds);
+
+        // 파일명 조회
+        let filename = isMultiple ? 'downloadFile.zip' : 'downloadFile.bin';
+        const filenameRegex = /file[Nn]ame[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
+        const matches = filenameRegex.exec(response.headers['content-disposition']);
+        if (matches != null && matches[1]) {
+          filename = matches[1].replace(/['"]/g, '');
+        }
+
+        // 파일 다운로드 생성
+        url = window.URL.createObjectURL(new Blob([response.data]));
+        link = document.createElement('a');
+        link.href = url;
+        link.setAttribute('download', filename);
+        document.body.appendChild(link);
+        link.click();
+      } catch (error) {
+        alert('파일 다운로드 중 오류가 발생했습니다.');
+      } finally {
+        // 리소스 정리
+        setTimeout(() => {
+          if (url) {
+            window.URL.revokeObjectURL(url);
+          }
+          if (link && link.parentNode) {
+            document.body.removeChild(link);
+          }
+        }, 100);
+      }
     },
-    watch: {},
-    computed: {
+
+    // 삭제
+    async fnDelete() {
+      let isCheck = confirm("해당 페이지를 삭제하시겠습니까?");
+      if (!isCheck) {
+        return;
+      }
+
+      try {
+        const response = await deleteDcryProc(this.pageId);
+        alert('해당 페이지를 삭제했습니다.');
+
+        this.fnMoveTo('PicHistorySearch');
+      } catch (error) {
+        if (error.response) {
+          alert(error.response.data.message);
+        }
+        console.error(error.message);
+      }
     },
-    mounted() { },
+
+    // 페이지 이동
+    fnMoveTo(page, id) {
+      if (this.$isEmpty(id)) {
+        this.$router.push({ name: page });
+      } else {
+        this.$router.push({ name: page, query: { id: id } });
+      }
+    },
+  },
 };
 </script>
(파일 끝에 줄바꿈 문자 없음)
client/views/pages/user/PicHistoryInsert.vue
--- client/views/pages/user/PicHistoryInsert.vue
+++ client/views/pages/user/PicHistoryInsert.vue
@@ -15,34 +15,33 @@
     <form class="insert-form mb-50">
       <dl>
         <dd>
-          <label for="id" class="require">제목</label>
-          <div class="wfull"><input type="text" id="id" placeholder="제목을 입력하세요."></div>
+          <label for="sj" class="require">제목</label>
+          <div class="wfull"><input type="text" id="sj" placeholder="제목을 입력하세요." v-model="reqDTO.sj"></div>
         </dd>
         <div class="hr"></div>
         <dd>
-          <label for="year">생산연도</label>
-          <input type="text" id="year" placeholder="생산연도를 입력하세요">
+          <label for="prdctnYear">생산연도</label>
+          <input type="text" id="prdctnYear" placeholder="생산연도를 입력하세요" v-model="reqDTO.prdctnYear">
         </dd>
         <div class="hr"></div>
         <dd>
-          <label for="address">주소</label>
-          <div class="wfull"><input type="text" id="address" placeholder="주소를 입력하세요"></div>
+          <label for="adres">주소</label>
+          <div class="wfull"><input type="text" id="adres" placeholder="주소를 입력하세요" v-model="reqDTO.adres"></div>
         </dd>
         <div class="hr"></div>
         <dd>
           <label for="text">내용</label>
           <div class="wfull">
-            <EditorComponent :contents="insertDTO.cn" />
+            <EditorComponent v-model:contents="reqDTO.cn" />
           </div>
         </dd>
         <div class="hr"></div>
         <dd>
           <label for="category" class="flex align-center">
-            <p>카테고리</p><button type="button" class="category-add" @click="openModal">추가하기</button>
+            <p>카테고리</p><button type="button" class="category-add" @click="fnToggleModal">추가하기</button>
           </label>
           <ul class="category">
-            <li v-for="(category, index) in selectedCtgries" :key="index"> {{ category }} <button type="button" class="cancel" @click="removeCategory(index)"><b>✕</b></button>
-            </li>
+            <li v-for="(item, idx) of selectedCtgries" :key="idx">{{ item.ctgryNm }} <button type="button" class="cancel" @click="fnDelCtgry(item.ctgryId)"><b>✕</b></button></li>
           </ul>
         </dd>
         <div class="hr"></div>
@@ -54,22 +53,32 @@
               <div class="invalid-feedback"><img :src="erroricon" alt=""><span>첨부파일은 건당 최대 10GB를 초과할 수 없습니다.</span></div>
             </li>
             <li class="file-insert">
-              <input type="file" id="fileInput" class="file-input" multiple @change="showFileNames" accept="image/jpeg,image/png,image/gif,image/jpg">
+              <input type="file" id="fileInput" class="file-input" multiple accept="image/jpeg,image/png,image/gif,image/jpg" @change="handleFileSelect">
               <label for="fileInput" class="file-label mb-20" @dragover.prevent="handleDragOver" @dragleave.prevent="handleDragLeave" @drop.prevent="handleDrop" :class="{ 'drag-over': isDragging }">
-                <div class="flex-center align-center"><img :src="fileicon" alt="">
+                <div class="flex-center align-center">
+                  <img :src="fileicon" alt="">
                   <p>파일첨부하기</p>
                 </div>
                 <p>파일을 첨부하시려면 이 영역으로 파일을 끌고 오거나 클릭해주세요</p>
               </label>
               <p class="mb-10">파일목록</p>
               <div id="fileNames" class="file-names">
-                <span v-if="fileNames.length === 0">선택된 파일이 없습니다.</span>
-                <div v-for="(file, index) in fileNames" :key="index" class="flex-sp-bw mb-5 file-wrap">
+                <div v-if="reqDTO.files.length === 0 && multipartFiles.length === 0">선택된 파일이 없습니다.</div>
+                <!-- 새로 추가된 파일 목록 -->
+                <div v-for="(file, idx) of multipartFiles" :key="idx" class="flex-sp-bw mb-5 file-wrap">
                   <div class="file-name">
-                    <img :src="file.icon" alt="fileicon">
+                    <img src="/client/resources/images/icon/imgicon.png" alt="fileicon">
                     <p>{{ file.name }}</p>
                   </div>
-                  <button type="button" class="cancel" @click="removeFile(index)"><b>✕</b></button>
+                  <button type="button" class="cancel" @click="fnDelFile('new', idx)"><b>✕</b></button>
+                </div>
+                <!-- 기존 등록된 파일 목록 -->
+                <div v-for="(file, idx) of reqDTO.files" :key="idx" class="flex-sp-bw mb-5 file-wrap">
+                  <div class="file-name">
+                    <img src="/client/resources/images/icon/imgicon.png" alt="fileicon">
+                    <p>{{ file.fileNm }}</p>
+                  </div>
+                  <button type="button" class="cancel" @click="fnDelFile('old', file.fileId)"><b>✕</b></button>
                 </div>
               </div>
             </li>
@@ -79,18 +88,21 @@
     </form>
     <div class="btn-group flex-center">
       <button type="button" class="cancel" @click="fnMoveTo('PicHistorySearch')">취소</button>
-      <button type="button" class="register" @click="submitForm">등록</button>
+      <button type="button" class="register" @click="submitForm">
+        <span v-if="$isEmpty(pageId)">등록</span>
+        <span v-else>수정</span>
+      </button>
     </div>
   </div>
-  <CategorySelectModal v-if="isModalOpen" :selectedCtgries="selectedCtgries" @toggleModal="fnToggleModal" />
+  <CategorySelectModal v-if="isModalOpen" :selectedCtgries="selectedCtgries" @toggleModal="fnToggleModal" @addCtgries="fnAddCtgries" />
 </template>
 <script>
-import axios from 'axios';
-import apiClient from '../../../resources/api';
 import { DoubleLeftOutlined, LeftOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons-vue';
 // COMPONENT
 import EditorComponent from '../../component/EditorComponent.vue';
 import CategorySelectModal from '../../component/modal/CategorySelectModal.vue';
+// API
+import { findDcryProc, saveDcry, updateDcry } from '@/resources/api/dcry';
 
 export default {
   components: {
@@ -111,37 +123,90 @@
       fileicon: 'client/resources/images/icon/file.png',
       searchicon: 'client/resources/images/icon/search.png',
 
+      pageId: null,
+
       isModalOpen: false,
       isDragging: false,
 
-      items: [
-        { id: 1, category: '카테고리 1', selected: false },
-        { id: 2, category: '카테고리 2', selected: false },
-        { id: 3, category: '카테고리 3', selected: false },
-      ],
       fileNames: [],
-      insertDTO: {
-        sj: null, //제목
-        cn: null, //내용
-        adres: null, // 주소
-        prdctnYear: null, // 생산연도
-        ty: 'P', // 타입 ( P: 사진, V: 영상 )
-        ctgryIds: null, // 카테고리 정보
+
+      // 등록/수정 요청 객체
+      reqDTO: {
+        dcryId: null,
+        sj: null,
+        cn: null,
+        adres: null,
+        prdctnYear: null,
+        ty: 'P',
+        fileId: null,
+        files: [],
+        ctgryIds: [],
       },
 
-      selectedFiles: [],
+      multipartFiles: [],
       selectedCtgries: [], // 카테고리 목록
     };
   },
-  computed: {
-    filteredItems() {
-      // This could be modified to support filtering based on searchQuery
-      return this.items.filter(item =>
-        item.category.includes(this.searchQuery)
-      );
+
+  computed: {},
+
+  created() {
+    this.pageId = this.$route.query.id;
+    if (!this.$isEmpty(this.pageId)) {
+      this.fnFindDcry(); // 상세 조회
     }
   },
+
   methods: {
+    // 상세 조회
+    async fnFindDcry() {
+      try {
+        const response = await findDcryProc(this.pageId);
+        this.copyToDcryReqDTO(response.data.data.dcry);
+      } catch (error) {
+        alert('조회중 오류가 발생했습니다.');
+        this.fnMoveTo('PicHistorySearch'); // 목록으로 이동
+
+        if (error.response) {
+          alert(error.response.data.message);
+        }
+        console.error(error.message);
+      }
+    },
+
+    // dcry > reqDTO
+    copyToDcryReqDTO(dcry) {
+      const copyFields = Object.keys(this.reqDTO).filter(key => key !== 'dcryId' && key !== 'ty' && key !== 'files');
+      copyFields.forEach(field => {
+        this.reqDTO[field] = this.$isEmpty(dcry[field]) ? null : dcry[field];
+      });
+
+      this.reqDTO.ty = 'P'; // 사진기록물
+      this.reqDTO.files = dcry.files.length > 0 ? dcry.files : []; // 기존 첨부파일
+
+      this.multipartFiles = [];
+      this.selectedCtgries = dcry.ctgrys.length > 0 ? dcry.ctgrys : [];
+
+      console.log(this.reqDTO);
+    },
+
+    // 카테고리 모달 열기/닫기
+    fnToggleModal() {
+      this.isModalOpen = !this.isModalOpen;
+    },
+
+    // 카테고리 등록
+    fnAddCtgries(selectedCtgries) {
+      this.selectedCtgries = [...this.selectedCtgries, ...selectedCtgries];
+
+      this.fnToggleModal(); // 카테고리 모달 닫기
+    },
+
+    // 카테고리 삭제
+    fnDelCtgry(id) {
+      this.selectedCtgries = this.selectedCtgries.filter(item => item.ctgryId !== id);
+    },
+
     // 드래그 앤 드롭 이벤트 핸들러
     handleDragOver(event) {
       this.isDragging = true;
@@ -151,7 +216,14 @@
     },
     handleDrop(event) {
       this.isDragging = false;
+
       const files = event.dataTransfer.files;
+      if (files.length > 0) {
+        this.processFiles(files);
+      }
+    },
+    handleFileSelect(event) {
+      const files = event.target.files;
       if (files.length > 0) {
         this.processFiles(files);
       }
@@ -159,169 +231,112 @@
 
     // 파일 업로드 처리 함수
     processFiles(files) {
-      // 파일 타입 검증 (이미지 파일만 허용)
-      const allowedTypes = ['jpg', 'jpeg', 'png', 'gif'];
+      const allowedTypes = ['jpg', 'jpeg', 'png', 'gif']; // 이미지 파일만 허용
       const maxSize = 10 * 1024 * 1024 * 1024; // 10GB
 
-      for (let i = 0; i < files.length; i++) {
-        const fileType = files[i].name.split('.').pop().toLowerCase();
+      for (let file of files) {
+        const fileType = file.name.split('.').pop().toLowerCase();
+
+        // 파일 타입 검증
         if (!allowedTypes.includes(fileType)) {
-          alert(`${files[i].name} 파일은 허용되지 않는 형식입니다. 이미지 파일(jpg, jpeg, png, gif)만 업로드 가능합니다.`);
+          alert(`${file.name} 파일은 허용되지 않는 형식입니다. 이미지 파일(jpg, jpeg, png, gif)만 업로드 가능합니다.`);
           return;
         }
 
-        // 파일 크기 제한 체크 (10GB)
-        if (files[i].size > maxSize) {
-          alert(`${files[i].name} 파일이 10GB를 초과합니다.`);
+        // 파일 크기 제한 검증
+        if (file.size > maxSize) {
+          alert(`${file.name} 파일이 10GB를 초과합니다.`);
           return;
         }
-      }
 
-      // 실제 File 객체들을 저장
-      for (let i = 0; i < files.length; i++) {
-        this.selectedFiles.push(files[i]);
-      }
-
-      // UI에 표시할 파일 정보 저장
-      for (let i = 0; i < files.length; i++) {
-        const file = files[i];
-        const fileType = file.name.split('.').pop().toLowerCase();  // 파일 확장자 추출
-
-        // 파일 타입에 따른 아이콘 선택
-        let iconPath = this.fileicon; // 기본 아이콘
-
-        if (['jpg', 'jpeg', 'png', 'gif'].includes(fileType)) {
-          iconPath = 'client/resources/images/icon/imgicon.png';
-        } else if (['pdf'].includes(fileType)) {
-          iconPath = 'client/resources/images/icon/pdficon.png';
-        } else if (['xls', 'xlsx'].includes(fileType)) {
-          iconPath = 'client/resources/images/icon/excelicon.png';
-        } else if (['hwp'].includes(fileType)) {
-          iconPath = 'client/resources/images/icon/hwpicon.png';
-        }
-
-        // 파일 이름과 아이콘을 목록에 추가
-        this.fileNames.push({
-          name: file.name,
-          icon: iconPath,
-          size: this.formatFileSize(file.size)
-        });
+        this.multipartFiles.push(file);
       }
     },
 
-    fnToggleModal(selectedCtgryIds) {
-      this.isModalOpen = !this.isModalOpen;
-      if (selectedCtgryIds && selectedCtgryIds.length > 0) {
-        this.insertDTO.ctgryIds = selectedCtgryIds;
+    // 파일 삭제
+    fnDelFile(type, separator) {
+      if (type === 'new') {
+        this.multipartFiles.splice(separator, 1);
+      } else if (type === 'old') {
+        this.reqDTO.files = this.reqDTO.files.filter(item => item.fileId !== separator);
       }
-    },
-    removeCategory(index) {
-      // Remove category from the list
-      this.selectedCtgries.splice(index, 1);
-    },
-    openModal() {
-      this.isModalOpen = true;
-    },
-    closeModal() {
-      this.isModalOpen = false;
-    },
-    showFileNames(event) {
-      const files = event.target.files;
-      if (files.length > 0) {
-        this.processFiles(files);
-      }
-    },
-    removeFile(index) {
-      // UI 목록과 실제 파일 객체 목록에서 모두 제거
-      this.fileNames.splice(index, 1);
-      this.selectedFiles.splice(index, 1);
-    },
-    formatFileSize(bytes) {
-      if (bytes === 0) return '0 Bytes';
-      const k = 1024;
-      const sizes = ['Bytes', 'KB', 'MB', 'GB'];
-      const i = Math.floor(Math.log(bytes) / Math.log(k));
-      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
     },
 
     // 등록
-    submitForm() {
-      const vm = this;
-
-      // 폼 요소들의 값을 가져오기
-      const titleInput = document.getElementById('id');
-      const yearInput = document.getElementById('year');
-      const addressInput = document.getElementById('address');
-
-      // insertDTO 업데이트
-      vm.insertDTO.sj = titleInput ? titleInput.value : null;
-      vm.insertDTO.prdctnYear = yearInput ? yearInput.value : null;
-      vm.insertDTO.adres = addressInput ? addressInput.value : null;
-
+    async submitForm() {
       // 유효성 검사
-      if (!vm.insertDTO.sj) {
+      if (!this.reqDTO.sj) {
         alert("제목을 입력해 주세요.");
         return;
       }
-      if (vm.selectedFiles.length < 1) {
+      if (this.$isEmpty(this.pageId) && this.multipartFiles.length < 1) {
         alert("파일을 1개 이상 첨부해 주세요.");
         return;
       }
 
-      // 데이터 세팅
-      const formData = new FormData();
+      try {
+        const formData = new FormData();
 
-      // 텍스트 데이터 추가
-      formData.append('sj', vm.insertDTO.sj);
-      formData.append('cn', vm.insertDTO.cn || '');
-      formData.append('adres', vm.insertDTO.adres || '');
-      formData.append('prdctnYear', vm.insertDTO.prdctnYear || '');
-      formData.append('ty', vm.insertDTO.ty);
+        // 텍스트 데이터 추가
+        formData.append('sj', this.reqDTO.sj);
+        formData.append('cn', this.reqDTO.cn);
+        formData.append('adres', this.reqDTO.adres);
+        formData.append('prdctnYear', this.reqDTO.prdctnYear);
+        formData.append('ty', this.reqDTO.ty);
 
-      // 카테고리 IDs 추가
-      if (vm.insertDTO.ctgryIds && vm.insertDTO.ctgryIds.length > 0) {
-        // 백엔드 요구사항에 따라 조정 필요
-        vm.insertDTO.ctgryIds.forEach((id, index) => {
-          formData.append(`ctgryIds[${index}]`, id);
-        });
-      }
+        // 게시물 아이디
+        if (!this.$isEmpty(this.pageId)) {
+          formData.append('dcryId', this.pageId);
+        }
 
-      // 파일 추가
-      for (let i = 0; i < vm.selectedFiles.length; i++) {
-        formData.append("multipartFiles", vm.selectedFiles[i]);
-      }
+        // 파일 아이디
+        if (!this.$isEmpty(this.reqDTO.fileId)) {
+          formData.append('fileId', this.reqDTO.fileId);
+        }
 
-      // API 통신
-      axios({
-        url: "/dcry/saveDcry.file",
-        method: "post",
-        headers: {
-          "Content-Type": "multipart/form-data",
-        },
-        data: formData,
-      }).then(response => {
+        // 카테고리 Ids 추가
+        if (this.selectedCtgries && this.selectedCtgries.length > 0) {
+          for (let ctgry of this.selectedCtgries) {
+            formData.append("ctgryIds", ctgry.ctgryId);
+          }
+        }
+
+        // 파일 추가
+        for (let file of this.multipartFiles) {
+          formData.append("multipartFiles", file);
+        }
+
+        // 기존파일 수정
+        if (!this.$isEmpty(this.pageId) && this.reqDTO.files.length > 0) {
+          for (let file of this.reqDTO.files) {
+            formData.append("files", file.fileId);
+          }
+        }
+
+        // API 통신
+        const response = this.$isEmpty(this.pageId) ? await saveDcry(formData) : await updateDcry(formData);
         let result = response.data;
         let id = result.data.dcryId;
-        alert("등록 되었습니다.");
+        alert(this.$isEmpty(this.pageId) ? "등록되었습니다." : "수정되었습니다.");
 
         // 상세 페이지로 이동
-        vm.fnMoveTo('PicHistoryDetail', id);
-      }).catch(error => {
+        this.fnMoveTo('PicHistoryDetail', id);
+      } catch (error) {
         if (error.response) {
           alert(error.response.data.message);
         } else {
           alert("에러가 발생했습니다.");
         }
         console.error(error.message);
-      });
+      };
     },
 
     // 페이지 이동
     fnMoveTo(page, id) {
-      if (id !== null || id !== '') {
-        this.$router.push({ name: page, query: { id: id } });
-      } else {
+      if (this.$isEmpty(id)) {
         this.$router.push({ name: page });
+      } else {
+        this.$router.push({ name: page, query: { id: id } });
       }
     }
   }
webpack.config.js
--- webpack.config.js
+++ webpack.config.js
@@ -1,6 +1,7 @@
 const HtmlWebpackPlugin = require('html-webpack-plugin');
 const { VueLoaderPlugin } = require('vue-loader');
 const webpack = require('webpack');
+const path = require('path');
 
 const { PROJECT_NAME, BASE_DIR, SERVICE_STATUS } = require('./Global');
 
@@ -13,6 +14,13 @@
     app: [`${BASE_DIR}/client/views/index.js`]
   },
 
+  resolve: {
+    extensions: ['.js', '.vue', '.json'],
+    alias: {
+      '@': path.resolve(__dirname, `${BASE_DIR}/client`), // @ 경로를 client 폴더로 지정
+    }
+  },
+
   module: {
     rules: [{
       test: /\.vue?$/,
Add a comment
List