diff --git a/.gitignore b/.gitignore index 8173b55..f4195f7 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ yarn-error.log* /.idea/misc.xml /.idea/modules.xml /.idea/vcs.xml +/.idea/git_toolbox_blame.xml +/.idea/git_toolbox_prj.xml diff --git a/src/RouteInfo.js b/src/RouteInfo.js index a4b2150..16814c2 100644 --- a/src/RouteInfo.js +++ b/src/RouteInfo.js @@ -21,6 +21,8 @@ import { MetaItemView, RankManage, MetaCraftingView, + RankInfoView, + MetaInstanceView } from './pages/DataManage'; import { Board, @@ -72,6 +74,8 @@ const RouteInfo = () => { } /> } /> } /> + } /> + } /> } /> diff --git a/src/apis/Dictionary.js b/src/apis/Dictionary.js index 6f7d8ae..2890717 100644 --- a/src/apis/Dictionary.js +++ b/src/apis/Dictionary.js @@ -77,6 +77,43 @@ export const CraftingDictionaryExport = async (token, params) => { } }; +export const getInstanceDictionaryList = async (token, searchType, searchData, contentsType, accessType, order, size, currentPage) => { + try { + const response = await Axios.get(`/api/v1/dictionary/instance/list?search_type=${searchType}&search_data=${searchData} + &contents_type=${contentsType}&access_type=${accessType} + &orderby=${order}&page_no=${currentPage}&page_size=${size}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + return response.data; + } catch (error) { + console.error('getInstanceDictionaryList API error:', error); + throw error; + } +}; + +export const InstanceDictionaryExport = async (token, params) => { + try { + await Axios.get(`/api/v1/dictionary/instance/excel-export?search_type=${params.search_type}&search_data=${params.search_data} + &contents_type=${params.contents_type}&access_type=${params.access_type} + &lang=${params.lang}&task_id=${params.taskId}`, { + headers: { Authorization: `Bearer ${token}` }, + responseType: 'blob' + }).then(response => { + responseFileDownload(response, { + defaultFileName: 'instanceDictionary' + }); + }); + } catch (e) { + if (e instanceof Error) { + throw new Error('InstanceDictionaryExport Error', e); + } + } +}; + export const BrandView = async (token) => { try { const res = await Axios.get( diff --git a/src/apis/OpenAI.js b/src/apis/OpenAI.js deleted file mode 100644 index 365f88e..0000000 --- a/src/apis/OpenAI.js +++ /dev/null @@ -1,21 +0,0 @@ -//AI api 연결 - -import { Axios } from '../utils'; - - -export const AnalyzeAI = async (token, params) => { - try { - const res = await Axios.post('/api/v1/ai/analyze', params, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json' - }, - }); - - return res.data; - } catch (e) { - if (e instanceof Error) { - throw new Error('analyzeAI Error', e); - } - } -}; diff --git a/src/assets/data/apis/index.js b/src/assets/data/apis/index.js index 7582650..252a2b8 100644 --- a/src/assets/data/apis/index.js +++ b/src/assets/data/apis/index.js @@ -4,6 +4,7 @@ import historyAPI from './historyAPI.json'; import eventAPI from './eventAPI.json'; import rankingAPI from './rankingAPI.json'; import metaCraftingAPI from './metaCraftingAPI.json'; +import metaInstanceAPI from './metaInstanceAPI.json'; export { itemAPI, @@ -11,5 +12,6 @@ export { historyAPI, eventAPI, rankingAPI, - metaCraftingAPI + metaCraftingAPI, + metaInstanceAPI }; \ No newline at end of file diff --git a/src/assets/data/apis/metaInstanceAPI.json b/src/assets/data/apis/metaInstanceAPI.json new file mode 100644 index 0000000..8bcc25f --- /dev/null +++ b/src/assets/data/apis/metaInstanceAPI.json @@ -0,0 +1,11 @@ +{ + "baseUrl": "/api/v1/dictionary/instance", + "endpoints": { + "getInstanceDictionaryList": { + "method": "GET", + "url": "/list", + "dataPath": "data", + "paramFormat": "query" + } + } +} \ No newline at end of file diff --git a/src/assets/data/menuConfig.js b/src/assets/data/menuConfig.js index 8b03463..d13bc39 100644 --- a/src/assets/data/menuConfig.js +++ b/src/assets/data/menuConfig.js @@ -100,7 +100,7 @@ export const menuConfig = { permissions: { read: authType.gameLogRead }, - view: false, + view: true, authLevel: adminAuthLevel.NONE }, businesslogview: { @@ -127,6 +127,14 @@ export const menuConfig = { view: true, authLevel: adminAuthLevel.NONE }, + instancedictionary: { + title: '인스턴스 조회', + permissions: { + read: authType.instanceDictionaryRead + }, + view: true, + authLevel: adminAuthLevel.NONE + }, rankmanage: { title: '랭킹 점수 관리', permissions: { diff --git a/src/assets/data/pages/metaInstanceSearch.json b/src/assets/data/pages/metaInstanceSearch.json new file mode 100644 index 0000000..4e30cf2 --- /dev/null +++ b/src/assets/data/pages/metaInstanceSearch.json @@ -0,0 +1,49 @@ +{ + "initialSearchParams": { + "search_type": "ID", + "search_data": "", + "contents_type": "ALL", + "access_type": "ALL", + "orderBy": "DESC", + "pageSize": 50, + "currentPage": 1 + }, + + "searchFields": [ + { + "type": "select", + "id": "search_type", + "optionsRef": "instanceSearchType", + "col": 1 + }, + { + "type": "text", + "id": "search_data", + "placeholder": "인스턴스 입력", + "width": "300px", + "col": 1 + }, + { + "type": "select", + "id": "contents_type", + "label": "컨텐츠 타입", + "optionsRef": "opInstanceContentsType", + "col": 1 + }, + { + "type": "select", + "id": "access_type", + "label": "입장 방식", + "optionsRef": "opInstanceAccessType", + "col": 1 + } + ], + + "apiInfo": { + "endpointName": "getInstanceDictionaryList", + "loadOnMount": true, + "pageField": "page_no", + "pageSizeField": "page_size", + "orderField": "orderBy" + } +} \ No newline at end of file diff --git a/src/i18n.js b/src/i18n.js index 121f5f9..833eea0 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -35,6 +35,8 @@ const resources = { SAVE_COMPLETED: '저장이 완료되었습니다.', SAVE_CONFIRM: '저장 하시겠습니까?', UPDATE_CONFIRM: '수정하시겠습니까?', + UPDATE_VALUE: '변경할 값을 입력해주세요.', + UPDATE_VALUE_COMMENT: '변경할 값을 입력해주세요.\n({{comment}})', LENGTH_TEXT_LIMIT_100: '요청사유는 100글자 까지만 입력하실 수 있습니다.({{count}}/100)', LENGTH_NUMBER_POINT_2: '숫자, 소수점 둘째자리', EXCEL_SELECT: 'Excel 파일을 선택해주세요.', @@ -55,8 +57,12 @@ const resources = { DOWNLOAD_COMPLETE: '다운이 완료되었습니다.', DOWNLOAD_FAIL: '다운이 실패하였습니다.', DELETE_STATUS_ONLY_WAIT: '대기상태의 데이터만 삭제가 가능합니다.', + UPDATE_STATUS_ONLY_RUNNING: '진행상태의 데이터만 새로고침이 가능합니다.', TABLE_DATA_NOT_FOUND: '데이터가 없습니다.', ITEM_ID_EMPTY_WARNING: '아이템 아이디를 입력해주세요.', + //login + PASSWORD_INIT_COMPLETE: '비밀번호 초기화 메일이 발송되었습니다.\r\n메일을 확인해주세요.', + PASSWORD_INIT_ERROR: '비밀번호 초기화에 실패하였습니다. 잠시 후 다시 한번 진행해 주세요.\n오류가 지속될 경우, 담당자에게 문의해주세요.', //user NICKNAME_CHANGES_CONFIRM: '닉네임을 변경하시겠습니까?', NICKNAME_CHANGES_COMPLETE: '닉네임 변경이 완료되었습니다.', @@ -148,6 +154,15 @@ const resources = { SCHEDULE_SELECT_DELETE: "선택된 스케줄을 삭제하시겠습니까?", SCHEDULE_REGIST_CONFIRM: "스케줄을 등록하시겠습니까?", SCHEDULE_UPDATE_CONFIRM: "스케줄을 수정하시겠습니까?", + SCHEDULE_MODAL_START_DIFF_BASE_WARNING: "기준시간은 시작 시간보다 작을 수 없습니다.", + SCHEDULE_MODAL_START_DIFF_END_WARNING: "종료시간은 시작 시간보다 작을 수 없습니다.", + SCHEDULE_REFRESH_GUID_NULL_WARNING: "재조회 후 다시 시도해주세요.", + SCHEDULE_SELECT_UPDATE: "선택된 스케줄의 랭킹을 강제 새로고침 하시겠습니까?", + SCHEDULE_SELECT_INIT: "선택된 스케줄의 랭킹을 강제 초기화 하시겠습니까?", + SCHEDULE_SELECT_SNAPSHOT: "선택된 스케줄의 랭킹을 강제 스냅샷 하시겠습니까?", + SCHEDULE_REFRESH_COMPLETE: '랭킹 새로고침을 서버에 요청하였습니다.\n변경사항은 잠시 후 확인해주세요.', + SCHEDULE_INIT_COMPLETE: '랭킹 초기화를 서버에 요청하였습니다.\n변경사항은 잠시 후 확인해주세요.', + SCHEDULE_SNAPSHOT_COMPLETE: '랭킹 스냅샷을 서버에 요청하였습니다.\n변경사항은 잠시 후 확인해주세요.', //메뉴 배너 MENU_BANNER_TITLE: "메뉴 배너 관리", MENU_BANNER_CREATE: "메뉴 배너 등록", @@ -185,6 +200,7 @@ const resources = { FILE_LAND_AUCTION: 'Caliverse_Land_Auction.xlsx', FILE_BUSINESS_LOG: 'Caliverse_Log', FILE_BATTLE_EVENT: 'Caliverse_Battle_Event.xlsx', + FILE_RANKING_SNAPSHOT: 'Caliverse_Ranking_Snapshot.xlsx', FILE_GAME_LOG_CURRENCY: 'Caliverse_Game_Log_Currency', FILE_GAME_LOG_USER_CREATE: 'Caliverse_Game_Log_User_Create', FILE_GAME_LOG_USER_LOGIN: 'Caliverse_Game_Log_User_Login', diff --git a/src/pages/DataManage/MetaInstanceView.js b/src/pages/DataManage/MetaInstanceView.js new file mode 100644 index 0000000..5e55506 --- /dev/null +++ b/src/pages/DataManage/MetaInstanceView.js @@ -0,0 +1,207 @@ +import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { AnimatedPageWrapper } from '../../components/common/Layout'; +import { + Title, + TableStyle, + FormWrapper, + TableWrapper, + DownloadContainer, CircularProgressWrapper, +} from '../../styles/Components'; + +import { withAuth } from '../../hooks/hook'; +import { + authType, +} from '../../assets/data'; +import { useTranslation } from 'react-i18next'; +import { + AnimatedTabs, + ViewTableInfo, +} from '../../components/common'; +import { TableSkeleton } from '../../components/Skeleton/TableSkeleton'; +import CircularProgress from '../../components/common/CircularProgress'; + +import { + INITIAL_PAGE_LIMIT, +} from '../../assets/data/adminConstants'; +import ExcelExportButton from '../../components/common/button/ExcelExportButton'; +import Pagination from '../../components/common/Pagination/Pagination'; +import { CommonSearchBar } from '../../components/searchBar'; +import { languageNames } from '../../assets/data/types'; +import useCommonSearch from '../../hooks/useCommonSearch'; + +const MetaInstanceView = () => { + const token = sessionStorage.getItem('token'); + const { t } = useTranslation(); + const tableRef = useRef(null); + + const [activeLanguage, setActiveLanguage] = useState('ko'); + + const [downloadState, setDownloadState] = useState({ + loading: false, + progress: 0 + }); + + const { + config, + searchParams, + data: dataList, + handleSearch, + handleReset, + handleOrderByChange, + updateSearchParams, + loading, + configLoaded, + handlePageChange, + handlePageSizeChange + } = useCommonSearch("metaInstanceSearch"); + + useEffect(()=>{ + setDownloadState({ + loading: false, + progress: 0 + }); + },[dataList]); + + const tableHeaders = useMemo(() => { + return [ + { id: 'instance_id', label: '인스턴스 ID', width: '100px' }, + { id: 'instance_name', label: '인스턴스 명', width: '200px' }, + { id: 'owner', label: '소유권', width: '200px' }, + { id: 'building_id', label: '빌딩 ID', width: '100px' }, + { id: 'building_socket', label: '빌딩 소켓 넘버', width: '80px' }, + { id: 'contents_type', label: '컨텐츠 타입', width: '150px' }, + { id: 'map_id', label: '맵 ID', width: '80px' }, + { id: 'limit_count', label: '제한 인원 수', width: '100px'}, + { id: 'over_limit', label: '정원 초과 시 추가 생성 여부', width: '90px' }, + { id: 'access_type', label: '입장 방식', width: '80px' }, + { id: 'access_id', label: '입장시 필요 아이템', width: '100px' }, + { id: 'voice_chat', label: '음성채팅 옵션', width: '100px' }, + { id: 'view_type', label: '시야 타입', width: '100px' }, + ]; + }, []); + + const renderTableForLanguage = useCallback((languageKey) => { + // 해당 언어의 아이템 리스트 가져오기 + const languageInstanceList = dataList?.instance_list?.[languageKey] || []; + + return ( + <> + + + + + {tableHeaders.map(header => ( + {header.label} + ))} + + + + {languageInstanceList?.map((item, index) => ( + + + {item.instance_id} + {item.instance_name || '-'} + {item.owner || '-'} + {item.building_id || '-'} + {item.building_socket} + {item.contents_type} + {item.map_id} + {item.limit_count} + {item.over_limit} + {item.access_type} + {item.access_id} + {item.voice_chat} + {item.view_type} + + + ))} + + + + {languageInstanceList.length > 0 && + + } + + ); + }, [dataList, loading, tableHeaders, searchParams, handlePageChange]); + + // 언어별 탭 아이템 생성 + const tabItems = useMemo(() => { + // 실제 데이터에서 사용 가능한 언어만 탭으로 생성 + const availableLanguages = dataList?.instance_list ? Object.keys(dataList.instance_list) : ['ko', 'en', 'ja']; + + return availableLanguages.map(langKey => ({ + key: langKey, + label: languageNames[langKey.charAt(0).toUpperCase() + langKey.slice(1)] || langKey.toUpperCase(), + children: renderTableForLanguage(langKey) + })); + }, [dataList, renderTableForLanguage]); + + + const handleTabChange = (key) => { + setActiveLanguage(key); + }; + + const excelParams = useMemo(() => ({ + ...searchParams, + lang: activeLanguage + }), [searchParams, activeLanguage]); + + return ( + + 인스턴스 조회 + + { + if (executeSearch) { + handleSearch(newParams); + } else { + updateSearchParams(newParams); + } + }} + onReset={handleReset} + /> + + + + + {downloadState.loading && ( + + + + )} + + + { + loading ? : + + } + + ); +}; + +export default withAuth(authType.instanceDictionaryRead)(MetaInstanceView); \ No newline at end of file diff --git a/src/pages/DataManage/index.js b/src/pages/DataManage/index.js index 0ecf36f..2bcf810 100644 --- a/src/pages/DataManage/index.js +++ b/src/pages/DataManage/index.js @@ -6,3 +6,4 @@ export { default as MetaItemView} from './MetaItemView'; export { default as MetaCraftingView} from './MetaCraftingView'; export { default as RankManage} from './RankManage'; export { default as RankInfoView} from './RankInfoView'; +export { default as MetaInstanceView} from './MetaInstanceView'; diff --git a/src/utils/common.js b/src/utils/common.js index b6df39b..3593e5d 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -180,7 +180,7 @@ export const responseFileDownload = (response, options = {}) => { const contentType = response.headers['content-type'] || response.headers['Content-Type']; const contentDisposition = response.headers['content-disposition'] || response.headers['Content-Disposition']; - // Excel, CSV, ZIP 파일 형식 검증 (CSV 추가) + // Excel, CSV, ZIP 파일 형식 검증 const isValidType = contentType && ( contentType.includes('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') || contentType.includes('text/csv') || @@ -263,4 +263,39 @@ export const calculateTotals = (data) => { }); return acc; }, {}) || {}; -}; \ No newline at end of file +}; + +/** + * 밀리초를 시:분:초 형식(HH:MM:SS)으로 변환 + * @param {number} milliseconds - 변환할 밀리초 + * @returns {string} HH:MM:SS 형식의 시간 문자열 + */ +export const formatTimeFromMilliseconds = (milliseconds) => { + if (milliseconds === null || milliseconds === undefined || isNaN(milliseconds)) { + return ''; + } + + const totalSeconds = Math.floor(milliseconds / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +}; + +/** + * 초를 시:분:초 형식(HH:MM:SS)으로 변환 + * @param {number} totalSeconds - 변환할 초 + * @returns {string} HH:MM:SS 형식의 시간 문자열 + */ +export const formatTimeFromSeconds = (totalSeconds) => { + if (totalSeconds === null || totalSeconds === undefined || isNaN(totalSeconds)) { + return ''; + } + + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +};