웹 개발/[KLOZ] 웹 프로젝트

[WebOrder] 이미지 출력 (Vue dialog, IBSheet)

cha430 2025. 9. 30. 11:43

지금 하는 작업은 공통으로 쓸 컴포넌트를 만드는 것이다.

ItemUpload(임시로) 컴포넌트에 IBSheet로 리스트를 불러오는 ItemList.vue 와 

이미지를 클릭하면 dialog 에 slide로 이미지가 조회되도록 ImgDialog.vue 를 만들고 있다.

 

일단 지금은

최상위 부모가 ItemUpload 고, ItemList랑 ImgDialog 는 자식이다.

부모 ┌ 자식 

        └ 자식

 

부모 ─ 자식 ─ 자식 으로 하지 않은 이유는

각각 공통으로 다른 곳에서 써야하기 때문이다. 자식끼리 결합되면 복잡해지니까 ..

 

 

시트에 fileSeq, MAX(fileSerl) 로 마지막 serl 이미지 파일을 조회하는 기능과

이미지 클릭 시 fileSeq에 해당하는 여러 fileSerl 파일이 슬라이드로 조회되는 기능이 있다.

 

 

1. Vue dialog

2. IBSheet


전체 코드는 아래에 정리

 

 

1. Vue dialog

 

한 줄 한 줄 정리

 

 

 

큰 흐름

 

1. 사용자가 ItemUpload.vue 접속

2. ItemList 에서 ShowImg 이벤트 발생

3. ItemUpload.showImg가 ImgDialog.open(fileSeq, itemName) 호출

4. ImgDialog 에서 open() 실행

  : ImgDialog가 GET /file/getFileImages.do?fileSeq=... 요청

5. FileRestController.java

  : 서버가 fileSeq에 연결된 (fileSeq, fileSerl) 목록과 view URL 반환

6. ImgDialog가 images 배열을 만들어 v-window에 바인드(각 이미지는 fileUrl)

7. 브라우저가 viewFileImage.do?fileSeq=...&fileSerl=...로 이미지 요청

8. 서버 viewFileImage.do가 파일(디스크)을 찾아 바이너리 응답

9. 이미지 표시

 

 

 

상세 흐름

 

1. 사용자가 ItemUpload.vue 접속

2. ItemList 에서 ShowImg 이벤트 발생

<v-main>
    <v-container fluid class="d-flex flex-column h-100">
        <div class="flex-grow-1">
            <ItemList @showImg="showImg" ref="itemListRef"/>
        </div>
    </v-container>
</v-main>

 

3. ItemUpload.showImg가 ImgDialog.open(fileSeq, itemName) 호출

const showImg = ({ fileSeq, itemName }) => {
    if (fileSeq) {
        imgDialog.value?.open(fileSeq, itemName);
    } else {
        consoleLog(`ItemUpload 이미지 조회 시 fileSeq 없음 : ${itemName}`);
    }
};

 

4. ImgDialog 에서 open() 실행

 : ImgDialog가 GET /file/getFileImages.do?fileSeq=... 요청

const open = async (fileSeq, itemName = "이미지") => {
    dialogTitle.value = itemName;
    // currentItemSeq.value = itemSeq;
    isOpen.value = true;

    try {
        const res = await axios.get(`../../file/getFileImages.do?fileSeq=${fileSeq}`);
        if (res.data && res.data.length) {
            images.value = res.data.map(f => ({
                fileUrl : f.fileUrl,
                fileSeq : f.fileSeq,
                fileSerl: f.fileSerl
        }));
            window.value = 0;
        } else {
            images.value = [];
        }

        console.log("images.value", images.value);
        console.log("res.data", res.data);

    } catch (e) {
        consoleLog("이미지 로드 실패", e);
        images.value = [];
    }
};

 

5. FileRestController.java

 : 서버가 fileSeq에 연결된 (fileSeq, fileSerl) 목록과 view URL 반환

@GetMapping("/getFileImages.do")
public ResponseEntity<List<Map<String, Object>>> getFileImages(@RequestParam Integer fileSeq) {

    List<FileDto.FileItem> files = fileService.getFileImages(fileSeq);

    List<Map<String, Object>> result = files.stream()
       .map(f -> {
          Map<String, Object> m = new HashMap<>();
          m.put("fileSeq", f.getFileSeq());
          m.put("fileSerl", f.getFileSerl());
          m.put("fileUrl", "../../file/viewFileImage.do?fileSeq=" 
          + f.getFileSeq() + "&fileSerl=" + f.getFileSerl());
          return m;
       })
       .collect(Collectors.toList());

    return ResponseEntity.ok(result);
}

 

6. ImgDialog가 images 배열을 만들어 v-window에 바인드(각 이미지는 fileUrl)

 

7. 브라우저가 viewFileImage.do?fileSeq=...&fileSerl=...로 이미지 요청

 

8. 서버 viewFileImage.do가 파일(디스크)을 찾아 바이너리 응답

@GetMapping("/viewFileImage.do")
public ResponseEntity<Resource> viewFileImage(
    @RequestParam Integer fileSeq,
    @RequestParam Integer fileSerl) throws Exception {

    // fileSeq + fileSerl 로 파일 조회
    FileDto.FileItem fileDto = fileService.findFileItemByPK(fileSeq, fileSerl);

    String uploadPath = properties.getFilePath("item");
    String filePath = fileDto.getFilePath();
    String fileName = fileDto.getSavedFileName();

    File file = new File(uploadPath + filePath + fileName);
    if (!file.exists()) {
       return ResponseEntity.notFound().build();
    }

    Resource resource = new UrlResource(file.toURI());

    MediaType mediaType = MediaTypeFactory
       .getMediaType(fileName)
       .orElse(MediaType.APPLICATION_OCTET_STREAM);

    return ResponseEntity.ok()
       .contentType(mediaType)
       .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + fileName + "\"")
       .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") // 캐싱 방지
       .header("Pragma", "no-cache")
       .header("Expires", "0")
       .body(resource);
}

 

