From c2663a1e9d139848ab90171a16f3b82f228723ae Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Fri, 8 May 2026 13:59:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20=EA=B5=AD=EC=A0=9C=ED=99=94=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20?= =?UTF-8?q?Vuetify=20=EB=8F=99=EA=B8=B0=ED=99=94,=20SideMenu=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=ED=85=8D=EC=8A=A4=ED=8A=B8=201=EC=B0=A8=20?= =?UTF-8?q?=EC=B9=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/20260508_국제화도입.md | 35 ++++++++++++++++++++++++++++++ package.json | 1 + src/components/SideMenu.vue | 28 ++++++++++++------------ src/i18n/index.js | 43 +++++++++++++++++++++++++++++++++++++ src/locales/en.json | 20 +++++++++++++++++ src/locales/ja.json | 20 +++++++++++++++++ src/locales/ko.json | 20 +++++++++++++++++ src/main.js | 15 +++++++++++++ src/plugins/vuetify.js | 29 +++++++++++++++++++++---- 9 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 docs/20260508_국제화도입.md create mode 100644 src/i18n/index.js create mode 100644 src/locales/en.json create mode 100644 src/locales/ja.json create mode 100644 src/locales/ko.json diff --git a/docs/20260508_국제화도입.md b/docs/20260508_국제화도입.md new file mode 100644 index 0000000..345f197 --- /dev/null +++ b/docs/20260508_국제화도입.md @@ -0,0 +1,35 @@ +# 국제화(i18n) 도입 계획 + +## 목표 +- Vue 2 + Vuetify 2 기반에서 한국어(ko), 영어(en), 일본어(ja) 3개 언어를 지원한다. +- 브라우저 설정을 기본 로케일로 사용하되, 사용자가 선택한 언어가 있으면(localStorage) 그 값을 우선한다. + +## 체크리스트 +- [x] vue-i18n v8 도입 및 기본 설정(`src/i18n/index.js`) +- [x] 리소스 파일 생성(`src/locales/ko.json`, `src/locales/en.json`, `src/locales/ja.json`) +- [x] Vuetify 언어팩 연동(`src/plugins/vuetify.js`) 및 초기 로케일 동기화 +- [x] `main.js`에서 vue-i18n ↔ Vuetify 로케일 동기화 로직 추가 +- [ ] 공통 컴포넌트 문자열 치환(네비/레이아웃/다이얼로그) + - [x] `src/components/SideMenu.vue` 로그아웃/에러/기본 메뉴 텍스트 치환 +- [ ] 주요 뷰(`views/Agent/*`) 1차 치환 +- [ ] 날짜/숫자/통화 포맷 정책 적용(ja: JPY 소수점 미사용 등) +- [ ] 하드코딩 탐지/미번역 키 점검(정규식 스캔 + missing 핸들러) +- [ ] 언어 전환 UX(드롭다운) 및 영속 저장(localStorage) + +## 키 네이밍 규칙 +- 네임스페이스 기반: `common.*`, `comp.*`, `view.*` +- 예) `common.logout`, `common.error.unknown`, `comp.sideMenu.calc.live` + +## 개발 메모 +- 서버 메뉴가 비어 기본 메뉴를 사용하는 분기는 i18n 키를 사용하도록 업데이트함. +- 서버에서 전달되는 텍스트는 과도기적으로 기존 값을 그대로 사용하며, 추후 서버가 키를 전달하도록 협업 예정. + +## 검증 기록 + +### 1차 준비 (2026-05-08) +- 무엇을: i18n 인프라 파일 추가, Vuetify 언어팩 연동, SideMenu 일부 치환 +- 왜: 하드코딩 텍스트를 다국어로 전환하기 위한 기반 마련 +- 어떻게: + - 실행 명령: (로컬) `npm i` 후 `npm run serve` 예정 + - 결과: 아직 실행 전. 의존성 설치 및 로컬 실행 시에 확인 예정 + - 보완: 언어 전환 UI/UX 및 나머지 화면 치환, 하드코딩 스캔 적용 예정 diff --git a/package.json b/package.json index fef4633..fe3b499 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "cropperjs": "^1.6.2", "vue": "^2.6.11", "vue-router": "^3.2.0", + "vue-i18n": "^8.28.2", "vue-show-more-text": "^2.0.2", "vue2-datepicker": "^3.11.1", "vue2-editor": "^2.10.3", diff --git a/src/components/SideMenu.vue b/src/components/SideMenu.vue index fa2b1e1..afd4a58 100644 --- a/src/components/SideMenu.vue +++ b/src/components/SideMenu.vue @@ -12,7 +12,7 @@ >
@@ -20,7 +20,7 @@ :to="item.route" active-class="blue white--text" > - {{ item.title }} + {{ item.titleKey ? $t(item.titleKey) : item.title }} mdi-chevron-right @@ -33,18 +33,18 @@ no-action >
- {{ childItem.title }} + {{ childItem.titleKey ? $t(childItem.titleKey) : childItem.title }}
@@ -56,7 +56,7 @@ mdi-logout - 로그아웃 + {{ $t('common.logout') }} @@ -99,20 +99,20 @@ export default { // 빈 메뉴일 경우 기본 단독 메뉴를 제공한다. // 요구사항: 정산 관련 기본 메뉴를 추가한다. this.items = [ - { title: '소속 크리에이터', route: '/agent/creators' }, - { title: '크리에이터별 라이브 정산', route: '/agent/calculate/live' }, - { title: '크리에이터별 콘텐츠 정산', route: '/agent/calculate/content-by-date' }, - { title: '크리에이터별 콘텐츠 후원 정산', route: '/agent/calculate/content-donation-by-date' }, - { title: '크리에이터별 커뮤니티 정산', route: '/agent/calculate/community-post' }, - { title: '크리에이터별 채널 후원 정산', route: '/agent/calculate/channel-donation' }, + { titleKey: 'comp.sideMenu.creators', route: '/agent/creators' }, + { titleKey: 'comp.sideMenu.calc.live', route: '/agent/calculate/live' }, + { titleKey: 'comp.sideMenu.calc.content', route: '/agent/calculate/content-by-date' }, + { titleKey: 'comp.sideMenu.calc.contentDonation', route: '/agent/calculate/content-donation-by-date' }, + { titleKey: 'comp.sideMenu.calc.community', route: '/agent/calculate/community-post' }, + { titleKey: 'comp.sideMenu.calc.channelDonation', route: '/agent/calculate/channel-donation' }, ] } } else { - this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") + this.notifyError(this.$t('common.error.unknown')) this.logoutWithoutNetwork(); } } catch (e) { - this.notifyError("알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!") + this.notifyError(this.$t('common.error.unknown')) this.logoutWithoutNetwork(); } finally { this.isLoading = false diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..27a6b4f --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,43 @@ +import Vue from 'vue' +import VueI18n from 'vue-i18n' +import ko from '@/locales/ko.json' +import en from '@/locales/en.json' +import ja from '@/locales/ja.json' + +Vue.use(VueI18n) + +function detectLocale() { + try { + const saved = localStorage.getItem('locale') + if (saved) return saved + } catch (e) { + // ignore + } + + const list = (navigator.languages && navigator.languages.length + ? navigator.languages + : [navigator.language || 'en']) + .map(l => String(l).toLowerCase()) + + for (const l of list) { + if (l.startsWith('ko')) return 'ko' + if (l.startsWith('ja')) return 'ja' + if (l.startsWith('en')) return 'en' + } + return 'en' // 매칭 실패 시 기본값 +} + +const i18n = new VueI18n({ + locale: detectLocale(), + fallbackLocale: 'en', + messages: { ko, en, ja }, + silentTranslationWarn: process.env.NODE_ENV === 'production', + missing(locale, key) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn(`[i18n missing] ${locale}: ${key}`) + } + } +}) + +export default i18n diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..f9f126e --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,20 @@ +{ + "common": { + "logout": "Log out", + "error": { + "unknown": "An unknown error occurred. Please sign in again." + } + }, + "comp": { + "sideMenu": { + "creators": "Affiliated creators", + "calc": { + "live": "Settlement by live stream", + "content": "Settlement by content", + "contentDonation": "Settlement by content donations", + "community": "Settlement by community posts", + "channelDonation": "Settlement by channel donations" + } + } + } +} diff --git a/src/locales/ja.json b/src/locales/ja.json new file mode 100644 index 0000000..0b2872f --- /dev/null +++ b/src/locales/ja.json @@ -0,0 +1,20 @@ +{ + "common": { + "logout": "ログアウト", + "error": { + "unknown": "不明なエラーが発生しました。再度ログインしてください。" + } + }, + "comp": { + "sideMenu": { + "creators": "所属クリエイター", + "calc": { + "live": "クリエイター別ライブ精算", + "content": "クリエイター別コンテンツ精算", + "contentDonation": "クリエイター別コンテンツ支援精算", + "community": "クリエイター別コミュニティ精算", + "channelDonation": "クリエイター別チャンネル支援精算" + } + } + } +} diff --git a/src/locales/ko.json b/src/locales/ko.json new file mode 100644 index 0000000..0bb890c --- /dev/null +++ b/src/locales/ko.json @@ -0,0 +1,20 @@ +{ + "common": { + "logout": "로그아웃", + "error": { + "unknown": "알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!" + } + }, + "comp": { + "sideMenu": { + "creators": "소속 크리에이터", + "calc": { + "live": "크리에이터별 라이브 정산", + "content": "크리에이터별 콘텐츠 정산", + "contentDonation": "크리에이터별 콘텐츠 후원 정산", + "community": "크리에이터별 커뮤니티 정산", + "channelDonation": "크리에이터별 채널 후원 정산" + } + } + } +} diff --git a/src/main.js b/src/main.js index ccb3a50..9eab205 100644 --- a/src/main.js +++ b/src/main.js @@ -2,6 +2,7 @@ import Vue from 'vue' import './plugins/axios' import App from './App.vue' import vuetify from './plugins/vuetify' +import i18n from './i18n' import router from './router' import store from './store' @@ -15,7 +16,21 @@ Vue.use(VuetifyDialog, { } }) +// Vuetify 언어와 vue-i18n 로케일 동기화 +try { + vuetify.framework.lang.current = i18n.locale + if (i18n.vm && i18n.vm.$watch) { + i18n.vm.$watch('locale', (val) => { + vuetify.framework.lang.current = val + try { localStorage.setItem('locale', val) } catch (e) { /* ignore */ } + }) + } +} catch (e) { + // ignore +} + new Vue({ + i18n, vuetify, router, store, diff --git a/src/plugins/vuetify.js b/src/plugins/vuetify.js index 5bdec19..cd7261c 100644 --- a/src/plugins/vuetify.js +++ b/src/plugins/vuetify.js @@ -1,7 +1,28 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify/lib/framework'; +import Vue from 'vue' +import Vuetify from 'vuetify/lib/framework' +import en from 'vuetify/lib/locale/en' +import ko from 'vuetify/lib/locale/ko' +import ja from 'vuetify/lib/locale/ja' -Vue.use(Vuetify); +Vue.use(Vuetify) + +function detectVuetifyLocale() { + try { + const saved = localStorage.getItem('locale') + if (saved) return saved + } catch (e) { + // ignore + } + const l = (navigator.languages && navigator.languages[0]) || navigator.language || 'en' + const low = String(l).toLowerCase() + if (low.startsWith('ko')) return 'ko' + if (low.startsWith('ja')) return 'ja' + return 'en' +} export default new Vuetify({ -}); + lang: { + locales: { en, ko, ja }, + current: detectVuetifyLocale() + } +})