지금 하는 작업은 공통으로 쓸 컴포넌트를 만드는 것이다.
ItemUpload(임시로) 컴포넌트에 IBSheet로 리스트를 불러오는 ItemList.vue 와
이미지를 클릭하면 dialog 에 slide로 이미지가 조회되도록 ImgDialog.vue 를 만들고 있다.
일단 지금은
최상위 부모가 ItemUpload 고, ItemList랑 ImgDialog 는 자식이다.
부모 ┌ 자식
└ 자식
부모 ─ 자식 ─ 자식 으로 하지 않은 이유는
각각 공통으로 다른 곳에서 써야하기 때문이다. 자식끼리 결합되면 복잡해지니까 ..
시트에 fileSeq, MAX(fileSerl) 로 마지막 serl 이미지 파일을 조회하는 기능과
이미지 클릭 시 fileSeq에 해당하는 여러 fileSerl 파일이 슬라이드로 조회되는 기능이 있다.
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.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>
'웹 개발 > [KLOZ] 웹 프로젝트' 카테고리의 다른 글
| [WebOrder] Vue컴포넌트에서 세션 (session) 가져다 쓰기 / 백단에서 가져다쓰기 (0) | 2025.11.16 |
|---|---|
| [WebOrder] 다국어 처리 (VueDatePicker, IBSheet) (0) | 2025.10.21 |
| [WebOrder] CodeHelp 이용 (v-model, onChange) (0) | 2025.09.26 |
| [WebOrder] vuetify rules 사용 (0) | 2025.09.18 |
| [WebOrder] $t 정의 에러, vue-i18n (0) | 2025.09.15 |