9. 이미지 표시

 

 

 


 

한 줄 한 줄 정리



1) ItemUpload.vue (화면 구성 + 이벤트 연결)

<ItemList @showImg="showImg" ref="itemListRef"/>

 

→ ItemList 컴포넌트가 showImg 이벤트를 발생시키면 ItemUpload의 showImg 함수가 실행

<ImgDialog ref="imgDialog" :can-delete="canDelete" @deleted="doDeleted" :itemListRef="itemListRef"/>

 

→ ImgDialog 인스턴스를 imgDialog로 참조(열기/조작 가능). 삭제 완료 시 deleted 이벤트를 받고 doDeleted 실행

 

const imgDialog = ref(null)

 

→ 자바스크립트에서 다이얼로그 참조를 보관

 

const showImg = ({ fileSeq, itemName }) => {
    imgDialog.value?.open(fileSeq, itemName);
};

 

→ showImg가 호출되면 ImgDialog.open(fileSeq, itemName)를 실행 (파일 시퀀스로 다이얼로그 열기)

 

const doDeleted = () => {
    itemListRef.value.doSearch();
};

 

→ 이미지 삭제 후 목록을 새로고침(IBSheet 재조회)

 

 


 

 

2) ItemList.vue — 검색 / 리스트 (이미지 클릭으로 showImg 이벤트 발생)

 

const doSearch = () => {
    const s_userSeq = document.getElementById('s_userSeq')
    const userSeq = s_userSeq ? s_userSeq.value : null

    doSearchPaging(sheet1, {
        url: "../../common/getItemList.do",
        method: "POST",
        reqHeader: { "Content-Type": "application/json" },
        param: {
            data: {
                kwdItemName: kwdItemName.value,
                kwdItemNo: kwdItemNo.value,
                userSeq: userSeq
            }
        }
    })
}


→ 검색 버튼(또는 외부 호출)으로 doSearch() 실행

→ doSearchPaging 으로 서버에 POST 요청, 결과가 시트에 바인딩

 

defineExpose({ doSearch });

 

→ 부모(ItemUpload)가 itemListRef.value.doSearch()로 호출할 수 있게 함   (doDeleted() 에서)

 

const emit = defineEmits(['showImg']);

 

→ 이미지 클릭 시(시트의 특정 컬럼에 버튼/링크를 걸어두었을 것) emit('showImg', { fileSeq, itemName }) 하도록 구현되어 있음(해당 emit 호출 코드가 생략되어 있지만, 구조상 그렇게 동작)

 

 


 

3) ImgDialog.vue — 다이얼로그 열기(이미지 로드) / 삭제 / 닫기

 

 

const emit = defineEmits(['deleted']);

 

 

→ 삭제 완료 시 상위로 이벤트 전송

const props = defineProps({
    canDelete: { type: Boolean, default: false },  // 부모에서 받은 삭제 가능 여부
});

 

→ 삭제 권한 플래그

 : 부모에서 자식으로 내려주는 속성(props) 정의.

이렇게 defindProps로 선언했기 때문에 그냥 canDelete 로 사용 가능.

아니면 props.canDelete로 접근해야 한다.

 

 : default 가 있기 때문에 부모가 :canDelete 를 넘기지 않으면 기본값은 false.

그러나 지금 ItemUpload에서 true를 선언했기 때문에 true가 넘어가는 중.

const canDelete = true;

 

 

여기서 props, emit 설명이 궁금하다면 ~~!!

 

 

const open = async (fileSeq, itemName = "이미지") => {
    dialogTitle.value = itemName;
    isOpen.value = true;

    const res = await axios.get(`../../file/getFileImages.do?fileSeq=${fileSeq}`);
    if (res.data && res.data.length) {
        images.value = res.data.map(f => ({
            fileUrl : f.fileUrl,
            fileSeq : f.fileSeq,
            fileSerl: f.fileSerl
    }));
        window.value = 0;
    } else {
        images.value = [];
    }
};

 

→ 서버 응답(배열)을 images로 변환 저장. fileUrl은 viewFileImage.do?fileSeq=...&fileSerl=... 형태임.

 

<v-window-item
    v-for="(img, i) in images"
    :key="i"
    :value="i"
>

 

→ images 배열 각각을 아이템으로 렌더링. (주의: v-window-item에 :value="i" 를 꼭 줘야 올바르게 인덱스 매칭)

 

<v-img
    :src="img.fileUrl"
    width="400"
    style="cursor: pointer"
    @click="close"
/>

 

→ 브라우저가 img.fileUrl로 GET 요청을 보내 이미지를 받아와 화면에 표시

 

const selectedFileInfo = computed(() => {
    return images.value[window.value] || {};
});

 

→ 현재 보여지는 이미지(인덱스 기준)의 {fileSeq, fileSerl, fileUrl}을 반환

(삭제 버튼 클릭 시 현재 보여지는 이미지를 객체로 반환)

 

const deleteFile = () => {
    const fileInfo = selectedFileInfo.value;
	...
    confirm.value.open("이미지를 삭제하시겠습니까?")
       ...
            const { fileSeq, fileSerl } = fileInfo;
            return axios.post('../../file/deleteFile.do', { fileSeq, fileSerl })
                .then(() => {
                    images.value.splice(window.value, 1);

                    if (images.value.length === 0) {
                        close();
                    } else if (window.value >= images.value.length) {
                        window.value = images.value.length - 1;
                    }
                    emit('deleted');
                });
        })
        ...
};

 

→ 사용자 확인 → 서버에 deleteFile.do 호출 (payload: { fileSeq, fileSerl })

 

images.value.splice(window.value, 1);

→ 화면 목록에서 제거

 

emit('deleted');

→ 부모(ItemUpload)로 알림

→ 부모가 itemListRef.value.doSearch() 호출하여 목록 갱신

 

 

