test #39

Merged
klaus merged 9 commits from test into main 2026-05-08 07:37:29 +00:00
9 changed files with 193 additions and 18 deletions
Showing only changes of commit c2663a1e9d - Show all commits

View 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 및 나머지 화면 치환, 하드코딩 스캔 적용 예정

View File

@@ -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",

View File

@@ -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
View 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
View 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
View 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
View File

@@ -0,0 +1,20 @@
{
"common": {
"logout": "로그아웃",
"error": {
"unknown": "알 수 없는 오류가 발생했습니다. 다시 로그인 해주세요!"
}
},
"comp": {
"sideMenu": {
"creators": "소속 크리에이터",
"calc": {
"live": "크리에이터별 라이브 정산",
"content": "크리에이터별 콘텐츠 정산",
"contentDonation": "크리에이터별 콘텐츠 후원 정산",
"community": "크리에이터별 커뮤니티 정산",
"channelDonation": "크리에이터별 채널 후원 정산"
}
}
}
}

View File

@@ -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,

View File

@@ -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()
}
})