
--- client/views/pages/AppRouter.js
+++ client/views/pages/AppRouter.js
... | ... | @@ -229,7 +229,7 @@ |
229 | 229 |
} |
230 | 230 |
store.commit('setPath', path); |
231 | 231 |
store.commit('setPageAuth', pageAuth); |
232 |
- if (path.includes('/main.page')) { |
|
232 |
+ if (to.path === filters.ctxPath('/') || path.includes('/main.page')) { |
|
233 | 233 |
await cntnStatsSave(null, mbrAuth); // 메인 페이지 접속 시 사용자 접속 통계 증가 |
234 | 234 |
} |
235 | 235 |
next(); |
--- client/views/pages/adm/member/userManagement/UserManagementSelectList.vue
+++ client/views/pages/adm/member/userManagement/UserManagementSelectList.vue
... | ... | @@ -158,7 +158,7 @@ |
158 | 158 |
// 실행 |
159 | 159 |
try { |
160 | 160 |
const response = await mbrListProc(toRaw(data)); |
161 |
- this.mblTelnoSplit(response.data.data.list); |
|
161 |
+ // this.mblTelnoSplit(response.data.data.list); |
|
162 | 162 |
this.list = response.data.data.list; |
163 | 163 |
this.search = response.data.data.pagination; |
164 | 164 |
this.makeTbody(); |
... | ... | @@ -246,7 +246,7 @@ |
246 | 246 |
// 실행 |
247 | 247 |
try { |
248 | 248 |
const response = await insertProc(data); |
249 |
- alert(response.data["message"]); |
|
249 |
+ // alert(response.data["message"]); |
|
250 | 250 |
this.fnViewDetail(data); |
251 | 251 |
} catch (error) { |
252 | 252 |
const errorData = error.response.data; |
--- client/views/pages/adm/system/LoginPolicy/LoginPolicy.vue
+++ client/views/pages/adm/system/LoginPolicy/LoginPolicy.vue
... | ... | @@ -47,22 +47,6 @@ |
47 | 47 |
</div> |
48 | 48 |
</div> |
49 | 49 |
<div class="layout"> |
50 |
- <label class="form-title">Context Path 설정</label> |
|
51 |
- <div class="form-group"> |
|
52 |
- <div class="check-area"> |
|
53 |
- <div class="form-check"> |
|
54 |
- <input type="text" id="cntxtPth" class="form-control sm" v-model="cntxtPth" ref="cntxtPth" /> |
|
55 |
- </div> |
|
56 |
- <button class="btn sm main" @click="saveByContextPath">저장</button> |
|
57 |
- </div> |
|
58 |
- <span class="ml10 gray"> |
|
59 |
- <strong>/</strong> 또는 <strong>/경로</strong> 형식으로 입력하세요. |
|
60 |
- </span> |
|
61 |
- </div> |
|
62 |
- </div> |
|
63 |
- <div class="layout"> |
|
64 |
- </div> |
|
65 |
- <div class="layout"> |
|
66 | 50 |
<label class="form-title">이메일 2차 인증 설정</label> |
67 | 51 |
<div class="form-group"> |
68 | 52 |
<div class="check-area"> |
... | ... | @@ -80,6 +64,22 @@ |
80 | 64 |
<!-- <p>{{ allowMultipleLogin ? '중복 로그인을 허용하고 있습니다.' : '중복 로그인을 허용하지 않습니다.' }}</p>--> |
81 | 65 |
</div> |
82 | 66 |
</div> |
67 |
+ <div class="layout"> |
|
68 |
+ </div> |
|
69 |
+ <div class="layout"> |
|
70 |
+ <label class="form-title">Context Path 설정</label> |
|
71 |
+ <div class="form-group"> |
|
72 |
+ <div class="check-area"> |
|
73 |
+ <div class="form-check"> |
|
74 |
+ <input type="text" id="cntxtPth" class="form-control sm" v-model="cntxtPth" ref="cntxtPth" /> |
|
75 |
+ </div> |
|
76 |
+ <button class="btn sm main" @click="saveByContextPath">저장</button> |
|
77 |
+ </div> |
|
78 |
+ <span class="ml10 gray"> |
|
79 |
+ <strong>/</strong> 또는 <strong>/경로</strong> 형식으로 입력하세요. |
|
80 |
+ </span> |
|
81 |
+ </div> |
|
82 |
+ </div> |
|
83 | 83 |
</div> |
84 | 84 |
</div> |
85 | 85 |
</div> |
--- client/views/pages/login/AdminLogin.vue
+++ client/views/pages/login/AdminLogin.vue
... | ... | @@ -43,8 +43,9 @@ |
43 | 43 |
<p>인증코드 입력</p> |
44 | 44 |
</div> |
45 | 45 |
<div> |
46 |
- <p>{{memberInfo.email}}로 전송된 6자리 인증코드를 입력하세요.</p> |
|
47 |
- <input type="text" class="form-control md" ref="code" @input="inputCode" v-model="memberInfo.code" maxlength="6" placeholder="인증코드를 입력하세요."/> |
|
46 |
+ <p>{{ memberInfo.email }}로 전송된 6자리 인증코드를 입력하세요.</p> |
|
47 |
+ <input type="text" class="form-control md" ref="code" @input="inputCode" v-model="memberInfo.code" |
|
48 |
+ maxlength="6" placeholder="인증코드를 입력하세요." /> |
|
48 | 49 |
</div> |
49 | 50 |
<div class="btn-wrap"> |
50 | 51 |
<button class="btn sm main" @click="fnCheck">인증코드 확인</button> |
... | ... | @@ -119,12 +120,12 @@ |
119 | 120 |
const res = await loginProc(this.member); |
120 | 121 |
if (res.status == 200) { |
121 | 122 |
// 2차 인증에 필요한 이메일 정보가 있는지 확인 |
122 |
- if(res.data.email) { |
|
123 |
+ if (res.data.email) { |
|
123 | 124 |
this.memberInfo = res.data; // 인증코드 전송을 위한 이메일 정보 저장 |
124 |
- // 없을 경우 2차 인증 패스 |
|
125 |
+ // 없을 경우 2차 인증 패스 |
|
125 | 126 |
} else { |
126 | 127 |
this.loginStep2 = true; // 2차 인증 패스 |
127 |
- this.loginSuccess(res); // 로그인 성공 처리 |
|
128 |
+ this.loginSuccessProc(res); // 로그인 성공 처리 |
|
128 | 129 |
} |
129 | 130 |
this.loginStep1 = true; // 1차 인증 성공 |
130 | 131 |
} |
... | ... | @@ -167,64 +168,7 @@ |
167 | 168 |
try { |
168 | 169 |
const res = await check2ndAuthProc(this.memberInfo); |
169 | 170 |
if (res.status == 200) { |
170 |
- this.loginSuccess(res); // 로그인 성공 처리 |
|
171 |
- // const loginType = res.headers['login-type']; // 세션/토큰 로그인 구분 |
|
172 |
- // if (loginType === 'J') { |
|
173 |
- // // JWT 방식 |
|
174 |
- // store.commit("setAuthorization", res.headers.authorization); |
|
175 |
- // store.commit("setLoginMode", "J"); |
|
176 |
- // localStorage.setItem("loginMode", "J"); |
|
177 |
- // const base64String = store.state.authorization.split(".")[1]; |
|
178 |
- // const base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); |
|
179 |
- // const jsonPayload = decodeURIComponent( |
|
180 |
- // atob(base64).split("").map((c) => { |
|
181 |
- // return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); |
|
182 |
- // }).join("") |
|
183 |
- // ); |
|
184 |
- // const mbr = JSON.parse(jsonPayload); |
|
185 |
- // store.commit("setMbrId", mbr.mbrId); |
|
186 |
- // store.commit("setMbrNm", mbr.mbrNm); |
|
187 |
- // store.commit("setRoles", mbr.roles); |
|
188 |
- // } else if (loginType === 'S') { |
|
189 |
- // store.commit("setLoginMode", "S"); |
|
190 |
- // localStorage.setItem("loginMode", "S"); |
|
191 |
- // const mbr = res.data; |
|
192 |
- // store.commit("setAuthorization", null); |
|
193 |
- // store.commit("setMbrId", mbr.mbrId); |
|
194 |
- // store.commit("setMbrNm", mbr.mbrNm); |
|
195 |
- // const roles = mbr.roles.map(r => ({ authority: r.authrtCd })); |
|
196 |
- // store.commit("setRoles", roles); |
|
197 |
- // } else { |
|
198 |
- // alert("알 수 없는 로그인 방식입니다."); |
|
199 |
- // return; |
|
200 |
- // } |
|
201 |
- // const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
|
202 |
- // let url = this.restoreRedirect("redirect"); |
|
203 |
- // if (url != null && url != "") { |
|
204 |
- // const ctx = store.state.contextPath; |
|
205 |
- // if (ctx !== "") { |
|
206 |
- // // redirect 값에서 Context Path 추가 |
|
207 |
- // url = this.$filters.ctxPath(url); |
|
208 |
- // } else { |
|
209 |
- // // redirect 값에서 기존 Context Path 제거 |
|
210 |
- // url = url.replace(/^\/[^\/]+/, ""); // 첫 번째 '/' 이후의 경로만 남김 |
|
211 |
- // } |
|
212 |
- // const routeExists = this.$router.getRoutes().some(route => route.path === url); |
|
213 |
- |
|
214 |
- // if (url == this.$filters.ctxPath("/searchId.page") || url == this.$filters.ctxPath("/resetPswd.page")) { |
|
215 |
- // this.$router.push({ path: this.$filters.ctxPath("/main.page") }); |
|
216 |
- // } else if (routeExists) { |
|
217 |
- // this.$router.push({ path: url }); |
|
218 |
- // } else { |
|
219 |
- // this.$router.push({ |
|
220 |
- // path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
221 |
- // }); |
|
222 |
- // } |
|
223 |
- // } else { |
|
224 |
- // this.$router.push({ |
|
225 |
- // path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
226 |
- // }); |
|
227 |
- // } |
|
171 |
+ await this.loginSuccessProc(res); // 로그인 성공 처리 |
|
228 | 172 |
} |
229 | 173 |
} catch (error) { |
230 | 174 |
const errorData = error.response.data; |
... | ... | @@ -238,67 +182,167 @@ |
238 | 182 |
}, |
239 | 183 |
|
240 | 184 |
// 로그인 성공 시 |
241 |
- loginSuccess(res) { |
|
185 |
+ async loginSuccessProc(res) { |
|
242 | 186 |
const loginType = res.headers['login-type']; // 세션/토큰 로그인 구분 |
243 | 187 |
if (loginType === 'J') { |
188 |
+ this.handleJWTLogin(res); |
|
244 | 189 |
// JWT 방식 |
245 |
- store.commit("setAuthorization", res.headers.authorization); |
|
246 |
- store.commit("setLoginMode", "J"); |
|
247 |
- localStorage.setItem("loginMode", "J"); |
|
248 |
- const base64String = store.state.authorization.split(".")[1]; |
|
249 |
- const base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); |
|
250 |
- const jsonPayload = decodeURIComponent( |
|
251 |
- atob(base64).split("").map((c) => { |
|
252 |
- return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); |
|
253 |
- }).join("") |
|
254 |
- ); |
|
255 |
- const mbr = JSON.parse(jsonPayload); |
|
256 |
- store.commit("setMbrId", mbr.mbrId); |
|
257 |
- store.commit("setMbrNm", mbr.mbrNm); |
|
258 |
- store.commit("setRoles", mbr.roles); |
|
190 |
+ // store.commit("setAuthorization", res.headers.authorization); |
|
191 |
+ // store.commit("setLoginMode", "J"); |
|
192 |
+ // localStorage.setItem("loginMode", "J"); |
|
193 |
+ // const base64String = store.state.authorization.split(".")[1]; |
|
194 |
+ // const base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); |
|
195 |
+ // const jsonPayload = decodeURIComponent( |
|
196 |
+ // atob(base64).split("").map((c) => { |
|
197 |
+ // return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); |
|
198 |
+ // }).join("") |
|
199 |
+ // ); |
|
200 |
+ // const mbr = JSON.parse(jsonPayload); |
|
201 |
+ // store.commit("setMbrId", mbr.mbrId); |
|
202 |
+ // store.commit("setMbrNm", mbr.mbrNm); |
|
203 |
+ // store.commit("setRoles", mbr.roles); |
|
259 | 204 |
} else if (loginType === 'S') { |
260 |
- store.commit("setLoginMode", "S"); |
|
261 |
- localStorage.setItem("loginMode", "S"); |
|
262 |
- const mbr = res.data; |
|
263 |
- store.commit("setAuthorization", null); |
|
264 |
- store.commit("setMbrId", mbr.mbrId); |
|
265 |
- store.commit("setMbrNm", mbr.mbrNm); |
|
266 |
- const roles = mbr.roles.map(r => ({ authority: r.authrtCd })); |
|
267 |
- store.commit("setRoles", roles); |
|
205 |
+ this.handleSessionLogin(res); |
|
206 |
+ // store.commit("setLoginMode", "S"); |
|
207 |
+ // localStorage.setItem("loginMode", "S"); |
|
208 |
+ // const mbr = res.data; |
|
209 |
+ // store.commit("setAuthorization", null); |
|
210 |
+ // store.commit("setMbrId", mbr.mbrId); |
|
211 |
+ // store.commit("setMbrNm", mbr.mbrNm); |
|
212 |
+ // const roles = mbr.roles.map(r => ({ authority: r.authrtCd })); |
|
213 |
+ // store.commit("setRoles", roles); |
|
268 | 214 |
} else { |
269 | 215 |
alert("알 수 없는 로그인 방식입니다."); |
270 | 216 |
return; |
271 | 217 |
} |
272 | 218 |
|
273 |
- const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
|
274 |
- let url = this.restoreRedirect("redirect"); |
|
275 |
- if (url != null && url != "") { |
|
276 |
- const ctx = store.state.contextPath; |
|
277 |
- if (ctx !== "") { |
|
278 |
- // redirect 값에서 Context Path 추가 |
|
279 |
- url = this.$filters.ctxPath(url); |
|
280 |
- } else { |
|
281 |
- // redirect 값에서 기존 Context Path 제거 |
|
282 |
- url = url.replace(/^\/[^\/]+/, ""); // 첫 번째 '/' 이후의 경로만 남김 |
|
283 |
- } |
|
284 |
- const routeExists = this.$router.getRoutes().some(route => route.path === url); |
|
219 |
+ await this.handleLoginSuccess(); |
|
220 |
+ // const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
|
221 |
+ // let url = this.restoreRedirect("redirect"); |
|
222 |
+ // console.log("리다이렉트 URL:", url, isAdmin); |
|
223 |
+ // if (url != null && url != "") { |
|
224 |
+ // const ctx = store.state.contextPath; |
|
225 |
+ // console.log("Context Path:", ctx); |
|
226 |
+ // if (ctx !== "") { |
|
227 |
+ // console.log("Context Path가 설정되어 있습니다."); |
|
228 |
+ // // redirect 값에서 Context Path 추가 |
|
229 |
+ // url = this.$filters.ctxPath(url); |
|
230 |
+ // } else { |
|
231 |
+ // console.log("Context Path가 설정되어 있지 않습니다."); |
|
232 |
+ // // redirect 값에서 기존 Context Path 제거 |
|
233 |
+ // url = url.replace(/^\/[^\/]+/, ""); // 첫 번째 '/' 이후의 경로만 남김 |
|
234 |
+ // } |
|
235 |
+ // console.log("Context Path 처리 후 리다이렉트 URL:", url); |
|
236 |
+ // const routeExists = this.$router.getRoutes().some(route => route.path === url); |
|
237 |
+ // console.log("리다이렉트 경로 존재 여부:", routeExists); |
|
285 | 238 |
|
286 |
- if (url == this.$filters.ctxPath("/searchId.page") || url == this.$filters.ctxPath("/resetPswd.page")) { |
|
287 |
- this.$router.push({ path: this.$filters.ctxPath("/main.page") }); |
|
288 |
- } else if (routeExists) { |
|
289 |
- this.$router.push({ path: url }); |
|
290 |
- } else { |
|
291 |
- this.$router.push({ |
|
292 |
- path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
293 |
- }); |
|
294 |
- } |
|
239 |
+ // if (url == this.$filters.ctxPath("/searchId.page") || url == this.$filters.ctxPath("/resetPswd.page")) { |
|
240 |
+ // this.$router.push({ path: this.$filters.ctxPath("/main.page") }); |
|
241 |
+ // } else if (routeExists) { |
|
242 |
+ // this.$router.push({ path: url }); |
|
243 |
+ // } else { |
|
244 |
+ // this.$router.push({ |
|
245 |
+ // path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
246 |
+ // }); |
|
247 |
+ // } |
|
248 |
+ // } else { |
|
249 |
+ // this.$router.push({ |
|
250 |
+ // path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
251 |
+ // }); |
|
252 |
+ // } |
|
253 |
+ }, |
|
254 |
+ |
|
255 |
+ // JWT 로그인 처리 |
|
256 |
+ handleJWTLogin(res) { |
|
257 |
+ const token = res.headers.authorization; |
|
258 |
+ const userInfo = this.parseJWT(token); |
|
259 |
+ |
|
260 |
+ this.setAuthInfo("J", token, userInfo); |
|
261 |
+ }, |
|
262 |
+ |
|
263 |
+ // 세션 로그인 처리 |
|
264 |
+ handleSessionLogin(res) { |
|
265 |
+ const userInfo = res.data; |
|
266 |
+ const roles = userInfo.roles.map(r => ({ authority: r.authrtCd })); |
|
267 |
+ |
|
268 |
+ this.setAuthInfo("S", null, { ...userInfo, roles }); |
|
269 |
+ }, |
|
270 |
+ |
|
271 |
+ // JWT 토큰 파싱 |
|
272 |
+ parseJWT(token) { |
|
273 |
+ const base64String = token.split(".")[1]; |
|
274 |
+ const base64 = base64String.replace(/-/g, "+").replace(/_/g, "/"); |
|
275 |
+ const jsonPayload = decodeURIComponent( |
|
276 |
+ atob(base64).split("").map((c) => { |
|
277 |
+ return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); |
|
278 |
+ }).join("") |
|
279 |
+ ); |
|
280 |
+ return JSON.parse(jsonPayload); |
|
281 |
+ }, |
|
282 |
+ |
|
283 |
+ // 인증 정보 저장 |
|
284 |
+ setAuthInfo(loginMode, token, userInfo) { |
|
285 |
+ // Vuex 상태 저장 |
|
286 |
+ store.commit("setLoginMode", loginMode); |
|
287 |
+ store.commit("setAuthorization", token); |
|
288 |
+ store.commit("setMbrId", userInfo.mbrId); |
|
289 |
+ store.commit("setMbrNm", userInfo.mbrNm); |
|
290 |
+ store.commit("setRoles", userInfo.roles); |
|
291 |
+ |
|
292 |
+ // localStorage 저장 |
|
293 |
+ localStorage.setItem("loginMode", loginMode); |
|
294 |
+ localStorage.setItem("mbrId", userInfo.mbrId); |
|
295 |
+ localStorage.setItem("mbrNm", userInfo.mbrNm); |
|
296 |
+ localStorage.setItem("roles", JSON.stringify(userInfo.roles)); |
|
297 |
+ |
|
298 |
+ if (token) { |
|
299 |
+ localStorage.setItem("authorization", token); |
|
295 | 300 |
} else { |
296 |
- this.$router.push({ |
|
297 |
- path: isAdmin ? this.$filters.ctxPath("/adm/main.page") : this.$filters.ctxPath("/") |
|
298 |
- }); |
|
301 |
+ localStorage.removeItem("authorization"); |
|
299 | 302 |
} |
300 | 303 |
}, |
301 | 304 |
|
305 |
+ // ========== 로그인 성공 후 처리 ========== |
|
306 |
+ async handleLoginSuccess() { |
|
307 |
+ const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
|
308 |
+ let redirectUrl = this.restoreRedirect("redirect") |
|
309 |
+ // 리다이렉트 URL 정리 |
|
310 |
+ if (redirectUrl && this.shouldRedirectToMain(redirectUrl)) { |
|
311 |
+ redirectUrl = this.$filters.ctxPath("/"); |
|
312 |
+ } |
|
313 |
+ // Context Path 처리 |
|
314 |
+ if (redirectUrl && !redirectUrl.startsWith(store.state.contextPath) && store.state.contextPath) { |
|
315 |
+ redirectUrl = this.$filters.ctxPath(redirectUrl); |
|
316 |
+ } |
|
317 |
+ |
|
318 |
+ // 라우터 존재 여부 확인 후 이동 |
|
319 |
+ const targetPath = this.getValidRedirectPath(redirectUrl, isAdmin); |
|
320 |
+ await this.$nextTick(); |
|
321 |
+ this.$router.push({ path: targetPath }); |
|
322 |
+ |
|
323 |
+ // 세션 정리 |
|
324 |
+ sessionStorage.removeItem("redirect"); |
|
325 |
+ }, |
|
326 |
+ |
|
327 |
+ // 리다이렉트 URL이 메인 페이지로 이동해야 하는지 확인 |
|
328 |
+ shouldRedirectToMain(url) { |
|
329 |
+ return url.includes("/searchId.page") || |
|
330 |
+ url.includes("/resetPswd.page") || |
|
331 |
+ url.includes("/login.page"); |
|
332 |
+ }, |
|
333 |
+ |
|
334 |
+ // 유효한 리다이렉트 경로를 반환 |
|
335 |
+ getValidRedirectPath(redirectUrl, isAdmin) { |
|
336 |
+ if (redirectUrl && !redirectUrl.includes("login.page")) { |
|
337 |
+ const routeExists = this.$router.getRoutes().some(route => route.path === redirectUrl); |
|
338 |
+ if (routeExists) return redirectUrl; |
|
339 |
+ } |
|
340 |
+ |
|
341 |
+ return isAdmin ? |
|
342 |
+ this.$filters.ctxPath("/adm/main.page") : |
|
343 |
+ this.$filters.ctxPath("/"); |
|
344 |
+ }, |
|
345 |
+ |
|
302 | 346 |
// 인증코드 재전송 |
303 | 347 |
async fnResend() { |
304 | 348 |
this.isLoading = true; |
--- client/views/pages/login/Login.vue
+++ client/views/pages/login/Login.vue
... | ... | @@ -1,107 +1,97 @@ |
1 | 1 |
<template> |
2 |
- <div class="login-page page"> |
|
3 |
- <div> |
|
4 |
- <div class="background-img"> |
|
5 |
- <p> |
|
6 |
- 어서오세요.<br /> |
|
7 |
- 로그인하세요. |
|
8 |
- </p> |
|
9 |
- </div> |
|
10 |
- <div class="login-wrap"> |
|
11 |
- <div class="login"> |
|
12 |
- <div |
|
13 |
- :class="{ |
|
14 |
- 'login-title': true, |
|
15 |
- 'user-login': !isAdminPage, |
|
16 |
- }" |
|
17 |
- > |
|
18 |
- LOGIN |
|
19 |
- </div> |
|
20 |
- <div class="form-group"> |
|
21 |
- <label for="id" class="login-label">아이디</label> |
|
22 |
- <input |
|
23 |
- type="text" |
|
24 |
- name="" |
|
25 |
- id="id" |
|
26 |
- class="form-control md" |
|
27 |
- placeholder="아이디를 입력하세요" |
|
28 |
- v-model="member['lgnId']" |
|
29 |
- /> |
|
30 |
- </div> |
|
31 |
- <div class="form-group"> |
|
32 |
- <label for="pw" class="login-label">비밀번호</label> |
|
33 |
- <input |
|
34 |
- type="password" |
|
35 |
- name="" |
|
36 |
- id="pw" |
|
37 |
- class="form-control md" |
|
38 |
- placeholder="비밀번호를 입력하세요" |
|
39 |
- v-model="member['pswd']" |
|
40 |
- @keydown.enter="fnLogin" |
|
41 |
- /> |
|
42 |
- </div> |
|
43 |
- <button |
|
44 |
- class="btn md main user-btn" |
|
45 |
- v-if="!isAdminPage" |
|
46 |
- @click="fnLogin" |
|
47 |
- @keydown.enter="fnLogin" |
|
48 |
- > |
|
49 |
- 로그인 |
|
50 |
- </button> |
|
51 |
- <button |
|
52 |
- class="btn md main" |
|
53 |
- v-else |
|
54 |
- @click="fnLogin" |
|
55 |
- @keydown.enter="fnLogin" |
|
56 |
- > |
|
57 |
- 로그인 |
|
2 |
+ <div class="login-page page" :class="{ 'loading-blur': isLoading }" :style="{ 'cursor': isLoading ? 'wait' : '' }"> |
|
3 |
+ <div> |
|
4 |
+ <div class="background-img"> |
|
5 |
+ <p> |
|
6 |
+ 어서오세요.<br /> |
|
7 |
+ 로그인하세요. |
|
8 |
+ </p> |
|
9 |
+ </div> |
|
10 |
+ <div class="login-wrap" v-if="!loginStep1 && !loginStep2"> |
|
11 |
+ <div class="login"> |
|
12 |
+ <div :class="{ |
|
13 |
+ 'login-title': true, |
|
14 |
+ 'user-login': !isAdminPage, |
|
15 |
+ }"> |
|
16 |
+ LOGIN |
|
17 |
+ </div> |
|
18 |
+ <div class="form-group"> |
|
19 |
+ <label for="id" class="login-label">아이디</label> |
|
20 |
+ <input type="text" name="" id="id" class="form-control md" placeholder="아이디를 입력하세요" |
|
21 |
+ v-model="member['lgnId']" /> |
|
22 |
+ </div> |
|
23 |
+ <div class="form-group"> |
|
24 |
+ <label for="pw" class="login-label">비밀번호</label> |
|
25 |
+ <input type="password" name="" id="pw" class="form-control md" placeholder="비밀번호를 입력하세요" |
|
26 |
+ v-model="member['pswd']" @keydown.enter="fnLogin" /> |
|
27 |
+ </div> |
|
28 |
+ <button class="btn md main user-btn" v-if="!isAdminPage" @click="fnLogin" @keydown.enter="fnLogin"> |
|
29 |
+ 로그인 |
|
30 |
+ </button> |
|
31 |
+ <button class="btn md main" v-else @click="fnLogin" @keydown.enter="fnLogin"> |
|
32 |
+ 로그인 |
|
33 |
+ </button> |
|
34 |
+ |
|
35 |
+ <div class="input-group" v-if="!isAdminPage"> |
|
36 |
+ <p class="pl10 pr10 cursor" @click="moveSearchId">아이디찾기</p> |
|
37 |
+ <p class="pl10 pr0 cursor" @click="moveResetPswd">비밀번호 초기화</p> |
|
38 |
+ </div> |
|
39 |
+ |
|
40 |
+ <!-- OAuth2 로그인 버튼들 --> |
|
41 |
+ <div v-if="!isAdminPage"> |
|
42 |
+ <div> |
|
43 |
+ <span>또는</span> |
|
44 |
+ </div> |
|
45 |
+ <div> |
|
46 |
+ <button @click="fnOAuthLogin('kakao')" :disabled="isOAuthLoading"> |
|
47 |
+ 카카오로 로그인 |
|
58 | 48 |
</button> |
59 | 49 |
|
60 |
- <div |
|
61 |
- class="input-group" |
|
62 |
- v-if="!isAdminPage" |
|
63 |
- > |
|
64 |
- <p class="pl10 pr10 cursor" @click="moveSearchId">아이디찾기</p> |
|
65 |
- <p class="pl10 pr0 cursor" @click="moveResetPswd">비밀번호 초기화</p> |
|
66 |
- </div> |
|
67 |
- |
|
68 |
- <!-- OAuth2 로그인 버튼들 --> |
|
69 |
- <div v-if="!isAdminPage"> |
|
70 |
- <div> |
|
71 |
- <span>또는</span> |
|
72 |
- </div> |
|
73 |
- <div> |
|
74 |
- <button @click="fnOAuthLogin('kakao')" :disabled="isOAuthLoading"> |
|
75 |
- 카카오로 로그인 |
|
76 |
- </button> |
|
77 |
- |
|
78 |
- <button @click="fnOAuthLogin('naver')" :disabled="isOAuthLoading"> |
|
79 |
- 네이버로 로그인 |
|
80 |
- </button> |
|
81 |
- |
|
82 |
- <button @click="fnOAuthLogin('google')" :disabled="isOAuthLoading"> |
|
83 |
- 구글로 로그인 |
|
84 |
- </button> |
|
85 |
- </div> |
|
86 |
- </div> |
|
50 |
+ <button @click="fnOAuthLogin('naver')" :disabled="isOAuthLoading"> |
|
51 |
+ 네이버로 로그인 |
|
52 |
+ </button> |
|
53 |
+ |
|
54 |
+ <button @click="fnOAuthLogin('google')" :disabled="isOAuthLoading"> |
|
55 |
+ 구글로 로그인 |
|
56 |
+ </button> |
|
87 | 57 |
</div> |
58 |
+ </div> |
|
59 |
+ </div> |
|
60 |
+ </div> |
|
61 |
+ <div class="login-wrap" v-else-if="loginStep1 && !loginStep2"> |
|
62 |
+ <div> |
|
63 |
+ <p>인증코드 입력</p> |
|
64 |
+ </div> |
|
65 |
+ <div> |
|
66 |
+ <p>{{memberInfo.email}}로 전송된 6자리 인증코드를 입력하세요.</p> |
|
67 |
+ <input type="text" class="form-control md" ref="code" @input="inputCode" v-model="memberInfo.code" maxlength="6" placeholder="인증코드를 입력하세요."/> |
|
68 |
+ </div> |
|
69 |
+ <div class="btn-wrap"> |
|
70 |
+ <button class="btn sm main" @click="fnCheck">인증코드 확인</button> |
|
71 |
+ </div> |
|
72 |
+ <div> |
|
73 |
+ <p>인증코드를 받지 못하셨나요?</p> |
|
74 |
+ <button class="btn sm tertiary" @click="fnResend">인증코드 다시받기</button> |
|
88 | 75 |
</div> |
89 | 76 |
</div> |
90 | 77 |
</div> |
78 |
+ </div> |
|
91 | 79 |
</template> |
92 | 80 |
|
93 | 81 |
<script> |
94 | 82 |
import { useStore } from "vuex"; |
95 | 83 |
import store from "../AppStore"; |
96 | 84 |
import { loginProc, getUserInfo, oauthLogin } from "../../../resources/api/login"; |
85 |
+import { check2ndAuthProc, sendAuthEmailProc } from "../../../resources/api/email"; |
|
97 | 86 |
import queryParams from "../../../resources/js/queryParams"; |
98 | 87 |
|
99 | 88 |
export default { |
100 | 89 |
mixins: [queryParams], |
101 |
- |
|
90 |
+ |
|
102 | 91 |
data() { |
103 | 92 |
return { |
104 |
- member: { lgnId: null, pswd: null }, |
|
93 |
+ isLoading: false, |
|
94 |
+ member: { lgnId: null, pswd: null, lgnReqPage: 'U' }, |
|
105 | 95 |
store: useStore(), |
106 | 96 |
isAdminPage: false, |
107 | 97 |
isOAuthLoading: false, |
... | ... | @@ -110,6 +100,11 @@ |
110 | 100 |
// { name: 'naver', label: '네이버로 로그인' }, |
111 | 101 |
// { name: 'google', label: '구글로 로그인' } |
112 | 102 |
// ] |
103 |
+ |
|
104 |
+ memberInfo: { email: '', code: '' }, |
|
105 |
+ // 인증 절차 |
|
106 |
+ loginStep1: false, // 1차 인증 |
|
107 |
+ loginStep2: false, // 2차 인증 |
|
113 | 108 |
}; |
114 | 109 |
}, |
115 | 110 |
|
... | ... | @@ -144,47 +139,117 @@ |
144 | 139 |
}, |
145 | 140 |
|
146 | 141 |
hasOAuthParams() { |
147 |
- return window.location.search.includes('oauth_success') || |
|
148 |
- window.location.search.includes('error') || |
|
149 |
- this.$route.query.oauth_success || |
|
150 |
- this.$route.query.error; |
|
142 |
+ return window.location.search.includes('oauth_success') || |
|
143 |
+ window.location.search.includes('error') || |
|
144 |
+ this.$route.query.oauth_success || |
|
145 |
+ this.$route.query.error; |
|
151 | 146 |
}, |
152 | 147 |
|
153 | 148 |
// ========== 일반 로그인 ========== |
154 | 149 |
async fnLogin() { |
150 |
+ this.isLoading = true; |
|
155 | 151 |
try { |
156 | 152 |
const res = await loginProc(this.member); |
157 | 153 |
if (res.status !== 200) return; |
158 | 154 |
|
159 |
- const loginType = res.headers['login-type']; |
|
160 |
- |
|
161 |
- if (loginType === 'J') { |
|
162 |
- this.handleJWTLogin(res); |
|
163 |
- } else if (loginType === 'S') { |
|
164 |
- this.handleSessionLogin(res); |
|
155 |
+ // 2차 인증에 필요한 이메일 정보가 있는지 확인 |
|
156 |
+ if (res.data.email) { |
|
157 |
+ this.memberInfo = res.data; // 인증코드 전송을 위한 이메일 정보 저장 |
|
158 |
+ // 없을 경우 2차 인증 패스 |
|
165 | 159 |
} else { |
166 |
- alert("알 수 없는 로그인 방식입니다."); |
|
167 |
- return; |
|
160 |
+ this.loginStep2 = true; // 2차 인증 패스 |
|
161 |
+ await this.loginSuccessProc(res); // 로그인 성공 처리 |
|
168 | 162 |
} |
163 |
+ this.loginStep1 = true; // 1차 인증 성공 |
|
169 | 164 |
|
170 |
- await this.handleLoginSuccess(); |
|
171 |
- |
|
165 |
+ // const loginType = res.headers['login-type']; |
|
166 |
+ |
|
167 |
+ // if (loginType === 'J') { |
|
168 |
+ // this.handleJWTLogin(res); |
|
169 |
+ // } else if (loginType === 'S') { |
|
170 |
+ // this.handleSessionLogin(res); |
|
171 |
+ // } else { |
|
172 |
+ // alert("알 수 없는 로그인 방식입니다."); |
|
173 |
+ // return; |
|
174 |
+ // } |
|
175 |
+ |
|
176 |
+ // await this.handleLoginSuccess(); |
|
177 |
+ |
|
172 | 178 |
} catch (error) { |
173 | 179 |
alert(error.response?.data?.message || "로그인에 실패했습니다."); |
180 |
+ } finally { |
|
181 |
+ this.isLoading = false; |
|
174 | 182 |
} |
175 | 183 |
}, |
176 | 184 |
|
185 |
+ // 인증코드 입력 |
|
186 |
+ inputCode(event) { |
|
187 |
+ const input = event.target.value.replace(/[^0-9]/g, ''); |
|
188 |
+ this.memberInfo.code = input; |
|
189 |
+ }, |
|
190 |
+ |
|
191 |
+ // 인증코드 확인 |
|
192 |
+ async fnCheck() { |
|
193 |
+ try { |
|
194 |
+ const res = await check2ndAuthProc(this.memberInfo); |
|
195 |
+ if (res.status == 200) { |
|
196 |
+ await this.loginSuccessProc(res); // 로그인 성공 처리 |
|
197 |
+ } |
|
198 |
+ } catch (error) { |
|
199 |
+ const errorData = error.response.data; |
|
200 |
+ if (errorData.message != null && errorData.message != "") { |
|
201 |
+ alert(error.response.data.message); |
|
202 |
+ this.$refs.code.focus(); |
|
203 |
+ } else { |
|
204 |
+ alert("에러가 발생했습니다.\n관리자에게 문의해주세요."); |
|
205 |
+ } |
|
206 |
+ } |
|
207 |
+ }, |
|
208 |
+ |
|
209 |
+ // 인증코드 재전송 |
|
210 |
+ async fnResend() { |
|
211 |
+ this.isLoading = true; |
|
212 |
+ try { |
|
213 |
+ const res = await sendAuthEmailProc(this.memberInfo); |
|
214 |
+ if (res.status == 200) { |
|
215 |
+ alert(res.data.message); |
|
216 |
+ } |
|
217 |
+ } catch (error) { |
|
218 |
+ const errorData = error.response.data; |
|
219 |
+ if (errorData.message != null && errorData.message != "") { |
|
220 |
+ alert(error.response.data.message); |
|
221 |
+ } else { |
|
222 |
+ alert("에러가 발생했습니다.\n관리자에게 문의해주세요."); |
|
223 |
+ } |
|
224 |
+ } finally { |
|
225 |
+ this.isLoading = false; |
|
226 |
+ } |
|
227 |
+ }, |
|
228 |
+ |
|
229 |
+ // 로그인 성공 시 |
|
230 |
+ async loginSuccessProc(res) { |
|
231 |
+ const loginType = res.headers['login-type']; // 세션/토큰 로그인 구분 |
|
232 |
+ if (loginType === 'J') { |
|
233 |
+ this.handleJWTLogin(res); |
|
234 |
+ } else if (loginType === 'S') { |
|
235 |
+ this.handleSessionLogin(res); |
|
236 |
+ } else { |
|
237 |
+ alert("알 수 없는 로그인 방식입니다."); |
|
238 |
+ return; |
|
239 |
+ } |
|
240 |
+ await this.handleLoginSuccess(); |
|
241 |
+ }, |
|
177 | 242 |
handleJWTLogin(res) { |
178 | 243 |
const token = res.headers.authorization; |
179 | 244 |
const userInfo = this.parseJWT(token); |
180 |
- |
|
245 |
+ |
|
181 | 246 |
this.setAuthInfo("J", token, userInfo); |
182 | 247 |
}, |
183 | 248 |
|
184 | 249 |
handleSessionLogin(res) { |
185 | 250 |
const userInfo = res.data; |
186 | 251 |
const roles = userInfo.roles.map(r => ({ authority: r.authrtCd })); |
187 |
- |
|
252 |
+ |
|
188 | 253 |
this.setAuthInfo("S", null, { ...userInfo, roles }); |
189 | 254 |
}, |
190 | 255 |
|
... | ... | @@ -206,13 +271,13 @@ |
206 | 271 |
store.commit("setMbrId", userInfo.mbrId); |
207 | 272 |
store.commit("setMbrNm", userInfo.mbrNm); |
208 | 273 |
store.commit("setRoles", userInfo.roles); |
209 |
- |
|
274 |
+ |
|
210 | 275 |
// localStorage 저장 |
211 | 276 |
localStorage.setItem("loginMode", loginMode); |
212 | 277 |
localStorage.setItem("mbrId", userInfo.mbrId); |
213 | 278 |
localStorage.setItem("mbrNm", userInfo.mbrNm); |
214 | 279 |
localStorage.setItem("roles", JSON.stringify(userInfo.roles)); |
215 |
- |
|
280 |
+ |
|
216 | 281 |
if (token) { |
217 | 282 |
localStorage.setItem("authorization", token); |
218 | 283 |
} else { |
... | ... | @@ -229,18 +294,18 @@ |
229 | 294 |
|
230 | 295 |
async handleOAuthCallback() { |
231 | 296 |
const { error, errorMessage, oauthSuccess, loginMode } = this.parseOAuthParams(); |
232 |
- |
|
297 |
+ |
|
233 | 298 |
if (error) { |
234 | 299 |
this.handleOAuthError(error, errorMessage); |
235 | 300 |
return; |
236 | 301 |
} |
237 |
- |
|
302 |
+ |
|
238 | 303 |
if (oauthSuccess !== 'true' && oauthSuccess !== true) return; |
239 |
- |
|
304 |
+ |
|
240 | 305 |
try { |
241 | 306 |
store.commit("setLoginMode", loginMode || "J"); |
242 | 307 |
localStorage.setItem("loginMode", loginMode || "J"); |
243 |
- |
|
308 |
+ |
|
244 | 309 |
if (loginMode === 'J') { |
245 | 310 |
await this.handleOAuthJWT(); |
246 | 311 |
} else if (loginMode === 'S') { |
... | ... | @@ -248,11 +313,11 @@ |
248 | 313 |
} else { |
249 | 314 |
throw new Error('알 수 없는 로그인 모드: ' + loginMode); |
250 | 315 |
} |
251 |
- |
|
316 |
+ |
|
252 | 317 |
this.cleanupOAuth(); |
253 | 318 |
await this.$nextTick(); |
254 | 319 |
await this.handleLoginSuccess(); |
255 |
- |
|
320 |
+ |
|
256 | 321 |
} catch (error) { |
257 | 322 |
this.handleOAuthError('processing_error', error.message); |
258 | 323 |
} |
... | ... | @@ -261,7 +326,7 @@ |
261 | 326 |
parseOAuthParams() { |
262 | 327 |
const urlParams = new URLSearchParams(window.location.search); |
263 | 328 |
const routeQuery = this.$route.query; |
264 |
- |
|
329 |
+ |
|
265 | 330 |
return { |
266 | 331 |
error: urlParams.get('error') || routeQuery.error, |
267 | 332 |
errorMessage: urlParams.get('message') || routeQuery.message, |
... | ... | @@ -275,12 +340,12 @@ |
275 | 340 |
if (!oauthToken) { |
276 | 341 |
throw new Error('OAuth 토큰을 찾을 수 없습니다.'); |
277 | 342 |
} |
278 |
- |
|
343 |
+ |
|
279 | 344 |
const fullToken = oauthToken.startsWith('Bearer ') ? oauthToken : `Bearer ${oauthToken}`; |
280 | 345 |
store.commit("setAuthorization", fullToken); |
281 | 346 |
localStorage.setItem("authorization", fullToken); |
282 | 347 |
this.deleteCookie('oauth_access_token'); |
283 |
- |
|
348 |
+ |
|
284 | 349 |
try { |
285 | 350 |
const userInfo = this.parseJWT(fullToken.replace('Bearer ', '')); |
286 | 351 |
this.setAuthInfo("J", fullToken, userInfo); |
... | ... | @@ -293,7 +358,7 @@ |
293 | 358 |
store.commit("setAuthorization", null); |
294 | 359 |
localStorage.removeItem("authorization"); |
295 | 360 |
this.deleteCookie('oauth_access_token'); |
296 |
- |
|
361 |
+ |
|
297 | 362 |
await this.fetchUserInfoFromServer(); |
298 | 363 |
}, |
299 | 364 |
|
... | ... | @@ -302,15 +367,15 @@ |
302 | 367 |
if (!userInfoRes || userInfoRes.status !== 200) { |
303 | 368 |
throw new Error('사용자 정보를 가져올 수 없습니다.'); |
304 | 369 |
} |
305 |
- |
|
370 |
+ |
|
306 | 371 |
const userInfo = userInfoRes.data.data; |
307 |
- const roles = Array.isArray(userInfo.roles) ? |
|
308 |
- userInfo.roles.map(r => ({ authority: r.authrtCd || r.authority })) : |
|
372 |
+ const roles = Array.isArray(userInfo.roles) ? |
|
373 |
+ userInfo.roles.map(r => ({ authority: r.authrtCd || r.authority })) : |
|
309 | 374 |
userInfo.roles; |
310 |
- |
|
375 |
+ |
|
311 | 376 |
const loginMode = localStorage.getItem("loginMode"); |
312 | 377 |
const token = userInfo.token || localStorage.getItem("authorization"); |
313 |
- |
|
378 |
+ |
|
314 | 379 |
this.setAuthInfo(loginMode, token, { ...userInfo, roles }); |
315 | 380 |
}, |
316 | 381 |
|
... | ... | @@ -318,41 +383,41 @@ |
318 | 383 |
async handleLoginSuccess() { |
319 | 384 |
const isAdmin = store.state.roles.some(role => role.authority === "ROLE_ADMIN"); |
320 | 385 |
let redirectUrl = this.restoreRedirect("redirect") || sessionStorage.getItem('oauth_redirect'); |
321 |
- |
|
386 |
+ |
|
322 | 387 |
// 리다이렉트 URL 정리 |
323 | 388 |
if (redirectUrl && this.shouldRedirectToMain(redirectUrl)) { |
324 | 389 |
redirectUrl = this.$filters.ctxPath("/"); |
325 | 390 |
} |
326 |
- |
|
391 |
+ |
|
327 | 392 |
// Context Path 처리 |
328 | 393 |
if (redirectUrl && !redirectUrl.startsWith(store.state.contextPath) && store.state.contextPath) { |
329 | 394 |
redirectUrl = this.$filters.ctxPath(redirectUrl); |
330 | 395 |
} |
331 |
- |
|
396 |
+ |
|
332 | 397 |
// 라우터 존재 여부 확인 후 이동 |
333 | 398 |
const targetPath = this.getValidRedirectPath(redirectUrl, isAdmin); |
334 | 399 |
await this.$nextTick(); |
335 | 400 |
this.$router.push({ path: targetPath }); |
336 |
- |
|
401 |
+ |
|
337 | 402 |
// 세션 정리 |
338 | 403 |
sessionStorage.removeItem("redirect"); |
339 | 404 |
sessionStorage.removeItem("oauth_redirect"); |
340 | 405 |
}, |
341 | 406 |
|
342 | 407 |
shouldRedirectToMain(url) { |
343 |
- return url.includes("/searchId.page") || |
|
344 |
- url.includes("/resetPswd.page") || |
|
345 |
- url.includes("/login.page"); |
|
408 |
+ return url.includes("/searchId.page") || |
|
409 |
+ url.includes("/resetPswd.page") || |
|
410 |
+ url.includes("/login.page"); |
|
346 | 411 |
}, |
347 | 412 |
|
348 | 413 |
getValidRedirectPath(redirectUrl, isAdmin) { |
349 |
- if (redirectUrl) { |
|
414 |
+ if (redirectUrl && !redirectUrl.includes("login.page")) { |
|
350 | 415 |
const routeExists = this.$router.getRoutes().some(route => route.path === redirectUrl); |
351 | 416 |
if (routeExists) return redirectUrl; |
352 | 417 |
} |
353 |
- |
|
354 |
- return isAdmin ? |
|
355 |
- this.$filters.ctxPath("/adm/main.page") : |
|
418 |
+ |
|
419 |
+ return isAdmin ? |
|
420 |
+ this.$filters.ctxPath("/adm/main.page") : |
|
356 | 421 |
this.$filters.ctxPath("/"); |
357 | 422 |
}, |
358 | 423 |
|
... | ... | @@ -360,10 +425,10 @@ |
360 | 425 |
handleOAuthError(error, errorMessage) { |
361 | 426 |
this.isOAuthLoading = false; |
362 | 427 |
this.cleanupOAuth(); |
363 |
- |
|
428 |
+ |
|
364 | 429 |
const message = decodeURIComponent(errorMessage || error || 'OAuth 로그인에 실패했습니다.'); |
365 | 430 |
alert(`소셜 로그인 실패: ${message}`); |
366 |
- |
|
431 |
+ |
|
367 | 432 |
this.$router.push({ path: this.$filters.ctxPath("/login.page") }); |
368 | 433 |
}, |
369 | 434 |
|
... | ... | @@ -372,7 +437,7 @@ |
372 | 437 |
sessionStorage.removeItem('oauth_provider'); |
373 | 438 |
sessionStorage.removeItem('oauth_start_time'); |
374 | 439 |
this.isOAuthLoading = false; |
375 |
- |
|
440 |
+ |
|
376 | 441 |
const cleanUrl = window.location.pathname; |
377 | 442 |
window.history.replaceState({}, document.title, cleanUrl); |
378 | 443 |
}, |
... | ... | @@ -394,8 +459,8 @@ |
394 | 459 |
query: { tab: "id" } |
395 | 460 |
}); |
396 | 461 |
}, |
397 |
- |
|
398 |
- moveResetPswd() { |
|
462 |
+ |
|
463 |
+ moveResetPswd() { |
|
399 | 464 |
this.$router.push({ |
400 | 465 |
path: this.$filters.ctxPath("/resetPswd.page"), |
401 | 466 |
query: { tab: "pw" } |
... | ... | @@ -403,4 +468,12 @@ |
403 | 468 |
} |
404 | 469 |
} |
405 | 470 |
}; |
406 |
-</script>(파일 끝에 줄바꿈 문자 없음) |
|
471 |
+</script> |
|
472 |
+ |
|
473 |
+<style scoped> |
|
474 |
+.loading-blur { |
|
475 |
+ pointer-events: none; |
|
476 |
+ opacity: 0.4; |
|
477 |
+ filter: grayscale(20%) blur(1px); |
|
478 |
+} |
|
479 |
+</style>(파일 끝에 줄바꿈 문자 없음) |
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?