+++ src/admin/hook/useMenuList.ts
... | ... | @@ -0,0 +1,84 @@ |
| 1 | +import { useEffect, useState } from 'react'; | |
| 2 | +import { adminAuthJson } from '../../api/adminAuth'; | |
| 3 | +import type { MenuItem } from '../component/menu/MenuList'; | |
| 4 | + | |
| 5 | +type BackendMenuItem = { | |
| 6 | + menuNo?: number | string; | |
| 7 | + menuNm?: string; | |
| 8 | + url?: string; | |
| 9 | + upperMenuId?: number | string; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +type MenuLeftResponse = { | |
| 13 | + head?: BackendMenuItem[]; | |
| 14 | + menu?: BackendMenuItem[]; | |
| 15 | +}; | |
| 16 | + | |
| 17 | +const fallbackHeadMenuList: MenuItem[] = [ | |
| 18 | + { name: '테스트 메뉴1', no: '1', url: '#', upperNo: '0' }, | |
| 19 | + { name: '테스트 메뉴2', no: '2', url: '#', upperNo: '0' }, | |
| 20 | + { name: '테스트 메뉴3', no: '3', url: '#', upperNo: '0' }, | |
| 21 | +]; | |
| 22 | + | |
| 23 | +const fallbackMenuList: MenuItem[] = [ | |
| 24 | + { name: '테스트 1', no: '6', url: '#', upperNo: '1' }, | |
| 25 | + { name: '테스트 2', no: '7', url: '#', upperNo: '1' }, | |
| 26 | + { name: '테스트 3', no: '8', url: '#', upperNo: '2' }, | |
| 27 | +]; | |
| 28 | + | |
| 29 | +function toMenuItem(item: BackendMenuItem): MenuItem { | |
| 30 | + return { | |
| 31 | + no: String(item.menuNo ?? ''), | |
| 32 | + name: item.menuNm ?? '', | |
| 33 | + url: item.url ?? '#', | |
| 34 | + upperNo: String(item.upperMenuId ?? '0'), | |
| 35 | + }; | |
| 36 | +} | |
| 37 | + | |
| 38 | +export const useMenuList = () => { | |
| 39 | + const [headMenuList, setHeadMenuList] = useState<MenuItem[]>(fallbackHeadMenuList); | |
| 40 | + const [menuList, setMenuList] = useState<MenuItem[]>(fallbackMenuList); | |
| 41 | + const [isLoading, setIsLoading] = useState(true); | |
| 42 | + const [errorMessage, setErrorMessage] = useState<string | null>(null); | |
| 43 | + | |
| 44 | + useEffect(() => { | |
| 45 | + let mounted = true; | |
| 46 | + | |
| 47 | + adminAuthJson<MenuLeftResponse>('/sym/mms/menuLeft.do') | |
| 48 | + .then((data) => { | |
| 49 | + if (!mounted) { | |
| 50 | + return; | |
| 51 | + } | |
| 52 | + | |
| 53 | + const nextHeadMenuList = (data.head ?? []).map(toMenuItem).filter((item) => item.no && item.name); | |
| 54 | + const nextMenuList = (data.menu ?? []).map(toMenuItem).filter((item) => item.no && item.name); | |
| 55 | + | |
| 56 | + setHeadMenuList(nextHeadMenuList.length > 0 ? nextHeadMenuList : fallbackHeadMenuList); | |
| 57 | + setMenuList(nextMenuList.length > 0 ? nextMenuList : fallbackMenuList); | |
| 58 | + setErrorMessage(null); | |
| 59 | + }) | |
| 60 | + .catch((error: unknown) => { | |
| 61 | + if (!mounted) { | |
| 62 | + return; | |
| 63 | + } | |
| 64 | + | |
| 65 | + setErrorMessage(error instanceof Error ? error.message : '메뉴 조회에 실패했습니다.'); | |
| 66 | + }) | |
| 67 | + .finally(() => { | |
| 68 | + if (mounted) { | |
| 69 | + setIsLoading(false); | |
| 70 | + } | |
| 71 | + }); | |
| 72 | + | |
| 73 | + return () => { | |
| 74 | + mounted = false; | |
| 75 | + }; | |
| 76 | + }, []); | |
| 77 | + | |
| 78 | + return { | |
| 79 | + headMenuList, | |
| 80 | + menuList, | |
| 81 | + isLoading, | |
| 82 | + errorMessage, | |
| 83 | + }; | |
| 84 | +}; |
--- src/admin/layout/AdminSideBar.tsx
+++ src/admin/layout/AdminSideBar.tsx
... | ... | @@ -1,31 +1,19 @@ |
| 1 |
-import {MenuList} from "../component/menu/MenuList.tsx";
|
|
| 2 |
- |
|
| 3 |
-const menuList = [ |
|
| 4 |
- {name: "1", no: '6', url: '테스트', upperNo: '1'},
|
|
| 5 |
- {name: "2", no: '7', url: '테스트', upperNo: '1'},
|
|
| 6 |
- {name: "3", no: '8', url: '테스트', upperNo: '2'},
|
|
| 7 |
- {name: "4", no: '9', url: '테스트', upperNo: '2'},
|
|
| 8 |
- {name: "5", no: '10', url: '테스트', upperNo: '2'},
|
|
| 9 |
- |
|
| 10 |
-] |
|
| 11 |
- |
|
| 12 |
-const headMenuList = [ |
|
| 13 |
- {name: "테스트 메뉴1", no: '1', url: '테스트', upperNo: '0'},
|
|
| 14 |
- {name: "테스트 메뉴2", no: '2', url: '테스트', upperNo: '0'},
|
|
| 15 |
- {name: "테스트 메뉴3", no: '3', url: '테스트', upperNo: '0'},
|
|
| 16 |
- {name: "테스트 메뉴4", no: '4', url: '테스트', upperNo: '0'},
|
|
| 17 |
- {name: "테스트 메뉴5", no: '5', url: '테스트', upperNo: '0'},
|
|
| 18 |
-] |
|
| 1 |
+import { MenuList } from '../component/menu/MenuList.tsx';
|
|
| 2 |
+import { useMenuList } from '../hook/useMenuList.ts';
|
|
| 19 | 3 |
|
| 20 | 4 |
export const AdminSideBar = () => {
|
| 21 |
- return ( |
|
| 22 |
- <div className={"menu_wrap"}>
|
|
| 23 |
- <h1 className={"logo"}>
|
|
| 24 |
- <a href="/">DashBoard</a> |
|
| 25 |
- </h1> |
|
| 26 |
- <nav className={"menu"}>
|
|
| 27 |
- <MenuList headMenuList={headMenuList} menuList={menuList}/>
|
|
| 28 |
- </nav> |
|
| 29 |
- </div> |
|
| 30 |
- ); |
|
| 31 |
-}(No newline at end of file) |
|
| 5 |
+ const { headMenuList, menuList, isLoading, errorMessage } = useMenuList();
|
|
| 6 |
+ |
|
| 7 |
+ return ( |
|
| 8 |
+ <div className="menu_wrap"> |
|
| 9 |
+ <h1 className="logo"> |
|
| 10 |
+ <a href="/">DashBoard</a> |
|
| 11 |
+ </h1> |
|
| 12 |
+ <nav className="menu"> |
|
| 13 |
+ {isLoading && <p style={{ padding: '0 20px', color: '#fff' }}>메뉴 로딩중...</p>}
|
|
| 14 |
+ {errorMessage && <p style={{ padding: '0 20px', color: '#ffd1d1' }}>{errorMessage}</p>}
|
|
| 15 |
+ <MenuList headMenuList={headMenuList} menuList={menuList} />
|
|
| 16 |
+ </nav> |
|
| 17 |
+ </div> |
|
| 18 |
+ ); |
|
| 19 |
+}; |
+++ src/api/adminAuth.ts
... | ... | @@ -0,0 +1,123 @@ |
| 1 | +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''; | |
| 2 | +const ADMIN_LOGIN_URL = '/uat/uia/actionSecurityLogin.do'; | |
| 3 | +const ADMIN_ID = 'admin'; | |
| 4 | +const ADMIN_PASSWORD = '1'; | |
| 5 | + | |
| 6 | +type ApiEnvelope<T> = { | |
| 7 | + success?: boolean; | |
| 8 | + message?: string; | |
| 9 | + data?: T; | |
| 10 | +}; | |
| 11 | + | |
| 12 | +let loginPromise: Promise<void> | null = null; | |
| 13 | + | |
| 14 | +function toApiUrl(path: string) { | |
| 15 | + if (/^https?:\/\//i.test(path)) { | |
| 16 | + return path; | |
| 17 | + } | |
| 18 | + | |
| 19 | + return `${API_BASE_URL}${path}`; | |
| 20 | +} | |
| 21 | + | |
| 22 | +function createHeaders(headers?: HeadersInit) { | |
| 23 | + return new Headers(headers); | |
| 24 | +} | |
| 25 | + | |
| 26 | +async function adminLogin() { | |
| 27 | + if (!loginPromise) { | |
| 28 | + loginPromise = fetch(toApiUrl(ADMIN_LOGIN_URL), { | |
| 29 | + method: 'POST', | |
| 30 | + credentials: 'include', | |
| 31 | + headers: { | |
| 32 | + 'Content-Type': 'application/x-www-form-urlencoded', | |
| 33 | + 'X-Requested-With': 'XMLHttpRequest', | |
| 34 | + }, | |
| 35 | + body: new URLSearchParams({ | |
| 36 | + id: ADMIN_ID, | |
| 37 | + password: ADMIN_PASSWORD, | |
| 38 | + }), | |
| 39 | + }) | |
| 40 | + .then(async (response) => { | |
| 41 | + if (!response.ok) { | |
| 42 | + throw new Error(`Admin login failed: ${response.status}`); | |
| 43 | + } | |
| 44 | + | |
| 45 | + const contentType = response.headers.get('content-type') ?? ''; | |
| 46 | + if (contentType.includes('application/json')) { | |
| 47 | + const result = (await response.json()) as ApiEnvelope<unknown>; | |
| 48 | + if (result.success === false) { | |
| 49 | + throw new Error(result.message ?? 'Admin login failed'); | |
| 50 | + } | |
| 51 | + } | |
| 52 | + }) | |
| 53 | + .finally(() => { | |
| 54 | + loginPromise = null; | |
| 55 | + }); | |
| 56 | + } | |
| 57 | + | |
| 58 | + return loginPromise; | |
| 59 | +} | |
| 60 | + | |
| 61 | +async function needsAdminLogin(response: Response) { | |
| 62 | + if (response.type === 'opaqueredirect' || response.status === 0) { | |
| 63 | + return true; | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (response.status >= 300 && response.status < 400) { | |
| 67 | + return true; | |
| 68 | + } | |
| 69 | + | |
| 70 | + if (response.status === 401 || response.status === 403) { | |
| 71 | + return true; | |
| 72 | + } | |
| 73 | + | |
| 74 | + const contentType = response.headers.get('content-type') ?? ''; | |
| 75 | + | |
| 76 | + if (contentType.includes('text/html')) { | |
| 77 | + const html = await response.clone().text(); | |
| 78 | + return html.includes('/uat/uia/actionMain.do') || html.includes('actionSecurityLogin.do'); | |
| 79 | + } | |
| 80 | + | |
| 81 | + if (contentType.includes('application/json')) { | |
| 82 | + try { | |
| 83 | + const result = (await response.clone().json()) as ApiEnvelope<unknown>; | |
| 84 | + const message = result.message ?? ''; | |
| 85 | + return result.success === false && /login|로그인|/.test(message); | |
| 86 | + } catch { | |
| 87 | + return false; | |
| 88 | + } | |
| 89 | + } | |
| 90 | + | |
| 91 | + return false; | |
| 92 | +} | |
| 93 | + | |
| 94 | +export async function adminAuthFetch(path: string, init: RequestInit = {}, retry = true): Promise<Response> { | |
| 95 | + const response = await fetch(toApiUrl(path), { | |
| 96 | + ...init, | |
| 97 | + credentials: 'include', | |
| 98 | + headers: createHeaders(init.headers), | |
| 99 | + redirect: 'manual', | |
| 100 | + }); | |
| 101 | + | |
| 102 | + if (retry && (await needsAdminLogin(response))) { | |
| 103 | + await adminLogin(); | |
| 104 | + return adminAuthFetch(path, init, false); | |
| 105 | + } | |
| 106 | + | |
| 107 | + return response; | |
| 108 | +} | |
| 109 | + | |
| 110 | +export async function adminAuthJson<T>(path: string, init?: RequestInit): Promise<T> { | |
| 111 | + const response = await adminAuthFetch(path, init); | |
| 112 | + | |
| 113 | + if (!response.ok) { | |
| 114 | + throw new Error(`API request failed: ${response.status}`); | |
| 115 | + } | |
| 116 | + | |
| 117 | + const result = (await response.json()) as ApiEnvelope<T>; | |
| 118 | + if (result.success === false) { | |
| 119 | + throw new Error(result.message ?? 'API request failed'); | |
| 120 | + } | |
| 121 | + | |
| 122 | + return result.data as T; | |
| 123 | +} |
--- vite.config.ts
+++ vite.config.ts
... | ... | @@ -1,10 +1,25 @@ |
| 1 |
-import { defineConfig } from 'vite'
|
|
| 1 |
+import { defineConfig, loadEnv } from 'vite'
|
|
| 2 | 2 |
import react from '@vitejs/plugin-react' |
| 3 | 3 |
|
| 4 |
-export default defineConfig({
|
|
| 5 |
- plugins: [react()], |
|
| 6 |
- server: {
|
|
| 7 |
- port: 5173, |
|
| 8 |
- host: '0.0.0.0' |
|
| 9 |
- }, |
|
| 4 |
+export default defineConfig(({ mode }) => {
|
|
| 5 |
+ const env = loadEnv(mode, process.cwd(), '') |
|
| 6 |
+ const apiProxyTarget = env.VITE_API_PROXY_TARGET || 'http://localhost:9999' |
|
| 7 |
+ |
|
| 8 |
+ return {
|
|
| 9 |
+ plugins: [react()], |
|
| 10 |
+ server: {
|
|
| 11 |
+ port: 5173, |
|
| 12 |
+ host: '0.0.0.0', |
|
| 13 |
+ proxy: {
|
|
| 14 |
+ '/uat': { target: apiProxyTarget, changeOrigin: true },
|
|
| 15 |
+ '/cmm': { target: apiProxyTarget, changeOrigin: true },
|
|
| 16 |
+ '/sym': { target: apiProxyTarget, changeOrigin: true },
|
|
| 17 |
+ '/cop': { target: apiProxyTarget, changeOrigin: true },
|
|
| 18 |
+ '/uss': { target: apiProxyTarget, changeOrigin: true },
|
|
| 19 |
+ '/sec': { target: apiProxyTarget, changeOrigin: true },
|
|
| 20 |
+ '/sts': { target: apiProxyTarget, changeOrigin: true },
|
|
| 21 |
+ '/react': { target: apiProxyTarget, changeOrigin: true },
|
|
| 22 |
+ }, |
|
| 23 |
+ }, |
|
| 24 |
+ } |
|
| 10 | 25 |
}) |
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?