From e1d0c8338d1479c2951bee98c199eee83893d094 Mon Sep 17 00:00:00 2001 From: zichun Date: Fri, 10 Apr 2026 11:36:16 +0800 Subject: [PATCH] feat(web): SPA i18n — locale context, en-US + zh-CN, language switcher --- web/src/components/StatusBadge.tsx | 41 ++++++++++++++++++++++------------------- web/src/i18n/LocaleContext.tsx | 80 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/i18n/messages.ts | 193 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ web/src/layout/AppLayout.tsx | 89 +++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------- web/src/main.tsx | 9 ++++++--- 5 files changed, 346 insertions(+), 66 deletions(-) create mode 100644 web/src/i18n/LocaleContext.tsx create mode 100644 web/src/i18n/messages.ts diff --git a/web/src/components/StatusBadge.tsx b/web/src/components/StatusBadge.tsx index 873c7b7..d68c08d 100644 --- a/web/src/components/StatusBadge.tsx +++ b/web/src/components/StatusBadge.tsx @@ -1,25 +1,28 @@ +import { useT } from '@/i18n/LocaleContext' +import type { MessageKey } from '@/i18n/messages' + interface Props { status: string } -// Status → Tailwind class via the shared @layer components classes -// declared in index.css. Falls back to 'badge-pending' for unknown -// statuses so a new server-side enum value renders as a neutral pill -// instead of throwing. +const CLS_MAP: Record = { + DRAFT: 'badge-draft', + CONFIRMED: 'badge-confirmed', + SHIPPED: 'badge-shipped', + RECEIVED: 'badge-received', + COMPLETED: 'badge-completed', + CANCELLED: 'badge-cancelled', + IN_PROGRESS: 'badge-in-progress', + PENDING: 'badge-pending', + POSTED: 'badge-confirmed', + SETTLED: 'badge-shipped', + REVERSED: 'badge-cancelled', +} + export function StatusBadge({ status }: Props) { - const cls = - { - DRAFT: 'badge-draft', - CONFIRMED: 'badge-confirmed', - SHIPPED: 'badge-shipped', - RECEIVED: 'badge-received', - COMPLETED: 'badge-completed', - CANCELLED: 'badge-cancelled', - IN_PROGRESS: 'badge-in-progress', - PENDING: 'badge-pending', - POSTED: 'badge-confirmed', - SETTLED: 'badge-shipped', - REVERSED: 'badge-cancelled', - }[status] ?? 'badge-pending' - return {status} + const t = useT() + const cls = CLS_MAP[status] ?? 'badge-pending' + const key = `status.${status}` as MessageKey + const label = t(key) + return {label} } diff --git a/web/src/i18n/LocaleContext.tsx b/web/src/i18n/LocaleContext.tsx new file mode 100644 index 0000000..f6a430d --- /dev/null +++ b/web/src/i18n/LocaleContext.tsx @@ -0,0 +1,80 @@ +// vibe_erp SPA locale context. +// +// Provides a `useT()` hook that returns a translation function +// `t('key')` bound to the active locale. The active locale is +// stored in localStorage and defaults to the browser's language. +// +// **Why client-side, not server-driven.** The backend already has +// ICU4J i18n with per-plug-in locale chains. The SPA's UI chrome +// (sidebar, buttons, status labels) is a separate concern — it +// runs entirely in the browser and should not require an API call +// to render a button label. The two i18n systems share the same +// locale code (en-US, zh-CN) so they stay in sync when the user +// picks a language. + +import { + createContext, + useCallback, + useContext, + useState, + type ReactNode, +} from 'react' +import { en, locales, type LocaleCode, type MessageKey } from './messages' + +const STORAGE_KEY = 'vibeerp.locale' + +function detectLocale(): LocaleCode { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored && stored in locales) return stored as LocaleCode + + const browserLang = navigator.language + if (browserLang.startsWith('zh')) return 'zh-CN' + return 'en-US' +} + +interface LocaleState { + locale: LocaleCode + setLocale: (code: LocaleCode) => void + t: (key: MessageKey) => string +} + +const LocaleContext = createContext(null) + +export function LocaleProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState(detectLocale) + + const setLocale = useCallback((code: LocaleCode) => { + localStorage.setItem(STORAGE_KEY, code) + setLocaleState(code) + }, []) + + const messages = locales[locale] + + const t = useCallback( + (key: MessageKey): string => messages[key] ?? en[key] ?? key, + [messages], + ) + + return ( + + {children} + + ) +} + +export function useT() { + const ctx = useContext(LocaleContext) + if (!ctx) throw new Error('useT must be used inside ') + return ctx.t +} + +export function useLocale() { + const ctx = useContext(LocaleContext) + if (!ctx) throw new Error('useLocale must be used inside ') + return ctx +} + +export const AVAILABLE_LOCALES: { code: LocaleCode; label: string }[] = [ + { code: 'en-US', label: 'English' }, + { code: 'zh-CN', label: '中文' }, +] diff --git a/web/src/i18n/messages.ts b/web/src/i18n/messages.ts new file mode 100644 index 0000000..b4b409d --- /dev/null +++ b/web/src/i18n/messages.ts @@ -0,0 +1,193 @@ +// vibe_erp SPA message bundles. +// +// Each locale is a flat key→string map. Keys use dot-notation +// grouped by area (nav.*, page.*, action.*, status.*, label.*). +// +// The framework's backend i18n uses ICU4J with MessageFormat +// patterns. The SPA uses plain strings for v1 — ICU MessageFormat +// in the browser (via @formatjs/intl-messageformat) is a v1.x +// enhancement when plural/gender/number formatting is needed +// client-side. + +export type MessageKey = keyof typeof en + +export const en = { + // ─── Navigation ──────────────────────────────────────────── + 'nav.overview': 'Overview', + 'nav.dashboard': 'Dashboard', + 'nav.catalog': 'Catalog & Partners', + 'nav.items': 'Items', + 'nav.uoms': 'Units of Measure', + 'nav.partners': 'Partners', + 'nav.inventory': 'Inventory', + 'nav.locations': 'Locations', + 'nav.balances': 'Stock Balances', + 'nav.movements': 'Stock Movements', + 'nav.orders': 'Orders', + 'nav.salesOrders': 'Sales Orders', + 'nav.purchaseOrders': 'Purchase Orders', + 'nav.production': 'Production', + 'nav.workOrders': 'Work Orders', + 'nav.shopFloor': 'Shop Floor', + 'nav.finance': 'Finance', + 'nav.accounts': 'Chart of Accounts', + 'nav.journalEntries': 'Journal Entries', + 'nav.system': 'System', + 'nav.users': 'Users', + 'nav.roles': 'Roles', + + // ─── Actions ─────────────────────────────────────────────── + 'action.confirm': 'Confirm', + 'action.ship': 'Ship', + 'action.receive': 'Receive', + 'action.cancel': 'Cancel', + 'action.start': 'Start', + 'action.complete': 'Complete', + 'action.create': 'Create', + 'action.save': 'Save', + 'action.back': '← Back', + 'action.logout': 'Logout', + 'action.signIn': 'Sign in', + 'action.signingIn': 'Signing in…', + 'action.creating': 'Creating…', + 'action.addLine': '+ Add line', + 'action.assign': 'Assign', + 'action.revoke': 'Revoke', + 'action.newItem': '+ New Item', + 'action.newPartner': '+ New Partner', + 'action.newLocation': '+ New Location', + 'action.newOrder': '+ New Order', + 'action.newWorkOrder': '+ New Work Order', + 'action.newUser': '+ New User', + 'action.newRole': '+ New Role', + 'action.newAccount': '+ New Account', + 'action.adjustStock': 'Adjust Stock', + + // ─── Status badges ──────────────────────────────────────── + 'status.DRAFT': 'Draft', + 'status.CONFIRMED': 'Confirmed', + 'status.SHIPPED': 'Shipped', + 'status.RECEIVED': 'Received', + 'status.COMPLETED': 'Completed', + 'status.CANCELLED': 'Cancelled', + 'status.IN_PROGRESS': 'In Progress', + 'status.PENDING': 'Pending', + 'status.POSTED': 'Posted', + 'status.SETTLED': 'Settled', + 'status.REVERSED': 'Reversed', + + // ─── Common labels ──────────────────────────────────────── + 'label.code': 'Code', + 'label.name': 'Name', + 'label.type': 'Type', + 'label.status': 'Status', + 'label.active': 'Active', + 'label.email': 'Email', + 'label.phone': 'Phone', + 'label.total': 'Total', + 'label.quantity': 'Quantity', + 'label.unitPrice': 'Unit Price', + 'label.date': 'Date', + 'label.currency': 'Currency', + 'label.description': 'Description', + 'label.username': 'Username', + 'label.password': 'Password', + 'label.signedInAs': 'Signed in as', + 'label.noRows': 'No rows.', + 'label.loading': 'Loading…', + 'label.error': 'Error', +} as const + +export const zhCN: Record = { + // ─── 导航 ────────────────────────────────────────────────── + 'nav.overview': '概览', + 'nav.dashboard': '仪表盘', + 'nav.catalog': '产品与合作伙伴', + 'nav.items': '物料', + 'nav.uoms': '计量单位', + 'nav.partners': '合作伙伴', + 'nav.inventory': '库存', + 'nav.locations': '库位', + 'nav.balances': '库存余额', + 'nav.movements': '库存变动', + 'nav.orders': '订单', + 'nav.salesOrders': '销售订单', + 'nav.purchaseOrders': '采购订单', + 'nav.production': '生产', + 'nav.workOrders': '工单', + 'nav.shopFloor': '车间看板', + 'nav.finance': '财务', + 'nav.accounts': '科目表', + 'nav.journalEntries': '日记账', + 'nav.system': '系统', + 'nav.users': '用户', + 'nav.roles': '角色', + + // ─── 操作 ────────────────────────────────────────────────── + 'action.confirm': '确认', + 'action.ship': '发货', + 'action.receive': '收货', + 'action.cancel': '取消', + 'action.start': '开始', + 'action.complete': '完成', + 'action.create': '创建', + 'action.save': '保存', + 'action.back': '← 返回', + 'action.logout': '退出', + 'action.signIn': '登录', + 'action.signingIn': '登录中…', + 'action.creating': '创建中…', + 'action.addLine': '+ 添加行', + 'action.assign': '分配', + 'action.revoke': '撤销', + 'action.newItem': '+ 新物料', + 'action.newPartner': '+ 新合作伙伴', + 'action.newLocation': '+ 新库位', + 'action.newOrder': '+ 新订单', + 'action.newWorkOrder': '+ 新工单', + 'action.newUser': '+ 新用户', + 'action.newRole': '+ 新角色', + 'action.newAccount': '+ 新科目', + 'action.adjustStock': '库存调整', + + // ─── 状态 ────────────────────────────────────────────────── + 'status.DRAFT': '草稿', + 'status.CONFIRMED': '已确认', + 'status.SHIPPED': '已发货', + 'status.RECEIVED': '已收货', + 'status.COMPLETED': '已完成', + 'status.CANCELLED': '已取消', + 'status.IN_PROGRESS': '进行中', + 'status.PENDING': '待处理', + 'status.POSTED': '已过账', + 'status.SETTLED': '已结算', + 'status.REVERSED': '已冲销', + + // ─── 通用标签 ────────────────────────────────────────────── + 'label.code': '编码', + 'label.name': '名称', + 'label.type': '类型', + 'label.status': '状态', + 'label.active': '启用', + 'label.email': '邮箱', + 'label.phone': '电话', + 'label.total': '合计', + 'label.quantity': '数量', + 'label.unitPrice': '单价', + 'label.date': '日期', + 'label.currency': '币种', + 'label.description': '描述', + 'label.username': '用户名', + 'label.password': '密码', + 'label.signedInAs': '当前用户', + 'label.noRows': '暂无数据', + 'label.loading': '加载中…', + 'label.error': '错误', +} + +export const locales = { + 'en-US': en, + 'zh-CN': zhCN, +} as const + +export type LocaleCode = keyof typeof locales diff --git a/web/src/layout/AppLayout.tsx b/web/src/layout/AppLayout.tsx index 84f1c40..6e1745b 100644 --- a/web/src/layout/AppLayout.tsx +++ b/web/src/layout/AppLayout.tsx @@ -1,84 +1,77 @@ -// vibe_erp main app shell. -// -// Top bar (logo + version + user + logout) and a permanent left -// sidebar grouped by Packaged Business Capability (PBC). The -// `` renders whichever child route the router matched. -// -// **Why a static nav array.** The framework already exposes -// menu metadata at /api/v1/_meta/metadata, but plumbing that into -// the SPA is a Phase 3 (Tier 1 customization) concern. v1 SPA -// hard-codes a sensible default that mirrors the implemented PBCs; -// a future chunk replaces this with a metadata-driven sidebar so -// plug-ins can contribute new menus through their YAML. +// vibe_erp main app shell with i18n support. import { NavLink, Outlet } from 'react-router-dom' import { useEffect, useState } from 'react' import { useAuth } from '@/auth/AuthContext' +import { useLocale, useT, AVAILABLE_LOCALES } from '@/i18n/LocaleContext' +import type { MessageKey } from '@/i18n/messages' import { meta } from '@/api/client' import type { MetaInfo } from '@/types/api' interface NavItem { to: string - label: string + labelKey: MessageKey } interface NavGroup { - heading: string + headingKey: MessageKey items: NavItem[] } const NAV: NavGroup[] = [ { - heading: 'Overview', - items: [{ to: '/', label: 'Dashboard' }], + headingKey: 'nav.overview', + items: [{ to: '/', labelKey: 'nav.dashboard' }], }, { - heading: 'Catalog & Partners', + headingKey: 'nav.catalog', items: [ - { to: '/items', label: 'Items' }, - { to: '/uoms', label: 'Units of Measure' }, - { to: '/partners', label: 'Partners' }, + { to: '/items', labelKey: 'nav.items' }, + { to: '/uoms', labelKey: 'nav.uoms' }, + { to: '/partners', labelKey: 'nav.partners' }, ], }, { - heading: 'Inventory', + headingKey: 'nav.inventory', items: [ - { to: '/locations', label: 'Locations' }, - { to: '/balances', label: 'Stock Balances' }, - { to: '/movements', label: 'Stock Movements' }, + { to: '/locations', labelKey: 'nav.locations' }, + { to: '/balances', labelKey: 'nav.balances' }, + { to: '/movements', labelKey: 'nav.movements' }, ], }, { - heading: 'Orders', + headingKey: 'nav.orders', items: [ - { to: '/sales-orders', label: 'Sales Orders' }, - { to: '/purchase-orders', label: 'Purchase Orders' }, + { to: '/sales-orders', labelKey: 'nav.salesOrders' }, + { to: '/purchase-orders', labelKey: 'nav.purchaseOrders' }, ], }, { - heading: 'Production', + headingKey: 'nav.production', items: [ - { to: '/work-orders', label: 'Work Orders' }, - { to: '/shop-floor', label: 'Shop Floor' }, + { to: '/work-orders', labelKey: 'nav.workOrders' }, + { to: '/shop-floor', labelKey: 'nav.shopFloor' }, ], }, { - heading: 'Finance', + headingKey: 'nav.finance', items: [ - { to: '/accounts', label: 'Chart of Accounts' }, - { to: '/journal-entries', label: 'Journal Entries' }, + { to: '/accounts', labelKey: 'nav.accounts' }, + { to: '/journal-entries', labelKey: 'nav.journalEntries' }, ], }, { - heading: 'System', + headingKey: 'nav.system', items: [ - { to: '/users', label: 'Users' }, - { to: '/roles', label: 'Roles' }, + { to: '/users', labelKey: 'nav.users' }, + { to: '/roles', labelKey: 'nav.roles' }, ], }, ] export function AppLayout() { const { username, logout } = useAuth() + const { locale, setLocale } = useLocale() + const t = useT() const [info, setInfo] = useState(null) useEffect(() => { @@ -99,9 +92,9 @@ export function AppLayout() {