LanguageManager.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Collections.Specialized;
  4. using System.Globalization;
  5. using System.IO;
  6. using System.Linq;
  7. using System.Reflection;
  8. using System.Text.RegularExpressions;
  9. using System.Threading;
  10. using System.Windows.Forms;
  11. using System.Xml;
  12. namespace UAS_MES_NEW.I18n
  13. {
  14. /// <summary>
  15. /// 多语言管理器(全球化基础设施,支持 中文/英文/越南文/印度文)。
  16. /// 设计要点:
  17. /// 1) 字典的 key 直接使用「中文源文」,因此无需为每个控件手工起名;
  18. /// 2) T(key) 查询当前语言→默认语言(zh)→原文(带回退),缺失时显示原文,界面永不空白;
  19. /// 3) Localize(Form/Control) 递归遍历控件树(含菜单、页签、网格列头、DevExpress Caption),
  20. /// 并记录每个控件首次的中文文本,使运行时切换语言可正确来回还原。
  21. /// </summary>
  22. public static class LanguageManager
  23. {
  24. // 嵌入资源逻辑名:根命名空间.I18n.Lang.xml
  25. private static readonly string ResourceName =
  26. typeof(LanguageManager).Namespace + ".Lang.xml";
  27. // 嵌入资源逻辑名:根命名空间.I18n.Templates.xml
  28. private static readonly string TemplatesResourceName =
  29. typeof(LanguageManager).Namespace + ".Templates.xml";
  30. // code -> (中文源文 -> 译文)
  31. private static readonly Dictionary<string, Dictionary<string, string>> _dict =
  32. new Dictionary<string, Dictionary<string, string>>();
  33. // 动态错误模板列表(中文正则 -> 各语言模板)
  34. private static readonly List<MessageTemplate> _templates =
  35. new List<MessageTemplate>();
  36. // code -> 显示名(如 中文 / English)
  37. private static readonly Dictionary<string, string> _langNames =
  38. new Dictionary<string, string>();
  39. private static string _currentCulture = "zh";
  40. private static string _defaultCulture = "zh";
  41. /// <summary>支持的语言:code -> 显示名。顺序与 Lang.xml 中一致。</summary>
  42. public static Dictionary<string, string> SupportedLanguages { get; private set; }
  43. /// <summary>语言切换后触发,供已打开的窗体重新本地化自身。</summary>
  44. public static event EventHandler LanguageChanged;
  45. // 记录控件/菜单项首次出现的中文文本,用于语言来回切换时还原源文
  46. private static readonly Dictionary<object, string> _originals =
  47. new Dictionary<object, string>();
  48. private static readonly Dictionary<object, string> _originalsCap =
  49. new Dictionary<object, string>();
  50. private static readonly HashSet<Form> _subscribed = new HashSet<Form>();
  51. static LanguageManager()
  52. {
  53. LoadResources();
  54. LoadTemplates();
  55. SupportedLanguages = new Dictionary<string, string>(_langNames);
  56. if (!_dict.ContainsKey(_currentCulture))
  57. _currentCulture = _defaultCulture;
  58. }
  59. private static void LoadResources()
  60. {
  61. try
  62. {
  63. Assembly asm = Assembly.GetExecutingAssembly();
  64. Stream s = asm.GetManifestResourceStream(ResourceName);
  65. if (s == null)
  66. {
  67. // 兜底:按名称模糊匹配,避免资源命名差异导致加载失败
  68. foreach (string rn in asm.GetManifestResourceNames())
  69. {
  70. if (rn.EndsWith("Lang.xml", StringComparison.OrdinalIgnoreCase))
  71. {
  72. s = asm.GetManifestResourceStream(rn);
  73. break;
  74. }
  75. }
  76. }
  77. if (s == null) return;
  78. XmlDocument doc = new XmlDocument();
  79. doc.Load(s);
  80. XmlElement root = doc.DocumentElement;
  81. if (root == null) return;
  82. foreach (XmlNode langNode in root.SelectNodes("Language"))
  83. {
  84. XmlElement le = langNode as XmlElement;
  85. if (le == null) continue;
  86. string code = le.GetAttribute("code");
  87. if (string.IsNullOrEmpty(code)) continue;
  88. _langNames[code] = le.GetAttribute("name");
  89. if (!_dict.ContainsKey(code))
  90. _dict[code] = new Dictionary<string, string>(StringComparer.Ordinal);
  91. }
  92. // 新格式:每个 <Item key="中文" en=".." vi=".." hi=".."/> 一行四语
  93. XmlNodeList items = root.SelectNodes("Items/Item");
  94. if (items != null && items.Count > 0)
  95. {
  96. foreach (XmlNode itemNode in items)
  97. {
  98. XmlElement ie = itemNode as XmlElement;
  99. if (ie == null) continue;
  100. string key = ie.GetAttribute("key");
  101. if (string.IsNullOrEmpty(key)) continue;
  102. foreach (string code in new[] { "en", "vi", "id", "zh" })
  103. {
  104. string val = ie.GetAttribute(code);
  105. if (!string.IsNullOrEmpty(val) && _dict.ContainsKey(code))
  106. _dict[code][key] = val;
  107. }
  108. }
  109. }
  110. else
  111. {
  112. // 兼容旧格式:<Language code=".."><Item key="..">译文</Item></Language>
  113. foreach (XmlNode langNode in root.SelectNodes("Language"))
  114. {
  115. XmlElement le = langNode as XmlElement;
  116. if (le == null) continue;
  117. string code = le.GetAttribute("code");
  118. if (string.IsNullOrEmpty(code)) continue;
  119. var map = new Dictionary<string, string>(StringComparer.Ordinal);
  120. foreach (XmlNode itemNode in le.SelectNodes("Item"))
  121. {
  122. XmlElement ie = itemNode as XmlElement;
  123. if (ie == null) continue;
  124. string key = ie.GetAttribute("key");
  125. if (!string.IsNullOrEmpty(key))
  126. map[key] = ie.InnerText;
  127. }
  128. _dict[code] = map;
  129. }
  130. }
  131. XmlAttribute def = root.Attributes["Default"];
  132. if (def != null && _dict.ContainsKey(def.Value))
  133. _defaultCulture = def.Value;
  134. }
  135. catch
  136. {
  137. // 加载失败时 T() 会回退为 key,界面仍可运行
  138. }
  139. }
  140. /// <summary>
  141. /// 加载动态错误模板(I18n/Templates.xml 嵌入资源)。
  142. /// 模板用于把数据库返回的“中文正则模式”替换为带变量的多语言文本。
  143. /// </summary>
  144. private static void LoadTemplates()
  145. {
  146. try
  147. {
  148. Assembly asm = Assembly.GetExecutingAssembly();
  149. Stream s = asm.GetManifestResourceStream(TemplatesResourceName);
  150. if (s == null)
  151. {
  152. foreach (string rn in asm.GetManifestResourceNames())
  153. {
  154. if (rn.EndsWith("Templates.xml", StringComparison.OrdinalIgnoreCase))
  155. {
  156. s = asm.GetManifestResourceStream(rn);
  157. break;
  158. }
  159. }
  160. }
  161. if (s == null) return;
  162. XmlDocument doc = new XmlDocument();
  163. doc.Load(s);
  164. XmlElement root = doc.DocumentElement;
  165. if (root == null) return;
  166. foreach (XmlNode node in root.SelectNodes("Template"))
  167. {
  168. XmlElement te = node as XmlElement;
  169. if (te == null) continue;
  170. string zhPattern = te.GetAttribute("zh");
  171. if (string.IsNullOrEmpty(zhPattern)) continue;
  172. var translations = new Dictionary<string, string>(StringComparer.Ordinal);
  173. foreach (string code in new[] { "en", "vi", "id", "zh" })
  174. {
  175. string val = te.GetAttribute(code);
  176. if (!string.IsNullOrEmpty(val))
  177. translations[code] = val;
  178. }
  179. _templates.Add(new MessageTemplate(zhPattern, translations));
  180. }
  181. }
  182. catch
  183. {
  184. // 模板加载失败不影响主程序运行
  185. }
  186. }
  187. /// <summary>
  188. /// 动态模板翻译:把数据库返回的带变量中文提示翻译成当前语言。
  189. /// 匹配成功后,用正则捕获组填充模板中的 {0}、{1}... 占位符。
  190. /// </summary>
  191. public static string TranslateTemplate(string text)
  192. {
  193. if (string.IsNullOrEmpty(text) || _currentCulture == _defaultCulture) return text;
  194. foreach (var tmpl in _templates)
  195. {
  196. Match m = tmpl.Regex.Match(text);
  197. if (!m.Success) continue;
  198. if (!tmpl.Translations.TryGetValue(_currentCulture, out string format))
  199. {
  200. if (!tmpl.Translations.TryGetValue(_defaultCulture, out format))
  201. continue;
  202. }
  203. // 用捕获组填充 {0}、{1}...
  204. string[] groups = new string[m.Groups.Count - 1];
  205. for (int i = 1; i < m.Groups.Count; i++)
  206. groups[i - 1] = m.Groups[i].Value;
  207. try { return string.Format(format, groups); }
  208. catch { return format; }
  209. }
  210. return text;
  211. }
  212. private class MessageTemplate
  213. {
  214. public Regex Regex { get; }
  215. public Dictionary<string, string> Translations { get; }
  216. public MessageTemplate(string zhPattern, Dictionary<string, string> translations)
  217. {
  218. Regex = new Regex(zhPattern, RegexOptions.Compiled);
  219. Translations = translations;
  220. }
  221. }
  222. /// <summary>当前语言代码(如 zh / en / vi / hi)。</summary>
  223. public static string CurrentCulture
  224. {
  225. get { return _currentCulture; }
  226. }
  227. /// <summary>
  228. /// 程序启动时调用:读取已保存的语言并应用(不重复持久化)。
  229. /// 应在显示任何窗体之前调用。
  230. /// </summary>
  231. public static void Initialize()
  232. {
  233. string saved = LoadSavedCulture();
  234. if (string.IsNullOrEmpty(saved) || !_dict.ContainsKey(saved))
  235. saved = _defaultCulture;
  236. ApplyCulture(saved, false);
  237. }
  238. /// <summary>切换语言并持久化到用户配置。</summary>
  239. public static void SetCulture(string cultureCode)
  240. {
  241. SetCulture(cultureCode, true);
  242. }
  243. /// <summary>切换语言。persist=true 时写入用户配置文件。</summary>
  244. public static void SetCulture(string cultureCode, bool persist)
  245. {
  246. if (string.IsNullOrEmpty(cultureCode) || !_dict.ContainsKey(cultureCode))
  247. cultureCode = _defaultCulture;
  248. ApplyCulture(cultureCode, persist);
  249. }
  250. private static void ApplyCulture(string cultureCode, bool persist)
  251. {
  252. _currentCulture = cultureCode;
  253. try
  254. {
  255. CultureInfo ci = new CultureInfo(cultureCode);
  256. Thread.CurrentThread.CurrentUICulture = ci;
  257. Thread.CurrentThread.CurrentCulture = ci;
  258. }
  259. catch
  260. {
  261. // 无效的 culture 不影响界面文本切换
  262. }
  263. if (persist)
  264. SaveCulture(cultureCode);
  265. LanguageChanged?.Invoke(null, EventArgs.Empty);
  266. }
  267. /// <summary>
  268. /// 获取当前语言的翻译文本。key 通常为中文源文。
  269. /// 查找顺序:当前语言 -> 默认语言(zh) -> (key 去前后空格再试) -> fallback -> key。
  270. /// 缺失时返回原文,保证界面永不空白。
  271. /// </summary>
  272. public static string T(string key, string fallback = null)
  273. {
  274. if (string.IsNullOrEmpty(key)) return key;
  275. if (_dict.TryGetValue(_currentCulture, out var map) &&
  276. map.TryGetValue(key, out var val) && !string.IsNullOrEmpty(val))
  277. return val;
  278. if (_dict.TryGetValue(_defaultCulture, out var dmap) &&
  279. dmap.TryGetValue(key, out var dval) && !string.IsNullOrEmpty(dval))
  280. return dval;
  281. // 去前后空格再试一次(兼容「姓名 : 」这类带尾随空格的标签)
  282. string tk = key.Trim();
  283. if (tk != key)
  284. {
  285. if (_dict.TryGetValue(_currentCulture, out var m2) &&
  286. m2.TryGetValue(tk, out var v2) && !string.IsNullOrEmpty(v2)) return v2;
  287. if (_dict.TryGetValue(_defaultCulture, out var dm2) &&
  288. dm2.TryGetValue(tk, out var dv2) && !string.IsNullOrEmpty(dv2)) return dv2;
  289. }
  290. return fallback ?? key;
  291. }
  292. /// <summary>
  293. /// 对一段混合文本进行翻译:扫描其中包含的所有中文 key,并替换为当前语言译文。
  294. /// 用于处理数据库返回的带变量前缀/后缀的中文提示(如 "SN:xxx 无工单归属记录,必须先归属工单")。
  295. /// 按 key 长度降序匹配,避免短 key 截断长 key。
  296. /// 安全回退:替换后如果仍然残留明显中文(说明没完整翻译),则返回原文,避免中英夹杂。
  297. /// </summary>
  298. public static string TranslateContainedKeys(string text)
  299. {
  300. if (string.IsNullOrEmpty(text)) return text;
  301. if (_currentCulture == _defaultCulture) return text;
  302. if (!_dict.TryGetValue(_currentCulture, out var map)) return text;
  303. // 取出所有非空且翻译不同于原文的中文 key,按长度降序排列,
  304. // 这样长句子优先匹配,短词不会先把长句子的组成部分替换掉。
  305. var keys = map
  306. .Where(kv => !string.IsNullOrEmpty(kv.Value)
  307. && kv.Value != kv.Key
  308. && ContainsChinese(kv.Key))
  309. .OrderByDescending(kv => kv.Key.Length)
  310. .ToList();
  311. string result = text;
  312. foreach (var kv in keys)
  313. {
  314. if (result.Contains(kv.Key))
  315. result = result.Replace(kv.Key, kv.Value);
  316. }
  317. // 如果替换后仍残留明显中文,说明片段替换不完整,回退原文避免界面混乱。
  318. if (CountChinese(result) > 2)
  319. return text;
  320. return result;
  321. }
  322. /// <summary>统计字符串中中文字符数量。</summary>
  323. private static int CountChinese(string text)
  324. {
  325. if (string.IsNullOrEmpty(text)) return 0;
  326. int count = 0;
  327. foreach (char c in text)
  328. {
  329. if (c >= 0x4E00 && c <= 0x9FFF) count++;
  330. }
  331. return count;
  332. }
  333. /// <summary>判断字符串中是否包含中文字符。</summary>
  334. private static bool ContainsChinese(string text)
  335. {
  336. if (string.IsNullOrEmpty(text)) return false;
  337. foreach (char c in text)
  338. {
  339. if (c >= 0x4E00 && c <= 0x9FFF) return true;
  340. }
  341. return false;
  342. }
  343. /// <summary>
  344. /// 本地化整个窗体:遍历控件树翻译静态文本,并订阅 LanguageChanged
  345. /// 以便运行时切换语言时自动刷新。可在窗体构造函数 InitializeComponent() 之后调用。
  346. /// </summary>
  347. public static void Localize(Form form)
  348. {
  349. if (form == null) return;
  350. if (!_subscribed.Contains(form))
  351. {
  352. _subscribed.Add(form);
  353. form.FormClosed += (s, e) => { _subscribed.Remove(form); };
  354. LanguageChanged += (s, e) =>
  355. {
  356. try { Localize((Control)form); } catch { }
  357. };
  358. }
  359. Localize((Control)form);
  360. }
  361. /// <summary>
  362. /// 本地化一个控件及其所有子控件(递归)。常用于用户控件或局部刷新。
  363. /// </summary>
  364. public static void Localize(Control root)
  365. {
  366. if (root == null) return;
  367. try
  368. {
  369. TranslateText(root);
  370. TranslateCaption(root);
  371. // DataGridView 列头
  372. if (root is DataGridView dgv)
  373. {
  374. foreach (DataGridViewColumn col in dgv.Columns)
  375. {
  376. try
  377. {
  378. if (!_originals.ContainsKey(col)) _originals[col] = col.HeaderText ?? "";
  379. string src = _originals[col];
  380. if (!string.IsNullOrEmpty(src)) col.HeaderText = T(src);
  381. }
  382. catch { }
  383. }
  384. }
  385. // 菜单/工具栏(MenuStrip、ToolStrip、StatusStrip 均是 Control,但其项不是 Control)
  386. if (root is ToolStrip ts)
  387. {
  388. foreach (ToolStripItem item in ts.Items)
  389. LocalizeItem(item);
  390. }
  391. }
  392. catch { }
  393. foreach (Control child in root.Controls)
  394. Localize(child);
  395. }
  396. private static void LocalizeItem(ToolStripItem item)
  397. {
  398. if (item == null) return;
  399. try
  400. {
  401. if (!_originals.ContainsKey(item)) _originals[item] = item.Text ?? "";
  402. string src = _originals[item];
  403. if (!string.IsNullOrEmpty(src)) item.Text = T(src);
  404. }
  405. catch { }
  406. try
  407. {
  408. if (item is ToolStripDropDownItem dd)
  409. {
  410. foreach (ToolStripItem sub in dd.DropDownItems)
  411. LocalizeItem(sub);
  412. }
  413. }
  414. catch { }
  415. }
  416. private static void TranslateText(Control ctl)
  417. {
  418. try
  419. {
  420. // 标记为 NoTranslate 的控件(如语言选择下拉框本身)不参与翻译,
  421. // 避免翻译其 Text 后触发 SelectedIndexChanged 造成语言来回切换
  422. if ("NoTranslate".Equals(ctl.Tag as string, StringComparison.Ordinal)) return;
  423. if (!_originals.ContainsKey(ctl)) _originals[ctl] = ctl.Text ?? "";
  424. string src = _originals[ctl];
  425. if (!string.IsNullOrEmpty(src)) ctl.Text = T(src);
  426. }
  427. catch { }
  428. }
  429. // DevExpress 等控件用 Caption 而非 Text(如 BarItem、部分状态栏项)。
  430. // 仅当 Caption 存在且与 Text 不同(避免重复翻译)时处理。
  431. private static void TranslateCaption(Control ctl)
  432. {
  433. try
  434. {
  435. if ("NoTranslate".Equals(ctl.Tag as string, StringComparison.Ordinal)) return;
  436. PropertyInfo prop = ctl.GetType().GetProperty("Caption", typeof(string));
  437. if (prop == null || !prop.CanRead || !prop.CanWrite) return;
  438. string cap = (string)prop.GetValue(ctl) ?? "";
  439. if (string.IsNullOrEmpty(cap) || cap == ctl.Text) return;
  440. if (!_originalsCap.ContainsKey(ctl)) _originalsCap[ctl] = cap;
  441. string src = _originalsCap[ctl];
  442. if (!string.IsNullOrEmpty(src)) prop.SetValue(ctl, T(src));
  443. }
  444. catch { }
  445. }
  446. // ---- 便捷对话框:自动用当前语言翻译文本与标题 ----
  447. // 用法:把代码里的 MessageBox.Show("请选择数据库!", "提示", ...)
  448. // 改为 LanguageManager.ShowError("请选择数据库!", "提示", ...)
  449. // 其中字符串即中文源文(key),与 Localize 机制一致。
  450. public static DialogResult ShowInfo(string textKey, string titleKey = "提示")
  451. {
  452. return MessageBox.Show(T(textKey), T(titleKey), MessageBoxButtons.OK, MessageBoxIcon.Information);
  453. }
  454. public static DialogResult ShowWarning(string textKey, string titleKey = "提示")
  455. {
  456. return MessageBox.Show(T(textKey), T(titleKey), MessageBoxButtons.OK, MessageBoxIcon.Warning);
  457. }
  458. public static DialogResult ShowError(string textKey, string titleKey = "错误")
  459. {
  460. return MessageBox.Show(T(textKey), T(titleKey), MessageBoxButtons.OK, MessageBoxIcon.Error);
  461. }
  462. public static DialogResult ShowQuestion(string textKey, string titleKey = "提示")
  463. {
  464. return MessageBox.Show(T(textKey), T(titleKey), MessageBoxButtons.YesNo, MessageBoxIcon.Question);
  465. }
  466. // ---- 持久化:%LocalAppData%\UAS_MES_NEW\lang.cfg ----
  467. private static string ConfigPath
  468. {
  469. get
  470. {
  471. try
  472. {
  473. string dir = Path.Combine(
  474. Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
  475. "UAS_MES_NEW");
  476. if (!Directory.Exists(dir))
  477. Directory.CreateDirectory(dir);
  478. return Path.Combine(dir, "lang.cfg");
  479. }
  480. catch
  481. {
  482. return "lang.cfg";
  483. }
  484. }
  485. }
  486. private static void SaveCulture(string code)
  487. {
  488. try { File.WriteAllText(ConfigPath, code); }
  489. catch { /* 忽略写入失败 */ }
  490. }
  491. private static string LoadSavedCulture()
  492. {
  493. try
  494. {
  495. if (File.Exists(ConfigPath))
  496. return File.ReadAllText(ConfigPath).Trim();
  497. }
  498. catch { /* 忽略读取失败 */ }
  499. return null;
  500. }
  501. }
  502. }