- 自定义动画:animations.register() 注册的动画动态注入 keyframes 真实生效(此前仅存 Map 实际 fallback slide) - max 超限:通过实例注册表真正 close() 最早的 toast(此前仅删内存记录,DOM 残留) - 钩子收敛:beforeClose/beforeUpdate 支持返回 false 拦截;接入 click/hover/dragStart/dragEnd/animationStart/animationEnd/progressStart/progressEnd/configChange/themeChange/localeChange/beforeInit/afterInit/beforeDestroy/afterDestroy;HOOK_NAMES 移除永不触发的声明 - destroy() 全量清理:hooks、实例注册表、全部 metona-toast-* 样式标签;动画注册表真正清空 - applyThemeVariables 重复注入修复;init() 支持插件对象并复活 _destroyed;AnimationConfig 类型放宽 - VERSION 统一到 constants.ts 唯一维护;删除失效 scripts;新增 .gitattributes 统一 LF - docs.html 补 v0.2.1 新 API 文档;CHANGELOG 更新;测试 309 用例全过
637 lines
15 KiB
TypeScript
637 lines
15 KiB
TypeScript
/**
|
|
* MetonaToast i18n — 国际化管理
|
|
* @module i18n
|
|
* @version 0.3.0
|
|
*/
|
|
|
|
import { LOCALES } from './constants.js';
|
|
import type { I18nUtils, LocaleInfo } from './types.js';
|
|
|
|
// 当前语言状态
|
|
let currentLocale = 'zh-CN';
|
|
const localeListeners: Set<(locale: string) => void> = new Set();
|
|
let fallbackLocale = 'zh-CN';
|
|
|
|
/**
|
|
* 获取当前语言
|
|
*/
|
|
export const getCurrentLocale = (): string => {
|
|
return currentLocale;
|
|
};
|
|
|
|
/**
|
|
* 设置当前语言
|
|
*/
|
|
export const setCurrentLocale = (locale: string): void => {
|
|
if (!LOCALES[locale]) {
|
|
console.warn(`Locale "${locale}" not found, falling back to "${fallbackLocale}"`);
|
|
locale = fallbackLocale;
|
|
}
|
|
|
|
currentLocale = locale;
|
|
notifyLocaleListeners(locale);
|
|
saveLocale(locale);
|
|
};
|
|
|
|
/**
|
|
* 获取回退语言
|
|
*/
|
|
export const getFallbackLocale = (): string => {
|
|
return fallbackLocale;
|
|
};
|
|
|
|
/**
|
|
* 设置回退语言
|
|
*/
|
|
export const setFallbackLocale = (locale: string): void => {
|
|
if (!LOCALES[locale]) {
|
|
console.warn(`Fallback locale "${locale}" not found`);
|
|
return;
|
|
}
|
|
fallbackLocale = locale;
|
|
};
|
|
|
|
/**
|
|
* 翻译函数
|
|
*/
|
|
export const t = (key: string, params: Record<string, string | number> = {}): string => {
|
|
const currentTranslation = getTranslation(currentLocale, key);
|
|
if (currentTranslation !== undefined) {
|
|
return interpolate(currentTranslation, params);
|
|
}
|
|
|
|
if (currentLocale !== fallbackLocale) {
|
|
const fallbackTranslation = getTranslation(fallbackLocale, key);
|
|
if (fallbackTranslation !== undefined) {
|
|
return interpolate(fallbackTranslation, params);
|
|
}
|
|
}
|
|
|
|
console.warn(`Translation missing for key "${key}" in locale "${currentLocale}"`);
|
|
return key;
|
|
};
|
|
|
|
/**
|
|
* 获取翻译
|
|
*/
|
|
const getTranslation = (locale: string, key: string): string | undefined => {
|
|
const localeData = LOCALES[locale];
|
|
if (!localeData) return undefined;
|
|
|
|
const keys = key.split('.');
|
|
let result: unknown = localeData;
|
|
|
|
for (const k of keys) {
|
|
if (result && typeof result === 'object' && k in (result as Record<string, unknown>)) {
|
|
result = (result as Record<string, unknown>)[k];
|
|
} else {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
return typeof result === 'string' ? result : undefined;
|
|
};
|
|
|
|
/**
|
|
* 插值函数
|
|
*/
|
|
const interpolate = (str: string, params: Record<string, string | number>): string => {
|
|
return str.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
|
return params[key] !== undefined ? String(params[key]) : _match;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 检查翻译是否存在
|
|
*/
|
|
export const hasTranslation = (key: string): boolean => {
|
|
return getTranslation(currentLocale, key) !== undefined ||
|
|
getTranslation(fallbackLocale, key) !== undefined;
|
|
};
|
|
|
|
/**
|
|
* 获取所有翻译
|
|
*/
|
|
export const getTranslations = (locale: string): Record<string, unknown> => {
|
|
return LOCALES[locale] || {};
|
|
};
|
|
|
|
/**
|
|
* 添加翻译
|
|
*/
|
|
export const addTranslations = (locale: string, translations: Record<string, unknown>): void => {
|
|
if (!LOCALES[locale]) {
|
|
LOCALES[locale] = {};
|
|
}
|
|
deepMerge(LOCALES[locale], translations as Record<string, string>);
|
|
};
|
|
|
|
/**
|
|
* 深度合并对象
|
|
*/
|
|
const deepMerge = (target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> => {
|
|
for (const key in source) {
|
|
if (source[key] instanceof Object && key in target && target[key] instanceof Object) {
|
|
deepMerge(target[key] as Record<string, unknown>, source[key] as Record<string, unknown>);
|
|
} else {
|
|
target[key] = source[key];
|
|
}
|
|
}
|
|
return target;
|
|
};
|
|
|
|
/**
|
|
* 移除翻译
|
|
*/
|
|
export const removeTranslation = (locale: string, key: string): void => {
|
|
const localeData = LOCALES[locale];
|
|
if (!localeData) return;
|
|
|
|
const keys = key.split('.');
|
|
let current: Record<string, unknown> = localeData;
|
|
|
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
if (current[keys[i]] && typeof current[keys[i]] === 'object') {
|
|
current = current[keys[i]] as Record<string, unknown>;
|
|
} else {
|
|
return;
|
|
}
|
|
}
|
|
|
|
delete current[keys[keys.length - 1]];
|
|
};
|
|
|
|
/**
|
|
* 清除翻译
|
|
*/
|
|
export const clearTranslations = (locale: string): void => {
|
|
if (LOCALES[locale]) {
|
|
LOCALES[locale] = {};
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 获取支持的语言列表
|
|
*/
|
|
export const getSupportedLocales = (): string[] => {
|
|
return Object.keys(LOCALES);
|
|
};
|
|
|
|
/**
|
|
* 检查语言是否支持
|
|
*/
|
|
export const isLocaleSupported = (locale: string): boolean => {
|
|
return locale in LOCALES;
|
|
};
|
|
|
|
/**
|
|
* 获取语言名称
|
|
*/
|
|
export const getLocaleName = (locale: string): string => {
|
|
const names: Record<string, string> = {
|
|
'zh-CN': '简体中文',
|
|
'zh-TW': '繁體中文',
|
|
'en-US': 'English (US)',
|
|
'en-GB': 'English (UK)',
|
|
'ja': '日本語',
|
|
'ko': '한국어',
|
|
'fr': 'Français',
|
|
'de': 'Deutsch',
|
|
'es': 'Español',
|
|
'pt': 'Português',
|
|
'ru': 'Русский',
|
|
'ar': 'العربية',
|
|
'hi': 'हिन्दी',
|
|
'th': 'ไทย',
|
|
'vi': 'Tiếng Việt',
|
|
'id': 'Bahasa Indonesia',
|
|
'ms': 'Bahasa Melayu',
|
|
'tr': 'Türkçe',
|
|
'it': 'Italiano',
|
|
'nl': 'Nederlands',
|
|
'pl': 'Polski',
|
|
'uk': 'Українська',
|
|
'cs': 'Čeština',
|
|
'sv': 'Svenska',
|
|
'da': 'Dansk',
|
|
'fi': 'Suomi',
|
|
'nb': 'Norsk',
|
|
'el': 'Ελληνικά',
|
|
'he': 'עברית',
|
|
'hu': 'Magyar',
|
|
'ro': 'Română',
|
|
'bg': 'Български',
|
|
'hr': 'Hrvatski',
|
|
'sk': 'Slovenčina',
|
|
'sl': 'Slovenščina',
|
|
'et': 'Eesti',
|
|
'lv': 'Latviešu',
|
|
'lt': 'Lietuvių',
|
|
'ca': 'Català',
|
|
'gl': 'Galego',
|
|
'eu': 'Euskara',
|
|
'cy': 'Cymraeg',
|
|
'ga': 'Gaeilge',
|
|
'mt': 'Malti',
|
|
'is': 'Íslenska',
|
|
'mk': 'Македонски',
|
|
'sq': 'Shqip',
|
|
'sr': 'Српски',
|
|
'bs': 'Bosanski',
|
|
'me': 'Crnogorski',
|
|
'ka': 'ქართული',
|
|
'hy': 'Հայերեն',
|
|
'az': 'Azərbaycan',
|
|
'uz': "O'zbek",
|
|
'kk': 'Қазақ',
|
|
'ky': 'Кыргыз',
|
|
'tg': 'Тоҷикӣ',
|
|
'tk': 'Türkmen',
|
|
'mn': 'Монгол',
|
|
'ne': 'नेपाली',
|
|
'si': 'සිංහල',
|
|
'my': 'မြန်မာ',
|
|
'km': 'ខ្មែរ',
|
|
'lo': 'ລາວ',
|
|
'am': 'አማርኛ',
|
|
'sw': 'Kiswahili',
|
|
'yo': 'Yorùbá',
|
|
'ig': 'Igbo',
|
|
'ha': 'Hausa',
|
|
'zu': 'isiZulu',
|
|
'af': 'Afrikaans',
|
|
'xh': 'isiXhosa',
|
|
'st': 'Sesotho',
|
|
'tn': 'Setswana',
|
|
'ts': 'Xitsonga',
|
|
'ss': 'siSwati',
|
|
've': 'Tshivenda',
|
|
'nr': 'isiNdebele',
|
|
};
|
|
|
|
return names[locale] || locale;
|
|
};
|
|
|
|
/**
|
|
* 获取语言方向
|
|
*/
|
|
export const getLocaleDirection = (locale: string): 'ltr' | 'rtl' => {
|
|
const rtlLocales = ['ar', 'he', 'fa', 'ur', 'yi', 'ps', 'sd', 'ug'];
|
|
return rtlLocales.includes(locale) ? 'rtl' : 'ltr';
|
|
};
|
|
|
|
/**
|
|
* 获取语言信息
|
|
*/
|
|
export const getLocaleInfo = (locale: string): LocaleInfo => {
|
|
return {
|
|
code: locale,
|
|
name: getLocaleName(locale),
|
|
direction: getLocaleDirection(locale),
|
|
isSupported: isLocaleSupported(locale),
|
|
isCurrent: locale === currentLocale,
|
|
isFallback: locale === fallbackLocale,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 获取所有语言信息
|
|
*/
|
|
export const getAllLocaleInfo = (): LocaleInfo[] => {
|
|
return getSupportedLocales().map(getLocaleInfo);
|
|
};
|
|
|
|
/**
|
|
* 保存语言到本地存储
|
|
*/
|
|
export const saveLocale = (locale: string): void => {
|
|
if (typeof localStorage !== 'undefined') {
|
|
try {
|
|
localStorage.setItem('metona-toast-locale', locale);
|
|
} catch (e) {
|
|
console.warn('Failed to save locale:', e);
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 从本地存储加载语言
|
|
*/
|
|
export const loadLocale = (): string => {
|
|
if (typeof localStorage !== 'undefined') {
|
|
try {
|
|
return localStorage.getItem('metona-toast-locale') || getDefaultLocale();
|
|
} catch (e) {
|
|
console.warn('Failed to load locale:', e);
|
|
}
|
|
}
|
|
return getDefaultLocale();
|
|
};
|
|
|
|
/**
|
|
* 获取默认语言
|
|
*/
|
|
export const getDefaultLocale = (): string => {
|
|
if (typeof navigator !== 'undefined') {
|
|
const browserLocale = navigator.language || (navigator as unknown as { userLanguage?: string }).userLanguage;
|
|
if (browserLocale && isLocaleSupported(browserLocale)) {
|
|
return browserLocale;
|
|
}
|
|
|
|
const shortLocale = browserLocale?.split('-')[0];
|
|
if (shortLocale && isLocaleSupported(shortLocale)) {
|
|
return shortLocale;
|
|
}
|
|
}
|
|
|
|
return fallbackLocale;
|
|
};
|
|
|
|
/**
|
|
* 初始化国际化系统
|
|
*/
|
|
export const initI18n = (): void => {
|
|
const savedLocale = loadLocale();
|
|
setCurrentLocale(savedLocale);
|
|
};
|
|
|
|
/**
|
|
* 切换语言
|
|
*/
|
|
export const switchLocale = (locale: string): void => {
|
|
setCurrentLocale(locale);
|
|
};
|
|
|
|
/**
|
|
* 添加语言监听器
|
|
*/
|
|
export const addLocaleListener = (listener: (locale: string) => void): () => void => {
|
|
localeListeners.add(listener);
|
|
return () => {
|
|
localeListeners.delete(listener);
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 移除语言监听器
|
|
*/
|
|
export const removeLocaleListener = (listener: (locale: string) => void): void => {
|
|
localeListeners.delete(listener);
|
|
};
|
|
|
|
/**
|
|
* 通知语言监听器
|
|
*/
|
|
const notifyLocaleListeners = (locale: string): void => {
|
|
localeListeners.forEach((listener) => {
|
|
try {
|
|
listener(locale);
|
|
} catch (e) {
|
|
console.error('Locale listener error:', e);
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 清除所有语言监听器
|
|
*/
|
|
export const clearLocaleListeners = (): void => {
|
|
localeListeners.clear();
|
|
};
|
|
|
|
/**
|
|
* 格式化数字
|
|
*/
|
|
export const formatNumber = (number: number, options: Intl.NumberFormatOptions = {}): string => {
|
|
try {
|
|
return new Intl.NumberFormat(currentLocale, options).format(number);
|
|
} catch (e) {
|
|
return number.toString();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 格式化货币
|
|
*/
|
|
export const formatCurrency = (amount: number, currency = 'USD', options: Intl.NumberFormatOptions = {}): string => {
|
|
try {
|
|
return new Intl.NumberFormat(currentLocale, {
|
|
style: 'currency',
|
|
currency,
|
|
...options,
|
|
}).format(amount);
|
|
} catch (e) {
|
|
return amount.toString();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 格式化百分比
|
|
*/
|
|
export const formatPercent = (value: number, options: Intl.NumberFormatOptions = {}): string => {
|
|
try {
|
|
return new Intl.NumberFormat(currentLocale, {
|
|
style: 'percent',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 2,
|
|
...options,
|
|
}).format(value / 100);
|
|
} catch (e) {
|
|
return `${value}%`;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 格式化日期
|
|
*/
|
|
export const formatDate = (date: Date | number | string, options: Intl.DateTimeFormatOptions = {}): string => {
|
|
try {
|
|
const dateObj = date instanceof Date ? date : new Date(date);
|
|
return new Intl.DateTimeFormat(currentLocale, options).format(dateObj);
|
|
} catch (e) {
|
|
return String(date);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 格式化时间
|
|
*/
|
|
export const formatTime = (date: Date | number | string, options: Intl.DateTimeFormatOptions = {}): string => {
|
|
return formatDate(date, {
|
|
hour: 'numeric',
|
|
minute: 'numeric',
|
|
second: 'numeric',
|
|
...options,
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 格式化相对时间
|
|
*/
|
|
export const formatRelativeTime = (date: Date | number | string, options: Intl.RelativeTimeFormatOptions = {}): string => {
|
|
try {
|
|
const dateObj = date instanceof Date ? date : new Date(date);
|
|
const now = new Date();
|
|
const diff = dateObj.getTime() - now.getTime();
|
|
|
|
const rtf = new Intl.RelativeTimeFormat(currentLocale, {
|
|
numeric: 'auto',
|
|
...options,
|
|
});
|
|
|
|
const units: Array<{ unit: Intl.RelativeTimeFormatUnit; ms: number }> = [
|
|
{ unit: 'year', ms: 365 * 24 * 60 * 60 * 1000 },
|
|
{ unit: 'month', ms: 30 * 24 * 60 * 60 * 1000 },
|
|
{ unit: 'week', ms: 7 * 24 * 60 * 60 * 1000 },
|
|
{ unit: 'day', ms: 24 * 60 * 60 * 1000 },
|
|
{ unit: 'hour', ms: 60 * 60 * 1000 },
|
|
{ unit: 'minute', ms: 60 * 1000 },
|
|
{ unit: 'second', ms: 1000 },
|
|
];
|
|
|
|
for (const { unit, ms } of units) {
|
|
if (Math.abs(diff) >= ms || unit === 'second') {
|
|
const value = Math.round(diff / ms);
|
|
return rtf.format(value, unit);
|
|
}
|
|
}
|
|
|
|
return String(date);
|
|
} catch (e) {
|
|
return String(date);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 格式化列表 — 优先使用 Intl.ListFormat,降级到逗号拼接
|
|
*/
|
|
export const formatList = (list: string[], _options: Record<string, unknown> = {}): string => {
|
|
const intl = Intl as typeof Intl & { ListFormat?: new (locale: string, options?: Record<string, unknown>) => { format: (items: string[]) => string } };
|
|
if (typeof intl.ListFormat === 'function') {
|
|
try {
|
|
return new intl.ListFormat(currentLocale, {
|
|
style: 'long',
|
|
type: 'conjunction',
|
|
}).format(list);
|
|
} catch (_e) { /* fallback below */ }
|
|
}
|
|
return list.join(', ');
|
|
};
|
|
|
|
/**
|
|
* 格式化复数
|
|
*/
|
|
export const formatPlural = (count: number, options: Intl.PluralRulesOptions = {}): string => {
|
|
try {
|
|
return new Intl.PluralRules(currentLocale, options).select(count);
|
|
} catch (e) {
|
|
return count === 1 ? 'one' : 'other';
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 复数翻译
|
|
*/
|
|
export const plural = (key: string, count: number, params: Record<string, string | number> = {}): string => {
|
|
const pluralForm = formatPlural(count);
|
|
const pluralKey = `${key}.${pluralForm}`;
|
|
|
|
if (hasTranslation(pluralKey)) {
|
|
return t(pluralKey, { ...params, count });
|
|
}
|
|
|
|
if (hasTranslation(key)) {
|
|
return t(key, { ...params, count });
|
|
}
|
|
|
|
return key;
|
|
};
|
|
|
|
/**
|
|
* 日期时间格式化选项
|
|
*/
|
|
export const dateTimeFormats: Record<string, Intl.DateTimeFormatOptions> = {
|
|
short: { year: 'numeric', month: 'short', day: 'numeric' },
|
|
medium: { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: 'numeric' },
|
|
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long', hour: 'numeric', minute: 'numeric', second: 'numeric' },
|
|
time: { hour: 'numeric', minute: 'numeric', second: 'numeric' },
|
|
date: { year: 'numeric', month: 'long', day: 'numeric' },
|
|
weekday: { weekday: 'long' },
|
|
month: { month: 'long' },
|
|
year: { year: 'numeric' },
|
|
};
|
|
|
|
/**
|
|
* 数字格式化选项
|
|
*/
|
|
export const numberFormats: Record<string, Intl.NumberFormatOptions> = {
|
|
integer: { maximumFractionDigits: 0 },
|
|
decimal: { minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
|
percent: { style: 'percent', minimumFractionDigits: 0, maximumFractionDigits: 2 },
|
|
currency: { style: 'currency', currency: 'USD' },
|
|
};
|
|
|
|
/**
|
|
* 国际化工具 — 代理层
|
|
*/
|
|
export const i18nUtils: I18nUtils = {
|
|
t,
|
|
plural,
|
|
getCurrentLocale,
|
|
setCurrentLocale,
|
|
switchLocale,
|
|
getFallbackLocale,
|
|
setFallbackLocale,
|
|
hasTranslation,
|
|
getTranslations,
|
|
addTranslations,
|
|
removeTranslation,
|
|
clearTranslations,
|
|
getSupportedLocales,
|
|
isLocaleSupported,
|
|
getLocaleName,
|
|
getLocaleDirection,
|
|
getLocaleInfo,
|
|
getAllLocaleInfo,
|
|
formatNumber,
|
|
formatCurrency,
|
|
formatPercent,
|
|
formatDate,
|
|
formatTime,
|
|
formatRelativeTime,
|
|
formatList,
|
|
formatPlural,
|
|
addLocaleListener,
|
|
removeLocaleListener,
|
|
clearLocaleListeners,
|
|
initI18n,
|
|
saveLocale,
|
|
loadLocale,
|
|
getDefaultLocale,
|
|
};
|
|
|
|
/**
|
|
* 预设语言包
|
|
*/
|
|
export const presetLocales: Record<string, {
|
|
name: string;
|
|
nativeName: string;
|
|
direction: 'ltr' | 'rtl';
|
|
translations: Record<string, string>;
|
|
}> = {
|
|
'zh-CN': {
|
|
name: '简体中文',
|
|
nativeName: '简体中文',
|
|
direction: 'ltr',
|
|
translations: LOCALES['zh-CN'],
|
|
},
|
|
'en-US': {
|
|
name: 'English (US)',
|
|
nativeName: 'English (US)',
|
|
direction: 'ltr',
|
|
translations: LOCALES['en-US'],
|
|
},
|
|
};
|
|
|
|
export { i18nUtils as default };
|