refactor: 优化 i18n 类型安全与渲染性能
- 导出 TranslationKey 类型,翻译 key 拼写错误编译期即报错 - zh.ts 使用 TranslationKey 约束,确保中英文 key 同步 - useMemo 包装 context value,避免不必要的全局重渲染 - ConfirmDialog confirmText 默认值改用 t() 而非硬编码英文 - SchemaProperties 递归组件改为 prop 传递 t,减少 useContext 调用 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ type ConfirmDialogProps = {
|
|||||||
variant?: 'danger' | 'warning';
|
variant?: 'danger' | 'warning';
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText = 'Confirm', variant = 'danger' }: ConfirmDialogProps) {
|
export default function ConfirmDialog({ open, onConfirm, onCancel, title, description, confirmText, variant = 'danger' }: ConfirmDialogProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
|
const iconColor = variant === 'danger' ? 'text-danger bg-danger-muted' : 'text-warning bg-warning-muted';
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export default function ConfirmDialog({ open, onConfirm, onCancel, title, descri
|
|||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
className={variant === 'danger' ? 'btn-danger' : 'btn-primary'}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText ?? t('common.confirm')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Replaces raw JSON.stringify output with readable tables and schema trees.
|
* Replaces raw JSON.stringify output with readable tables and schema trees.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n, type TFunction } from '../lib/i18n';
|
||||||
|
|
||||||
/* ===== Helpers ===== */
|
/* ===== Helpers ===== */
|
||||||
|
|
||||||
@@ -159,8 +159,7 @@ export function ParametersView({ parameters }: { parameters: unknown }) {
|
|||||||
|
|
||||||
/* ===== Schema Properties Tree ===== */
|
/* ===== Schema Properties Tree ===== */
|
||||||
|
|
||||||
function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: number }) {
|
function SchemaProperties({ schema, depth = 0, t }: { schema: SchemaObj; depth?: number; t: TFunction }) {
|
||||||
const { t } = useI18n();
|
|
||||||
const properties = schema.properties;
|
const properties = schema.properties;
|
||||||
const requiredSet = new Set(schema.required || []);
|
const requiredSet = new Set(schema.required || []);
|
||||||
|
|
||||||
@@ -217,8 +216,8 @@ function SchemaProperties({ schema, depth = 0 }: { schema: SchemaObj; depth?: nu
|
|||||||
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
{t('dashboard.schema.default')} <code className="font-mono">{JSON.stringify(prop.default)}</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} />}
|
{hasChildren && <SchemaProperties schema={prop} depth={depth + 1} t={t} />}
|
||||||
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} />}
|
{isArray && prop.items && <SchemaProperties schema={prop.items} depth={depth + 1} t={t} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -247,7 +246,7 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
|||||||
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
{body.required && <span className="text-danger ml-2 normal-case tracking-normal text-[11px]">{t('dashboard.schema.required')}</span>}
|
||||||
</p>
|
</p>
|
||||||
<div className="border border-border-default rounded-lg p-3">
|
<div className="border border-border-default rounded-lg p-3">
|
||||||
<SchemaProperties schema={body.schema} />
|
<SchemaProperties schema={body.schema} t={t} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -274,7 +273,7 @@ export function RequestBodyView({ requestBody }: { requestBody: unknown }) {
|
|||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{media.schema ? (
|
{media.schema ? (
|
||||||
media.schema.properties ? (
|
media.schema.properties ? (
|
||||||
<SchemaProperties schema={media.schema} />
|
<SchemaProperties schema={media.schema} t={t} />
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-[13px]">
|
<div className="flex items-center gap-2 text-[13px]">
|
||||||
<TypeBadge type={resolveType(media.schema)} />
|
<TypeBadge type={resolveType(media.schema)} />
|
||||||
@@ -351,13 +350,13 @@ export function ResponsesView({ responses }: { responses: unknown }) {
|
|||||||
{schema && (schema.properties || schema.items?.properties || schema.type) && (
|
{schema && (schema.properties || schema.items?.properties || schema.type) && (
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
{schema.properties ? (
|
{schema.properties ? (
|
||||||
<SchemaProperties schema={schema} />
|
<SchemaProperties schema={schema} t={t} />
|
||||||
) : schema.type === 'array' && schema.items?.properties ? (
|
) : schema.type === 'array' && schema.items?.properties ? (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] text-text-muted mb-1">
|
<div className="text-[11px] text-text-muted mb-1">
|
||||||
<TypeBadge type="array" /> {t('dashboard.schema.ofObjects')}
|
<TypeBadge type="array" /> {t('dashboard.schema.ofObjects')}
|
||||||
</div>
|
</div>
|
||||||
<SchemaProperties schema={schema.items} />
|
<SchemaProperties schema={schema.items} t={t} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-[13px]">
|
<div className="flex items-center gap-2 text-[13px]">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTheme } from '../lib/theme';
|
import { useTheme } from '../lib/theme';
|
||||||
import { useI18n } from '../lib/i18n';
|
import { useI18n, type TranslationKey } from '../lib/i18n';
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
light: (
|
light: (
|
||||||
@@ -31,7 +31,7 @@ export default function ThemeToggle() {
|
|||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setTheme(key)}
|
onClick={() => setTheme(key)}
|
||||||
title={t(`theme.${key}`)}
|
title={t(`theme.${key}` as TranslationKey)}
|
||||||
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
|
className={`flex items-center justify-center w-8 h-7 rounded-md transition-all duration-150 ${
|
||||||
theme === key
|
theme === key
|
||||||
? 'bg-bg-elevated text-text-primary shadow-sm'
|
? 'bg-bg-elevated text-text-primary shadow-sm'
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||||
import en from './i18n/en';
|
import en from './i18n/en';
|
||||||
import zh from './i18n/zh';
|
import zh from './i18n/zh';
|
||||||
|
|
||||||
export type Locale = 'en' | 'zh';
|
export type Locale = 'en' | 'zh';
|
||||||
|
export type TranslationKey = keyof typeof en;
|
||||||
|
|
||||||
type Translations = Record<string, string>;
|
type AllTranslations = Record<Locale, Record<TranslationKey, string>>;
|
||||||
type AllTranslations = Record<Locale, Translations>;
|
|
||||||
|
|
||||||
const translations: AllTranslations = { en, zh };
|
const translations: AllTranslations = { en, zh };
|
||||||
|
|
||||||
|
/** Use `tk()` to cast dynamic key strings (e.g. template literals) to TranslationKey */
|
||||||
|
export const tk = (key: string) => key as TranslationKey;
|
||||||
|
|
||||||
|
export type TFunction = (key: TranslationKey, params?: Record<string, string | number>) => string;
|
||||||
|
|
||||||
type I18nContextType = {
|
type I18nContextType = {
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
setLocale: (l: Locale) => void;
|
setLocale: (l: Locale) => void;
|
||||||
t: (key: string, params?: Record<string, string | number>) => string;
|
t: TFunction;
|
||||||
};
|
};
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType | null>(null);
|
const I18nContext = createContext<I18nContextType | null>(null);
|
||||||
@@ -31,7 +36,7 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem('agent-fox-locale', l);
|
localStorage.setItem('agent-fox-locale', l);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const t = useCallback((key: string, params?: Record<string, string | number>): string => {
|
const t = useCallback((key: TranslationKey, params?: Record<string, string | number>): string => {
|
||||||
let text = translations[locale][key] ?? key;
|
let text = translations[locale][key] ?? key;
|
||||||
if (params) {
|
if (params) {
|
||||||
Object.entries(params).forEach(([k, v]) => {
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
@@ -41,8 +46,10 @@ export function I18nProvider({ children }: { children: ReactNode }) {
|
|||||||
return text;
|
return text;
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ locale, setLocale, t }), [locale, setLocale, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nContext.Provider value={{ locale, setLocale, t }}>
|
<I18nContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</I18nContext.Provider>
|
</I18nContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const en: Record<string, string> = {
|
const en = {
|
||||||
// ===== Landing Page =====
|
// ===== Landing Page =====
|
||||||
|
|
||||||
// Nav
|
// Nav
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
const zh: Record<string, string> = {
|
import type { TranslationKey } from '../i18n';
|
||||||
|
|
||||||
|
const zh: Record<TranslationKey, string> = {
|
||||||
// ===== Landing Page =====
|
// ===== Landing Page =====
|
||||||
|
|
||||||
// Nav
|
// Nav
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useI18n } from '../../lib/i18n';
|
import { useI18n, tk } from '../../lib/i18n';
|
||||||
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
import { useScrollReveal } from '../../hooks/useScrollReveal';
|
||||||
|
|
||||||
type PlanKey = 'free' | 'pro' | 'enterprise';
|
type PlanKey = 'free' | 'pro' | 'enterprise';
|
||||||
@@ -48,30 +48,30 @@ export default function PricingSection() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Popular badge */}
|
{/* Popular badge */}
|
||||||
{featured && t(`pricing.${key}.badge`) && (
|
{featured && t(tk(`pricing.${key}.badge`)) && (
|
||||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full text-[11px] font-semibold text-white"
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2 px-3 py-0.5 rounded-full text-[11px] font-semibold text-white"
|
||||||
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
style={{ background: 'linear-gradient(135deg, var(--fox-amber), var(--fox-orange))' }}>
|
||||||
{t(`pricing.${key}.badge`)}
|
{t(tk(`pricing.${key}.badge`))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Plan name + price */}
|
{/* Plan name + price */}
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<h3 className="text-base font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
<h3 className="text-base font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>
|
||||||
{t(`pricing.${key}.name`)}
|
{t(tk(`pricing.${key}.name`))}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-4xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
<span className="text-4xl font-bold tracking-tight" style={{ fontFamily: 'var(--font-heading)', color: 'var(--text-primary)' }}>
|
||||||
{t(`pricing.${key}.price`)}
|
{t(tk(`pricing.${key}.price`))}
|
||||||
</span>
|
</span>
|
||||||
{t(`pricing.${key}.period`) && (
|
{t(tk(`pricing.${key}.period`)) && (
|
||||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||||
{t(`pricing.${key}.period`)}
|
{t(tk(`pricing.${key}.period`))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
<p className="text-sm mt-2" style={{ color: 'var(--text-secondary)' }}>
|
||||||
{t(`pricing.${key}.desc`)}
|
{t(tk(`pricing.${key}.desc`))}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ export default function PricingSection() {
|
|||||||
<svg className="w-4 h-4 shrink-0" style={{ color: 'var(--fox-amber)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
<svg className="w-4 h-4 shrink-0" style={{ color: 'var(--fox-amber)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||||
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
<path d="M5 13l4 4L19 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
{t(`pricing.${key}.f${j + 1}`)}
|
{t(tk(`pricing.${key}.f${j + 1}`))}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -93,7 +93,7 @@ export default function PricingSection() {
|
|||||||
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 cursor-pointer`}
|
className={`block w-full text-center py-2.5 rounded-xl text-sm font-medium transition-all duration-200 hover:-translate-y-0.5 cursor-pointer`}
|
||||||
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
|
style={{ background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }}
|
||||||
>
|
>
|
||||||
{t(`pricing.${key}.cta`)}
|
{t(tk(`pricing.${key}.cta`))}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
@@ -106,7 +106,7 @@ export default function PricingSection() {
|
|||||||
: { background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }
|
: { background: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-default)' }
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t(`pricing.${key}.cta`)}
|
{t(tk(`pricing.${key}.cta`))}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user