const close = () => {
    isOpen.value = false;
    images.value = [];
};

 

→ 다이얼로그 닫기 및 메모리 해제

 

 

 

 


 

 

 

4) 백엔드: FileRestController (서버측 엔드포인트)

 

- @GetMapping("/getFileImages.do")

List<FileDto.FileItem> files = fileService.getFileImages(fileSeq);

 

 

→ DB에서 file_seq = ? 인 모든 tbl_file_info 레코드를 가져옴(각 row에 file_serl 있음).

 

List<Map<String, Object>> result = files.stream()
    .map(f -> {
       Map<String, Object> m = new HashMap<>();
       m.put("fileSeq", f.getFileSeq());
       m.put("fileSerl", f.getFileSerl());
       m.put("fileUrl", "../../file/viewFileImage.do?fileSeq=" 
       + f.getFileSeq() + "&fileSerl=" + f.getFileSerl());
       return m;
    })
    .collect(Collectors.toList());

 

 

→ 프론트가 바로 v-img에 넣어 쓸 수 있도록 fileUrl을 함께 만들어 JSON으로 반환.

 

return ResponseEntity.ok(result);

 

→ 프론트로 JSON 배열 반환.

 

 

- @GetMapping("/viewFileImage.do")

public ResponseEntity<Resource> viewFileImage(
    @RequestParam Integer fileSeq,
    @RequestParam Integer fileSerl)throws Exception {

 

→ DB에서 (file_seq, file_serl) 조합으로 정확히 한 행을 조회

조회해야하는 파일 이미지가 여러 장이라고 fileSeq로만 조회할 경우 마지막 이미지가 여러 장 출력된다.

 

FileDto.FileItem fileDto = fileService.findFileItemByPK(fileSeq, fileSerl);

 

→ DB에서 (file_seq, file_serl) 조합으로 정확히 한 행을 조회

 

File file = new File(uploadPath + filePath + fileName);
Resource resource = new UrlResource(file.toURI());

 

→ 디스크에 저장된 실제 파일을 Resource로 만든 뒤

 

    return ResponseEntity.ok()
       .contentType(mediaType)
       .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + fileName + "\"")
       .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") // 캐싱 방지
       .header("Pragma", "no-cache")
       .header("Expires", "0")
       .body(resource);
}

 

→ 이미지 바이트를 HTTP 응답으로 내려줌(브라우저가 이 응답을 받아 <img>처럼 렌더링함). 캐시 방지 헤더도 포함되어 있음.

 

 

 

- @PostMapping("/deleteFile.do")

 

fileService.deleteFile(fileSeq, fileSerl);

 

→ DB 레코드 삭제 + 디스크 파일 삭제

 

boolean hasFile = fileService.existsFileSeq(fileSeq);

if (!hasFile) {
    fileService.clearItemFileSeq(fileSeq);
}

 

→ 만약 그 fileSeq에 더 이상 남은 파일이 없다면(= 모든 serl 삭제됨) 해당 item의 file_seq 칼럼을 지움.

 

 

 

 






 

 

 

 

 


2. IBSheet

 

doSearchPaging 을 통해 ../../common/getItemList.do  경로로 요청을 보내면

@PostMapping("/getItemList.do")
public ItemDto getItemList(@RequestBody ItemParamDto itemParamDto) {
    return commonService.getItemList(itemParamDto);
}

 

CommonServiceImpl

for (ItemDto.Item item : list) {
    String baseUrl = "../../file/getFileInfo.do";
    if (item.getFileSeq() != null && item.getFileSerl() != null ) {
       String fileUrl = "|" + baseUrl
          + "?fileSeq=" + item.getFileSeq()
          + "||30|||"; // |width|height|||
       item.setFileUrl(fileUrl);
    } else {
       item.setFileUrl(null);
    }
    item.setUserSeq(userSeq);
}

 

  이런식으로 fileUrl 에 하나씩 경로가 대입되고, 그게 Get요청을 보내면

@GetMapping("/getFileInfo.do")
public ResponseEntity<Resource> getFileInfo( @RequestParam Integer fileSeq ) throws Exception  {

    FileDto.FileItem fileDto = fileService.getFileInfo(fileSeq);

    String uploadPath = properties.getFilePath("item");
    String filePath = fileDto.getFilePath();
    String fileName = fileDto.getSavedFileName();

    String fullFilePath = uploadPath + filePath;

    String downFileNm = null;

    return FileUtils.downloadFile(fullFilePath, fileName, downFileNm);
}

 

C드라이브에 있는 경로와 (uploadPath) 파일명 등을 조합하여 FileUtils를 통해 이미지를 출력한다.

 

이때 fileService.getFileInfo 는 fileSeq를 통해 MAX(fileSerl)을 조회하기 때문에 최신 이미지를 출력하게 된다.

 


 

 

전체 코드

ImgDialog

ItemList

ItemUpload

FileRestController

CommonRestController

 

 

 

ImgDialog.vue

<template>
    <v-dialog v-model="isOpen" max-width="500">
        <v-card>
            <v-card-title style="font-size: 13px !important; background-color: #cf4683; color: white;">
                {{ dialogTitle }}
            </v-card-title>
            <v-card-text>
                <div v-if="images.length">
                    <vueper-slides
                        v-model="currentIndex"
                        :arrows="true"
                        bullets
                        width="500px"
                        :slide-ratio="1 / 1"
                        bullets-outside="true"
                        autoplay
                        :transition-speed="250"
                        no-shadow>
                        <vueper-slide
                            v-for="(img, i) in images"
                            :key="i"
                            :image="img.fileUrl"
                            style="cursor: pointer;" />
<!--                        @click.prevent="close" 이건 클릭하면 창 닫힘 기능-->
                    </vueper-slides>
            </v-card-text>
            <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn v-if="canDelete && images.length" color="red" text @click="deleteFile">
                    이미지 삭제
                </v-btn>
                <v-btn color="grey" text @click="close">닫기</v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>
    <alert-dialog ref="alert"/>
    <confirm-dialog ref="confirm"/>
