208 lines
6.0 KiB
PHP
208 lines
6.0 KiB
PHP
<?php
|
||
/**
|
||
* 多语言(i18n)系统
|
||
*
|
||
* 使用 JSON 语言包,支持动态切换。
|
||
* 用法:
|
||
* echo __('common.home'); // "首页"
|
||
* echo __('product.buy_now'); // "立即购买"
|
||
* echo __('sign.points_gained', 5); // "获得 5 积分"(支持 {points} 占位符)
|
||
* Lang::setLocale('en_US'); // 切换语言
|
||
*/
|
||
if (!defined('IN_APP')) exit('Forbidden');
|
||
|
||
class Lang
|
||
{
|
||
/** @var string 当前语言代码 */
|
||
private static $locale = 'zh_CN';
|
||
|
||
/** @var array 已加载的语言包缓存 [code => data] */
|
||
private static $cache = [];
|
||
|
||
/** @var string 语言文件目录 */
|
||
private static $dir;
|
||
|
||
/**
|
||
* 设置语言目录(通常在 init.php 中调用一次)。
|
||
*/
|
||
public static function setDir($dir)
|
||
{
|
||
self::$dir = rtrim($dir, '/\\');
|
||
}
|
||
|
||
/**
|
||
* 获取当前语言代码。
|
||
*/
|
||
public static function getLocale()
|
||
{
|
||
return self::$locale;
|
||
}
|
||
|
||
/**
|
||
* 切换语言。
|
||
* @param string $locale 如 'zh_CN', 'en_US'
|
||
* @return bool 是否成功
|
||
*/
|
||
public static function setLocale($locale)
|
||
{
|
||
$file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json';
|
||
if (!file_exists($file)) return false;
|
||
self::$locale = $locale;
|
||
// 清除旧缓存
|
||
if (isset(self::$cache[$locale])) unset(self::$cache[$locale]);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 从 cookie / session / 参数自动检测并设置语言。
|
||
*/
|
||
public static function detectLocale()
|
||
{
|
||
// 1. Session(由 lang.php 设置,优先级最高,保证整站会话内一致)
|
||
if (!empty($_SESSION['lang'])) {
|
||
$lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_SESSION['lang']);
|
||
if (self::setLocale($lang)) return;
|
||
}
|
||
// 2. URL 参数 ?lang=xx(一次性切换,写入 cookie + session)
|
||
if (!empty($_GET['lang'])) {
|
||
$lang = preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['lang']);
|
||
if (self::setLocale($lang)) {
|
||
$_SESSION['lang'] = $lang;
|
||
setcookie('fnw_lang', $lang, time() + 31536000, '/');
|
||
return;
|
||
}
|
||
}
|
||
// 3. Cookie
|
||
if (!empty($_COOKIE['fnw_lang'])) {
|
||
$lang = $_COOKIE['fnw_lang'];
|
||
if (self::setLocale($lang)) return;
|
||
}
|
||
// 4. 默认中文
|
||
self::$locale = 'zh_CN';
|
||
}
|
||
|
||
/**
|
||
* 翻译函数(核心)。
|
||
*
|
||
* @param string $key 点分隔键,如 'common.home' 或 'hero.title'
|
||
* @param mixed ...$args 占位参数(按顺序替换 {0}, {1}... 或命名参数)
|
||
* @return string 翻译后的文本,找不到则返回 key 本身
|
||
*/
|
||
public static function trans($key, ...$args)
|
||
{
|
||
$data = self::load(self::$locale);
|
||
$value = self::getNested($data, $key);
|
||
|
||
if ($value === null) {
|
||
// 回退到中文
|
||
if (self::$locale !== 'zh_CN') {
|
||
$dataZh = self::load('zh_CN');
|
||
$value = self::getNested($dataZh, $key);
|
||
}
|
||
if ($value === null) return $key;
|
||
}
|
||
|
||
// 替换占位符
|
||
if (!empty($args)) {
|
||
$value = self::replacePlaceholders($value, $args);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
/**
|
||
* 获取所有可用语言列表。
|
||
* @return array [['code'=>'zh_CN','name'=>'简体中文'], ...]
|
||
*/
|
||
public static function available()
|
||
{
|
||
$list = [];
|
||
if (!is_dir(self::$dir)) return $list;
|
||
foreach (glob(self::$dir . DIRECTORY_SEPARATOR . '*.json') as $f) {
|
||
$code = pathinfo($f, PATHINFO_FILENAME);
|
||
$data = json_decode(file_get_contents($f), true);
|
||
if (isset($data['_meta']['name'])) {
|
||
$list[] = [
|
||
'code' => $code,
|
||
'name' => $data['_meta']['name'],
|
||
'direction' => $data['_meta']['direction'] ?? 'ltr',
|
||
];
|
||
}
|
||
}
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 获取当前语言的 HTML lang 属性值。
|
||
*/
|
||
public static function htmlLang()
|
||
{
|
||
$map = ['zh_CN' => 'zh-CN', 'en_US' => 'en'];
|
||
return $map[self::$locale] ?? substr(self::$locale, 0, 2);
|
||
}
|
||
|
||
// ─── 内部方法 ───
|
||
|
||
/**
|
||
* 加载语言包(带缓存)。
|
||
*/
|
||
private static function load($locale)
|
||
{
|
||
if (isset(self::$cache[$locale])) return self::$cache[$locale];
|
||
$file = self::$dir . DIRECTORY_SEPARATOR . $locale . '.json';
|
||
if (!file_exists($file)) return [];
|
||
$json = file_get_contents($file);
|
||
self::$cache[$locale] = json_decode($json, true) ?: [];
|
||
return self::$cache[$locale];
|
||
}
|
||
|
||
/**
|
||
* 从嵌套数组中用点分隔 key 取值。
|
||
*/
|
||
private static function getNested($arr, $key)
|
||
{
|
||
$keys = explode('.', $key);
|
||
$val = $arr;
|
||
foreach ($keys as $k) {
|
||
if (!is_array($val) || !array_key_exists($k, $val)) return null;
|
||
$val = $val[$k];
|
||
}
|
||
return is_string($val) || is_numeric($val) ? (string)$val : null;
|
||
}
|
||
|
||
/**
|
||
* 替换文本中的占位符。
|
||
* 支持 {named} 和 {0}, {1} 格式。
|
||
*/
|
||
private static function replacePlaceholders($text, $args)
|
||
{
|
||
// 命名参数: __('key', ['points' => 5]) 或 __('key', 5)
|
||
if (count($args) === 1 && is_array($args[0])) {
|
||
$map = $args[0];
|
||
foreach ($map as $k => $v) {
|
||
$text = str_replace('{' . $k . '}', $v, $text);
|
||
}
|
||
} else {
|
||
// 位置参数: {0}, {1}
|
||
foreach ($args as $i => $v) {
|
||
$text = str_replace('{' . $i . '}', $v, $text);
|
||
}
|
||
}
|
||
return $text;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 全局翻译函数快捷方式。
|
||
*
|
||
* @param string $key 翻译键
|
||
* @param mixed ...$args 占位参数
|
||
* @return string
|
||
*/
|
||
if (!function_exists('__')) {
|
||
function __($key, ...$args)
|
||
{
|
||
return Lang::trans($key, ...$args);
|
||
}
|
||
}
|