Commit e1d0c8338d1479c2951bee98c199eee83893d094

Authored by zichun
1 parent 4f88c6d4

feat(web): SPA i18n — locale context, en-US + zh-CN, language switcher

Adds client-side i18n infrastructure to the SPA (CLAUDE.md
guardrail #6: global/i18n from day one).

New files:
  - i18n/messages.ts: flat key-value message bundles for en-US
    and zh-CN. Keys use dot-notation (nav.*, action.*, status.*,
    label.*). ~80 keys per locale covering navigation, actions,
    status badges, and common labels.
  - i18n/LocaleContext.tsx: LocaleProvider + useT() hook + useLocale()
    hook. Active locale stored in localStorage, defaults to the
    browser's navigator.language. Auto-detects zh-* → zh-CN.

Wired into the SPA:
  - main.tsx wraps the app in <LocaleProvider>
  - AppLayout sidebar uses t(key) for every heading and item
  - Top bar has a locale dropdown (English / 中文) that
    switches the entire sidebar + status labels instantly
  - StatusBadge uses t('status.DRAFT') etc. so statuses render
    as '草稿' / '已确认' / '已发货' in Chinese

The i18n system is intentionally simple: plain strings, no ICU
MessageFormat patterns (those live on the backend via ICU4J).
A future chunk can adopt @formatjs/intl-messageformat if the
SPA needs plural/gender/number formatting client-side.

Not yet translated: page-level titles and form labels (they
still use hard-coded English). The infrastructure is in place;
translating individual pages is incremental.
web/src/components/StatusBadge.tsx
  1 +import { useT } from '@/i18n/LocaleContext'
  2 +import type { MessageKey } from '@/i18n/messages'
  3 +
1 4 interface Props {
2 5 status: string
3 6 }
4 7  
5   -// Status → Tailwind class via the shared @layer components classes
6   -// declared in index.css. Falls back to 'badge-pending' for unknown
7   -// statuses so a new server-side enum value renders as a neutral pill
8   -// instead of throwing.
  8 +const CLS_MAP: Record<string, string> = {
  9 + DRAFT: 'badge-draft',
  10 + CONFIRMED: 'badge-confirmed',
  11 + SHIPPED: 'badge-shipped',
  12 + RECEIVED: 'badge-received',
  13 + COMPLETED: 'badge-completed',
  14 + CANCELLED: 'badge-cancelled',
  15 + IN_PROGRESS: 'badge-in-progress',
  16 + PENDING: 'badge-pending',
  17 + POSTED: 'badge-confirmed',
  18 + SETTLED: 'badge-shipped',
  19 + REVERSED: 'badge-cancelled',
  20 +}
  21 +
9 22 export function StatusBadge({ status }: Props) {
10   - const cls =
11   - {
12   - DRAFT: 'badge-draft',
13   - CONFIRMED: 'badge-confirmed',
14   - SHIPPED: 'badge-shipped',
15   - RECEIVED: 'badge-received',
16   - COMPLETED: 'badge-completed',
17   - CANCELLED: 'badge-cancelled',
18   - IN_PROGRESS: 'badge-in-progress',
19   - PENDING: 'badge-pending',
20   - POSTED: 'badge-confirmed',
21   - SETTLED: 'badge-shipped',
22   - REVERSED: 'badge-cancelled',
23   - }[status] ?? 'badge-pending'
24   - return <span className={cls}>{status}</span>
  23 + const t = useT()
  24 + const cls = CLS_MAP[status] ?? 'badge-pending'
  25 + const key = `status.${status}` as MessageKey
  26 + const label = t(key)
  27 + return <span className={cls}>{label}</span>
25 28 }
... ...
web/src/i18n/LocaleContext.tsx 0 → 100644
  1 +// vibe_erp SPA locale context.
  2 +//
  3 +// Provides a `useT()` hook that returns a translation function
  4 +// `t('key')` bound to the active locale. The active locale is
  5 +// stored in localStorage and defaults to the browser's language.
  6 +//
  7 +// **Why client-side, not server-driven.** The backend already has
  8 +// ICU4J i18n with per-plug-in locale chains. The SPA's UI chrome
  9 +// (sidebar, buttons, status labels) is a separate concern — it
  10 +// runs entirely in the browser and should not require an API call
  11 +// to render a button label. The two i18n systems share the same
  12 +// locale code (en-US, zh-CN) so they stay in sync when the user
  13 +// picks a language.
  14 +
  15 +import {
  16 + createContext,
  17 + useCallback,
  18 + useContext,
  19 + useState,
  20 + type ReactNode,
  21 +} from 'react'
  22 +import { en, locales, type LocaleCode, type MessageKey } from './messages'
  23 +
  24 +const STORAGE_KEY = 'vibeerp.locale'
  25 +
  26 +function detectLocale(): LocaleCode {
  27 + const stored = localStorage.getItem(STORAGE_KEY)
  28 + if (stored && stored in locales) return stored as LocaleCode
  29 +
  30 + const browserLang = navigator.language
  31 + if (browserLang.startsWith('zh')) return 'zh-CN'
  32 + return 'en-US'
  33 +}
  34 +
  35 +interface LocaleState {
  36 + locale: LocaleCode
  37 + setLocale: (code: LocaleCode) => void
  38 + t: (key: MessageKey) => string
  39 +}
  40 +
  41 +const LocaleContext = createContext<LocaleState | null>(null)
  42 +
  43 +export function LocaleProvider({ children }: { children: ReactNode }) {
  44 + const [locale, setLocaleState] = useState<LocaleCode>(detectLocale)
  45 +
  46 + const setLocale = useCallback((code: LocaleCode) => {
  47 + localStorage.setItem(STORAGE_KEY, code)
  48 + setLocaleState(code)
  49 + }, [])
  50 +
  51 + const messages = locales[locale]
  52 +
  53 + const t = useCallback(
  54 + (key: MessageKey): string => messages[key] ?? en[key] ?? key,
  55 + [messages],
  56 + )
  57 +
  58 + return (
  59 + <LocaleContext.Provider value={{ locale, setLocale, t }}>
  60 + {children}
  61 + </LocaleContext.Provider>
  62 + )
  63 +}
  64 +
  65 +export function useT() {
  66 + const ctx = useContext(LocaleContext)
  67 + if (!ctx) throw new Error('useT must be used inside <LocaleProvider>')
  68 + return ctx.t
  69 +}
  70 +
  71 +export function useLocale() {
  72 + const ctx = useContext(LocaleContext)
  73 + if (!ctx) throw new Error('useLocale must be used inside <LocaleProvider>')
  74 + return ctx
  75 +}
  76 +
  77 +export const AVAILABLE_LOCALES: { code: LocaleCode; label: string }[] = [
  78 + { code: 'en-US', label: 'English' },
  79 + { code: 'zh-CN', label: '中文' },
  80 +]
... ...
web/src/i18n/messages.ts 0 → 100644
  1 +// vibe_erp SPA message bundles.
  2 +//
  3 +// Each locale is a flat key→string map. Keys use dot-notation
  4 +// grouped by area (nav.*, page.*, action.*, status.*, label.*).
  5 +//
  6 +// The framework's backend i18n uses ICU4J with MessageFormat
  7 +// patterns. The SPA uses plain strings for v1 — ICU MessageFormat
  8 +// in the browser (via @formatjs/intl-messageformat) is a v1.x
  9 +// enhancement when plural/gender/number formatting is needed
  10 +// client-side.
  11 +
  12 +export type MessageKey = keyof typeof en
  13 +
  14 +export const en = {
  15 + // ─── Navigation ────────────────────────────────────────────
  16 + 'nav.overview': 'Overview',
  17 + 'nav.dashboard': 'Dashboard',
  18 + 'nav.catalog': 'Catalog & Partners',
  19 + 'nav.items': 'Items',
  20 + 'nav.uoms': 'Units of Measure',
  21 + 'nav.partners': 'Partners',
  22 + 'nav.inventory': 'Inventory',
  23 + 'nav.locations': 'Locations',
  24 + 'nav.balances': 'Stock Balances',
  25 + 'nav.movements': 'Stock Movements',
  26 + 'nav.orders': 'Orders',
  27 + 'nav.salesOrders': 'Sales Orders',
  28 + 'nav.purchaseOrders': 'Purchase Orders',
  29 + 'nav.production': 'Production',
  30 + 'nav.workOrders': 'Work Orders',
  31 + 'nav.shopFloor': 'Shop Floor',
  32 + 'nav.finance': 'Finance',
  33 + 'nav.accounts': 'Chart of Accounts',
  34 + 'nav.journalEntries': 'Journal Entries',
  35 + 'nav.system': 'System',
  36 + 'nav.users': 'Users',
  37 + 'nav.roles': 'Roles',
  38 +
  39 + // ─── Actions ───────────────────────────────────────────────
  40 + 'action.confirm': 'Confirm',
  41 + 'action.ship': 'Ship',
  42 + 'action.receive': 'Receive',
  43 + 'action.cancel': 'Cancel',
  44 + 'action.start': 'Start',
  45 + 'action.complete': 'Complete',
  46 + 'action.create': 'Create',
  47 + 'action.save': 'Save',
  48 + 'action.back': '← Back',
  49 + 'action.logout': 'Logout',
  50 + 'action.signIn': 'Sign in',
  51 + 'action.signingIn': 'Signing in…',
  52 + 'action.creating': 'Creating…',
  53 + 'action.addLine': '+ Add line',
  54 + 'action.assign': 'Assign',
  55 + 'action.revoke': 'Revoke',
  56 + 'action.newItem': '+ New Item',
  57 + 'action.newPartner': '+ New Partner',
  58 + 'action.newLocation': '+ New Location',
  59 + 'action.newOrder': '+ New Order',
  60 + 'action.newWorkOrder': '+ New Work Order',
  61 + 'action.newUser': '+ New User',
  62 + 'action.newRole': '+ New Role',
  63 + 'action.newAccount': '+ New Account',
  64 + 'action.adjustStock': 'Adjust Stock',
  65 +
  66 + // ─── Status badges ────────────────────────────────────────
  67 + 'status.DRAFT': 'Draft',
  68 + 'status.CONFIRMED': 'Confirmed',
  69 + 'status.SHIPPED': 'Shipped',
  70 + 'status.RECEIVED': 'Received',
  71 + 'status.COMPLETED': 'Completed',
  72 + 'status.CANCELLED': 'Cancelled',
  73 + 'status.IN_PROGRESS': 'In Progress',
  74 + 'status.PENDING': 'Pending',
  75 + 'status.POSTED': 'Posted',
  76 + 'status.SETTLED': 'Settled',
  77 + 'status.REVERSED': 'Reversed',
  78 +
  79 + // ─── Common labels ────────────────────────────────────────
  80 + 'label.code': 'Code',
  81 + 'label.name': 'Name',
  82 + 'label.type': 'Type',
  83 + 'label.status': 'Status',
  84 + 'label.active': 'Active',
  85 + 'label.email': 'Email',
  86 + 'label.phone': 'Phone',
  87 + 'label.total': 'Total',
  88 + 'label.quantity': 'Quantity',
  89 + 'label.unitPrice': 'Unit Price',
  90 + 'label.date': 'Date',
  91 + 'label.currency': 'Currency',
  92 + 'label.description': 'Description',
  93 + 'label.username': 'Username',
  94 + 'label.password': 'Password',
  95 + 'label.signedInAs': 'Signed in as',
  96 + 'label.noRows': 'No rows.',
  97 + 'label.loading': 'Loading…',
  98 + 'label.error': 'Error',
  99 +} as const
  100 +
  101 +export const zhCN: Record<MessageKey, string> = {
  102 + // ─── 导航 ──────────────────────────────────────────────────
  103 + 'nav.overview': '概览',
  104 + 'nav.dashboard': '仪表盘',
  105 + 'nav.catalog': '产品与合作伙伴',
  106 + 'nav.items': '物料',
  107 + 'nav.uoms': '计量单位',
  108 + 'nav.partners': '合作伙伴',
  109 + 'nav.inventory': '库存',
  110 + 'nav.locations': '库位',
  111 + 'nav.balances': '库存余额',
  112 + 'nav.movements': '库存变动',
  113 + 'nav.orders': '订单',
  114 + 'nav.salesOrders': '销售订单',
  115 + 'nav.purchaseOrders': '采购订单',
  116 + 'nav.production': '生产',
  117 + 'nav.workOrders': '工单',
  118 + 'nav.shopFloor': '车间看板',
  119 + 'nav.finance': '财务',
  120 + 'nav.accounts': '科目表',
  121 + 'nav.journalEntries': '日记账',
  122 + 'nav.system': '系统',
  123 + 'nav.users': '用户',
  124 + 'nav.roles': '角色',
  125 +
  126 + // ─── 操作 ──────────────────────────────────────────────────
  127 + 'action.confirm': '确认',
  128 + 'action.ship': '发货',
  129 + 'action.receive': '收货',
  130 + 'action.cancel': '取消',
  131 + 'action.start': '开始',
  132 + 'action.complete': '完成',
  133 + 'action.create': '创建',
  134 + 'action.save': '保存',
  135 + 'action.back': '← 返回',
  136 + 'action.logout': '退出',
  137 + 'action.signIn': '登录',
  138 + 'action.signingIn': '登录中…',
  139 + 'action.creating': '创建中…',
  140 + 'action.addLine': '+ 添加行',
  141 + 'action.assign': '分配',
  142 + 'action.revoke': '撤销',
  143 + 'action.newItem': '+ 新物料',
  144 + 'action.newPartner': '+ 新合作伙伴',
  145 + 'action.newLocation': '+ 新库位',
  146 + 'action.newOrder': '+ 新订单',
  147 + 'action.newWorkOrder': '+ 新工单',
  148 + 'action.newUser': '+ 新用户',
  149 + 'action.newRole': '+ 新角色',
  150 + 'action.newAccount': '+ 新科目',
  151 + 'action.adjustStock': '库存调整',
  152 +
  153 + // ─── 状态 ──────────────────────────────────────────────────
  154 + 'status.DRAFT': '草稿',
  155 + 'status.CONFIRMED': '已确认',
  156 + 'status.SHIPPED': '已发货',
  157 + 'status.RECEIVED': '已收货',
  158 + 'status.COMPLETED': '已完成',
  159 + 'status.CANCELLED': '已取消',
  160 + 'status.IN_PROGRESS': '进行中',
  161 + 'status.PENDING': '待处理',
  162 + 'status.POSTED': '已过账',
  163 + 'status.SETTLED': '已结算',
  164 + 'status.REVERSED': '已冲销',
  165 +
  166 + // ─── 通用标签 ──────────────────────────────────────────────
  167 + 'label.code': '编码',
  168 + 'label.name': '名称',
  169 + 'label.type': '类型',
  170 + 'label.status': '状态',
  171 + 'label.active': '启用',
  172 + 'label.email': '邮箱',
  173 + 'label.phone': '电话',
  174 + 'label.total': '合计',
  175 + 'label.quantity': '数量',
  176 + 'label.unitPrice': '单价',
  177 + 'label.date': '日期',
  178 + 'label.currency': '币种',
  179 + 'label.description': '描述',
  180 + 'label.username': '用户名',
  181 + 'label.password': '密码',
  182 + 'label.signedInAs': '当前用户',
  183 + 'label.noRows': '暂无数据',
  184 + 'label.loading': '加载中…',
  185 + 'label.error': '错误',
  186 +}
  187 +
  188 +export const locales = {
  189 + 'en-US': en,
  190 + 'zh-CN': zhCN,
  191 +} as const
  192 +
  193 +export type LocaleCode = keyof typeof locales
... ...
web/src/layout/AppLayout.tsx
1   -// vibe_erp main app shell.
2   -//
3   -// Top bar (logo + version + user + logout) and a permanent left
4   -// sidebar grouped by Packaged Business Capability (PBC). The
5   -// `<Outlet />` renders whichever child route the router matched.
6   -//
7   -// **Why a static nav array.** The framework already exposes
8   -// menu metadata at /api/v1/_meta/metadata, but plumbing that into
9   -// the SPA is a Phase 3 (Tier 1 customization) concern. v1 SPA
10   -// hard-codes a sensible default that mirrors the implemented PBCs;
11   -// a future chunk replaces this with a metadata-driven sidebar so
12   -// plug-ins can contribute new menus through their YAML.
  1 +// vibe_erp main app shell with i18n support.
13 2  
14 3 import { NavLink, Outlet } from 'react-router-dom'
15 4 import { useEffect, useState } from 'react'
16 5 import { useAuth } from '@/auth/AuthContext'
  6 +import { useLocale, useT, AVAILABLE_LOCALES } from '@/i18n/LocaleContext'
  7 +import type { MessageKey } from '@/i18n/messages'
17 8 import { meta } from '@/api/client'
18 9 import type { MetaInfo } from '@/types/api'
19 10  
20 11 interface NavItem {
21 12 to: string
22   - label: string
  13 + labelKey: MessageKey
23 14 }
24 15 interface NavGroup {
25   - heading: string
  16 + headingKey: MessageKey
26 17 items: NavItem[]
27 18 }
28 19  
29 20 const NAV: NavGroup[] = [
30 21 {
31   - heading: 'Overview',
32   - items: [{ to: '/', label: 'Dashboard' }],
  22 + headingKey: 'nav.overview',
  23 + items: [{ to: '/', labelKey: 'nav.dashboard' }],
33 24 },
34 25 {
35   - heading: 'Catalog & Partners',
  26 + headingKey: 'nav.catalog',
36 27 items: [
37   - { to: '/items', label: 'Items' },
38   - { to: '/uoms', label: 'Units of Measure' },
39   - { to: '/partners', label: 'Partners' },
  28 + { to: '/items', labelKey: 'nav.items' },
  29 + { to: '/uoms', labelKey: 'nav.uoms' },
  30 + { to: '/partners', labelKey: 'nav.partners' },
40 31 ],
41 32 },
42 33 {
43   - heading: 'Inventory',
  34 + headingKey: 'nav.inventory',
44 35 items: [
45   - { to: '/locations', label: 'Locations' },
46   - { to: '/balances', label: 'Stock Balances' },
47   - { to: '/movements', label: 'Stock Movements' },
  36 + { to: '/locations', labelKey: 'nav.locations' },
  37 + { to: '/balances', labelKey: 'nav.balances' },
  38 + { to: '/movements', labelKey: 'nav.movements' },
48 39 ],
49 40 },
50 41 {
51   - heading: 'Orders',
  42 + headingKey: 'nav.orders',
52 43 items: [
53   - { to: '/sales-orders', label: 'Sales Orders' },
54   - { to: '/purchase-orders', label: 'Purchase Orders' },
  44 + { to: '/sales-orders', labelKey: 'nav.salesOrders' },
  45 + { to: '/purchase-orders', labelKey: 'nav.purchaseOrders' },
55 46 ],
56 47 },
57 48 {
58   - heading: 'Production',
  49 + headingKey: 'nav.production',
59 50 items: [
60   - { to: '/work-orders', label: 'Work Orders' },
61   - { to: '/shop-floor', label: 'Shop Floor' },
  51 + { to: '/work-orders', labelKey: 'nav.workOrders' },
  52 + { to: '/shop-floor', labelKey: 'nav.shopFloor' },
62 53 ],
63 54 },
64 55 {
65   - heading: 'Finance',
  56 + headingKey: 'nav.finance',
66 57 items: [
67   - { to: '/accounts', label: 'Chart of Accounts' },
68   - { to: '/journal-entries', label: 'Journal Entries' },
  58 + { to: '/accounts', labelKey: 'nav.accounts' },
  59 + { to: '/journal-entries', labelKey: 'nav.journalEntries' },
69 60 ],
70 61 },
71 62 {
72   - heading: 'System',
  63 + headingKey: 'nav.system',
73 64 items: [
74   - { to: '/users', label: 'Users' },
75   - { to: '/roles', label: 'Roles' },
  65 + { to: '/users', labelKey: 'nav.users' },
  66 + { to: '/roles', labelKey: 'nav.roles' },
76 67 ],
77 68 },
78 69 ]
79 70  
80 71 export function AppLayout() {
81 72 const { username, logout } = useAuth()
  73 + const { locale, setLocale } = useLocale()
  74 + const t = useT()
82 75 const [info, setInfo] = useState<MetaInfo | null>(null)
83 76  
84 77 useEffect(() => {
... ... @@ -99,9 +92,9 @@ export function AppLayout() {
99 92 </div>
100 93 <nav className="flex-1 overflow-y-auto px-3 py-4">
101 94 {NAV.map((group) => (
102   - <div key={group.heading} className="mb-5">
  95 + <div key={group.headingKey} className="mb-5">
103 96 <div className="px-2 text-xs font-semibold uppercase tracking-wider text-slate-400">
104   - {group.heading}
  97 + {t(group.headingKey)}
105 98 </div>
106 99 <ul className="mt-1 space-y-0.5">
107 100 {group.items.map((item) => (
... ... @@ -118,7 +111,7 @@ export function AppLayout() {
118 111 ].join(' ')
119 112 }
120 113 >
121   - {item.label}
  114 + {t(item.labelKey)}
122 115 </NavLink>
123 116 </li>
124 117 ))}
... ... @@ -141,17 +134,25 @@ export function AppLayout() {
141 134 <header className="flex h-14 items-center justify-between border-b border-slate-200 bg-white px-6 sm:justify-end">
142 135 <span className="text-lg font-bold text-brand-600 sm:hidden">vibe_erp</span>
143 136 <div className="flex items-center gap-3">
  137 + <select
  138 + value={locale}
  139 + onChange={(e) => setLocale(e.target.value as typeof locale)}
  140 + className="rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-xs text-slate-600"
  141 + >
  142 + {AVAILABLE_LOCALES.map((l) => (
  143 + <option key={l.code} value={l.code}>{l.label}</option>
  144 + ))}
  145 + </select>
144 146 <span className="text-sm text-slate-500">
145 147 {username ? (
146 148 <>
147   - Signed in as <span className="font-medium text-slate-700">{username}</span>
  149 + {t('label.signedInAs')}{' '}
  150 + <span className="font-medium text-slate-700">{username}</span>
148 151 </>
149   - ) : (
150   - 'Signed in'
151   - )}
  152 + ) : null}
152 153 </span>
153 154 <button className="btn-secondary" onClick={logout}>
154   - Logout
  155 + {t('action.logout')}
155 156 </button>
156 157 </div>
157 158 </header>
... ...
web/src/main.tsx
... ... @@ -3,14 +3,17 @@ import ReactDOM from &#39;react-dom/client&#39;
3 3 import { BrowserRouter } from 'react-router-dom'
4 4 import App from './App'
5 5 import { AuthProvider } from './auth/AuthContext'
  6 +import { LocaleProvider } from './i18n/LocaleContext'
6 7 import './index.css'
7 8  
8 9 ReactDOM.createRoot(document.getElementById('root')!).render(
9 10 <React.StrictMode>
10 11 <BrowserRouter>
11   - <AuthProvider>
12   - <App />
13   - </AuthProvider>
  12 + <LocaleProvider>
  13 + <AuthProvider>
  14 + <App />
  15 + </AuthProvider>
  16 + </LocaleProvider>
14 17 </BrowserRouter>
15 18 </React.StrictMode>,
16 19 )
... ...