</template>
<script setup>
import { ref, computed  } from "vue";
import axios from "axios";
import alertDialog from '@/components/common/dialog/AlertDialog.vue';
import confirmDialog from '@/components/common/dialog/ConfirmDialog.vue';
import {consoleLog} from '@/assets/js/common/common.js';
import { VueperSlides, VueperSlide } from 'vueperslides'
import 'vueperslides/dist/vueperslides.css'

const alert = ref();
const confirm = ref();

const isOpen = ref(false);
const dialogTitle = ref("이미지");
const images = ref([]);
const window = ref(0); // v-currentIndex의 현재 index
const currentIndex = ref(0)

// const currentItemSeq = ref(null);
// const itemListRef = ref(null);

const emit = defineEmits(['deleted']);

const props = defineProps({
    canDelete: { type: Boolean, default: false },  // 부모에서 받은 삭제 가능 여부
    // itemListRef: Object
});

// dialog 열기
// const open = async (fileSeq, itemSeq, itemName = "이미지") => {
const open = async (fileSeq, itemName = "이미지") => {
    dialogTitle.value = itemName;
    // currentItemSeq.value = itemSeq;
    isOpen.value = true;

    try {
        const res = await axios.get(`../../file/getFileImages.do?fileSeq=${fileSeq}`);
        if (res.data && res.data.length) {
            images.value = res.data.map(f => ({
                fileUrl : f.fileUrl,
                fileSeq : f.fileSeq,
                fileSerl: f.fileSerl
        }));
            currentIndex.value = 0;
        } else {
            images.value = [];
        }

    } catch (e) {
        consoleLog("이미지 로드 실패", e);
        images.value = [];
    }
};

// 삭제 버튼 클릭 시 현재 이미지를 객체로 리턴
const selectedFileInfo = computed(() => {
    return images.value[currentIndex.value] || {};
});

// 삭제
const deleteFile = () => {
    const fileInfo = selectedFileInfo.value;
    if (!fileInfo.fileSeq) return;

    confirm.value.open("이미지를 삭제하시겠습니까?")
        .then(result => {
            if (!result) return;

            const { fileSeq, fileSerl } = fileInfo;
            return axios.post('../../file/deleteFile.do', { fileSeq, fileSerl })
                .then(() => {
                    // UI 처리
                    images.value.splice(currentIndex.value, 1);

                    if (images.value.length === 0) {
                        close();
                    } else if (currentIndex.value >= images.value.length) {
                        // 인덱스 보정
                        currentIndex.value = images.value.length - 1;
                    }

                    alert.value.open("이미지가 삭제되었습니다.");

                    // emit('deleted', currentItemSeq.value);
                    emit('deleted');
                });
        })
        .catch(e => {
            consoleLog("이미지 삭제 실패", e);
            alert.value.open("이미지 삭제에 실패했습니다.");
        });
};

// 닫기
const close = () => {
    isOpen.value = false;
    images.value = [];
};

defineExpose({ open });
</script>

<style scoped>

:deep(.vueperslides__bullet--active .default) {
    background-color: #cf4683;
    border: none;
}
:deep(.vueperslides__bullet) {
    background-color: #f3bed5;
    border-radius: 12px;
    border: none;
}

:deep(.vueperslides__bullet .default) {
    box-shadow: none;
    border: none;
}

</style>

 

여기선 vueper Slides 를 썼는데 

 

아래처럼 vuetify carousel 써도 된다.

동작이 조금 다름. vueper Slides 는 img 태그 못 씀

 

const window = ref(0); // v-window의 현재 index

삭제 부분에서
currentIndex 대신 window 써주기
ex) images.value.splice(window.value, 1);

 

이런 것들도 Script 에서 추가해주고. 아래는 템플릿.

 

<v-carousel
    height="400"
    show-arrows="hover"
    cycle
    hide-delimiter-background>
    <v-carousel-item
        v-for="(img, i) in images"
        :key="i">
        <v-sheet
            height="100%"
            class="d-flex justify-center align-center">
            <v-img
                :src="img.fileUrl"
                width="400"
                style="cursor: pointer"
                @click="close"/>
        </v-sheet>
    </v-carousel-item>
</v-carousel>

 

 

 

 

 

 

 

ItemList.vue

<template>
    <div>
        <!-- 검색 필드 -->
        <table class="main-content" style="width: 100%">
            <tbody>
            <tr class="title-row">
                <td style="width:20%;">
                    <v-text-field
                        v-model="kwdItemName"
                        label="품명"
                        density="compact"
                        variant="outlined"
                        clearable
                        @keydown.enter="doSearch"
                    />
                </td>
                <td style="width:10%;">
                    <v-text-field
                        v-model="kwdItemNo"
                        label="품번"
                        density="compact"
                        variant="outlined"
                        clearable
                        @keydown.enter="doSearch"
                    />
                </td>
                <td class="mb-5">
                    <v-btn @click="doSearch" color="secondary">
                        <v-icon icon="mdi-magnify" /> 조회
                    </v-btn>
                </td>
            </tr>
            </tbody>
        </table>
        <div class="mb-2">거래처별 품목 리스트</div>
        <div class="flex-grow-1">
        <!-- IBSheet 영역 -->
            <div id="listSheet" style="height:100%;"></div>
        </div>
    </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import loader from '@ibsheet/loader'
import {consoleLog, doSearchPaging} from '@/assets/js/common/common.js'

// import ImgDialog from '@/components/common/dialog/ImgDialog.vue'

const kwdItemName = ref('')
const kwdItemNo = ref('')
let sheet1 = null;

// 검색, 조회
const doSearch = () => {
    const s_userSeq = document.getElementById('s_userSeq')
    const userSeq = s_userSeq ? s_userSeq.value : null

    doSearchPaging(sheet1, {
        url: "../../common/getItemList.do",
        method: "POST",
        reqHeader: { "Content-Type": "application/json" },
        param: {
            data: {
                kwdItemName: kwdItemName.value,
                kwdItemNo: kwdItemNo.value,
                userSeq: userSeq
            }
        }
    })
}


