
--- client/resources/api/dcry.js
+++ client/resources/api/dcry.js
... | ... | @@ -1,4 +1,5 @@ |
1 |
-import { apiClient, fileClient } from "./index"; |
|
1 |
+import { apiClient } from "./index"; |
|
2 |
+import uploadService from "@/resources/js/uploadService"; |
|
2 | 3 |
|
3 | 4 |
// 기록물 목록 조회 |
4 | 5 |
export const findDcrysProc = (searchReqDTO) => { |
... | ... | @@ -11,13 +12,13 @@ |
11 | 12 |
} |
12 | 13 |
|
13 | 14 |
// 기록물 등록 |
14 |
-export const saveDcry = (formData) => { |
|
15 |
- return fileClient.post(`/dcry/saveDcry.file`, formData); |
|
15 |
+export const saveDcryProc = (formData) => { |
|
16 |
+ return uploadService.uploadWithPost(`/dcry/saveDcry.file`, formData); |
|
16 | 17 |
} |
17 | 18 |
|
18 | 19 |
// 기록물 수정 |
19 |
-export const updateDcry = (formData) => { |
|
20 |
- return fileClient.put(`/dcry/updateDcry.file`, formData); |
|
20 |
+export const updateDcryProc = (formData) => { |
|
21 |
+ return uploadService.uploadWithPut(`/dcry/updateDcry.file`, formData); |
|
21 | 22 |
} |
22 | 23 |
|
23 | 24 |
// 기록물 삭제 |
--- client/resources/api/file.js
+++ client/resources/api/file.js
... | ... | @@ -1,11 +1,11 @@ |
1 |
-import { apiClient } from "./index"; |
|
1 |
+import downloadService from "@/resources/js/downloadService"; |
|
2 | 2 |
|
3 | 3 |
// 파일 다운로드 |
4 |
-export const fileDownloadProc = (files) => { |
|
5 |
- return apiClient.get(`/file/${files.fileId}/${files.fileOrdr}/fileDownload.json`, { responseType: 'blob' }); |
|
4 |
+export const fileDownloadProc = (file) => { |
|
5 |
+ return downloadService.downloadFile(file, { responseType: 'blob' }); |
|
6 | 6 |
} |
7 | 7 |
|
8 |
-// 파일 다운로드 |
|
9 |
-export const multiFileDownloadProc = (fileIds) => { |
|
10 |
- return apiClient.post(`/file/multiFileDownload.json`, fileIds, { responseType: 'blob' }); |
|
8 |
+// 다중 파일 다운로드 |
|
9 |
+export const multiFileDownloadProc = (files) => { |
|
10 |
+ return downloadService.downloadMultipleFiles(files, { responseType: 'blob' }); |
|
11 | 11 |
}(파일 끝에 줄바꿈 문자 없음) |
--- client/resources/api/nesDta.js
+++ client/resources/api/nesDta.js
... | ... | @@ -1,4 +1,6 @@ |
1 |
-import { apiClient, fileClient } from "./index"; |
|
1 |
+// @/resources/api/nesDta.js |
|
2 |
+import { apiClient } from "./index"; |
|
3 |
+import uploadService from "@/resources/js/uploadService"; |
|
2 | 4 |
|
3 | 5 |
// 스크랩 자료 목록 조회 |
4 | 6 |
export const findAllNesDtasProc = (searchReqDTO) => { |
... | ... | @@ -10,14 +12,14 @@ |
10 | 12 |
return apiClient.get(`/nesDta/${nesDtaId}/findNesDta.json`); |
11 | 13 |
} |
12 | 14 |
|
13 |
-// 스크랩 자료 등록 |
|
15 |
+// 스크랩 자료 등록 (POST 메서드 사용) |
|
14 | 16 |
export const saveNesDtaProc = (formData) => { |
15 |
- return fileClient.post(`/nesDta/saveNesDta.file`, formData); |
|
17 |
+ return uploadService.uploadWithPost(`/nesDta/saveNesDta.file`, formData); |
|
16 | 18 |
} |
17 | 19 |
|
18 |
-// 스크랩 자료 수정 |
|
20 |
+// 스크랩 자료 수정 (PUT 메서드 사용) |
|
19 | 21 |
export const updateNesDtaProc = (formData) => { |
20 |
- return fileClient.put(`/nesDta/updateNesDta.file`, formData); |
|
22 |
+ return uploadService.uploadWithPut(`/nesDta/updateNesDta.file`, formData); |
|
21 | 23 |
} |
22 | 24 |
|
23 | 25 |
// 스크랩 자료 삭제 |
+++ client/resources/js/downloadService.js
... | ... | @@ -0,0 +1,190 @@ |
1 | +// @/resources/js/downloadService.js | |
2 | +import { apiClient } from '@/resources/api/index'; | |
3 | +import uploadProgressStore from '@/resources/js/uploadProgressStore'; | |
4 | + | |
5 | +// 파일 다운로드를 처리하는 서비스 | |
6 | +const downloadService = { | |
7 | + // 내부 타이머 변수 | |
8 | + _simulationTimer: null, | |
9 | + | |
10 | + // 마지막 시뮬레이션 진행률 | |
11 | + _lastSimulatedProgress: 0, | |
12 | + | |
13 | + // 진행률 시뮬레이션 시작 (서버 응답이 지연될 때) | |
14 | + _startProgressSimulation() { | |
15 | + // 이미 타이머가 있으면 정리 | |
16 | + if (this._simulationTimer) { | |
17 | + clearInterval(this._simulationTimer); | |
18 | + } | |
19 | + | |
20 | + // 현재 진행률 저장 | |
21 | + this._lastSimulatedProgress = uploadProgressStore.totalProgress; | |
22 | + | |
23 | + // 시작 진행률 설정 (최소 5%) | |
24 | + if (this._lastSimulatedProgress < 5) { | |
25 | + this._lastSimulatedProgress = 5; | |
26 | + uploadProgressStore.totalProgress = 5; | |
27 | + } | |
28 | + | |
29 | + // 진행률 시뮬레이션 시작 (최대 40%까지만) | |
30 | + let simulatedProgress = this._lastSimulatedProgress; | |
31 | + this._simulationTimer = setInterval(() => { | |
32 | + // 실제 진행이 시작되면 시뮬레이션 중단 | |
33 | + if (uploadProgressStore.totalProgress > simulatedProgress) { | |
34 | + this._lastSimulatedProgress = uploadProgressStore.totalProgress; | |
35 | + clearInterval(this._simulationTimer); | |
36 | + this._simulationTimer = null; | |
37 | + return; | |
38 | + } | |
39 | + | |
40 | + // 진행률 증가 (40%까지) | |
41 | + if (simulatedProgress < 40) { | |
42 | + simulatedProgress += Math.random() * 1.5; // 0~1.5% 랜덤하게 증가 | |
43 | + uploadProgressStore.totalProgress = Math.round(simulatedProgress); | |
44 | + this._lastSimulatedProgress = uploadProgressStore.totalProgress; | |
45 | + } | |
46 | + }, 200); // 0.2초마다 업데이트 | |
47 | + }, | |
48 | + | |
49 | + // 시뮬레이션 종료 | |
50 | + _stopProgressSimulation() { | |
51 | + if (this._simulationTimer) { | |
52 | + clearInterval(this._simulationTimer); | |
53 | + this._simulationTimer = null; | |
54 | + } | |
55 | + }, | |
56 | + | |
57 | + // 단일 파일 다운로드 메서드 | |
58 | + async downloadFile(file, options = {}) { | |
59 | + try { | |
60 | + // 다운로드 상태 초기화 (현재 진행률 유지) | |
61 | + const currentProgress = uploadProgressStore.totalProgress; | |
62 | + uploadProgressStore.startDownload(file.fileNm || '파일 다운로드 중...'); | |
63 | + uploadProgressStore.setStage('downloading'); | |
64 | + | |
65 | + // 이전 진행률이 있으면 유지 | |
66 | + if (currentProgress > 0) { | |
67 | + uploadProgressStore.totalProgress = currentProgress; | |
68 | + } | |
69 | + | |
70 | + // 즉시 진행률 시뮬레이션 시작 (서버 응답 전) | |
71 | + this._startProgressSimulation(); | |
72 | + | |
73 | + // apiClient를 사용하여 다운로드 요청 | |
74 | + const response = await apiClient.get(`/file/${file.fileId}/${file.fileOrdr || 1}/fileDownload.json`, { | |
75 | + responseType: 'blob', | |
76 | + ...options, | |
77 | + onDownloadProgress: (progressEvent) => { | |
78 | + // 시뮬레이션 중단 | |
79 | + this._stopProgressSimulation(); | |
80 | + | |
81 | + if (progressEvent.total) { | |
82 | + // 진행 상태 계산 | |
83 | + const realProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total); | |
84 | + | |
85 | + // 실제 진행률이 시뮬레이션 진행률보다 낮으면, 시뮬레이션 진행률부터 시작 | |
86 | + const startProgress = Math.max(this._lastSimulatedProgress, realProgress); | |
87 | + | |
88 | + // 남은 진행률을 60%~100% 범위로 매핑 | |
89 | + // 예: 실제 진행률이 0%일 때 40%, 100%일 때 100%가 되도록 | |
90 | + const adjustedProgress = 40 + (realProgress * 60 / 100); | |
91 | + | |
92 | + // 둘 중 더 큰 값을 사용 | |
93 | + uploadProgressStore.totalProgress = Math.round(Math.max(startProgress, adjustedProgress)); | |
94 | + } | |
95 | + | |
96 | + // 사용자 정의 onDownloadProgress 콜백이 있으면 호출 | |
97 | + if (options.onDownloadProgress) { | |
98 | + options.onDownloadProgress(progressEvent); | |
99 | + } | |
100 | + } | |
101 | + }); | |
102 | + | |
103 | + // 시뮬레이션 중단 (만약 아직 실행 중이라면) | |
104 | + this._stopProgressSimulation(); | |
105 | + | |
106 | + // 다운로드 완료 표시 | |
107 | + uploadProgressStore.totalProgress = 100; | |
108 | + | |
109 | + // 잠시 후 응답 반환 (완료 상태를 보여줄 시간 제공) | |
110 | + await new Promise(resolve => setTimeout(resolve, 300)); | |
111 | + | |
112 | + return response; | |
113 | + } catch (error) { | |
114 | + // 시뮬레이션 중단 | |
115 | + this._stopProgressSimulation(); | |
116 | + | |
117 | + // 오류 발생 시 상태 초기화 | |
118 | + uploadProgressStore.handleError(); | |
119 | + throw error; | |
120 | + } | |
121 | + }, | |
122 | + | |
123 | + // 다중 파일 다운로드 메서드 | |
124 | + async downloadMultipleFiles(files, options = {}) { | |
125 | + try { | |
126 | + // 다운로드 상태 초기화 (현재 진행률 유지) | |
127 | + const currentProgress = uploadProgressStore.totalProgress; | |
128 | + uploadProgressStore.startDownload('다중 파일 다운로드 중...'); | |
129 | + uploadProgressStore.setStage('downloading'); | |
130 | + | |
131 | + // 이전 진행률이 있으면 유지 | |
132 | + if (currentProgress > 0) { | |
133 | + uploadProgressStore.totalProgress = currentProgress; | |
134 | + } | |
135 | + | |
136 | + // 즉시 진행률 시뮬레이션 시작 (서버 응답 전) | |
137 | + this._startProgressSimulation(); | |
138 | + | |
139 | + // apiClient를 사용하여 다운로드 요청 | |
140 | + const response = await apiClient.post(`/file/multiFileDownload.json`, files, { | |
141 | + responseType: 'blob', | |
142 | + ...options, | |
143 | + onDownloadProgress: (progressEvent) => { | |
144 | + // 시뮬레이션 중단 | |
145 | + this._stopProgressSimulation(); | |
146 | + | |
147 | + if (progressEvent.total) { | |
148 | + // 진행 상태 계산 | |
149 | + const realProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total); | |
150 | + | |
151 | + // 실제 진행률이 시뮬레이션 진행률보다 낮으면, 시뮬레이션 진행률부터 시작 | |
152 | + const startProgress = Math.max(this._lastSimulatedProgress, realProgress); | |
153 | + | |
154 | + // 남은 진행률을 40%~100% 범위로 매핑 | |
155 | + // 예: 실제 진행률이 0%일 때 40%, 100%일 때 100%가 되도록 | |
156 | + const adjustedProgress = 40 + (realProgress * 60 / 100); | |
157 | + | |
158 | + // 둘 중 더 큰 값을 사용 | |
159 | + uploadProgressStore.totalProgress = Math.round(Math.max(startProgress, adjustedProgress)); | |
160 | + } | |
161 | + | |
162 | + // 사용자 정의 onDownloadProgress 콜백이 있으면 호출 | |
163 | + if (options.onDownloadProgress) { | |
164 | + options.onDownloadProgress(progressEvent); | |
165 | + } | |
166 | + } | |
167 | + }); | |
168 | + | |
169 | + // 시뮬레이션 중단 (만약 아직 실행 중이라면) | |
170 | + this._stopProgressSimulation(); | |
171 | + | |
172 | + // 다운로드 완료 표시 | |
173 | + uploadProgressStore.totalProgress = 100; | |
174 | + | |
175 | + // 잠시 후 응답 반환 (완료 상태를 보여줄 시간 제공) | |
176 | + await new Promise(resolve => setTimeout(resolve, 300)); | |
177 | + | |
178 | + return response; | |
179 | + } catch (error) { | |
180 | + // 시뮬레이션 중단 | |
181 | + this._stopProgressSimulation(); | |
182 | + | |
183 | + // 오류 발생 시 상태 초기화 | |
184 | + uploadProgressStore.handleError(); | |
185 | + throw error; | |
186 | + } | |
187 | + } | |
188 | +}; | |
189 | + | |
190 | +export default downloadService;(파일 끝에 줄바꿈 문자 없음) |
+++ client/resources/js/uploadProgressStore.js
... | ... | @@ -0,0 +1,133 @@ |
1 | +import { reactive } from 'vue'; | |
2 | + | |
3 | +// 파일 업로드/다운로드 상태를 관리하는 전역 스토어 | |
4 | +const uploadProgressStore = reactive({ | |
5 | + // 상태 데이터 | |
6 | + isUploading: false, // 모달 표시 여부 | |
7 | + currentFileIndex: 0, | |
8 | + totalFiles: 0, | |
9 | + currentFileName: '', | |
10 | + totalProgress: 0, | |
11 | + stage: '', // 'uploading', 'processing', 'downloading' | |
12 | + processingProgress: 0, // 서버 처리 단계의 가상 진행률 (0-100) | |
13 | + | |
14 | + // 상태 초기화 메서드 | |
15 | + resetState() { | |
16 | + this.isUploading = false; | |
17 | + this.currentFileIndex = 0; | |
18 | + this.totalFiles = 0; | |
19 | + this.currentFileName = ''; | |
20 | + this.totalProgress = 0; | |
21 | + this.stage = ''; | |
22 | + this.processingProgress = 0; | |
23 | + | |
24 | + // 진행 타이머가 있다면 정리 | |
25 | + if (this._processingTimer) { | |
26 | + clearInterval(this._processingTimer); | |
27 | + this._processingTimer = null; | |
28 | + } | |
29 | + }, | |
30 | + | |
31 | + // 업로드 시작 메서드 | |
32 | + startUpload(fileName, totalCount = 1) { | |
33 | + // 혹시 모를 이전 상태 초기화 | |
34 | + this.resetState(); | |
35 | + | |
36 | + // 새 업로드 시작 | |
37 | + this.isUploading = true; | |
38 | + this.currentFileIndex = 1; | |
39 | + this.totalFiles = totalCount; | |
40 | + this.currentFileName = fileName; | |
41 | + this.totalProgress = 0; | |
42 | + this.stage = 'uploading'; | |
43 | + }, | |
44 | + | |
45 | + // 다운로드 시작 메서드 | |
46 | + startDownload(fileName) { | |
47 | + // 혹시 모를 이전 상태 초기화 | |
48 | + this.resetState(); | |
49 | + | |
50 | + // 새 다운로드 시작 | |
51 | + this.isUploading = true; | |
52 | + this.currentFileIndex = 1; | |
53 | + this.totalFiles = 1; | |
54 | + this.currentFileName = fileName; | |
55 | + this.totalProgress = 0; | |
56 | + this.stage = 'downloading'; | |
57 | + }, | |
58 | + | |
59 | + // 진행 상태 업데이트 메서드 | |
60 | + updateProgress(loaded, total) { | |
61 | + if (total > 0) { | |
62 | + const progress = Math.round((loaded * 100) / total); | |
63 | + this.totalProgress = progress; | |
64 | + } | |
65 | + }, | |
66 | + | |
67 | + // 내부용 타이머 변수 | |
68 | + _processingTimer: null, | |
69 | + | |
70 | + // 업로드/다운로드 단계 설정 메서드 | |
71 | + setStage(stage) { | |
72 | + this.stage = stage; | |
73 | + | |
74 | + if (stage === 'uploading' || stage === 'downloading') { | |
75 | + // 업로드/다운로드 단계로 변경 시 처리 단계 타이머 정리 | |
76 | + if (this._processingTimer) { | |
77 | + clearInterval(this._processingTimer); | |
78 | + this._processingTimer = null; | |
79 | + } | |
80 | + this.processingProgress = 0; | |
81 | + } | |
82 | + else if (stage === 'processing') { | |
83 | + // 파일 업로드는 100% 완료 | |
84 | + this.totalProgress = 100; | |
85 | + | |
86 | + // 처리 단계 진행 시뮬레이션 시작 | |
87 | + this.processingProgress = 0; | |
88 | + | |
89 | + // 타이머가 이미 있으면 정리 | |
90 | + if (this._processingTimer) { | |
91 | + clearInterval(this._processingTimer); | |
92 | + } | |
93 | + | |
94 | + // 처리 단계 진행 시뮬레이션을 위한 타이머 설정 | |
95 | + // 최대 95%까지만 채워서 실제 완료 전에는 100%가 되지 않도록 함 | |
96 | + this._processingTimer = setInterval(() => { | |
97 | + if (this.processingProgress < 95) { | |
98 | + // 처음에는 빠르게, 나중에는 천천히 증가하도록 설정 | |
99 | + const increment = Math.max(1, 10 - Math.floor(this.processingProgress / 10)); | |
100 | + this.processingProgress += increment; | |
101 | + } else { | |
102 | + // 95%에 도달하면 타이머 정지 | |
103 | + clearInterval(this._processingTimer); | |
104 | + this._processingTimer = null; | |
105 | + } | |
106 | + }, 500); // 0.5초마다 업데이트 | |
107 | + } | |
108 | + }, | |
109 | + | |
110 | + // 서버 처리 완료 메서드 (API 응답 수신 후 호출) | |
111 | + completeProcessing() { | |
112 | + // 처리 타이머 정리 | |
113 | + if (this._processingTimer) { | |
114 | + clearInterval(this._processingTimer); | |
115 | + this._processingTimer = null; | |
116 | + } | |
117 | + | |
118 | + // 처리 진행률 100%로 설정 | |
119 | + this.processingProgress = 100; | |
120 | + }, | |
121 | + | |
122 | + // 모달 닫기 메서드 (alert 표시 후 호출) | |
123 | + closeModal() { | |
124 | + this.resetState(); | |
125 | + }, | |
126 | + | |
127 | + // 오류 발생 시 호출할 메서드 | |
128 | + handleError() { | |
129 | + this.resetState(); | |
130 | + } | |
131 | +}); | |
132 | + | |
133 | +export default uploadProgressStore;(파일 끝에 줄바꿈 문자 없음) |
+++ client/resources/js/uploadService.js
... | ... | @@ -0,0 +1,109 @@ |
1 | +import { fileClient } from '@/resources/api/index'; | |
2 | +import uploadProgressStore from '@/resources/js/uploadProgressStore'; | |
3 | + | |
4 | +// 파일 업로드를 처리하는 서비스 | |
5 | +const uploadService = { | |
6 | + // POST 메서드를 사용한 업로드 (등록) | |
7 | + async uploadWithPost(url, formData, options = {}) { | |
8 | + // 파일 정보 추출 | |
9 | + const file = formData.get('multipartFiles') || formData.get('file'); | |
10 | + | |
11 | + if (file) { | |
12 | + // 업로드 상태 초기화 및 파일 전송 단계 시작 (이 초기화는 유지) | |
13 | + uploadProgressStore.startUpload(file.name, 1); | |
14 | + } | |
15 | + | |
16 | + try { | |
17 | + // 1단계: 파일 전송 단계 | |
18 | + uploadProgressStore.setStage('uploading'); | |
19 | + | |
20 | + // fileClient.post를 사용하여 업로드 요청 | |
21 | + const response = await fileClient.post(url, formData, { | |
22 | + ...options, | |
23 | + onUploadProgress: (progressEvent) => { | |
24 | + // 진행 상태 업데이트 | |
25 | + uploadProgressStore.updateProgress(progressEvent.loaded, progressEvent.total); | |
26 | + | |
27 | + // 사용자 정의 onUploadProgress 콜백이 있으면 호출 | |
28 | + if (options.onUploadProgress) { | |
29 | + options.onUploadProgress(progressEvent); | |
30 | + } | |
31 | + | |
32 | + // 업로드가 완료되면 처리 중 단계로 전환 | |
33 | + if (progressEvent.loaded === progressEvent.total) { | |
34 | + // setTimeout을 사용하여 처리 단계 전환을 강제로 비동기 실행 | |
35 | + setTimeout(() => { | |
36 | + uploadProgressStore.setStage('processing'); | |
37 | + }, 100); | |
38 | + } | |
39 | + } | |
40 | + }); | |
41 | + | |
42 | + // 서버 응답 수신 - 처리 완료 표시 | |
43 | + uploadProgressStore.completeProcessing(); | |
44 | + | |
45 | + // 잠시 후 응답 반환 (처리 완료 상태를 보여줄 시간 제공) | |
46 | + await new Promise(resolve => setTimeout(resolve, 500)); | |
47 | + | |
48 | + // 응답 반환 (alert은 API 함수에서 처리) | |
49 | + return response; | |
50 | + } catch (error) { | |
51 | + // 오류 발생 시 상태 초기화 | |
52 | + uploadProgressStore.handleError(); | |
53 | + throw error; // 오류 다시 throw | |
54 | + } | |
55 | + }, | |
56 | + | |
57 | + // PUT 메서드를 사용한 업로드 (수정) | |
58 | + async uploadWithPut(url, formData, options = {}) { | |
59 | + // 파일 정보 추출 | |
60 | + const file = formData.get('multipartFiles') || formData.get('file'); | |
61 | + | |
62 | + if (file) { | |
63 | + // 업로드 상태 초기화 및 파일 전송 단계 시작 (이 초기화는 유지) | |
64 | + uploadProgressStore.startUpload(file.name, 1); | |
65 | + } | |
66 | + | |
67 | + try { | |
68 | + // 1단계: 파일 전송 단계 | |
69 | + uploadProgressStore.setStage('uploading'); | |
70 | + | |
71 | + // fileClient.put을 사용하여 업로드 요청 | |
72 | + const response = await fileClient.put(url, formData, { | |
73 | + ...options, | |
74 | + onUploadProgress: (progressEvent) => { | |
75 | + // 진행 상태 업데이트 | |
76 | + uploadProgressStore.updateProgress(progressEvent.loaded, progressEvent.total); | |
77 | + | |
78 | + // 사용자 정의 onUploadProgress 콜백이 있으면 호출 | |
79 | + if (options.onUploadProgress) { | |
80 | + options.onUploadProgress(progressEvent); | |
81 | + } | |
82 | + | |
83 | + // 업로드가 완료되면 처리 중 단계로 전환 | |
84 | + if (progressEvent.loaded === progressEvent.total) { | |
85 | + // setTimeout을 사용하여 처리 단계 전환을 강제로 비동기 실행 | |
86 | + setTimeout(() => { | |
87 | + uploadProgressStore.setStage('processing'); | |
88 | + }, 100); | |
89 | + } | |
90 | + } | |
91 | + }); | |
92 | + | |
93 | + // 서버 응답 수신 - 처리 완료 표시 | |
94 | + uploadProgressStore.completeProcessing(); | |
95 | + | |
96 | + // 잠시 후 응답 반환 (처리 완료 상태를 보여줄 시간 제공) | |
97 | + await new Promise(resolve => setTimeout(resolve, 500)); | |
98 | + | |
99 | + // 응답 반환 (alert은 API 함수에서 처리) | |
100 | + return response; | |
101 | + } catch (error) { | |
102 | + // 오류 발생 시 상태 초기화 | |
103 | + uploadProgressStore.handleError(); | |
104 | + throw error; // 오류 다시 throw | |
105 | + } | |
106 | + } | |
107 | +}; | |
108 | + | |
109 | +export default uploadService;(파일 끝에 줄바꿈 문자 없음) |
--- client/views/App.vue
+++ client/views/App.vue
... | ... | @@ -1,25 +1,40 @@ |
1 | 1 |
<template> |
2 |
- <div class="wrapper "> |
|
2 |
+ <div class="wrapper "> |
|
3 | 3 |
<Header /> |
4 |
- <div class="container "><router-view /> |
|
5 |
- <button class="scroll-up" @click="scrollToTop"> |
|
6 |
- <img src="../resources/images/icon/top.png" alt=""> |
|
7 |
- </button> |
|
4 |
+ <div class="container "><router-view /> |
|
5 |
+ <button class="scroll-up" @click="scrollToTop"> |
|
6 |
+ <img src="../resources/images/icon/top.png" alt=""> |
|
7 |
+ </button> |
|
8 | 8 |
</div> |
9 |
- |
|
10 | 9 |
</div> |
11 | 10 |
<Footer /> |
11 |
+ <file-upload-progress /> |
|
12 | 12 |
</template> |
13 | 13 |
<script> |
14 | 14 |
import Header from './layout/Header.vue'; |
15 | 15 |
import Footer from './layout/Footer.vue'; |
16 | 16 |
|
17 |
-export default { |
|
17 |
+import FileUploadProgress from '@/views/component/FileUploadProgress.vue'; |
|
18 |
+import uploadProgressStore from '@/resources/js/uploadProgressStore'; |
|
18 | 19 |
|
20 |
+export default { |
|
19 | 21 |
components: { |
20 |
- Header: Header, |
|
21 |
- Footer: Footer, |
|
22 |
+ Header, |
|
23 |
+ Footer, |
|
24 |
+ FileUploadProgress, |
|
22 | 25 |
}, |
26 |
+ |
|
27 |
+ created() { |
|
28 |
+ // 앱 초기화 시 업로드 상태 초기화 |
|
29 |
+ uploadProgressStore.resetState(); |
|
30 |
+ |
|
31 |
+ // 라우터 변경 시 업로드 상태 초기화 |
|
32 |
+ this.$router.beforeEach((to, from, next) => { |
|
33 |
+ uploadProgressStore.resetState(); |
|
34 |
+ next(); |
|
35 |
+ }); |
|
36 |
+ }, |
|
37 |
+ |
|
23 | 38 |
async mounted() { |
24 | 39 |
// Access Token이 없거나 만료된 경우 새로 발급 요청 |
25 | 40 |
const token = this.$store.state.authorization; // Vuex 스토어에서 직접 가져오기 |
... | ... | @@ -27,6 +42,7 @@ |
27 | 42 |
await this.refreshToken(); |
28 | 43 |
} |
29 | 44 |
}, |
45 |
+ |
|
30 | 46 |
methods: { |
31 | 47 |
async refreshToken() { |
32 | 48 |
try { |
... | ... | @@ -58,6 +74,7 @@ |
58 | 74 |
this.$router.push('/login.page'); // 로그인 페이지로 리다이렉트 |
59 | 75 |
} |
60 | 76 |
}, |
77 |
+ |
|
61 | 78 |
scrollToTop() { |
62 | 79 |
window.scrollTo({ |
63 | 80 |
top: 0, |
... | ... | @@ -66,5 +83,4 @@ |
66 | 83 |
}, |
67 | 84 |
}, |
68 | 85 |
} |
69 |
-</script> |
|
70 |
-<style></style>(파일 끝에 줄바꿈 문자 없음) |
|
86 |
+</script>(파일 끝에 줄바꿈 문자 없음) |
+++ client/views/component/FileUploadProgress.vue
... | ... | @@ -0,0 +1,267 @@ |
1 | +// @/components/common/FileUploadProgress.vue <template> | |
2 | + <transition name="fade"> | |
3 | + <div v-if="progress.isUploading" class="upload-modal-overlay"> | |
4 | + <div class="upload-modal"> | |
5 | + <div class="upload-modal-content"> | |
6 | + <div class="upload-icon"> | |
7 | + <!-- 파일 업로드 중 아이콘 --> | |
8 | + <svg v-if="currentStage === 'uploading'" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
9 | + <path d="M12 16V8M12 8L9 11M12 8L15 11" stroke="#4285F4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> | |
10 | + <path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#4285F4" stroke-width="2" /> | |
11 | + </svg> | |
12 | + <!-- 파일 다운로드 중 아이콘 --> | |
13 | + <svg v-else-if="currentStage === 'downloading'" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
14 | + <path d="M12 8V16M12 16L9 13M12 16L15 13" stroke="#4285F4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> | |
15 | + <path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#4285F4" stroke-width="2" /> | |
16 | + </svg> | |
17 | + <!-- 서버 처리 중 스피너 아이콘 --> | |
18 | + <svg v-else-if="currentStage === 'processing'" class="spinner" width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
19 | + <path d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20Z" fill="#4CAF50" opacity="0.3" /> | |
20 | + <path d="M12 2V4C16.41 4.0013 19.998 7.59252 20 12C20 16.41 16.41 20 12 20C7.59 20 4 16.41 4 12C4 10.15 4.63 8.45 5.69 7.1L4.07 5.87C2.66 7.63 1.99 9.78 2 12C2.05 17.57 6.53 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" fill="#4CAF50" /> | |
21 | + </svg> | |
22 | + </div> | |
23 | + <!-- 단계별 제목 --> | |
24 | + <h3> | |
25 | + <span v-if="currentStage === 'uploading'">파일 업로드 진행 중</span> | |
26 | + <span v-else-if="currentStage === 'downloading'">파일 다운로드 진행 중</span> | |
27 | + <span v-else-if="currentStage === 'processing'">서버 처리 중...</span> | |
28 | + </h3> | |
29 | + <!-- 파일 정보 --> | |
30 | + <div class="file-info"> | |
31 | + <span class="file-name">{{ progress.currentFileName }}</span> | |
32 | + <span class="file-count">{{ progress.currentFileIndex }}/{{ progress.totalFiles }}</span> | |
33 | + </div> | |
34 | + <!-- 진행 바 (업로드/다운로드 중 또는 서버 처리 중에 따라 다른 진행 바 표시) --> | |
35 | + <div v-if="currentStage === 'uploading' || currentStage === 'downloading'" class="progress-bar-container"> | |
36 | + <div class="progress-bar-fill" :class="{ | |
37 | + 'downloading': currentStage === 'downloading', | |
38 | + 'simulated': isSimulated | |
39 | + }" :style="{ width: progress.totalProgress + '%' }"></div> | |
40 | + </div> | |
41 | + <div v-else-if="currentStage === 'processing'" class="progress-bar-container"> | |
42 | + <!-- 서버 처리 중일 때는 processingProgress 사용 --> | |
43 | + <div class="progress-bar-fill processing" :style="{ width: progress.processingProgress + '%' }"></div> | |
44 | + </div> | |
45 | + <!-- 진행 상태 메시지 --> | |
46 | + <div class="progress-percentage"> | |
47 | + <span v-if="currentStage === 'uploading'">{{ progress.totalProgress }}% 완료</span> | |
48 | + <span v-else-if="currentStage === 'downloading'"> | |
49 | + <template v-if="progress.totalProgress <= 5">다운로드 요청 중...</template> | |
50 | + <template v-else-if="progress.totalProgress < 40 && isSimulated">다운로드 준비 중... {{ progress.totalProgress }}%</template> | |
51 | + <template v-else-if="progress.totalProgress < 40">다운로드 준비 완료</template> | |
52 | + <template v-else>{{ progress.totalProgress }}% 완료</template> | |
53 | + </span> | |
54 | + <span v-else-if="currentStage === 'processing' && progress.processingProgress < 100"> 처리 중... {{ progress.processingProgress }}% </span> | |
55 | + <span v-else-if="currentStage === 'processing' && progress.processingProgress === 100"> 처리 완료! </span> | |
56 | + </div> | |
57 | + </div> | |
58 | + </div> | |
59 | + </div> | |
60 | + </transition> | |
61 | +</template> | |
62 | +<script> | |
63 | +import uploadProgressStore from '@/resources/js/uploadProgressStore'; | |
64 | + | |
65 | +export default { | |
66 | + name: 'FileUploadProgress', | |
67 | + | |
68 | + data() { | |
69 | + return { | |
70 | + progress: uploadProgressStore, | |
71 | + currentStage: 'uploading', | |
72 | + isSimulated: false, | |
73 | + // 진행률 변화 추적을 위한 변수 | |
74 | + lastProgress: 0, | |
75 | + lastProgressUpdateTime: 0, | |
76 | + progressCheckTimer: null | |
77 | + }; | |
78 | + }, | |
79 | + | |
80 | + created() { | |
81 | + // 초기 상태 설정 | |
82 | + this.currentStage = this.progress.stage || 'uploading'; | |
83 | + this.lastProgressUpdateTime = Date.now(); | |
84 | + | |
85 | + // 진행률 변화 감지 타이머 설정 | |
86 | + this.progressCheckTimer = setInterval(() => { | |
87 | + const now = Date.now(); | |
88 | + | |
89 | + // 진행률이 정체되어 있으면 시뮬레이션 중으로 간주 | |
90 | + if (this.progress.totalProgress === this.lastProgress) { | |
91 | + // 1초 이상 정체된 경우에만 시뮬레이션으로 판단 | |
92 | + if (now - this.lastProgressUpdateTime > 1000) { | |
93 | + this.isSimulated = true; | |
94 | + } | |
95 | + } else { | |
96 | + // 진행률이 변경되면 타임스탬프 업데이트 | |
97 | + this.lastProgressUpdateTime = now; | |
98 | + this.isSimulated = false; | |
99 | + } | |
100 | + | |
101 | + this.lastProgress = this.progress.totalProgress; | |
102 | + }, 500); | |
103 | + }, | |
104 | + | |
105 | + beforeDestroy() { | |
106 | + // 타이머 정리 | |
107 | + if (this.progressCheckTimer) { | |
108 | + clearInterval(this.progressCheckTimer); | |
109 | + } | |
110 | + }, | |
111 | + | |
112 | + watch: { | |
113 | + // progress.stage 변경 감시 | |
114 | + 'progress.stage'(newStage) { | |
115 | + this.currentStage = newStage; | |
116 | + }, | |
117 | + | |
118 | + // 진행률 변경 감시 | |
119 | + 'progress.totalProgress'(newValue, oldValue) { | |
120 | + if (newValue !== oldValue) { | |
121 | + this.lastProgressUpdateTime = Date.now(); | |
122 | + | |
123 | + // 진행률이 크게 변경된 경우 (실제 진행률 업데이트로 간주) | |
124 | + if (Math.abs(newValue - oldValue) > 5) { | |
125 | + this.isSimulated = false; | |
126 | + } | |
127 | + } | |
128 | + } | |
129 | + } | |
130 | +} | |
131 | +</script> | |
132 | +<style scoped> | |
133 | +.fade-enter-active, | |
134 | +.fade-leave-active { | |
135 | + transition: opacity 0.3s; | |
136 | +} | |
137 | + | |
138 | +.fade-enter-from, | |
139 | +.fade-leave-to { | |
140 | + opacity: 0; | |
141 | +} | |
142 | + | |
143 | +.upload-modal-overlay { | |
144 | + position: fixed; | |
145 | + top: 0; | |
146 | + left: 0; | |
147 | + width: 100%; | |
148 | + height: 100%; | |
149 | + background-color: rgba(0, 0, 0, 0.5); | |
150 | + display: flex; | |
151 | + justify-content: center; | |
152 | + align-items: center; | |
153 | + z-index: 9999; | |
154 | +} | |
155 | + | |
156 | +.upload-modal { | |
157 | + background-color: white; | |
158 | + border-radius: 8px; | |
159 | + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); | |
160 | + width: 360px; | |
161 | + padding: 0; | |
162 | + overflow: hidden; | |
163 | +} | |
164 | + | |
165 | +.upload-modal-content { | |
166 | + padding: 24px; | |
167 | + display: flex; | |
168 | + flex-direction: column; | |
169 | + align-items: center; | |
170 | +} | |
171 | + | |
172 | +.upload-icon { | |
173 | + margin-bottom: 16px; | |
174 | +} | |
175 | + | |
176 | +/* 스피너 애니메이션 */ | |
177 | +.spinner { | |
178 | + animation: spin 1.5s linear infinite; | |
179 | +} | |
180 | + | |
181 | +@keyframes spin { | |
182 | + 0% { | |
183 | + transform: rotate(0deg); | |
184 | + } | |
185 | + | |
186 | + 100% { | |
187 | + transform: rotate(360deg); | |
188 | + } | |
189 | +} | |
190 | + | |
191 | +h3 { | |
192 | + font-size: 18px; | |
193 | + font-weight: 600; | |
194 | + margin: 0 0 16px 0; | |
195 | + color: #333; | |
196 | +} | |
197 | + | |
198 | +.file-info { | |
199 | + width: 100%; | |
200 | + margin-bottom: 12px; | |
201 | + display: flex; | |
202 | + justify-content: space-between; | |
203 | + font-size: 14px; | |
204 | +} | |
205 | + | |
206 | +.file-name { | |
207 | + color: #555; | |
208 | + max-width: 240px; | |
209 | + white-space: nowrap; | |
210 | + overflow: hidden; | |
211 | + text-overflow: ellipsis; | |
212 | +} | |
213 | + | |
214 | +.file-count { | |
215 | + color: #777; | |
216 | +} | |
217 | + | |
218 | +.progress-bar-container { | |
219 | + width: 100%; | |
220 | + height: 8px; | |
221 | + background-color: #f0f0f0; | |
222 | + border-radius: 4px; | |
223 | + overflow: hidden; | |
224 | + margin-bottom: 8px; | |
225 | +} | |
226 | + | |
227 | +.progress-bar-fill { | |
228 | + height: 100%; | |
229 | + background-color: #4285F4; | |
230 | + transition: width 0.3s ease; | |
231 | +} | |
232 | + | |
233 | +/* 다운로드 중일 때 진행 바 스타일 */ | |
234 | +.progress-bar-fill.downloading { | |
235 | + background-color: #34A853; | |
236 | +} | |
237 | + | |
238 | +/* 시뮬레이션 중일 때 진행 바 스타일 */ | |
239 | +.progress-bar-fill.simulated { | |
240 | + background: linear-gradient(90deg, #34A853 25%, #66BB6A 50%, #34A853 75%); | |
241 | + background-size: 200% 100%; | |
242 | + animation: loading 2s infinite linear; | |
243 | +} | |
244 | + | |
245 | +/* 서버 처리 중일 때 진행 바 스타일 */ | |
246 | +.progress-bar-fill.processing { | |
247 | + background: linear-gradient(90deg, #4CAF50 25%, #81C784 50%, #4CAF50 75%); | |
248 | + background-size: 200% 100%; | |
249 | + animation: loading 1.5s infinite linear; | |
250 | +} | |
251 | + | |
252 | +@keyframes loading { | |
253 | + 0% { | |
254 | + background-position: 100% 50%; | |
255 | + } | |
256 | + | |
257 | + 100% { | |
258 | + background-position: 0% 50%; | |
259 | + } | |
260 | +} | |
261 | + | |
262 | +.progress-percentage { | |
263 | + font-size: 14px; | |
264 | + color: #555; | |
265 | + font-weight: 500; | |
266 | +} | |
267 | +</style>(파일 끝에 줄바꿈 문자 없음) |
--- client/views/component/player/VideoComponent.vue
+++ client/views/component/player/VideoComponent.vue
... | ... | @@ -5,7 +5,9 @@ |
5 | 5 |
<p>이 비디오 형식은 웹 브라우저에서 직접 재생할 수 없습니다. 다운로드 버튼을 이용해 원본 파일을 다운로드하세요.</p> |
6 | 6 |
</div> |
7 | 7 |
<!-- 비디오 플레이어 --> |
8 |
- <video ref="videoPlayer" class="video-element" :src="videoUrl" controls @error="onError"></video> |
|
8 |
+ <!-- <video ref="videoPlayer" class="video-element" :src="videoUrl" controls @error="onError"></video> --> |
|
9 |
+ <video ref="videoPlayer" class="video-element" :src="videoUrl" controls controlsList="nodownload" oncontextmenu="return false;" @error="onError"> |
|
10 |
+ </video> |
|
9 | 11 |
</div> |
10 | 12 |
</template> |
11 | 13 |
<script> |
--- client/views/pages/AppRouter.js
+++ client/views/pages/AppRouter.js
... | ... | @@ -1,5 +1,7 @@ |
1 |
-import { createWebHistory, createRouter } from "vue-router"; |
|
2 | 1 |
import store from "./AppStore"; |
2 |
+import uploadProgressStore from '@/resources/js/uploadProgressStore'; |
|
3 |
+import { createWebHistory, createRouter } from "vue-router"; |
|
4 |
+ |
|
3 | 5 |
import { updateStatsByMenuId } from "../../resources/api/main"; |
4 | 6 |
|
5 | 7 |
// 공통 |
... | ... | @@ -165,6 +167,9 @@ |
165 | 167 |
} |
166 | 168 |
} |
167 | 169 |
|
170 |
+ // 모든 페이지 이동 시 업로드 상태 초기화 |
|
171 |
+ uploadProgressStore.resetState(); |
|
172 |
+ |
|
168 | 173 |
next(); |
169 | 174 |
}); |
170 | 175 |
|
--- client/views/pages/bbsDcryPhoto/PicHistoryDetail.vue
+++ client/views/pages/bbsDcryPhoto/PicHistoryDetail.vue
... | ... | @@ -38,7 +38,7 @@ |
38 | 38 |
<img :src="item.filePath" :alt="item.fileNm" /> |
39 | 39 |
</figure> |
40 | 40 |
<div class="float-div"> |
41 |
- <button type="button" class="btn-sm red" @click="fnDownload('selected', item)">다운로드</button> |
|
41 |
+ <button type="button" class="btn-sm red" @click="fnDownload('single', item)">다운로드</button> |
|
42 | 42 |
</div> |
43 | 43 |
</div> |
44 | 44 |
</div> |
... | ... | @@ -82,53 +82,18 @@ |
82 | 82 |
<button class="gray-line-bg " type="button" @click="fnMoveTo('list')">목록</button> |
83 | 83 |
</div> |
84 | 84 |
</div> |
85 |
- <div v-if="loading" class="loading-overlay"> |
|
86 |
- <div class="loading-spinner"></div> |
|
87 |
- <div> |
|
88 |
- <p>다운로드 중입니다</p> |
|
89 |
- <p>잠시만 기다려주세요</p> |
|
90 |
- </div> |
|
91 |
- </div> |
|
92 | 85 |
</template> |
93 | 86 |
<script> |
94 |
-import { ref } from 'vue'; |
|
95 |
-// Import Swiper Vue components |
|
96 |
-import { CaretRightOutlined, PauseOutlined } from '@ant-design/icons-vue'; |
|
97 |
-import { Swiper, SwiperSlide } from 'swiper/vue'; |
|
98 |
-// Import Swiper styles |
|
99 |
-import 'swiper/css'; |
|
100 |
-import 'swiper/css/free-mode'; |
|
101 |
-import 'swiper/css/navigation'; |
|
102 |
-import 'swiper/css/thumbs'; |
|
103 |
-// import required modules |
|
104 |
-import { FreeMode, Navigation, Thumbs } from 'swiper/modules'; |
|
105 | 87 |
// COMPONENT |
106 | 88 |
import ViewerComponent from '@/views/component/editor/ViewerComponent.vue'; |
107 | 89 |
// API |
90 |
+import uploadProgressStore from '@/resources/js/uploadProgressStore'; |
|
108 | 91 |
import { findDcryProc, deleteDcryProc } from '@/resources/api/dcry'; |
109 | 92 |
import { fileDownloadProc, multiFileDownloadProc } from '@/resources/api/file'; |
110 | 93 |
|
111 | 94 |
export default { |
112 | 95 |
components: { |
113 |
- PauseOutlined, |
|
114 |
- CaretRightOutlined, |
|
115 |
- Swiper, |
|
116 |
- SwiperSlide, |
|
117 | 96 |
ViewerComponent, |
118 |
- }, |
|
119 |
- |
|
120 |
- setup() { |
|
121 |
- const thumbsSwiper = ref(null); |
|
122 |
- |
|
123 |
- const setThumbsSwiper = (swiper) => { |
|
124 |
- thumbsSwiper.value = swiper; |
|
125 |
- }; |
|
126 |
- |
|
127 |
- return { |
|
128 |
- thumbsSwiper, |
|
129 |
- setThumbsSwiper, |
|
130 |
- modules: [FreeMode, Navigation, Thumbs], |
|
131 |
- }; |
|
132 | 97 |
}, |
133 | 98 |
|
134 | 99 |
data() { |
... | ... | @@ -175,10 +140,6 @@ |
175 | 140 |
this.currentImg = img; |
176 | 141 |
}, |
177 | 142 |
|
178 |
- handleMainSlideChange(swiper) { |
|
179 |
- this.activeIndex = swiper.realIndex; |
|
180 |
- }, |
|
181 |
- |
|
182 | 143 |
// 상세 조회 |
183 | 144 |
async fnFindDcry() { |
184 | 145 |
try { |
... | ... | @@ -206,14 +167,12 @@ |
206 | 167 |
|
207 | 168 |
// 파일 다운로드 |
208 | 169 |
async fnDownload(type, file) { |
209 |
- this.loading = true; |
|
210 |
- |
|
211 | 170 |
let url = null; |
212 | 171 |
let link = null; |
213 | 172 |
|
214 | 173 |
try { |
215 | 174 |
let fileList = []; |
216 |
- if (type === 'selected') { |
|
175 |
+ if (type === 'single') { |
|
217 | 176 |
fileList.push(file); |
218 | 177 |
} else if (type === 'all') { |
219 | 178 |
fileList = this.findResult.files; |
... | ... | @@ -222,35 +181,45 @@ |
222 | 181 |
let isMultiple = fileList.length > 1; |
223 | 182 |
let files = isMultiple ? fileList : fileList[0]; |
224 | 183 |
|
225 |
- // Call the API to get the file data |
|
226 | 184 |
const response = isMultiple ? await multiFileDownloadProc(files) : await fileDownloadProc(files); |
227 | 185 |
|
228 |
- // 파일명 추출 부분 수정 |
|
229 |
- let filename = isMultiple ? 'downloadFile.zip' : 'downloadFile.bin'; |
|
230 |
- const disposition = response.headers['content-disposition']; |
|
231 |
- if (disposition && disposition.includes('filename=')) { |
|
232 |
- const filenameRegex = /filename=["']?([^"';\n]*)["']?/i; |
|
233 |
- const matches = disposition.match(filenameRegex); |
|
234 |
- if (matches && matches[1]) { |
|
235 |
- filename = decodeURIComponent(matches[1]); |
|
186 |
+ let filename; |
|
187 |
+ if (type === 'single') { |
|
188 |
+ filename = fileList[0].fileNm; |
|
189 |
+ } else if (type === 'all') { |
|
190 |
+ const contentDisposition = response.headers['content-disposition']; |
|
191 |
+ if (contentDisposition) { |
|
192 |
+ const filenameRegex = /file[Nn]ame[^;=\n]*=((['"]).*?\2|[^;\n]*)/; |
|
193 |
+ const matches = filenameRegex.exec(contentDisposition); |
|
194 |
+ |
|
195 |
+ if (matches != null && matches[1]) { |
|
196 |
+ let extractedName = matches[1].replace(/['"]/g, ''); |
|
197 |
+ |
|
198 |
+ try { |
|
199 |
+ filename = decodeURIComponent(extractedName.replace(/\+/g, ' ')); |
|
200 |
+ } catch (e) { |
|
201 |
+ console.warn('파일명 디코딩 실패:', e); |
|
202 |
+ filename = extractedName.replace(/\+/g, ' '); |
|
203 |
+ } |
|
204 |
+ } |
|
236 | 205 |
} |
237 | 206 |
} |
238 | 207 |
|
239 |
- // 파일 다운로드 처리 |
|
240 |
- const blob = new Blob([response.data]); |
|
241 |
- const downloadUrl = window.URL.createObjectURL(blob); |
|
242 |
- const downloadLink = document.createElement('a'); |
|
243 |
- downloadLink.href = downloadUrl; |
|
244 |
- downloadLink.setAttribute('download', filename); |
|
245 |
- document.body.appendChild(downloadLink); |
|
246 |
- downloadLink.click(); |
|
208 |
+ // 파일 다운로드 생성 |
|
209 |
+ const url = window.URL.createObjectURL(new Blob([response.data])); |
|
210 |
+ const link = document.createElement('a'); |
|
211 |
+ link.href = url; |
|
212 |
+ link.setAttribute('download', filename); |
|
213 |
+ document.body.appendChild(link); |
|
214 |
+ link.click(); |
|
215 |
+ |
|
216 |
+ uploadProgressStore.closeModal(); |
|
247 | 217 |
} catch (error) { |
248 | 218 |
alert('파일 다운로드 중 오류가 발생했습니다.'); |
219 |
+ uploadProgressStore.closeModal(); |
|
249 | 220 |
} finally { |
250 |
- // Hide loading spinner and clean up |
|
221 |
+ // 리소스 정리 |
|
251 | 222 |
setTimeout(() => { |
252 |
- this.loading = false; // Hide loading spinner |
|
253 |
- |
|
254 | 223 |
if (url) { |
255 | 224 |
window.URL.revokeObjectURL(url); |
256 | 225 |
} |
--- client/views/pages/bbsDcryPhoto/PicHistoryInsert.vue
+++ client/views/pages/bbsDcryPhoto/PicHistoryInsert.vue
... | ... | @@ -115,27 +115,24 @@ |
115 | 115 |
<p>잠시만 기다려주세요</p> |
116 | 116 |
</div> |
117 | 117 |
</div> |
118 |
+ <file-upload-progress /> |
|
118 | 119 |
</template> |
119 | 120 |
<script> |
120 |
-import { DoubleLeftOutlined, LeftOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'; |
|
121 | 121 |
// COMPONENT |
122 | 122 |
import PrdctnSelectComponent from '@/views/component/PrdctnSelectComponent.vue'; |
123 | 123 |
import EditorComponent from '@/views/component/editor/EditorComponent.vue'; |
124 | 124 |
import CategoryListComponent from '@/views/component/CategoryListComponent.vue'; |
125 |
+import FileUploadProgress from '@/views/component/FileUploadProgress.vue'; |
|
125 | 126 |
// API |
126 |
-import { findDcryProc, saveDcry, updateDcry } from '@/resources/api/dcry'; |
|
127 |
+import { findDcryProc, saveDcryProc, updateDcryProc } from '@/resources/api/dcry'; |
|
127 | 128 |
|
128 | 129 |
export default { |
129 | 130 |
components: { |
130 |
- DoubleLeftOutlined, |
|
131 |
- LeftOutlined, |
|
132 |
- RightOutlined, |
|
133 |
- DoubleRightOutlined, |
|
134 |
- PrdctnSelectComponent, |
|
135 | 131 |
// COMPONENT |
136 | 132 |
PrdctnSelectComponent, |
137 | 133 |
EditorComponent, |
138 | 134 |
CategoryListComponent, |
135 |
+ FileUploadProgress, |
|
139 | 136 |
}, |
140 | 137 |
|
141 | 138 |
data() { |
... | ... | @@ -441,7 +438,7 @@ |
441 | 438 |
} |
442 | 439 |
|
443 | 440 |
// API 통신 |
444 |
- const response = this.$isEmpty(this.pageId) ? await saveDcry(formData) : await updateDcry(formData); |
|
441 |
+ const response = this.$isEmpty(this.pageId) ? await saveDcryProc(formData) : await updateDcryProc(formData); |
|
445 | 442 |
let id = response.data.data.dcryId; |
446 | 443 |
alert(this.$isEmpty(this.pageId) ? "등록되었습니다." : "수정되었습니다."); |
447 | 444 |
|
--- client/views/pages/bbsDcryVideo/VideoHistoryDetail.vue
+++ client/views/pages/bbsDcryVideo/VideoHistoryDetail.vue
... | ... | @@ -62,10 +62,6 @@ |
62 | 62 |
<button v-if="isRegister" type="button" class="blue-line" @click="fnMoveTo('edit', pageId)">수정</button> |
63 | 63 |
<button type="button" class="gray-line-bg" @click="fnMoveTo('list')">목록</button> |
64 | 64 |
<button type="button" class="gradient" @click="fnDownload">다운로드</button> |
65 |
- <div v-if="loading" class="loading-overlay"> |
|
66 |
- <div class="loading-spinner"></div> |
|
67 |
- <p>파일을 다운로드 중입니다...</p> |
|
68 |
- </div> |
|
69 | 65 |
</div> |
70 | 66 |
</div> |
71 | 67 |
</template> |
... | ... | @@ -74,6 +70,7 @@ |
74 | 70 |
import VideoComponent from '@/views/component/player/VideoComponent.vue'; |
75 | 71 |
import ViewerComponent from '@/views/component/editor/ViewerComponent.vue'; |
76 | 72 |
// API |
73 |
+import uploadProgressStore from '@/resources/js/uploadProgressStore'; |
|
77 | 74 |
import { findDcryProc, deleteDcryProc } from '@/resources/api/dcry'; |
78 | 75 |
import { fileDownloadProc } from '@/resources/api/file'; |
79 | 76 |
|
... | ... | @@ -97,7 +94,6 @@ |
97 | 94 |
pageId: null, |
98 | 95 |
findResult: {}, |
99 | 96 |
selectedFiles: [], |
100 |
- loading: false, |
|
101 | 97 |
|
102 | 98 |
isRegister: false, |
103 | 99 |
}; |
... | ... | @@ -142,30 +138,45 @@ |
142 | 138 |
async fnDownload() { |
143 | 139 |
let url = null; |
144 | 140 |
let link = null; |
145 |
- this.loading = true; // Show loading spinner |
|
146 | 141 |
|
147 | 142 |
try { |
148 |
- // 파일 ID 수집 |
|
149 |
- let file = this.findResult.files[0]; |
|
143 |
+ const file = this.findResult.files[0]; |
|
150 | 144 |
const response = await fileDownloadProc(file); |
151 | 145 |
|
152 |
- // 파일명 조회 |
|
153 |
- let filename = 'downloadFile.bin'; |
|
154 |
- const filenameRegex = /file[Nn]ame[^;=\n]*=((['"]).*?\2|[^;\n]*)/; |
|
155 |
- const matches = filenameRegex.exec(response.headers['content-disposition']); |
|
156 |
- if (matches != null && matches[1]) { |
|
157 |
- filename = matches[1].replace(/['"]/g, ''); |
|
146 |
+ let filename; |
|
147 |
+ if (file.fileNm) { |
|
148 |
+ filename = file.fileNm; |
|
149 |
+ } else { |
|
150 |
+ const contentDisposition = response.headers['content-disposition']; |
|
151 |
+ if (contentDisposition) { |
|
152 |
+ const filenameRegex = /file[Nn]ame[^;=\n]*=((['"]).*?\2|[^;\n]*)/; |
|
153 |
+ const matches = filenameRegex.exec(contentDisposition); |
|
154 |
+ |
|
155 |
+ if (matches != null && matches[1]) { |
|
156 |
+ let extractedName = matches[1].replace(/['"]/g, ''); |
|
157 |
+ |
|
158 |
+ try { |
|
159 |
+ filename = decodeURIComponent(extractedName.replace(/\+/g, ' ')); |
|
160 |
+ } catch (e) { |
|
161 |
+ console.warn('파일명 디코딩 실패:', e); |
|
162 |
+ filename = extractedName.replace(/\+/g, ' '); |
|
163 |
+ } |
|
164 |
+ } |
|
165 |
+ } |
|
158 | 166 |
} |
159 | 167 |
|
160 | 168 |
// 파일 다운로드 생성 |
161 |
- url = window.URL.createObjectURL(new Blob([response.data])); |
|
162 |
- link = document.createElement('a'); |
|
169 |
+ const url = window.URL.createObjectURL(new Blob([response.data])); |
|
170 |
+ const link = document.createElement('a'); |
|
163 | 171 |
link.href = url; |
164 | 172 |
link.setAttribute('download', filename); |
165 | 173 |
document.body.appendChild(link); |
166 | 174 |
link.click(); |
175 |
+ |
|
176 |
+ uploadProgressStore.closeModal(); |
|
167 | 177 |
} catch (error) { |
168 | 178 |
alert('파일 다운로드 중 오류가 발생했습니다.'); |
179 |
+ uploadProgressStore.closeModal(); |
|
169 | 180 |
} finally { |
170 | 181 |
// 리소스 정리 |
171 | 182 |
setTimeout(() => { |
... | ... | @@ -175,7 +186,6 @@ |
175 | 186 |
if (link && link.parentNode) { |
176 | 187 |
document.body.removeChild(link); |
177 | 188 |
} |
178 |
- this.loading = false; // Hide loading spinner |
|
179 | 189 |
}, 100); |
180 | 190 |
} |
181 | 191 |
}, |
--- client/views/pages/bbsDcryVideo/VideoHistoryInsert.vue
+++ client/views/pages/bbsDcryVideo/VideoHistoryInsert.vue
... | ... | @@ -61,13 +61,16 @@ |
61 | 61 |
<div id="fileNames" class="file-names"> |
62 | 62 |
<div v-if="requestDTO.files.length === 0 && multipartFiles.length === 0">선택된 파일이 없습니다.</div> |
63 | 63 |
<!-- 기존 등록된 파일 목록 --> |
64 |
- <div v-for="(file, idx) of requestDTO.files" :key="idx" class="flex-sp-bw mb-5 file-wrap"> |
|
65 |
- <div class="file-name"> |
|
66 |
- <img src="/client/resources/images/icon/imgicon.png" alt="fileicon"> |
|
67 |
- <p>{{ file.fileNm }}</p> |
|
64 |
+ <template v-for="(file, idx) of requestDTO.files" :key="idx"> |
|
65 |
+ <div v-if="file.fileOrdr == 1" class="flex-sp-bw mb-5 file-wrap"> |
|
66 |
+ <div class="file-name"> |
|
67 |
+ <img src="/client/resources/images/icon/imgicon.png" alt="fileicon"> |
|
68 |
+ <p>{{ file.fileNm }}</p> |
|
69 |
+ </div> |
|
70 |
+ <button type="button" class="cancel" @click="fnDelFile('old', file.fileOrdr)"><b>✕</b></button> |
|
68 | 71 |
</div> |
69 |
- <button type="button" class="cancel" @click="fnDelFile('old', file.fileOrdr)"><b>✕</b></button> |
|
70 |
- </div><!-- 새로 추가된 파일 목록 --> |
|
72 |
+ </template> |
|
73 |
+ <!-- 새로 추가된 파일 목록 --> |
|
71 | 74 |
<div v-for="(file, idx) of multipartFiles" :key="idx" class="flex-sp-bw mb-5 file-wrap"> |
72 | 75 |
<div class="file-name"> |
73 | 76 |
<img src="/client/resources/images/icon/imgicon.png" alt="fileicon"> |
... | ... | @@ -96,26 +99,24 @@ |
96 | 99 |
<p>잠시만 기다려주세요</p> |
97 | 100 |
</div> |
98 | 101 |
</div> |
102 |
+ <file-upload-progress /> |
|
99 | 103 |
</template> |
100 | 104 |
<script> |
101 |
-import { DoubleLeftOutlined, LeftOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'; |
|
102 | 105 |
// COMPONENT |
103 | 106 |
import PrdctnSelectComponent from '@/views/component/PrdctnSelectComponent.vue'; |
104 | 107 |
import EditorComponent from '@/views/component/editor/EditorComponent.vue'; |
105 | 108 |
import CategoryListComponent from '@/views/component/CategoryListComponent.vue'; |
109 |
+import FileUploadProgress from '@/views/component/FileUploadProgress.vue'; |
|
106 | 110 |
// API |
107 |
-import { findDcryProc, saveDcry, updateDcry } from '@/resources/api/dcry'; |
|
111 |
+import uploadProgressStore from '@/resources/js/uploadProgressStore'; |
|
112 |
+import { findDcryProc, saveDcryProc, updateDcryProc } from '@/resources/api/dcry'; |
|
108 | 113 |
|
109 | 114 |
export default { |
110 | 115 |
components: { |
111 |
- DoubleLeftOutlined, |
|
112 |
- LeftOutlined, |
|
113 |
- RightOutlined, |
|
114 |
- DoubleRightOutlined, |
|
115 |
- // COMPONENT |
|
116 | 116 |
PrdctnSelectComponent, |
117 | 117 |
EditorComponent, |
118 | 118 |
CategoryListComponent, |
119 |
+ FileUploadProgress, |
|
119 | 120 |
}, |
120 | 121 |
|
121 | 122 |
data() { |
... | ... | @@ -166,6 +167,9 @@ |
166 | 167 |
if (!this.$isEmpty(this.pageId)) { |
167 | 168 |
this.fnFindDcry(); // 상세 조회 |
168 | 169 |
} |
170 |
+ |
|
171 |
+ // 업로드 상태 초기화 - 모달이 떠있는 문제 해결 |
|
172 |
+ uploadProgressStore.resetState(); |
|
169 | 173 |
}, |
170 | 174 |
|
171 | 175 |
methods: { |
... | ... | @@ -229,7 +233,7 @@ |
229 | 233 |
|
230 | 234 |
// 파일 업로드 처리 함수 |
231 | 235 |
processFiles(files) { |
232 |
- const allowedTypes = ['mp4', 'mov', 'avi', 'wmv', 'mkv', 'webm', 'mxf']; // 'mxf' 추가 |
|
236 |
+ const allowedTypes = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'mxf']; |
|
233 | 237 |
const maxSize = 10 * 1024 * 1024 * 1024; // 10GB |
234 | 238 |
|
235 | 239 |
// 유효성 검사 |
... | ... | @@ -244,7 +248,7 @@ |
244 | 248 |
|
245 | 249 |
// 파일 타입 검증 |
246 | 250 |
if (!allowedTypes.includes(fileType)) { |
247 |
- alert(`${file.name} 파일은 허용되지 않는 형식입니다. 영상 파일(mp4, mov, avi, wmv, mkv, webm, mxf)만 업로드 가능합니다.`); |
|
251 |
+ alert(`${file.name} 파일은 허용되지 않는 형식입니다. 영상 파일(mp4, avi, mov, wmv, flv, mkv, webm, mxf)만 업로드 가능합니다.`); |
|
248 | 252 |
this.resetFileInput(); // input 파일 목록 비움 |
249 | 253 |
return; |
250 | 254 |
} |
... | ... | @@ -277,7 +281,7 @@ |
277 | 281 |
this.resetFileInput(); // input 파일 목록 비움 |
278 | 282 |
} |
279 | 283 |
} else if (type === 'old') { |
280 |
- this.requestDTO.files = this.requestDTO.files.filter(item => item.fileOrdr !== separator); |
|
284 |
+ this.requestDTO.files = []; |
|
281 | 285 |
} |
282 | 286 |
}, |
283 | 287 |
|
... | ... | @@ -365,7 +369,7 @@ |
365 | 369 |
} |
366 | 370 |
|
367 | 371 |
// API 통신 |
368 |
- const response = this.$isEmpty(this.pageId) ? await saveDcry(formData) : await updateDcry(formData); |
|
372 |
+ const response = this.$isEmpty(this.pageId) ? await saveDcryProc(formData) : await updateDcryProc(formData); |
|
369 | 373 |
let id = response.data.data.dcryId; |
370 | 374 |
alert(this.$isEmpty(this.pageId) ? "등록되었습니다." : "수정되었습니다."); |
371 | 375 |
|
--- client/views/pages/bbsNesDta/NewsReleaseInsert.vue
+++ client/views/pages/bbsNesDta/NewsReleaseInsert.vue
... | ... | @@ -61,14 +61,6 @@ |
61 | 61 |
<p class="mb-10">파일목록</p> |
62 | 62 |
<div id="fileNames" class="file-names"> |
63 | 63 |
<div v-if="requestDTO.files.length === 0 && multipartFiles.length === 0">선택된 파일이 없습니다.</div> |
64 |
- <!-- 새로 추가된 파일 목록 --> |
|
65 |
- <div v-for="(file, idx) of multipartFiles" :key="idx" class="flex-sp-bw mb-5 file-wrap"> |
|
66 |
- <div class="file-name"> |
|
67 |
- <img src="/client/resources/images/icon/imgicon.png" alt="fileicon"> |
|
68 |
- <p>{{ file.name }}</p> |
|
69 |
- </div> |
|
70 |
- <button type="button" class="cancel" @click="fnDelFile('new', idx)"><b>✕</b></button> |
|
71 |
- </div> |
|
72 | 64 |
<!-- 기존 등록된 파일 목록 --> |
73 | 65 |
<div v-for="(file, idx) of requestDTO.files" :key="idx" class="flex-sp-bw mb-5 file-wrap"> |
74 | 66 |
<div class="file-name"> |
... | ... | @@ -76,6 +68,14 @@ |
76 | 68 |
<p>{{ file.fileNm }}</p> |
77 | 69 |
</div> |
78 | 70 |
<button type="button" class="cancel" @click="fnDelFile('old', file.fileId)"><b>✕</b></button> |
71 |
+ </div> |
|
72 |
+ <!-- 새로 추가된 파일 목록 --> |
|
73 |
+ <div v-for="(file, idx) of multipartFiles" :key="idx" class="flex-sp-bw mb-5 file-wrap"> |
|
74 |
+ <div class="file-name"> |
|
75 |
+ <img src="/client/resources/images/icon/imgicon.png" alt="fileicon"> |
|
76 |
+ <p>{{ file.name }}</p> |
|
77 |
+ </div> |
|
78 |
+ <button type="button" class="cancel" @click="fnDelFile('new', idx)"><b>✕</b></button> |
|
79 | 79 |
</div> |
80 | 80 |
</div> |
81 | 81 |
</li> |
... | ... | @@ -91,26 +91,23 @@ |
91 | 91 |
</button> |
92 | 92 |
</div> |
93 | 93 |
</div> |
94 |
+ <file-upload-progress /> |
|
94 | 95 |
</template> |
95 | 96 |
<script> |
96 |
-import { DoubleLeftOutlined, LeftOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons-vue'; |
|
97 | 97 |
// COMPONENT |
98 | 98 |
import PrdctnSelectComponent from '@/views/component/PrdctnSelectComponent.vue'; |
99 | 99 |
import EditorComponent from '@/views/component/editor/EditorComponent.vue'; |
100 | 100 |
import CategoryListComponent from '@/views/component/CategoryListComponent.vue'; |
101 |
+import FileUploadProgress from '@/views/component/FileUploadProgress.vue'; |
|
101 | 102 |
// API |
102 | 103 |
import { findNesDtaProc, saveNesDtaProc, updateNesDtaProc } from '@/resources/api/nesDta'; |
103 | 104 |
|
104 | 105 |
export default { |
105 | 106 |
components: { |
106 |
- DoubleLeftOutlined, |
|
107 |
- LeftOutlined, |
|
108 |
- RightOutlined, |
|
109 |
- DoubleRightOutlined, |
|
110 |
- // COMPONENT |
|
111 | 107 |
PrdctnSelectComponent, |
112 | 108 |
EditorComponent, |
113 | 109 |
CategoryListComponent, |
110 |
+ FileUploadProgress, |
|
114 | 111 |
}, |
115 | 112 |
|
116 | 113 |
data() { |
... | ... | @@ -295,7 +292,6 @@ |
295 | 292 |
try { |
296 | 293 |
const formData = new FormData(); |
297 | 294 |
|
298 |
- // 텍스트 데이터 추가 |
|
299 | 295 |
formData.append('sj', this.requestDTO.sj); |
300 | 296 |
|
301 | 297 |
if (!this.$isEmpty(this.pageId)) { |
... | ... | @@ -317,32 +313,13 @@ |
317 | 313 |
} |
318 | 314 |
} |
319 | 315 |
|
320 |
- // 파일 아이디 |
|
321 | 316 |
if (!this.$isEmpty(this.requestDTO.fileId)) { |
322 | 317 |
formData.append('fileId', this.requestDTO.fileId); |
323 | 318 |
} |
324 | 319 |
|
325 |
- // 카테고리 Ids 추가 |
|
326 | 320 |
if (this.selectedCtgries && this.selectedCtgries.length > 0) { |
327 | 321 |
for (let ctgry of this.selectedCtgries) { |
328 | 322 |
formData.append("ctgryIds", ctgry.ctgryId); |
329 |
- } |
|
330 |
- } |
|
331 |
- |
|
332 |
- // 파일 추가 |
|
333 |
- if (this.multipartFiles.length > 0) { |
|
334 |
- formData.append("multipartFiles", this.multipartFiles[0]); |
|
335 |
- |
|
336 |
- // 썸네일 정보 추가 |
|
337 |
- if (this.selectedThumb !== null) { |
|
338 |
- formData.append('selectedThumb', this.multipartFiles[0].name); |
|
339 |
- } |
|
340 |
- } |
|
341 |
- |
|
342 |
- // 기존파일 수정 |
|
343 |
- if (!this.$isEmpty(this.pageId) && this.requestDTO.files.length > 0) { |
|
344 |
- for (let file of this.requestDTO.files) { |
|
345 |
- formData.append("files", file.fileOrdr); |
|
346 | 323 |
} |
347 | 324 |
} |
348 | 325 |
|
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?