mm_lang 2.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/lang.js CHANGED
@@ -1,44 +1,138 @@
1
1
  /**
2
- * 持久化适配:嵌套词条字典与 Langs 扁平键值互转、存储实例装配
2
+ * 内置语言类
3
+ * 国际化(i18n)核心:扁平词条表上的取词、回退、复数与格式化
4
+ * 词条表与持久化同构:内存即 `语言代码.层级键 → 文本` 的扁平形态,save/load 无需转换
5
+ * 只接受扁平入参(键为 `语言.层级键`,或控制键),嵌套语言包由调用方展开(如 mm_tpl)
6
+ * 持久化通过 langsAdmin 管理的 Langs 存储实例(this.store)完成,本类不做文件 IO
7
+ * @class
3
8
  */
4
- const {
5
- flatten,
6
- unflatten,
7
- assignDeep,
8
- isControlKey,
9
- createStore
10
- } = require('./store.js');
9
+
10
+ const langs_admin = require('./langs_admin.js');
11
11
 
12
12
  /**
13
- * 内置语言类
14
- * 国际化(i18n)核心:按语言字典取值、层级键解析、回退机制、复数形式与格式化
15
- * 行为对齐 mm_expand 的 Lang,但不依赖外部模块与全局变量
16
- * 持久化能力通过组合的 Langs 存储实例(this.store)提供,本类不做文件 IO
17
- * @class
13
+ * 非词条的控制键,不参与取词、语言列表与持久化
14
+ * 词条键必含点号(`语言.键`),控制键均不含点号,二者天然可分
15
+ * @type {Array<string>}
16
+ */
17
+ const CONTROL_KEYS = ['default', 'fallback', 'locale', 'options', 'file', 'store', 'silent'];
18
+
19
+ /**
20
+ * 非字符串词条(复数数组等)落盘时的标记前缀
21
+ * 落盘后仍是字符串,满足 Langs 的 string→string 约束;读取时按前缀还原
22
+ * @type {string}
23
+ */
24
+ const MARK = '\u0001';
25
+
26
+ /**
27
+ * 判断是否为控制键
28
+ * @param {string} key 键名
29
+ * @returns {boolean} 是控制键返回 true
30
+ */
31
+ function isControlKey(key) {
32
+ return CONTROL_KEYS.indexOf(key) !== -1;
33
+ }
34
+
35
+ /**
36
+ * 校验参数为非空字符串,否则抛出类型错误
37
+ */
38
+ function checkKey(val, name) {
39
+ if (!val || typeof val !== 'string') {
40
+ throw new TypeError(name + '必须是字符串');
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 将词条值编码为可落盘的字符串(数组加标记后 JSON 化;数字/布尔转字符串)
46
+ * @param {*} val 词条值
47
+ * @returns {string|null} 存储字符串,不可序列化的值返回 null
48
+ */
49
+ function encodeValue(val) {
50
+ if (typeof val === 'string') {
51
+ return val;
52
+ }
53
+ if (Array.isArray(val)) {
54
+ return MARK + JSON.stringify(val);
55
+ }
56
+ if (typeof val === 'number' || typeof val === 'boolean') {
57
+ return String(val);
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /**
63
+ * 将落盘字符串还原为词条值(带标记前缀的还原为数组)
64
+ * @param {string} val 存储字符串
65
+ * @returns {*} 词条值
66
+ */
67
+ function decodeValue(val) {
68
+ if (typeof val !== 'string' || val.charAt(0) !== MARK) {
69
+ return val;
70
+ }
71
+ try {
72
+ return JSON.parse(val.slice(1));
73
+ } catch (e) {
74
+ return val;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * 按点号路径向对象写入值(仅 getDict 导出单语言词条时使用)
80
+ * @param {Object} obj 目标对象
81
+ * @param {string} path 点号路径
82
+ * @param {*} val 值
83
+ * @returns {void}
18
84
  */
85
+ function setByPath(obj, path, val) {
86
+ var keys = path.split('.');
87
+ var cur = obj;
88
+ for (var i = 0; i < keys.length - 1; i++) {
89
+ var next = cur[keys[i]];
90
+ if (!next || typeof next !== 'object' || Array.isArray(next)) {
91
+ next = cur[keys[i]] = {};
92
+ }
93
+ cur = next;
94
+ }
95
+ cur[keys[keys.length - 1]] = val;
96
+ }
97
+
98
+ /**
99
+ * 两位数补零
100
+ * @param {number} n 数值
101
+ * @returns {string} 补零后的字符串
102
+ */
103
+ function pad(n) {
104
+ return String(n).padStart(2, '0');
105
+ }
106
+
19
107
  class Lang {
20
108
  /**
21
109
  * 创建语言实例
22
- * @param {Object} config - 语言包配置,键为语言代码,值可为词条对象;可含 default、fallback、locale、file、store
110
+ * @param {Object} config - 扁平词条表:键为 `语言.层级键`,值为字符串/数字/布尔/数组;
111
+ * 可含控制键 default、fallback、locale、silent 及存储配置 file、store
23
112
  */
24
113
  constructor(config = {}) {
25
- var cg = Object.assign({}, config);
114
+ var cg = Object.assign({
115
+ default: 'zh_cn',
116
+ fallback: true
117
+ }, config);
26
118
  var file = cg.file || '';
27
119
  var injected = cg.store;
28
- // file / store 属于存储配置,不进入词条字典,避免被当作语言代码
29
120
  delete cg.file;
30
121
  delete cg.store;
31
122
 
123
+ // 词条值不能是嵌套对象,嵌套语言包需由调用方展开为扁平键
124
+ for (var key in cg) {
125
+ var val = cg[key];
126
+ if (!isControlKey(key) && val && typeof val === 'object' && !Array.isArray(val)) {
127
+ throw new TypeError('词条 "' + key + '" 的值不能是对象,请使用扁平键(语言.层级键)');
128
+ }
129
+ }
130
+
32
131
  /**
33
- * 语言包配置,语言代码对应词条字典
132
+ * 扁平词条表,键为 `语言代码.层级键`,与持久化格式一致;控制键并存于同一层
34
133
  * @type {Object}
35
134
  */
36
- this.config = Object.assign({
37
- zh_cn: {},
38
- en: {},
39
- default: 'zh_cn',
40
- fallback: true
41
- }, cg);
135
+ this.config = cg;
42
136
 
43
137
  /**
44
138
  * 复数规则表,键为语言代码,值为按数量返回形式的索引的函数
@@ -57,7 +151,7 @@ class Lang {
57
151
  * 当前语言代码
58
152
  * @type {string}
59
153
  */
60
- this.now = this.config.default || 'zh_cn';
154
+ this.now = cg.default || 'zh_cn';
61
155
 
62
156
  /**
63
157
  * 区域映射表,完整语言代码到基础语言代码
@@ -66,7 +160,7 @@ class Lang {
66
160
  this.locale = Object.assign({
67
161
  zh: 'zh_cn',
68
162
  en: 'en'
69
- }, this.config.locale);
163
+ }, cg.locale);
70
164
 
71
165
  /**
72
166
  * 持久化文件路径
@@ -75,49 +169,61 @@ class Lang {
75
169
  this.file = file;
76
170
 
77
171
  /**
78
- * 语言存储实例(数据侧,来自 langs.js),未配置 file 且未注入时为 null
172
+ * 语言存储实例:优先用注入实例,否则按文件路径经 langsAdmin 取共享实例,
173
+ * 两者都无则为 null(纯内存)
79
174
  * @type {Langs|null}
80
175
  */
81
- this.store = createStore(injected, file);
176
+ this.store = injected || (file ? langs_admin.get(file, { file: file }) : null);
82
177
  }
83
178
  }
84
179
 
85
180
  /**
86
- * 获取翻译文本
87
- * @param {string} key - 键,支持 a.b 形式的层级键
88
- * @param {...any} param - 参数,依次替换 {0}、{1} 等占位符
89
- * @returns {string} 翻译后的文本
181
+ * 取以 `语言.` 为前缀的全部词条键 - 内部方法
90
182
  */
91
- Lang.prototype.get = function (key, ...param) {
92
- if (!key || typeof (key) !== 'string') {
93
- throw new TypeError('key必须是字符串');
94
- }
95
- var val = this._getVal(key);
96
- if (typeof (val) === 'string' && param.length > 0) {
97
- for (var i = 0; i < param.length; i++) {
98
- var reg = new RegExp('\\{' + i + '\\}', 'ig');
99
- val = val.replace(reg, param[i]);
183
+ Lang.prototype._keysOf = function (lang) {
184
+ var prefix = lang + '.';
185
+ var keys = [];
186
+ for (var key in this.config) {
187
+ if (key.indexOf(prefix) === 0) {
188
+ keys.push(key);
100
189
  }
101
190
  }
191
+ return keys;
192
+ };
193
+
194
+ /**
195
+ * 占位符替换 - 内部方法
196
+ * useCount 为 true 时先替换 {count}(复数场景),序号占位符从 {1} 开始;否则从 {0} 开始
197
+ */
198
+ Lang.prototype._fill = function (str, count, param, useCount) {
199
+ var val = String(str);
200
+ if (useCount) {
201
+ val = val.replace(/\{count\}/ig, count);
202
+ }
203
+ var offset = useCount ? 1 : 0;
204
+ for (var i = 0; i < param.length; i++) {
205
+ val = val.replace(new RegExp('\\{' + (i + offset) + '\\}', 'ig'), param[i]);
206
+ }
102
207
  return val;
103
208
  };
104
209
 
105
210
  /**
106
- * 带回退机制获取语言值 - 内部方法
211
+ * 带回退机制获取词条 - 内部方法
107
212
  * 依次尝试:当前语言、基础语言、默认语言
108
213
  * @param {string} key - 键
109
214
  * @returns {any} 语言值
110
215
  * @private
111
216
  */
112
217
  Lang.prototype._getVal = function (key) {
113
- var val = this._getFrom(this.now, key);
218
+ var val = this.config[this.now + '.' + key];
114
219
  if (val === undefined && this.config.fallback) {
115
- var lang = this._getBase(this.now);
116
- if (lang && lang !== this.now) {
117
- val = this._getFrom(lang, key);
220
+ var base = this._getBase(this.now);
221
+ if (base !== this.now) {
222
+ val = this.config[base + '.' + key];
118
223
  }
119
- if (val === undefined && this.config.default && this.config.default !== this.now) {
120
- val = this._getFrom(this.config.default, key);
224
+ var def = this.config.default;
225
+ if (val === undefined && def && def !== this.now) {
226
+ val = this.config[def + '.' + key];
121
227
  }
122
228
  }
123
229
  if (val === undefined) {
@@ -126,32 +232,6 @@ Lang.prototype._getVal = function (key) {
126
232
  return val;
127
233
  };
128
234
 
129
- /**
130
- * 从指定语言中获取值 - 内部方法
131
- * @param {string} lang - 语言代码
132
- * @param {string} key - 键
133
- * @returns {any} 值
134
- * @private
135
- */
136
- Lang.prototype._getFrom = function (lang, key) {
137
- var lang_cg = this.config[lang];
138
- if (!lang_cg) {
139
- return undefined;
140
- }
141
- var arr = key.split('.');
142
- var val = lang_cg;
143
- for (var i = 0; i < arr.length; i++) {
144
- if (val === undefined) {
145
- break;
146
- }
147
- val = val[arr[i]];
148
- if (val === undefined || typeof (val) === 'string') {
149
- break;
150
- }
151
- }
152
- return val;
153
- };
154
-
155
235
  /**
156
236
  * 获取基础语言代码 - 内部方法
157
237
  * @param {string} lang - 完整语言代码
@@ -167,91 +247,32 @@ Lang.prototype._getBase = function (lang) {
167
247
  };
168
248
 
169
249
  /**
170
- * 设置语言键值到当前语言
171
- * @param {string} key - 键名,支持 a.b 形式的层级键
172
- * @param {any} val - 值
173
- * @returns {any} 返回设置的值
174
- */
175
- Lang.prototype.set = function (key, val) {
176
- if (!key || typeof (key) !== 'string') {
177
- throw new TypeError('key必须是字符串');
178
- }
179
- return this._set(key, val);
180
- };
181
-
182
- /**
183
- * 设置语言键值 - 内部方法
184
- * @param {string} key - 键名
185
- * @param {any} val - 值
186
- * @returns {any} 返回设置的值
250
+ * 输出日志 - 内部方法,使用 console
251
+ * 配置 silent 为 true 时静默(供上层自带日志体系的场景使用,如 mm_tpl)
252
+ * @param {string} level - 日志级别
253
+ * @param {string} message - 日志消息
254
+ * @param {...any} args - 附加参数
187
255
  * @private
188
256
  */
189
- Lang.prototype._set = function (key, val) {
190
- var lang = this.config[this.now];
191
- if (!lang) {
192
- this._log('error', '当前语言未初始化: ' + this.now);
193
- return val;
194
- }
195
- var arr = key.split('.');
196
- var obj = lang;
197
- for (var i = 0; i < arr.length; i++) {
198
- var k = arr[i];
199
- var v = obj[k];
200
- if (i === arr.length - 1) {
201
- obj[k] = val;
202
- } else if (v === undefined) {
203
- obj[k] = {};
204
- obj = obj[k];
205
- } else if (typeof (v) === 'object' && !Array.isArray(v)) {
206
- obj = v;
207
- } else {
208
- obj[k] = {};
209
- obj = obj[k];
210
- }
211
- }
212
- return val;
213
- };
214
-
215
- /**
216
- * 切换语言
217
- * @param {string} lang - 语言代码
218
- * @returns {string} 切换后的语言代码
219
- */
220
- Lang.prototype.setLang = function (lang) {
221
- if (!lang || typeof (lang) !== 'string') {
222
- throw new TypeError('lang必须是字符串');
223
- }
224
- if (isControlKey(lang)) {
225
- this._log('error', '语言未找到: ' + lang);
226
- return this.now;
227
- }
228
- if (this.config[lang]) {
229
- this.now = lang;
230
- return this.now;
231
- }
232
- var base_lang = this._getBase(lang);
233
- if (this.config[base_lang]) {
234
- this.now = base_lang;
235
- return this.now;
257
+ Lang.prototype._log = function (level, message, ...args) {
258
+ if (!this.config.silent) {
259
+ (console[level] || console.log).call(console, message, ...args);
236
260
  }
237
- this._log('error', '语言未找到: ' + lang);
238
- return this.now;
239
261
  };
240
262
 
241
263
  /**
242
- * 删除语言配置
243
- * @param {string} lang - 语言代码
244
- * @returns {boolean} 是否删除成功
264
+ * 获取翻译文本
265
+ * @param {string} key - 键,支持 a.b 形式的层级键
266
+ * @param {...any} param - 参数,依次替换 {0}、{1} 等占位符
267
+ * @returns {string} 翻译后的文本
245
268
  */
246
- Lang.prototype.del = function (lang) {
247
- if (!lang || typeof (lang) !== 'string') {
248
- throw new TypeError('lang必须是字符串');
249
- }
250
- if (this.config[lang]) {
251
- delete this.config[lang];
252
- return true;
269
+ Lang.prototype.get = function (key, ...param) {
270
+ checkKey(key, 'key');
271
+ var val = this._getVal(key);
272
+ if (typeof val === 'string' && param.length > 0) {
273
+ val = this._fill(val, 0, param, false);
253
274
  }
254
- return false;
275
+ return val;
255
276
  };
256
277
 
257
278
  /**
@@ -273,80 +294,120 @@ Lang.prototype.t = function (key, ...param) {
273
294
  * @returns {string} 翻译后的文本
274
295
  */
275
296
  Lang.prototype.tc = function (key, count, ...param) {
276
- if (!key || typeof (key) !== 'string') {
277
- throw new TypeError('key必须是字符串');
278
- }
279
- if (typeof (count) !== 'number') {
297
+ checkKey(key, 'key');
298
+ if (typeof count !== 'number') {
280
299
  throw new TypeError('count必须是数字');
281
300
  }
282
- var lang = this.now;
283
- var rule = this.rules[lang] || this.rules[this._getBase(lang)] || this.rules['en'];
284
- var idx = rule(count);
285
- var obj = this._getVal(key);
286
- var val;
287
- if (typeof (obj) === 'object' && obj !== null) {
288
- val = obj[idx];
301
+ var rule = this.rules[this.now] || this.rules[this._getBase(this.now)] || this.rules.en;
302
+ var val = this._getVal(key);
303
+ if (val !== null && typeof val === 'object') {
304
+ val = val[rule(count)];
289
305
  }
290
- if (typeof (val) === 'string') {
291
- val = val.replace(/\{count\}/ig, count);
292
- for (var i = 0; i < param.length; i++) {
293
- var reg = new RegExp('\\{' + (i + 1) + '\\}', 'ig');
294
- val = val.replace(reg, param[i]);
295
- }
306
+ if (typeof val === 'string') {
307
+ val = this._fill(val, count, param, true);
296
308
  }
297
309
  return val;
298
310
  };
299
311
 
300
312
  /**
301
- * 格式化日期
302
- * @param {Date|number} dt - 日期对象或时间戳
303
- * @param {string} format - 格式,默认 YYYY-MM-DD hh:mm:ss
304
- * @returns {string} 格式化后的日期字符串
313
+ * 设置语言键值到当前语言
314
+ * @param {string} key - 键名,支持 a.b 形式的层级键
315
+ * @param {any} val - 值
316
+ * @returns {any} 返回设置的值
305
317
  */
306
- Lang.prototype.formatDate = function (dt, format) {
307
- if (!dt) {
308
- throw new TypeError('date不能为空');
318
+ Lang.prototype.set = function (key, val) {
319
+ checkKey(key, 'key');
320
+ this.config[this.now + '.' + key] = val;
321
+ return val;
322
+ };
323
+
324
+ /**
325
+ * 切换语言
326
+ * 扁平结构下语言是否存在以是否有该语言前缀的词条为准
327
+ * @param {string} lang - 语言代码
328
+ * @returns {string} 切换后的语言代码
329
+ */
330
+ Lang.prototype.setLang = function (lang) {
331
+ checkKey(lang, 'lang');
332
+ var base = this._getBase(lang);
333
+ var target = this.hasLang(lang) ? lang
334
+ : base !== lang && this.hasLang(base) ? base : '';
335
+ if (isControlKey(lang) || !target) {
336
+ this._log('error', '语言未找到: ' + lang);
337
+ return this.now;
309
338
  }
310
- var fmt = format || 'YYYY-MM-DD hh:mm:ss';
311
- var d = new Date(dt);
312
- if (isNaN(d.getTime())) {
313
- throw new TypeError('无效的日期');
339
+ this.now = target;
340
+ return target;
341
+ };
342
+
343
+ /**
344
+ * 判断某语言是否存在词条
345
+ * @param {string} lang - 语言代码
346
+ * @returns {boolean} 存在该语言的词条时返回 true
347
+ */
348
+ Lang.prototype.hasLang = function (lang) {
349
+ return !!lang && typeof lang === 'string' && this._keysOf(lang).length > 0;
350
+ };
351
+
352
+ /**
353
+ * 删除某语言的全部词条
354
+ * @param {string} lang - 语言代码
355
+ * @returns {boolean} 是否删除成功
356
+ */
357
+ Lang.prototype.del = function (lang) {
358
+ checkKey(lang, 'lang');
359
+ var keys = this._keysOf(lang);
360
+ for (var i = 0; i < keys.length; i++) {
361
+ delete this.config[keys[i]];
314
362
  }
315
- var year = d.getFullYear();
316
- var month = String(d.getMonth() + 1).padStart(2, '0');
317
- var day = String(d.getDate()).padStart(2, '0');
318
- var hours = String(d.getHours()).padStart(2, '0');
319
- var minutes = String(d.getMinutes()).padStart(2, '0');
320
- var seconds = String(d.getSeconds()).padStart(2, '0');
321
- return fmt
322
- .replace('YYYY', year)
323
- .replace('yyyy', year)
324
- .replace('MM', month)
325
- .replace('DD', day)
326
- .replace('dd', day)
327
- .replace('HH', hours)
328
- .replace('hh', hours)
329
- .replace('mm', minutes)
330
- .replace('ss', seconds);
363
+ return keys.length > 0;
331
364
  };
332
365
 
333
366
  /**
334
- * 格式化数字
335
- * @param {number} num - 要格式化的数字
336
- * @param {Object} options - 选项,含 decimals、decimal_separator、thousands_separator
337
- * @returns {string} 格式化后的数字字符串
367
+ * 获取支持的语言列表
368
+ * 取词条键的首段去重,无词条的语言不会出现(空语言字典天然不可见)
369
+ * @returns {Array<string>} 语言代码数组
338
370
  */
339
- Lang.prototype.formatNumber = function (num, options) {
340
- if (typeof (num) !== 'number' || isNaN(num)) {
341
- throw new TypeError('num必须是有效的数字');
371
+ Lang.prototype.getLangs = function () {
372
+ var langs = [];
373
+ for (var key in this.config) {
374
+ var dot = key.indexOf('.');
375
+ if (dot === -1) {
376
+ continue;
377
+ }
378
+ var lang = key.slice(0, dot);
379
+ if (langs.indexOf(lang) === -1) {
380
+ langs.push(lang);
381
+ }
342
382
  }
343
- var opt = options || {};
344
- var decimals = typeof (opt.decimals) === 'number' ? opt.decimals : 2;
345
- var decimal_sepa = opt.decimal_separator || '.';
346
- var thou_sepa = opt.thousands_separator || ',';
347
- var parts = num.toFixed(decimals).split('.');
348
- parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thou_sepa);
349
- return parts.join(decimal_sepa);
383
+ return langs;
384
+ };
385
+
386
+ /**
387
+ * 导出某语言的全部词条
388
+ * 扁平词条按前缀取出并还原为嵌套字典,形态与语言包文件一致
389
+ * @param {string} lang - 语言代码
390
+ * @returns {Object|null} 嵌套词条字典,该语言无词条时返回 null
391
+ */
392
+ Lang.prototype.getDict = function (lang) {
393
+ var prefix = lang && typeof lang === 'string' ? lang + '.' : '';
394
+ var keys = prefix ? this._keysOf(lang) : [];
395
+ var dict = null;
396
+ for (var i = 0; i < keys.length; i++) {
397
+ dict = dict || {};
398
+ setByPath(dict, keys[i].slice(prefix.length), this.config[keys[i]]);
399
+ }
400
+ return dict;
401
+ };
402
+
403
+ /**
404
+ * 检查语言是否支持
405
+ * @param {string} lang - 语言代码
406
+ * @returns {boolean} 是否支持
407
+ */
408
+ Lang.prototype.isSupported = function (lang) {
409
+ return !!lang && typeof lang === 'string'
410
+ && (this.hasLang(lang) || this.hasLang(this._getBase(lang)));
350
411
  };
351
412
 
352
413
  /**
@@ -356,10 +417,8 @@ Lang.prototype.formatNumber = function (num, options) {
356
417
  * @returns {boolean} 是否注册成功
357
418
  */
358
419
  Lang.prototype.registerPlural = function (lang, rule) {
359
- if (!lang || typeof (lang) !== 'string') {
360
- throw new TypeError('lang必须是字符串');
361
- }
362
- if (typeof (rule) !== 'function') {
420
+ checkKey(lang, 'lang');
421
+ if (typeof rule !== 'function') {
363
422
  throw new TypeError('rule必须是函数');
364
423
  }
365
424
  this.rules[lang] = rule;
@@ -367,62 +426,76 @@ Lang.prototype.registerPlural = function (lang, rule) {
367
426
  };
368
427
 
369
428
  /**
370
- * 获取支持的语言列表
371
- * 过滤掉 default、fallback、locale、options、file、store 等非词条键
372
- * @returns {Array<string>} 语言代码数组
429
+ * 格式化日期
430
+ * @param {Date|number} dt - 日期对象或时间戳
431
+ * @param {string} format - 格式,默认 YYYY-MM-DD hh:mm:ss
432
+ * @returns {string} 格式化后的日期字符串
373
433
  */
374
- Lang.prototype.getLangs = function () {
375
- var langs = [];
376
- for (var key in this.config) {
377
- if (!isControlKey(key) && typeof (this.config[key]) === 'object') {
378
- langs.push(key);
379
- }
434
+ Lang.prototype.formatDate = function (dt, format) {
435
+ if (!dt) {
436
+ throw new TypeError('date不能为空');
380
437
  }
381
- return langs;
438
+ var d = new Date(dt);
439
+ if (isNaN(d.getTime())) {
440
+ throw new TypeError('无效的日期');
441
+ }
442
+ var year = d.getFullYear();
443
+ var map = {
444
+ YYYY: year,
445
+ yyyy: year,
446
+ MM: pad(d.getMonth() + 1),
447
+ DD: pad(d.getDate()),
448
+ dd: pad(d.getDate()),
449
+ HH: pad(d.getHours()),
450
+ hh: pad(d.getHours()),
451
+ mm: pad(d.getMinutes()),
452
+ ss: pad(d.getSeconds())
453
+ };
454
+ var fmt = format || 'YYYY-MM-DD hh:mm:ss';
455
+ for (var token in map) {
456
+ fmt = fmt.replace(token, map[token]);
457
+ }
458
+ return fmt;
382
459
  };
383
460
 
384
461
  /**
385
- * 获取或初始化存储实例 - 内部方法
386
- * 已注入存储实例时优先复用;仅有文件路径时按路径新建并缓存
387
- * @param {string} [file] 本次调用的持久化文件路径
388
- * @returns {Langs|null} 存储实例
389
- * @private
462
+ * 格式化数字
463
+ * @param {number} num - 要格式化的数字
464
+ * @param {Object} options - 选项,含 decimals、decimal_separator、thousands_separator
465
+ * @returns {string} 格式化后的数字字符串
390
466
  */
391
- Lang.prototype._getStore = function (file) {
392
- if (this.store) {
393
- if (file && this.store.config && !this.store.config.file) {
394
- this.store.config.file = file;
395
- }
396
- return this.store;
397
- }
398
- var target = file || this.file;
399
- if (target) {
400
- this.store = createStore(null, target);
401
- this.file = target;
402
- return this.store;
467
+ Lang.prototype.formatNumber = function (num, options) {
468
+ if (typeof num !== 'number' || isNaN(num)) {
469
+ throw new TypeError('num必须是有效的数字');
403
470
  }
404
- return null;
471
+ var opt = options || {};
472
+ var decimals = typeof opt.decimals === 'number' ? opt.decimals : 2;
473
+ var parts = num.toFixed(decimals).split('.');
474
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, opt.thousands_separator || ',');
475
+ return parts.join(opt.decimal_separator || '.');
405
476
  };
406
477
 
407
478
  /**
408
479
  * 保存语言包到持久化文件
409
- * 词条扁平化为 `语言代码.层级键 → 文本` 后写入存储实例,再由存储实例落盘
410
- * @param {string} [file] 持久化文件路径,缺省时用实例的 file 配置
480
+ * 内存词条表即扁平 string→string 形态(数组值按标记编码),直接写入存储实例落盘
411
481
  * @returns {boolean} 是否保存成功
412
482
  */
413
- Lang.prototype.save = function (file) {
414
- var store = this._getStore(file);
415
- if (!store) {
483
+ Lang.prototype.save = function () {
484
+ if (!this.store) {
416
485
  this._log('error', '未配置持久化文件路径或存储实例,无法保存语言包');
417
486
  return false;
418
487
  }
419
- var flat = flatten(this.config);
420
488
  try {
421
- store.clear();
422
- for (var key in flat) {
423
- store.set(key, flat[key]);
489
+ this.store.clear();
490
+ for (var key in this.config) {
491
+ if (!isControlKey(key)) {
492
+ var str = encodeValue(this.config[key]);
493
+ if (str !== null) {
494
+ this.store.set(key, str);
495
+ }
496
+ }
424
497
  }
425
- return store.save() !== false;
498
+ return this.store.save() !== false;
426
499
  } catch (e) {
427
500
  this._log('error', '语言包保存失败: ' + e.message);
428
501
  return false;
@@ -431,58 +504,27 @@ Lang.prototype.save = function (file) {
431
504
 
432
505
  /**
433
506
  * 从持久化文件加载语言包
434
- * 扁平键值还原为嵌套字典后合并进当前配置,文件中的词条优先
435
- * @param {string} [file] 持久化文件路径,缺省时用实例的 file 配置
507
+ * 存储中的扁平键值与内存结构一致,直接合并进词条表(文件中的词条优先),数组编码值还原
436
508
  * @returns {boolean} 是否加载成功
437
509
  */
438
- Lang.prototype.load = function (file) {
439
- var store = this._getStore(file);
440
- if (!store || typeof (store.load) !== 'function') {
510
+ Lang.prototype.load = function () {
511
+ if (!this.store) {
441
512
  this._log('error', '未配置持久化文件路径或存储实例,无法加载语言包');
442
513
  return false;
443
514
  }
444
- var ok = false;
445
515
  try {
446
- ok = store.load();
516
+ if (!this.store.load()) {
517
+ return false;
518
+ }
447
519
  } catch (e) {
448
520
  this._log('error', '语言包加载失败: ' + e.message);
449
521
  return false;
450
522
  }
451
- if (!ok) {
452
- return false;
523
+ var list = this.store.list();
524
+ for (var key in list) {
525
+ this.config[key] = decodeValue(list[key]);
453
526
  }
454
- assignDeep(this.config, unflatten(store.list()));
455
527
  return true;
456
528
  };
457
529
 
458
- /**
459
- * 检查语言是否支持
460
- * @param {string} lang - 语言代码
461
- * @returns {boolean} 是否支持
462
- */
463
- Lang.prototype.isSupported = function (lang) {
464
- if (!lang || typeof (lang) !== 'string' || isControlKey(lang)) {
465
- return false;
466
- }
467
- return !!this.config[lang] || !!this.config[this._getBase(lang)];
468
- };
469
-
470
- /**
471
- * 输出日志 - 内部方法,使用 console
472
- * 配置 silent 为 true 时静默(供上层自带日志体系的场景使用,如 mm_tpl)
473
- * @param {string} level - 日志级别
474
- * @param {string} message - 日志消息
475
- * @param {...any} args - 附加参数
476
- * @private
477
- */
478
- Lang.prototype._log = function (level, message, ...args) {
479
- if (this.config.silent) {
480
- return;
481
- }
482
- var fn = console[level] || console.log;
483
- if (typeof (fn) === 'function') {
484
- fn.call(console, message, ...args);
485
- }
486
- };
487
-
488
530
  module.exports = Lang;