// defineExpose({ doSearch, focusDeletedRow });
defineExpose({ doSearch });

const emit = defineEmits(['showImg']);

onMounted(() => {
    loader.config({
        registry: [{
            name: 'ibsheet',
            baseUrl: '../../sheet',
            theme: 'darkgray',
            locales: ['ko', 'en'],
            plugins: ['common']
        }]
    })

    loader.load();

    const initOptions = {
        Cfg: {
            SearchMode: 3,
            PageLength: 50,
            Style: "IBDG",
            FitWidth: true,
            EditArrowBehavior: 2
        },
        Def: {
            Row: {
                "Height": 30,
            },
        },
        LeftCols: [
            {Header: {Value: "No"}, Name: "SEQ", Type: "Int", Align: "center", Width: 50}
        ],
        Cols: [
            {Header: "이미지", Name: "fileUrl", Type: "Img", Align: "center", Width: 70, CanEdit: 0, DefaultValue: "|../../noImage.jpg||30|||", Cursor: 'pointer'},
            {Header: "품명", Name: "itemName", Type: "Text", Align: "left", Width: 400},
            {Header: "품번", Name: "itemNo", Type: "Text", Align: "left", Width: 200},
            {Header: "판매단가", Name: "itemPrice", Type: "Float", Align: "left", Width: 100},
            {Header: "규격", Name: "itemSpec", Type: "Text", Align: "center", Width: 100},
            {Header: "분류", Name: "itemTypeName", Type: "Text", Align: "center", Width: 100},
            {Header: "단위", Name: "itemUnitName", Type: "Text", Align: "center", Width: 100},
            {Header: "상태", Name: "itemStatusName", Type: "Text", Align: "center", Width: 100},
            {Header: "품목소분류", Name: "itemCategory1Name", Type: "Text", Align: "center", Width: 200},
            {Header: "품목중분류", Name: "itemCategory2Name", Type: "Text", Align: "center", Width: 150},
            {Header: "품목대분류", Name: "itemCategory3Name", Type: "Text", Align: "center", Width: 150}
        ],
        Events: {
            onRenderFirstFinish: () => {
                doSearch()
            },
            onClick: function (evtParam) {
                const sheet = evtParam.sheet;
                const clickedRow = evtParam.row;

                if (clickedRow && sheet.getRowKind(clickedRow) === "Data") {
                    if (evtParam.col === 'fileUrl') {
                        const fileSeq = sheet.getValue(clickedRow, "fileSeq");
                        const fileSerl = sheet.getValue(clickedRow, "fileSerl");
                        const itemName = sheet.getValue(clickedRow, "itemName");
                        const itemSeq = sheet.getValue(clickedRow, "itemSeq");

                        // ImgDialog 호출은 부모에게 이벤트 발생하게 함.
                        if (fileSeq) {
                            consoleLog(`[ItemList] 'showImg' 이벤트 발생: ${fileSeq}, ${itemName}`);
                            emit('showImg', { fileSeq, itemSeq, itemName });
                        }
                    }
                }
            },
            onReceiveData: (param) => {
                try {
                    const raw = JSON.parse(param.data);

                    const total = raw.total;
                    const list = raw.data.list;

                    return {
                        Total: total,
                        Data: list
                    };
                } catch (e) {
                    consoleLog("onReceiveData JSON 파싱 오류:", e);
                    return {
                        Total: 0,
                        data: []
                    };
                }
            },
        }
    }

    try {
        loader.createSheet({
            id: 'listSheet',
            el: 'listSheet',
            options: initOptions,
        }).then(sheet => {
            sheet1 = sheet
        })
    } catch(e) {
        consoleLog(e, "IBSheet 생성 실패")
    }
})




onUnmounted(() => {
    loader.removeSheet('listSheet');
})

</script>


<style scoped>

</style>

 

 


 

 

ItemUpload.vue

 

<template>
    <v-app>
        <left-and-header ref="left" :menu-name="'itemUpload'"></left-and-header>
        <v-main>
            <v-container fluid class="d-flex flex-column h-100">
                <div class="flex-grow-1">
                    <ItemList @showImg="showImg" ref="itemListRef"/>
                </div>
            </v-container>
        </v-main>
        <ImgDialog ref="imgDialog" :can-delete="canDelete" @deleted="doDeleted" :itemListRef="itemListRef"/>
        <Footer />
    </v-app>
</template>


<script setup>
import { ref } from 'vue'
import {consoleLog} from '@/assets/js/common/common.js'
import LeftAndHeader from "@/components/common/leftmenu/LeftAndHeader.vue"
import Footer from "@/components/common/footer/Footer.vue"
import ImgDialog from "@/components/common/dialog/ImgDialog.vue"
import ItemList from "@/components/common/codeHelp/ItemList.vue"

const imgDialog = ref(null)
const canDelete = true;
const itemListRef = ref(null);

const showImg = ({ fileSeq, itemName }) => {
    if (fileSeq) {
        imgDialog.value?.open(fileSeq, itemName);
    } else {
        consoleLog(`ItemUpload 이미지 조회 시 fileSeq 없음 : ${itemName}`);
    }
};

const doDeleted = () => {
    itemListRef.value.doSearch();
};

 

 

 

 

FileRestController.java

 

package dev.kloz.weborder.common.controller;

import java.io.File;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import dev.kloz.common.utils.FileUtils;
import dev.kloz.weborder.common.config.FileProperties;
import dev.kloz.weborder.common.dto.FileDto;
import dev.kloz.weborder.common.dto.ResultDto;
import dev.kloz.weborder.common.service.FileService;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/file")
@RequiredArgsConstructor
public class FileRestController {

    private final FileService fileService;
    private final FileProperties properties;

