feat(i18n): 사이드메뉴 국제화 키 매핑 적용 및 리소스 추가
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
140
docs/20260508_사이드메뉴국제화.md
Normal file
140
docs/20260508_사이드메뉴국제화.md
Normal file
@@ -0,0 +1,140 @@
|
||||
사이드메뉴 국제화(i18n) 적용 계획
|
||||
|
||||
초안 생성: 2026-05-08
|
||||
|
||||
1. 배경/목표
|
||||
- 현재 SideMenu는 서버에서 메뉴 목록을 받아 렌더링한다.
|
||||
- 클라이언트 코드(`src/components/SideMenu.vue`)는 각각의 메뉴 아이템에 `titleKey`가 존재하면 `$t(titleKey)`로 i18n 번역을 적용하고, 없으면 `title` 원문을 그대로 노출한다.
|
||||
- 목표: 서버가 내려주는 메뉴 중 i18n 적용 대상 텍스트는 설정된 언어(ko/en/ja)에 맞춰 번역이 적용되도록 한다. 추가로, 메뉴 텍스트 사전을 문서에 유지하여 번역 항목을 관리한다.
|
||||
|
||||
2. 현행 동작 확인(근거)
|
||||
- 렌더링 로직(발췌):
|
||||
- 단일 항목: `item.titleKey ? $t(item.titleKey) : item.title`
|
||||
- 그룹 타이틀: `item.titleKey ? $t(item.titleKey) : item.title`
|
||||
- 자식 항목: `childItem.titleKey ? $t(childItem.titleKey) : childItem.title`
|
||||
- 기본(fallback) 메뉴 키: `comp.sideMenu.creators`, `comp.sideMenu.calc.*` 일련의 키 사용 중.
|
||||
- 관련 파일: `src/components/SideMenu.vue`, `src/locales/ko.json`, `src/locales/en.json`, `src/locales/ja.json`
|
||||
|
||||
3. 제안 방법(서버/클라이언트 계약)
|
||||
- 서버 응답 스키마 확정(제안):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"titleKey": "comp.sideMenu.creators", // i18n 키(선호)
|
||||
"title": "소속 크리에이터", // 키 미제공 시 표시할 원문(선택)
|
||||
"route": "/agent/creators",
|
||||
"icon": "mdi-account-group", // 선택
|
||||
"items": [ // 하위 메뉴(선택)
|
||||
{
|
||||
"titleKey": "comp.sideMenu.calc.live",
|
||||
"title": "크리에이터별 라이브 정산",
|
||||
"route": "/agent/calculate/live"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
- 클라이언트 적용 원칙(변경 없음):
|
||||
- `titleKey`가 존재하면 `$t(titleKey)`로 번역 표시.
|
||||
- `titleKey`가 없으면 `title` 값을 그대로 표시(서버 원문 노출).
|
||||
- 그룹/자식 항목 동일 규칙 적용.
|
||||
- i18n 키 네이밍 규칙: `comp.sideMenu.<group?>.<name>`를 사용(기존 패턴 유지).
|
||||
|
||||
3-1. 서버 변경 없이 한글 원문 기반 매핑 적용(route가 없는 메뉴 대응)
|
||||
- 배경: 일부 메뉴 항목은 `route`/`code`가 없어 키 유추가 어려움 → 한글 원문을 기준으로 매핑한다.
|
||||
- 방법: 클라이언트에서 "한글 원문 → i18n 키" 사전을 유지하고, 메뉴 응답 수신 시 전처리로 `titleKey`를 주입한다.
|
||||
- 정규화 권장: `NFC` 정규화 + 다중 공백 축소 + `trim` 적용 후 매핑해 표기 미세 차이를 흡수한다.
|
||||
- 운영 원칙:
|
||||
- "기존에 있는 키는 재사용"한다(예: `comp.sideMenu.calc.live`, `comp.sideMenu.calc.community`, `comp.sideMenu.calc.channelDonation`, `view.signature.title`).
|
||||
- 서버가 "채널후원 정산"(공백 없음)으로 내려와도 키는 `comp.sideMenu.calc.channelDonation`으로 매핑해, 화면 ko는 기존 리소스값 "채널 후원 정산"으로 노출한다.
|
||||
- 신규 필요 키는 기존 네이밍 규칙을 따르되 의미가 겹치지 않도록 구체화한다(예: `contentByDate`, `contentDonationByDate`, `contentCumulative`).
|
||||
|
||||
3-2. 한글 원문 → i18n 키 매핑(초안)
|
||||
- 그룹 타이틀:
|
||||
- `콘텐츠 관리` → `comp.sideMenu.content.title` (신규)
|
||||
- `정산` → `comp.sideMenu.calc.title` (신규)
|
||||
- `시그니처 관리` → `view.signature.title` (기존 키 재사용)
|
||||
- 콘텐츠 관리 하위(신규):
|
||||
- `내 콘텐츠 리스트` → `comp.sideMenu.content.myList`
|
||||
- `카테고리 관리` → `comp.sideMenu.content.category`
|
||||
- `시리즈 관리` → `comp.sideMenu.content.series`
|
||||
- 정산 하위:
|
||||
- `라이브 정산` → `comp.sideMenu.calc.live` (기존 재사용)
|
||||
- `일자별 콘텐츠 정산` → `comp.sideMenu.calc.contentByDate` (신규)
|
||||
- `콘텐츠별 누적 현황` → `comp.sideMenu.calc.contentCumulative` (신규)
|
||||
- `일자별 콘텐츠 후원 정산` → `comp.sideMenu.calc.contentDonationByDate` (신규)
|
||||
- `커뮤니티 정산` → `comp.sideMenu.calc.community` (기존 재사용)
|
||||
- `채널후원 정산` → `comp.sideMenu.calc.channelDonation` (기존 재사용, 화면 ko는 "채널 후원 정산")
|
||||
|
||||
4. 구현 체크리스트
|
||||
- [ ] 서버 메뉴 응답에 `titleKey`를 포함하도록 계약/스키마 합의한다.
|
||||
- [ ] 새로 추가되는 메뉴도 동일 키 네이밍 규칙을 따른다(`comp.sideMenu.*`).
|
||||
- [ ] (route 없는 항목 대응) 한글 원문 기반 매핑 사전(`titleKoToKey`)을 정의·유지한다.
|
||||
- [ ] (route 없는 항목 대응) 전처리 유틸(`attachTitleKeyByKo`)로 메뉴 응답에 `titleKey`를 주입한다.
|
||||
- [ ] "채널후원 정산" → `comp.sideMenu.calc.channelDonation` 매핑이 적용되어 화면 ko가 "채널 후원 정산"으로 노출되는지 확인한다.
|
||||
- [ ] `src/locales/ko.json`에 모든 `titleKey`의 한국어 값을 등록/확인한다.
|
||||
- [ ] `src/locales/en.json`에 모든 `titleKey`의 영어 값을 등록/확인한다.
|
||||
- [ ] `src/locales/ja.json`에 모든 `titleKey`의 일본어 값을 등록/확인한다.
|
||||
- [ ] 서버가 `titleKey`를 누락한 경우 `title`로 정상 노출되는지 확인한다(회귀 테스트).
|
||||
- [ ] 빈 메뉴 응답 시 클라이언트 기본 메뉴(fallback)로 정상 노출/번역되는지 확인한다.
|
||||
|
||||
5. 메뉴 텍스트 사전(기존 키 선기입 + 추후 확장)
|
||||
- 기준 컬럼: `key | ko | en | ja | route`
|
||||
|
||||
| key | ko | en | ja | route |
|
||||
|---|---|---|---|---|
|
||||
| comp.sideMenu.content.title | 콘텐츠 관리 | Content management | コンテンツ管理 | |
|
||||
| comp.sideMenu.content.myList | 내 콘텐츠 리스트 | My contents | 自分のコンテンツ一覧 | |
|
||||
| comp.sideMenu.content.category | 카테고리 관리 | Category management | カテゴリ管理 | |
|
||||
| comp.sideMenu.content.series | 시리즈 관리 | Series management | シリーズ管理 | |
|
||||
| comp.sideMenu.calc.title | 정산 | Settlement | 精算 | |
|
||||
| comp.sideMenu.creators | 소속 크리에이터 | Affiliated creators | 所属クリエイター | /agent/creators |
|
||||
| comp.sideMenu.calc.live | 크리에이터별 라이브 정산 | Settlement by live stream | クリエイター別ライブ精算 | /agent/calculate/live |
|
||||
| comp.sideMenu.calc.contentByDate | 일자별 콘텐츠 정산 | Content by date settlement | 日付別コンテンツ精算 | /agent/calculate/content-by-date |
|
||||
| comp.sideMenu.calc.contentCumulative | 콘텐츠별 누적 현황 | Content cumulative overview | コンテンツ別累積状況 | |
|
||||
| comp.sideMenu.calc.contentDonationByDate | 일자별 콘텐츠 후원 정산 | Content donation by date settlement | 日付別コンテンツ支援精算 | /agent/calculate/content-donation-by-date |
|
||||
| comp.sideMenu.calc.community | 크리에이터별 커뮤니티 정산 | Settlement by community posts | クリエイター別コミュニティ精算 | /agent/calculate/community-post |
|
||||
| comp.sideMenu.calc.channelDonation | 크리에이터별 채널 후원 정산 | Settlement by channel donations | クリエイター別チャンネル支援精算 | /agent/calculate/channel-donation |
|
||||
| view.signature.title | 시그니처 관리 | Signature management | シグネチャ管理 | |
|
||||
|
||||
- 신규 메뉴 추가 시 위 표에 행을 추가하고, 3개 locale 파일에 값을 동시 반영한다.
|
||||
|
||||
5-1. 한글 원문 기반 매핑 사전(ko → key) 초안
|
||||
```
|
||||
// 예시: 클라이언트 전처리용 사전
|
||||
{
|
||||
"콘텐츠 관리": "comp.sideMenu.content.title",
|
||||
"내 콘텐츠 리스트": "comp.sideMenu.content.myList",
|
||||
"카테고리 관리": "comp.sideMenu.content.category",
|
||||
"시리즈 관리": "comp.sideMenu.content.series",
|
||||
|
||||
"정산": "comp.sideMenu.calc.title",
|
||||
"라이브 정산": "comp.sideMenu.calc.live",
|
||||
"일자별 콘텐츠 정산": "comp.sideMenu.calc.contentByDate",
|
||||
"콘텐츠별 누적 현황": "comp.sideMenu.calc.contentCumulative",
|
||||
"일자별 콘텐츠 후원 정산": "comp.sideMenu.calc.contentDonationByDate",
|
||||
"커뮤니티 정산": "comp.sideMenu.calc.community",
|
||||
"채널후원 정산": "comp.sideMenu.calc.channelDonation",
|
||||
|
||||
"시그니처 관리": "view.signature.title"
|
||||
}
|
||||
```
|
||||
|
||||
6. 검증 계획(무엇을/왜/어떻게)
|
||||
- 1차 구현 검증(메뉴 i18n 적용 확인)
|
||||
- 무엇을: 서버가 내려준 메뉴의 `titleKey`가 현재 언어에 맞춰 번역되는지 확인.
|
||||
- 왜: 사용자의 언어 설정에 따라 일관된 UI 텍스트 제공.
|
||||
- 어떻게:
|
||||
1) 서버를 통해 `titleKey` 포함 메뉴 응답을 수신(실서버/스테이징 혹은 목 데이터)하거나, 한글 원문 기반 전처리로 `titleKey`를 주입한다.
|
||||
2) 앱 언어를 ko/en/ja로 각각 전환 후 사이드메뉴 라벨 스냅샷 확인.
|
||||
3) 기대 결과: 표의 ko/en/ja 값과 화면 라벨이 일치.
|
||||
|
||||
- 2차 회귀 검증(fallback 및 누락 케이스)
|
||||
- 무엇을: `titleKey` 누락 또는 빈 메뉴 응답 시 동작 확인.
|
||||
- 왜: 서버 데이터 이상/변경 시에도 UX 붕괴 방지.
|
||||
- 어떻게:
|
||||
1) `titleKey` 없이 `title`만 포함된 메뉴 응답 → 한글 원문 기반 전처리가 `titleKey`를 주입하는지 확인. 매핑 누락 시에는 원문(title) 그대로 노출되는지 확인.
|
||||
2) 빈 배열 응답 → 클라이언트 기본 메뉴 6개가 노출되고 번역이 적용되는지 확인.
|
||||
|
||||
7. 정정/추가 기록
|
||||
- (추후 변경 시 이 섹션에 `정정` 항목을 누적 기록한다. 원문 삭제 금지.)
|
||||
@@ -65,6 +65,7 @@
|
||||
|
||||
<script>
|
||||
import * as api from '@/api/menu'
|
||||
import { attachTitleKeyByKo } from '@/utils/menuMapping'
|
||||
|
||||
export default {
|
||||
name: "SideMenu",
|
||||
@@ -94,7 +95,8 @@ export default {
|
||||
let res = await api.getMenus();
|
||||
if (res.status === 200 && res.data.success === true) {
|
||||
if (res.data.data && res.data.data.length > 0) {
|
||||
this.items = res.data.data
|
||||
// 서버가 titleKey를 내려주지 않는 항목은 한글 원문 기반 매핑으로 titleKey를 주입한다.
|
||||
this.items = attachTitleKeyByKo(res.data.data)
|
||||
} else {
|
||||
// 빈 메뉴일 경우 기본 단독 메뉴를 제공한다.
|
||||
// 요구사항: 정산 관련 기본 메뉴를 추가한다.
|
||||
|
||||
28
src/i18n/menuTitleMap.js
Normal file
28
src/i18n/menuTitleMap.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// 한글 원문 → i18n 키 매핑 사전
|
||||
// 운영 원칙:
|
||||
// - 기존 키는 재사용한다.
|
||||
// - 표기 미세 차이는 전처리 단계의 정규화(normKo)로 흡수한다.
|
||||
|
||||
export const titleKoToKey = {
|
||||
// 그룹 타이틀
|
||||
'콘텐츠 관리': 'comp.sideMenu.content.title',
|
||||
'정산': 'comp.sideMenu.calc.title',
|
||||
|
||||
// 콘텐츠 관리 하위
|
||||
'내 콘텐츠 리스트': 'comp.sideMenu.content.myList',
|
||||
'카테고리 관리': 'comp.sideMenu.content.category',
|
||||
'시리즈 관리': 'comp.sideMenu.content.series',
|
||||
|
||||
// 정산 하위
|
||||
'라이브 정산': 'comp.sideMenu.calc.live', // 기존 키 재사용
|
||||
'일자별 콘텐츠 정산': 'comp.sideMenu.calc.contentByDate',
|
||||
'콘텐츠별 누적 현황': 'comp.sideMenu.calc.contentCumulative',
|
||||
'일자별 콘텐츠 후원 정산': 'comp.sideMenu.calc.contentDonationByDate',
|
||||
'커뮤니티 정산': 'comp.sideMenu.calc.community', // 기존 키 재사용
|
||||
|
||||
// 서버 원문은 공백 없이 내려옴: "채널후원 정산" → 기존 키로 매핑
|
||||
'채널후원 정산': 'comp.sideMenu.calc.channelDonation',
|
||||
|
||||
// 시그니처 관리(사이드메뉴 타이틀은 view.signature.title 재사용)
|
||||
'시그니처 관리': 'view.signature.title',
|
||||
}
|
||||
@@ -55,11 +55,21 @@
|
||||
},
|
||||
"comp": {
|
||||
"sideMenu": {
|
||||
"content": {
|
||||
"title": "Content management",
|
||||
"myList": "My contents",
|
||||
"category": "Category management",
|
||||
"series": "Series management"
|
||||
},
|
||||
"creators": "Affiliated creators",
|
||||
"calc": {
|
||||
"title": "Settlement",
|
||||
"live": "Settlement by live stream",
|
||||
"content": "Settlement by content",
|
||||
"contentByDate": "Content by date settlement",
|
||||
"contentDonation": "Settlement by content donations",
|
||||
"contentDonationByDate": "Content donation by date settlement",
|
||||
"contentCumulative": "Content cumulative overview",
|
||||
"community": "Settlement by community posts",
|
||||
"channelDonation": "Settlement by channel donations"
|
||||
}
|
||||
|
||||
@@ -55,11 +55,21 @@
|
||||
},
|
||||
"comp": {
|
||||
"sideMenu": {
|
||||
"content": {
|
||||
"title": "コンテンツ管理",
|
||||
"myList": "自分のコンテンツ一覧",
|
||||
"category": "カテゴリ管理",
|
||||
"series": "シリーズ管理"
|
||||
},
|
||||
"creators": "所属クリエイター",
|
||||
"calc": {
|
||||
"title": "精算",
|
||||
"live": "クリエイター別ライブ精算",
|
||||
"content": "クリエイター別コンテンツ精算",
|
||||
"contentByDate": "日付別コンテンツ精算",
|
||||
"contentDonation": "クリエイター別コンテンツ支援精算",
|
||||
"contentDonationByDate": "日付別コンテンツ支援精算",
|
||||
"contentCumulative": "コンテンツ別累積状況",
|
||||
"community": "クリエイター別コミュニティ精算",
|
||||
"channelDonation": "クリエイター別チャンネル支援精算"
|
||||
}
|
||||
|
||||
@@ -55,11 +55,21 @@
|
||||
},
|
||||
"comp": {
|
||||
"sideMenu": {
|
||||
"content": {
|
||||
"title": "콘텐츠 관리",
|
||||
"myList": "내 콘텐츠 리스트",
|
||||
"category": "카테고리 관리",
|
||||
"series": "시리즈 관리"
|
||||
},
|
||||
"creators": "소속 크리에이터",
|
||||
"calc": {
|
||||
"title": "정산",
|
||||
"live": "크리에이터별 라이브 정산",
|
||||
"content": "크리에이터별 콘텐츠 정산",
|
||||
"contentByDate": "일자별 콘텐츠 정산",
|
||||
"contentDonation": "크리에이터별 콘텐츠 후원 정산",
|
||||
"contentDonationByDate": "일자별 콘텐츠 후원 정산",
|
||||
"contentCumulative": "콘텐츠별 누적 현황",
|
||||
"community": "크리에이터별 커뮤니티 정산",
|
||||
"channelDonation": "크리에이터별 채널 후원 정산"
|
||||
}
|
||||
|
||||
35
src/utils/menuMapping.js
Normal file
35
src/utils/menuMapping.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { titleKoToKey } from '@/i18n/menuTitleMap'
|
||||
|
||||
export function normKo(s) {
|
||||
if (!s || typeof s !== 'string') return s
|
||||
try {
|
||||
return s
|
||||
.normalize('NFC')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
} catch (e) {
|
||||
// 일부 환경에서 normalize 미지원 시 안전하게 진행
|
||||
return String(s).replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export function attachTitleKeyByKo(items) {
|
||||
if (!Array.isArray(items)) return items
|
||||
return items.map(item => {
|
||||
const next = { ...item }
|
||||
const title = normKo(item && item.title)
|
||||
const key = title ? titleKoToKey[title] : undefined
|
||||
if (key) {
|
||||
next.titleKey = key
|
||||
} else if (process && process.env && process.env.NODE_ENV !== 'production') {
|
||||
// 개발 환경에서만 경고 로그
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[menu-i18n] missing map for title:', item && item.title)
|
||||
}
|
||||
|
||||
if (Array.isArray(item && item.items) && item.items.length > 0) {
|
||||
next.items = attachTitleKeyByKo(item.items)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user