feat(i18n): 국제화 인프라 도입 및 Vuetify 동기화, SideMenu 기본 텍스트 1차 치환
This commit is contained in:
35
docs/20260508_국제화도입.md
Normal file
35
docs/20260508_국제화도입.md
Normal file
@@ -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 및 나머지 화면 치환, 하드코딩 스캔 적용 예정
|
||||
@@ -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",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<v-list
|
||||
v-for="item in items"
|
||||
:key="item.title"
|
||||
:key="item.titleKey || item.title"
|
||||
class="py-0"
|
||||
>
|
||||
<div v-if="!item.items">
|
||||
@@ -20,7 +20,7 @@
|
||||
:to="item.route"
|
||||
active-class="blue white--text"
|
||||
>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-title>{{ item.titleKey ? $t(item.titleKey) : item.title }}</v-list-item-title>
|
||||
<v-list-item-icon>
|
||||
<v-icon>mdi-chevron-right</v-icon>
|
||||
</v-list-item-icon>
|
||||
@@ -33,18 +33,18 @@
|
||||
no-action
|
||||
>
|
||||
<template v-slot:activator>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-title>{{ item.titleKey ? $t(item.titleKey) : item.title }}</v-list-item-title>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-for="childItem in item.items"
|
||||
:key="childItem.title"
|
||||
:key="childItem.titleKey || childItem.title"
|
||||
>
|
||||
<v-list-item
|
||||
:to="childItem.route"
|
||||
active-class="blue white--text"
|
||||
>
|
||||
<v-list-item-title>{{ childItem.title }}</v-list-item-title>
|
||||
<v-list-item-title>{{ childItem.titleKey ? $t(childItem.titleKey) : childItem.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</v-list-group>
|
||||
@@ -56,7 +56,7 @@
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
</v-list-item-icon>
|
||||
|
||||
<v-list-item-title>로그아웃</v-list-item-title>
|
||||
<v-list-item-title>{{ $t('common.logout') }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-layout>
|
||||
@@ -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
|
||||
|
||||
43
src/i18n/index.js
Normal file
43
src/i18n/index.js
Normal file
@@ -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
|
||||
20
src/locales/en.json
Normal file
20
src/locales/en.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/locales/ja.json
Normal file
20
src/locales/ja.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"common": {
|
||||
"logout": "ログアウト",
|
||||
"error": {
|
||||
"unknown": "不明なエラーが発生しました。再度ログインしてください。"
|
||||
}
|
||||
},
|
||||
"comp": {
|
||||
"sideMenu": {
|
||||
"creators": "所属クリエイター",
|
||||
"calc": {
|
||||
"live": "クリエイター別ライブ精算",
|
||||
"content": "クリエイター別コンテンツ精算",
|
||||
"contentDonation": "クリエイター別コンテンツ支援精算",
|
||||
"community": "クリエイター別コミュニティ精算",
|
||||
"channelDonation": "クリエイター別チャンネル支援精算"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/locales/ko.json
Normal file
20
src/locales/ko.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"common": {
|
||||
"logout": "로그아웃",
|
||||
"error": {
|
||||
"unknown": "알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!"
|
||||
}
|
||||
},
|
||||
"comp": {
|
||||
"sideMenu": {
|
||||
"creators": "소속 크리에이터",
|
||||
"calc": {
|
||||
"live": "크리에이터별 라이브 정산",
|
||||
"content": "크리에이터별 콘텐츠 정산",
|
||||
"contentDonation": "크리에이터별 콘텐츠 후원 정산",
|
||||
"community": "크리에이터별 커뮤니티 정산",
|
||||
"channelDonation": "크리에이터별 채널 후원 정산"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/main.js
15
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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user