    /**
     * FileSeq 여부 확인
     * @return
     */
    @GetMapping("/checkFileSeq.do")
    public ResultDto checkFileSeq(@RequestParam Integer itemSeq) {
       ResultDto.Result result = new ResultDto.Result();

       Integer fileSeq = fileService.checkFileSeq(itemSeq);

       result.setSeq(fileSeq); // null 그대로 내려감
       result.setStatus(1);

       return ResultDto.builder().data(result).build();
    }

    /**
     * 이미지 파일 업로드
     * @param files
     * @param fileSeq
     * @param userSeq
     * @return
     */
    @PostMapping("/insertFiles.do")
    public ResultDto insertFiles(
       // @RequestParam("file") List<MultipartFile> files,
       @RequestParam("files") List<MultipartFile> files,
       @RequestParam(value = "fileSeq", required = false) Integer fileSeq,
       @RequestParam("userSeq") Integer userSeq) {

       ResultDto.Result result = new ResultDto.Result();

       Integer savedFileSeq = fileService.insertFiles(files, fileSeq, userSeq);

       result.setSeq(savedFileSeq);

       return ResultDto.builder().data(result).build(); // 업로드 후 최종 fileSeq 반환
    }

    /**
     * 시트 품목 삭제로 인한 이미지 파일 삭제(한 개 또는 여러 개)
     * @param fileDeleteList
     * @return
     */
    @PostMapping("/deleteFiles.do")
    public ResultDto deleteFiles(@RequestBody List<FileDto.FileItem> fileDeleteList) {

       ResultDto.Result result = new ResultDto.Result();

       if (fileDeleteList == null || fileDeleteList.isEmpty()) {
          result.setStatus(0);
          return ResultDto.builder().data(result).build();
       }

       // 이미지 파일 없는 데이터도 있어서 거름.
       List<FileDto.FileItem> validFiles = fileDeleteList.stream()
          .filter(file -> file.getFileSeq() != null && file.getFileSerl() != null)
          .collect(Collectors.toList());

       if (validFiles.isEmpty()) {
          // 처리할 파일이 없는 경우
          result.setStatus(0);
          return ResultDto.builder().data(result).build();
       }

       try {
          for (FileDto.FileItem file : fileDeleteList) {
             fileService.deleteFiles(file.getFileSeq(), file.getFileSerl());
          }
          result.setStatus(1);
          return ResultDto.builder().data(result).build();
       } catch (Exception e) {
          e.printStackTrace();
          result.setStatus(0);
          return ResultDto.builder().data(result).build();
       }
    }

    /**
     * 단일 이미지 조회
     * sheet에서 조회할 ServiceImpl 에서 요청
     * @param fileSeq
     * @return
     * @throws Exception
     */
    @GetMapping("/getFileInfo.do")
    public ResponseEntity<Resource> getFileInfo( @RequestParam Integer fileSeq ) throws Exception  {

       FileDto.FileItem fileDto = fileService.getFileInfo(fileSeq);

       String uploadPath = properties.getFilePath("item");
       String filePath = fileDto.getFilePath();
       String fileName = fileDto.getSavedFileName();

       String fullFilePath = uploadPath + filePath;

       String downFileNm = null;

       return FileUtils.downloadFile(fullFilePath, fileName, downFileNm);
    }

    /**
     * 특정 fileSeq의 이미지 리스트 리턴
     * @param fileSeq
     * @return List<Map<String,Object>> JSON
     */
    @GetMapping("/getFileImages.do")
    public ResponseEntity<List<Map<String, Object>>> getFileImages(@RequestParam Integer fileSeq) {

       List<FileDto.FileItem> files = fileService.getFileImages(fileSeq);

       List<Map<String, Object>> result = files.stream()
          .map(f -> {
             Map<String, Object> m = new HashMap<>();
             m.put("fileSeq", f.getFileSeq());
             m.put("fileSerl", f.getFileSerl());
             m.put("fileUrl", "../../file/viewFileImage.do?fileSeq=" + f.getFileSeq() + "&fileSerl=" + f.getFileSerl());
             return m;
          })
          .collect(Collectors.toList());

       return ResponseEntity.ok(result);
    }

    /**
     * 이미지 파일 클릭 시 popup 조회용
     * @param fileSeq
     * @return
     * @throws Exception
     */
    @GetMapping("/viewFileImage.do")
    public ResponseEntity<Resource> viewFileImage(
       @RequestParam Integer fileSeq,
       @RequestParam Integer fileSerl) throws Exception {

       // fileSeq + fileSerl 로 파일 조회
       FileDto.FileItem fileDto = fileService.findFileItemByPK(fileSeq, fileSerl);

       String uploadPath = properties.getFilePath("item");
       String filePath = fileDto.getFilePath();
       String fileName = fileDto.getSavedFileName();

       File file = new File(uploadPath + filePath + fileName);
       if (!file.exists()) {
          return ResponseEntity.notFound().build();
       }

       Resource resource = new UrlResource(file.toURI());

       MediaType mediaType = MediaTypeFactory
          .getMediaType(fileName)
          .orElse(MediaType.APPLICATION_OCTET_STREAM);

       return ResponseEntity.ok()
          .contentType(mediaType)
          .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + fileName + "\"")
          .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") // 캐싱 방지
          .header("Pragma", "no-cache")
          .header("Expires", "0")
          .body(resource);
    }

    /**
     * 이미지 조회하는 dialog 에서 단일 삭제
     * @return
     */
    @PostMapping("/deleteFile.do")
    public ResultDto deleteFile(@RequestBody FileDto.FileItem fileDeleteList) {
       ResultDto.Result result = new ResultDto.Result();

       Integer fileSeq = fileDeleteList.getFileSeq();
       Integer fileSerl = fileDeleteList.getFileSerl();

       fileService.deleteFile(fileSeq, fileSerl);

       boolean hasFile = fileService.existsFileSeq(fileSeq);

       if (!hasFile) {
          fileService.clearItemFileSeq(fileSeq);
       }

       return ResultDto.builder().data(result).build();
    }
}

 

 

 




 

CommonRestController

 

package dev.kloz.weborder.common.controller;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import dev.kloz.weborder.admin.item.dto.ItemDto;
import dev.kloz.weborder.admin.item.dto.ItemParamDto;
import dev.kloz.weborder.common.service.CommonService;
import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/common")
@RequiredArgsConstructor
public class CommonRestController {

private final CommonService commonService;

    @PostMapping("/getItemList.do")
    public ItemDto getItemList(@RequestBody ItemParamDto itemParamDto) {
       return commonService.getItemList(itemParamDto);
    }

}

 

 

CommonServiceImpl

package dev.kloz.weborder.common.service.impl;

import java.util.ArrayList;

import org.springframework.stereotype.Service;

import dev.kloz.weborder.admin.item.dto.ItemDto;
import dev.kloz.weborder.admin.item.dto.ItemParamDto;
import dev.kloz.weborder.common.mapper.CommonMapper;
import dev.kloz.weborder.common.service.CommonService;
import dev.kloz.weborder.common.session.SessionKeys;
import dev.kloz.weborder.common.session.SessionManager;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class CommonServiceImpl implements CommonService {

    private final CommonMapper commonMapper;

    @Override
    public ItemDto getItemList(ItemParamDto itemParamDto) {

       Integer userSeq = Integer.parseInt(String.valueOf(SessionManager.get(SessionKeys.session_user_seq)));

       int page = itemParamDto.getIbpage();
       int pageLength = itemParamDto.getIbpagelength();
       int offset = (page - 1) * pageLength;
       itemParamDto.setOffset(offset);
       itemParamDto.setLimit(pageLength);

       int totalCount = commonMapper.totalCountItems(itemParamDto);

       ArrayList<ItemDto.Item> list = commonMapper.getItemList(itemParamDto);

       ItemDto result = new ItemDto();
       result.setTotal(totalCount);

       for (ItemDto.Item item : list) {
          String baseUrl = "../../file/getFileInfo.do";
          if (item.getFileSeq() != null && item.getFileSerl() != null ) {
             String fileUrl = "|" + baseUrl
                + "?fileSeq=" + item.getFileSeq()
                + "||30|||"; // |width|height|||
             item.setFileUrl(fileUrl);
          } else {
             item.setFileUrl(null);
          }
          item.setUserSeq(userSeq);
       }

       ItemDto.Data data = new ItemDto.Data();
       data.setList(list);
       result.setData(data);

       return result;
    }


}

 

 

 

 

commonMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="dev.kloz.weborder.common.mapper.CommonMapper">

    <!-- 조회 : 페이징, 검색 -->
    <select id="getItemList" parameterType="dev.kloz.weborder.admin.item.dto.ItemParamDto" resultType="dev.kloz.weborder.admin.item.dto.ItemDto$Item">
        SELECT
            ci.cust_seq,
            ci.item_seq,
            im.item_category1, c1.category_name AS itemCategory1Name,
            im.item_category2, c2.category_name AS itemCategory2Name,
            im.item_category3, c3.category_name AS itemCategory3Name,
            im.item_no,
            im.item_seq,
            im.item_name,
            im.item_spec,
            im.item_type, t.code_name AS itemTypeName,
            im.item_unit, u.code_name AS itemUnitName,
            im.item_status, s.code_name AS itemStatusName, im.apply_start_date, im.apply_end_date,
            fi.file_seq, fi.file_serl, fi.saved_file_name, fi.file_path,
            ip.item_price
        FROM tbl_cust_item ci
                 JOIN tbl_item_master im ON ci.item_seq = im.item_seq
                 LEFT JOIN tbl_code_detail t ON im.item_type = t.detail_seq AND t.master_seq = 1
                 LEFT JOIN tbl_code_detail u ON im.item_unit = u.detail_seq AND u.master_seq = 5
                 LEFT JOIN tbl_code_detail s ON im.item_status = s.detail_seq AND s.master_seq = 2
                 LEFT JOIN tbl_item_category c1 ON im.item_category1 = c1.category_seq
                 LEFT JOIN tbl_item_category c2 ON im.item_category2 = c2.category_seq
                 LEFT JOIN tbl_item_category c3 ON im.item_category3 = c3.category_seq
                 LEFT JOIN
                     (SELECT fi1.*
                      FROM tbl_file_info fi1
                               INNER JOIN (
                          SELECT file_seq, Max(file_serl) AS maxFileSerl
                          FROM tbl_file_info
                          GROUP BY file_seq
                      ) fi2 ON fi1.file_seq = fi2.file_seq AND fi1.file_serl = fi2.maxFileSerl
                     ) fi ON im.file_seq = fi.file_seq
                 LEFT JOIN
                     (SELECT ip1.*
                      FROM tbl_item_price ip1
                               INNER JOIN (
                          SELECT cust_seq, item_seq, MAX(price_serl) AS maxPriceSerl
                          FROM tbl_item_price
                          GROUP BY cust_seq, item_seq
                      ) ip2 ON ip1.cust_seq = ip2.cust_seq
                          AND ip1.item_seq = ip2.item_seq
                          AND ip1.price_serl = ip2.maxPriceSerl
                     ) ip ON ci.cust_seq = ip.cust_seq AND ci.item_seq = ip.item_seq
        WHERE ci.cust_seq = (
            SELECT cust_seq
            FROM tbl_user
            WHERE user_seq = #{data.userSeq}
        )
          AND CURRENT_DATE BETWEEN im.apply_start_date AND im.apply_end_date
          AND (#{data.kwdItemName} IS NULL OR #{data.kwdItemName} = '' OR im.item_name LIKE CONCAT('%', #{data.kwdItemName}, '%'))
          AND (#{data.kwdItemNo} IS NULL OR #{data.kwdItemNo} = '' OR im.item_no LIKE CONCAT('%', #{data.kwdItemNo}, '%'))
        ORDER BY im.item_name
            LIMIT #{limit} OFFSET #{offset};
    </select>

    <select id="totalCountItems" parameterType="dev.kloz.weborder.admin.item.dto.ItemParamDto" resultType="int">
        SELECT COUNT(*)
        FROM tbl_cust_item ci
                 JOIN tbl_item_master im ON ci.item_seq = im.item_seq
                 LEFT JOIN
             (SELECT ip1.*
              FROM tbl_item_price ip1
                       INNER JOIN (
                  SELECT cust_seq, item_seq, MAX(price_serl) AS maxPriceSerl
                  FROM tbl_item_price
                  GROUP BY cust_seq, item_seq
              ) ip2 ON ip1.cust_seq = ip2.cust_seq
                  AND ip1.item_seq = ip2.item_seq
                  AND ip1.price_serl = ip2.maxPriceSerl
             ) ip ON ci.cust_seq = ip.cust_seq AND ci.item_seq = ip.item_seq
        WHERE ci.cust_seq = (
            SELECT cust_seq
            FROM tbl_user
            WHERE user_seq = #{data.userSeq}
        )
          AND CURRENT_DATE BETWEEN im.apply_start_date AND im.apply_end_date
          AND (#{data.kwdItemName} IS NULL OR #{data.kwdItemName} = '' OR im.item_name LIKE CONCAT('%', #{data.kwdItemName}, '%'))
          AND (#{data.kwdItemNo} IS NULL OR #{data.kwdItemNo} = '' OR im.item_no LIKE CONCAT('%', #{data.kwdItemNo}, '%'))
        </select>
</mapper>

 

 

 

 

fileMapper.xml

 

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="dev.kloz.weborder.common.mapper.FileMapper" >

    <select id="checkFileSeq" parameterType="int" resultType="Integer">
        SELECT MAX(file_seq) FROM tbl_item_master
        WHERE item_seq = #{itemSeq}
    </select>

    <select id="getNextFileSeq" parameterType="int" resultType="Integer">
        SELECT IFNULL(MAX(file_seq), 0) +1 FROM tbl_item_master
    </select>

    <!-- file_seq 별 file_serl 저장 시 필요 -->
    <select id="getMaxFileSerl" parameterType="int" resultType="int">
        SELECT MAX(file_serl)
        FROM tbl_file_info
        WHERE file_seq = #{fileSeq}
    </select>

    <!-- 이미지 파일 저장-->
    <insert id="insertFile" parameterType="dev.kloz.weborder.common.dto.FileDto$FileItem">
        INSERT INTO tbl_file_info (
            work_type, file_seq, file_serl,
            file_path, file_size, original_file_name, saved_file_name,
            content_type, reg_user_seq, reg_date
        )
        VALUES (
                   1, #{fileSeq}, #{fileSerl},
                   #{filePath}, #{fileSize}, #{originalFileName}, #{savedFileName},
                   #{contentType}, #{userSeq}, NOW()
               )
            ON DUPLICATE KEY UPDATE
                                 file_path = VALUES(file_path),
                                 file_size = VALUES(file_size),
                                 original_file_name = VALUES(original_file_name),
                                 saved_file_name = VALUES(saved_file_name),
                                 content_type = VALUES(content_type),
                                 reg_user_seq = #{userSeq},
                                 reg_date = NOW()
    </insert>

    <!-- 품목 삭제 시 해당 파일만 삭제-->
    <delete id="deleteFile"  parameterType="map">
        DELETE FROM tbl_file_info
        WHERE file_seq = #{fileSeq}
          AND file_serl = #{fileSerl}
    </delete>

    <!-- 품목 삭제 시 file_seq 모두 삭제 -->
    <delete id="deleteAllFiles"  parameterType="map">
        DELETE FROM tbl_file_info
        WHERE file_seq = #{fileSeq}
    </delete>

    <!-- 파일 삭제 시 동일 file_seq , 다른 file_serl 남아있는지 확인 -->
    <select id="countFileSeq" resultType="int">
        SELECT COUNT(*) FROM tbl_file_info WHERE file_seq = #{fileSeq}
    </select>

    <!-- 현재 파일 삭제 || 수정 시 이전 파일 삭제 -->
    <delete id="deleteFilesBySeq" parameterType="int">
        DELETE FROM tbl_file_info
        WHERE file_seq = #{fileSeq}
    </delete>

    <!-- 파일 삭제 시 파일 확인용-->
    <select id="findFileItemByPK" parameterType="map" resultType="dev.kloz.weborder.common.dto.FileDto$FileItem">
        SELECT
            file_seq AS fileSeq,
            file_serl AS fileSerl,
            file_path AS filePath,
            original_file_name AS originalFileName,
            saved_file_name AS savedFileName
        FROM tbl_file_info
        WHERE file_seq = #{fileSeq} AND file_serl = #{fileSerl}
    </select>

    <!-- 시트에서 이미지 파일 조회 -->
    <select id="getFileInfo" parameterType="int" resultType="dev.kloz.weborder.common.dto.FileDto$FileItem">
        SELECT fi.file_seq,
               fi.file_serl,
               fi.saved_file_name,
               fi.file_path
        FROM tbl_file_info fi
                 INNER JOIN (
            SELECT file_seq, MAX(file_serl) AS maxFileSerl
            FROM tbl_file_info
            GROUP BY file_seq
        ) fmax ON fi.file_seq = fmax.file_seq AND fi.file_serl = fmax.maxFileSerl
        WHERE fi.file_seq = #{fileSeq}
    </select>

    <select id="selectFilesByFileSeq" parameterType="Integer" resultType="dev.kloz.weborder.common.dto.FileDto$FileItem">
        SELECT file_seq, file_serl, saved_file_name, file_path
        FROM tbl_file_info
        WHERE file_seq = #{fileSeq}
        ORDER BY file_serl ASC
    </select>

</mapper>