@kne/fastify-intl-admin 0.1.0
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/README.md +337 -0
- package/index.js +45 -0
- package/libs/controllers/lang-lib.js +165 -0
- package/libs/controllers/lang-type.js +115 -0
- package/libs/models/lang-lib.js +73 -0
- package/libs/models/lang-type.js +67 -0
- package/libs/schema/lang-lib.js +157 -0
- package/libs/schema/lang-type.js +90 -0
- package/libs/services/lang-lib.js +625 -0
- package/libs/services/lang-type.js +381 -0
- package/libs/utils/http-errors.js +48 -0
- package/libs/utils/i18n-file.js +134 -0
- package/libs/utils/list-query.js +58 -0
- package/package.json +66 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
const omit = require('lodash/omit');
|
|
3
|
+
const { normalizeListQuery } = require('../utils/list-query');
|
|
4
|
+
|
|
5
|
+
module.exports = fp(async (fastify, options) => {
|
|
6
|
+
const { models } = fastify[options.name];
|
|
7
|
+
const { Op } = fastify.sequelize.Sequelize;
|
|
8
|
+
const { ensureHttpErrors } = require('../utils/http-errors');
|
|
9
|
+
const httpErrors = ensureHttpErrors(fastify);
|
|
10
|
+
|
|
11
|
+
const createdUserInclude = () => ({
|
|
12
|
+
model: options.getUserModel(),
|
|
13
|
+
as: 'createdUser'
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const detail = async (authenticatePayload, { id }) => {
|
|
17
|
+
const record = await models.langType.findByPk(id);
|
|
18
|
+
if (!record) {
|
|
19
|
+
throw httpErrors.notFound('语言类型不存在');
|
|
20
|
+
}
|
|
21
|
+
return record;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const clearDefaultFlags = async excludeId => {
|
|
25
|
+
const defaults = await models.langType.findAll({
|
|
26
|
+
where: Object.assign({ isDefault: true }, excludeId != null ? { id: { [Op.ne]: excludeId } } : {})
|
|
27
|
+
});
|
|
28
|
+
for (const item of defaults) {
|
|
29
|
+
await item.update({ isDefault: false });
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const create = async (authenticatePayload, data) => {
|
|
34
|
+
const code = String(data.code || '').trim();
|
|
35
|
+
if (!code) {
|
|
36
|
+
throw httpErrors.badRequest('语言编码不能为空');
|
|
37
|
+
}
|
|
38
|
+
const existing = await models.langType.findOne({ where: { code } });
|
|
39
|
+
if (existing) {
|
|
40
|
+
throw httpErrors.badRequest('语言编码已存在');
|
|
41
|
+
}
|
|
42
|
+
const count = await models.langType.count();
|
|
43
|
+
// 第一条自动设为默认
|
|
44
|
+
const isDefault = count === 0;
|
|
45
|
+
// 新建默认排到最后
|
|
46
|
+
const last = await models.langType.findOne({
|
|
47
|
+
order: [
|
|
48
|
+
['sort', 'DESC'],
|
|
49
|
+
['id', 'DESC']
|
|
50
|
+
],
|
|
51
|
+
attributes: ['sort']
|
|
52
|
+
});
|
|
53
|
+
const sort = last == null ? 0 : (Number(last.sort) || 0) + 1;
|
|
54
|
+
return models.langType.create({
|
|
55
|
+
name: data.name,
|
|
56
|
+
code,
|
|
57
|
+
params: data.params,
|
|
58
|
+
description: data.description,
|
|
59
|
+
status: data.status || 'open',
|
|
60
|
+
sort,
|
|
61
|
+
isDefault,
|
|
62
|
+
userId: authenticatePayload && authenticatePayload.id
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const save = async (authenticatePayload, { id, ...data }) => {
|
|
67
|
+
const record = await detail(authenticatePayload, { id });
|
|
68
|
+
const payload = omit(data, ['code', 'createdAt', 'updatedAt', 'id', 'isDefault', 'sort']);
|
|
69
|
+
await record.update(payload);
|
|
70
|
+
return record;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const remove = async (authenticatePayload, { id }) => {
|
|
74
|
+
const record = await detail(authenticatePayload, { id });
|
|
75
|
+
const relatedCount = await models.langLib.count({
|
|
76
|
+
where: { locale: record.code }
|
|
77
|
+
});
|
|
78
|
+
if (relatedCount > 0) {
|
|
79
|
+
throw httpErrors.badRequest('该语言类型仍有关联词条,无法删除');
|
|
80
|
+
}
|
|
81
|
+
const wasDefault = !!record.isDefault;
|
|
82
|
+
await record.destroy();
|
|
83
|
+
if (wasDefault) {
|
|
84
|
+
const next = await models.langType.findOne({
|
|
85
|
+
order: [['createdAt', 'ASC']]
|
|
86
|
+
});
|
|
87
|
+
if (next) {
|
|
88
|
+
await next.update({ isDefault: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const setStatus = async (authenticatePayload, { id, status }) => {
|
|
94
|
+
const record = await detail(authenticatePayload, { id });
|
|
95
|
+
await record.update({ status });
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const setDefault = async (authenticatePayload, { id }) => {
|
|
99
|
+
const record = await detail(authenticatePayload, { id });
|
|
100
|
+
if (record.isDefault) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await clearDefaultFlags(record.id);
|
|
104
|
+
await record.update({ isDefault: true });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const listOrder = [
|
|
108
|
+
['sort', 'ASC'],
|
|
109
|
+
['createdAt', 'ASC'],
|
|
110
|
+
['id', 'ASC']
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/** direction: 'up' | 'down',与相邻项交换 sort */
|
|
114
|
+
const move = async (authenticatePayload, { id, direction }) => {
|
|
115
|
+
const dir = String(direction || '').toLowerCase();
|
|
116
|
+
if (dir !== 'up' && dir !== 'down') {
|
|
117
|
+
throw httpErrors.badRequest('direction 必须为 up 或 down');
|
|
118
|
+
}
|
|
119
|
+
const record = await detail(authenticatePayload, { id });
|
|
120
|
+
const rows = await models.langType.findAll({
|
|
121
|
+
order: listOrder,
|
|
122
|
+
attributes: ['id', 'sort']
|
|
123
|
+
});
|
|
124
|
+
const index = rows.findIndex(item => String(item.id) === String(record.id));
|
|
125
|
+
if (index < 0) {
|
|
126
|
+
throw httpErrors.notFound('语言类型不存在');
|
|
127
|
+
}
|
|
128
|
+
const targetIndex = dir === 'up' ? index - 1 : index + 1;
|
|
129
|
+
if (targetIndex < 0 || targetIndex >= rows.length) {
|
|
130
|
+
return { moved: false };
|
|
131
|
+
}
|
|
132
|
+
const neighbor = rows[targetIndex];
|
|
133
|
+
const currentSort = Number(record.sort) || 0;
|
|
134
|
+
const neighborSort = Number(neighbor.sort) || 0;
|
|
135
|
+
if (currentSort === neighborSort) {
|
|
136
|
+
await record.update({ sort: targetIndex });
|
|
137
|
+
await neighbor.update({ sort: index });
|
|
138
|
+
} else {
|
|
139
|
+
await record.update({ sort: neighborSort });
|
|
140
|
+
await neighbor.update({ sort: currentSort });
|
|
141
|
+
}
|
|
142
|
+
return { moved: true };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const getDefaultLocaleCode = async () => {
|
|
146
|
+
const record = await models.langType.findOne({ where: { isDefault: true } });
|
|
147
|
+
return record ? record.code : '';
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const translateRemaining = async (authenticatePayload, { id, namespace, limit }) => {
|
|
151
|
+
const targetLang = await detail(authenticatePayload, { id });
|
|
152
|
+
if (targetLang.status !== 'open') {
|
|
153
|
+
throw httpErrors.badRequest('目标语言未启用,无法补译');
|
|
154
|
+
}
|
|
155
|
+
if (targetLang.isDefault) {
|
|
156
|
+
throw httpErrors.badRequest('默认语言无需补译');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const defaultLocale = await getDefaultLocaleCode();
|
|
160
|
+
if (!defaultLocale) {
|
|
161
|
+
throw httpErrors.badRequest('未设置默认语言,无法补译');
|
|
162
|
+
}
|
|
163
|
+
if (targetLang.code === defaultLocale) {
|
|
164
|
+
throw httpErrors.badRequest('默认语言无需补译');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scopedNamespace = namespace != null ? String(namespace).trim() : '';
|
|
168
|
+
const defaultWhere = { locale: defaultLocale };
|
|
169
|
+
const targetWhere = { locale: targetLang.code };
|
|
170
|
+
if (scopedNamespace) {
|
|
171
|
+
defaultWhere.namespace = scopedNamespace;
|
|
172
|
+
targetWhere.namespace = scopedNamespace;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const defaultEntries = await models.langLib.findAll({
|
|
176
|
+
where: defaultWhere
|
|
177
|
+
});
|
|
178
|
+
if (defaultEntries.length === 0) {
|
|
179
|
+
throw httpErrors.badRequest(scopedNamespace ? `命名空间 ${scopedNamespace} 下默认语言暂无词条,无法补译` : '默认语言暂无词条,无法补译');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const existingTarget = await models.langLib.findAll({
|
|
183
|
+
where: targetWhere,
|
|
184
|
+
attributes: ['namespace', 'code']
|
|
185
|
+
});
|
|
186
|
+
const existingKeys = new Set(existingTarget.map(item => `${item.namespace}::${item.code}`));
|
|
187
|
+
const missingEntries = defaultEntries.filter(item => !existingKeys.has(`${item.namespace}::${item.code}`));
|
|
188
|
+
if (missingEntries.length === 0) {
|
|
189
|
+
throw httpErrors.badRequest(scopedNamespace ? `命名空间 ${scopedNamespace} 下该语言词条已全部翻译完成` : '该语言词条已全部翻译完成');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const userId = authenticatePayload && authenticatePayload.id;
|
|
193
|
+
const GLOBAL_NS = 'global';
|
|
194
|
+
const batchLimit = limit != null && Number.isFinite(Number(limit)) && Number(limit) > 0 ? Math.floor(Number(limit)) : null;
|
|
195
|
+
|
|
196
|
+
// 非 global 命名空间:优先从 global 同 code + 目标语言拉取
|
|
197
|
+
const needGlobalLookup = missingEntries.some(item => item.namespace !== GLOBAL_NS);
|
|
198
|
+
const globalTargetByCode = new Map();
|
|
199
|
+
if (needGlobalLookup) {
|
|
200
|
+
const codes = [...new Set(missingEntries.filter(item => item.namespace !== GLOBAL_NS).map(item => item.code))];
|
|
201
|
+
if (codes.length > 0) {
|
|
202
|
+
const globalTargets = await models.langLib.findAll({
|
|
203
|
+
where: {
|
|
204
|
+
namespace: GLOBAL_NS,
|
|
205
|
+
locale: targetLang.code,
|
|
206
|
+
code: { [Op.in]: codes }
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
globalTargets.forEach(item => {
|
|
210
|
+
const target = item.target != null ? String(item.target) : '';
|
|
211
|
+
if (target.trim()) {
|
|
212
|
+
globalTargetByCode.set(item.code, target);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const processable = [];
|
|
219
|
+
for (const source of missingEntries) {
|
|
220
|
+
const sourceTarget = source.target != null ? String(source.target) : '';
|
|
221
|
+
if (!sourceTarget.trim()) {
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
processable.push(source);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (processable.length === 0) {
|
|
228
|
+
throw httpErrors.badRequest('没有可补译的默认语言源文案');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const batchSources = batchLimit ? processable.slice(0, batchLimit) : processable;
|
|
232
|
+
const remainingCount = processable.length - batchSources.length;
|
|
233
|
+
|
|
234
|
+
const copiedRows = [];
|
|
235
|
+
const llmSources = [];
|
|
236
|
+
const texts = {};
|
|
237
|
+
|
|
238
|
+
for (const source of batchSources) {
|
|
239
|
+
const sourceTarget = String(source.target);
|
|
240
|
+
const key = `${source.namespace}::${source.code}`;
|
|
241
|
+
|
|
242
|
+
if (source.namespace !== GLOBAL_NS && globalTargetByCode.has(source.code)) {
|
|
243
|
+
copiedRows.push({
|
|
244
|
+
namespace: source.namespace,
|
|
245
|
+
code: source.code,
|
|
246
|
+
locale: targetLang.code,
|
|
247
|
+
target: globalTargetByCode.get(source.code),
|
|
248
|
+
reviewStatus: 'approved',
|
|
249
|
+
reviewRemark: null,
|
|
250
|
+
status: 'open',
|
|
251
|
+
userId
|
|
252
|
+
});
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
texts[key] = sourceTarget;
|
|
257
|
+
llmSources.push({
|
|
258
|
+
key,
|
|
259
|
+
namespace: source.namespace,
|
|
260
|
+
code: source.code
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const llmRows = [];
|
|
265
|
+
if (llmSources.length > 0) {
|
|
266
|
+
if (typeof options.generateTargets !== 'function') {
|
|
267
|
+
throw httpErrors.badRequest('未配置 generateTargets,无法补译');
|
|
268
|
+
}
|
|
269
|
+
const generated =
|
|
270
|
+
(await options.generateTargets({
|
|
271
|
+
locales: [targetLang.code],
|
|
272
|
+
defaultLocale,
|
|
273
|
+
texts
|
|
274
|
+
})) || {};
|
|
275
|
+
const translatedMap = generated[targetLang.code] && typeof generated[targetLang.code] === 'object' ? generated[targetLang.code] : {};
|
|
276
|
+
|
|
277
|
+
llmSources.forEach(item => {
|
|
278
|
+
llmRows.push({
|
|
279
|
+
namespace: item.namespace,
|
|
280
|
+
code: item.code,
|
|
281
|
+
locale: targetLang.code,
|
|
282
|
+
target: translatedMap[item.key] != null ? String(translatedMap[item.key]) : '',
|
|
283
|
+
reviewStatus: 'pending',
|
|
284
|
+
reviewRemark: null,
|
|
285
|
+
status: 'open',
|
|
286
|
+
userId
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const rows = copiedRows.concat(llmRows);
|
|
292
|
+
|
|
293
|
+
let created;
|
|
294
|
+
if (typeof models.langLib.bulkCreate === 'function') {
|
|
295
|
+
created = await models.langLib.bulkCreate(rows);
|
|
296
|
+
} else {
|
|
297
|
+
created = [];
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
created.push(await models.langLib.create(row));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
createdCount: created.length,
|
|
305
|
+
copiedCount: copiedRows.length,
|
|
306
|
+
generatedCount: llmRows.length,
|
|
307
|
+
remainingCount,
|
|
308
|
+
remainingSkipped: missingEntries.length - processable.length,
|
|
309
|
+
totalProcessable: processable.length
|
|
310
|
+
};
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const list = async (authenticatePayload, query) => {
|
|
314
|
+
const { filter, perPage, currentPage } = normalizeListQuery(query);
|
|
315
|
+
const where = {};
|
|
316
|
+
if (filter.status) {
|
|
317
|
+
where.status = filter.status;
|
|
318
|
+
}
|
|
319
|
+
if (filter.isDefault === true || filter.isDefault === 'true') {
|
|
320
|
+
where.isDefault = true;
|
|
321
|
+
}
|
|
322
|
+
if (filter.isDefault === false || filter.isDefault === 'false') {
|
|
323
|
+
where.isDefault = false;
|
|
324
|
+
}
|
|
325
|
+
if (filter.keyword) {
|
|
326
|
+
const keyword = `%${filter.keyword}%`;
|
|
327
|
+
where[Op.or] = [{ name: { [Op.iLike]: keyword } }, { code: { [Op.iLike]: keyword } }, { description: { [Op.iLike]: keyword } }];
|
|
328
|
+
}
|
|
329
|
+
const { rows, count } = await models.langType.findAndCountAll({
|
|
330
|
+
where,
|
|
331
|
+
include: [createdUserInclude()],
|
|
332
|
+
offset: perPage * (currentPage - 1),
|
|
333
|
+
limit: perPage,
|
|
334
|
+
order: listOrder
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const localeWhere = {};
|
|
338
|
+
if (filter.namespace) {
|
|
339
|
+
localeWhere.namespace = filter.namespace;
|
|
340
|
+
}
|
|
341
|
+
const localeRows = await models.langLib.findAll({
|
|
342
|
+
where: localeWhere,
|
|
343
|
+
attributes: ['locale']
|
|
344
|
+
});
|
|
345
|
+
const entryCountByLocale = {};
|
|
346
|
+
for (const item of localeRows) {
|
|
347
|
+
const locale = item.locale;
|
|
348
|
+
entryCountByLocale[locale] = (entryCountByLocale[locale] || 0) + 1;
|
|
349
|
+
}
|
|
350
|
+
const defaultLang = rows.find(item => item.isDefault) || (await models.langType.findOne({ where: { isDefault: true } }));
|
|
351
|
+
const defaultLocale = defaultLang ? defaultLang.code : '';
|
|
352
|
+
const defaultEntryCount = defaultLocale ? entryCountByLocale[defaultLocale] || 0 : 0;
|
|
353
|
+
|
|
354
|
+
const pageData = rows.map(row => {
|
|
355
|
+
const json = typeof row.toJSON === 'function' ? row.toJSON() : { ...row };
|
|
356
|
+
return Object.assign(json, {
|
|
357
|
+
entryCount: entryCountByLocale[row.code] || 0,
|
|
358
|
+
defaultEntryCount
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
pageData,
|
|
364
|
+
totalCount: count
|
|
365
|
+
};
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
Object.assign(fastify[options.name].services, {
|
|
369
|
+
langType: {
|
|
370
|
+
create,
|
|
371
|
+
detail,
|
|
372
|
+
save,
|
|
373
|
+
remove,
|
|
374
|
+
setStatus,
|
|
375
|
+
setDefault,
|
|
376
|
+
move,
|
|
377
|
+
translateRemaining,
|
|
378
|
+
list
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构造带 statusCode 的 HTTP 错误(不依赖 @fastify/sensible)
|
|
3
|
+
* @param {number} statusCode
|
|
4
|
+
* @param {string} message
|
|
5
|
+
* @returns {Error}
|
|
6
|
+
*/
|
|
7
|
+
const createHttpError = (statusCode, message) => {
|
|
8
|
+
const error = new Error(message);
|
|
9
|
+
error.statusCode = statusCode;
|
|
10
|
+
return error;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @returns {{ badRequest: Function, notFound: Function, internalServerError: Function }}
|
|
15
|
+
*/
|
|
16
|
+
const createHttpErrors = () => ({
|
|
17
|
+
badRequest: message => createHttpError(400, message),
|
|
18
|
+
notFound: message => createHttpError(404, message),
|
|
19
|
+
internalServerError: message => createHttpError(500, message)
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 确保 fastify 上有 httpErrors(优先沿用宿主 @fastify/sensible)
|
|
24
|
+
* @param {import('fastify').FastifyInstance} fastify
|
|
25
|
+
*/
|
|
26
|
+
const ensureHttpErrors = fastify => {
|
|
27
|
+
if (fastify.hasDecorator && fastify.hasDecorator('httpErrors') && fastify.httpErrors) {
|
|
28
|
+
return fastify.httpErrors;
|
|
29
|
+
}
|
|
30
|
+
if (fastify.httpErrors) {
|
|
31
|
+
return fastify.httpErrors;
|
|
32
|
+
}
|
|
33
|
+
const httpErrors = createHttpErrors();
|
|
34
|
+
if (typeof fastify.decorate === 'function') {
|
|
35
|
+
try {
|
|
36
|
+
fastify.decorate('httpErrors', httpErrors);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
// 已存在则忽略
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return fastify.httpErrors || httpErrors;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
module.exports = {
|
|
45
|
+
createHttpError,
|
|
46
|
+
createHttpErrors,
|
|
47
|
+
ensureHttpErrors
|
|
48
|
+
};
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
const DEFAULT_NAMESPACE = 'global';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 解析 .i18n 文件名。
|
|
5
|
+
* - `namespace.locale.i18n` → { namespace, locale }
|
|
6
|
+
* - `locale.i18n` → { namespace: 'global', locale }
|
|
7
|
+
* @param {string} filename
|
|
8
|
+
* @returns {{ namespace: string, locale: string }}
|
|
9
|
+
*/
|
|
10
|
+
const parseFilename = filename => {
|
|
11
|
+
const name = String(filename || '')
|
|
12
|
+
.trim()
|
|
13
|
+
.split(/[/\\]/)
|
|
14
|
+
.pop();
|
|
15
|
+
if (!name) {
|
|
16
|
+
throw new Error('文件名不能为空');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const withNs = name.match(/^(.+)\.([^.]+)\.i18n$/i);
|
|
20
|
+
if (withNs) {
|
|
21
|
+
const namespace = withNs[1].trim();
|
|
22
|
+
const locale = withNs[2].trim();
|
|
23
|
+
if (!namespace || !locale) {
|
|
24
|
+
throw new Error(`无效的 i18n 文件名: ${name}`);
|
|
25
|
+
}
|
|
26
|
+
return { namespace, locale };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const localeOnly = name.match(/^([^.]+)\.i18n$/i);
|
|
30
|
+
if (localeOnly) {
|
|
31
|
+
const locale = localeOnly[1].trim();
|
|
32
|
+
if (!locale) {
|
|
33
|
+
throw new Error(`无效的 i18n 文件名: ${name}`);
|
|
34
|
+
}
|
|
35
|
+
return { namespace: DEFAULT_NAMESPACE, locale };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
throw new Error(`无效的 i18n 文件名: ${name}`);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 构建导出文件名:`{namespace}.{locale}.i18n`
|
|
43
|
+
* @param {string} namespace
|
|
44
|
+
* @param {string} locale
|
|
45
|
+
* @returns {string}
|
|
46
|
+
*/
|
|
47
|
+
const buildFilename = (namespace, locale) => `${namespace}.${locale}.i18n`;
|
|
48
|
+
|
|
49
|
+
const escapeTarget = target =>
|
|
50
|
+
String(target == null ? '' : target)
|
|
51
|
+
.replace(/\\/g, '\\\\')
|
|
52
|
+
.replace(/"/g, '\\"');
|
|
53
|
+
|
|
54
|
+
const unescapeTarget = target => String(target == null ? '' : target).replace(/\\(.)/g, (_, ch) => ch);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 解析正文:每行 `code="target"`;空行忽略;目标值支持 `\"` / `\\` 转义。
|
|
58
|
+
* @param {string} content
|
|
59
|
+
* @returns {Array<{ code: string, target: string }>}
|
|
60
|
+
*/
|
|
61
|
+
const parseContent = content => {
|
|
62
|
+
const text = String(content == null ? '' : content);
|
|
63
|
+
const entries = [];
|
|
64
|
+
const linePattern = /^(.+?)="((?:\\.|[^"\\])*)"$/;
|
|
65
|
+
|
|
66
|
+
text.split(/\r?\n/).forEach(line => {
|
|
67
|
+
const raw = line.trim();
|
|
68
|
+
if (!raw) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const matched = raw.match(linePattern);
|
|
72
|
+
if (!matched) {
|
|
73
|
+
throw new Error(`无效的词条行: ${raw}`);
|
|
74
|
+
}
|
|
75
|
+
const code = matched[1].trim();
|
|
76
|
+
if (!code) {
|
|
77
|
+
throw new Error(`无效的词条行: ${raw}`);
|
|
78
|
+
}
|
|
79
|
+
entries.push({
|
|
80
|
+
code,
|
|
81
|
+
target: unescapeTarget(matched[2])
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
return entries;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 将词条列表序列化为正文:`code="target"`
|
|
89
|
+
* @param {Array<{ code: string, target?: string }>} entries
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
const buildContent = (entries = []) => entries.map(item => `${item.code}="${escapeTarget(item.target)}"`).join('\n');
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 按 namespace+locale 分组为导出文件列表。
|
|
96
|
+
* @param {Array<{ namespace: string, locale: string, code: string, target?: string }>} rows
|
|
97
|
+
* @returns {Array<{ filename: string, content: string }>}
|
|
98
|
+
*/
|
|
99
|
+
const groupRowsToFiles = (rows = []) => {
|
|
100
|
+
const groups = new Map();
|
|
101
|
+
rows.forEach(row => {
|
|
102
|
+
const namespace = row.namespace;
|
|
103
|
+
const locale = row.locale;
|
|
104
|
+
if (!namespace || !locale) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const key = `${namespace}\0${locale}`;
|
|
108
|
+
if (!groups.has(key)) {
|
|
109
|
+
groups.set(key, {
|
|
110
|
+
namespace,
|
|
111
|
+
locale,
|
|
112
|
+
entries: []
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
groups.get(key).entries.push({
|
|
116
|
+
code: row.code,
|
|
117
|
+
target: row.target == null ? '' : row.target
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return Array.from(groups.values()).map(({ namespace, locale, entries }) => ({
|
|
122
|
+
filename: buildFilename(namespace, locale),
|
|
123
|
+
content: buildContent(entries)
|
|
124
|
+
}));
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
DEFAULT_NAMESPACE,
|
|
129
|
+
parseFilename,
|
|
130
|
+
buildFilename,
|
|
131
|
+
parseContent,
|
|
132
|
+
buildContent,
|
|
133
|
+
groupRowsToFiles
|
|
134
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const FLAT_FILTER_KEYS = ['keyword', 'locale', 'reviewStatus', 'status', 'code', 'namespace', 'isDefault'];
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 归一化列表查询:兼容顶层 flat 字段与 filter 对象。
|
|
5
|
+
* @param {object} query
|
|
6
|
+
* @returns {{ filter: object, perPage: number, currentPage: number }}
|
|
7
|
+
*/
|
|
8
|
+
const normalizeListQuery = (query = {}) => {
|
|
9
|
+
const filter = Object.assign({}, query.filter || {});
|
|
10
|
+
FLAT_FILTER_KEYS.forEach(key => {
|
|
11
|
+
if (query[key] !== undefined && query[key] !== null && query[key] !== '' && filter[key] === undefined) {
|
|
12
|
+
filter[key] = query[key];
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
const perPage = Number(query.perPage) > 0 ? Number(query.perPage) : 20;
|
|
16
|
+
const currentPage = Number(query.currentPage) > 0 ? Number(query.currentPage) : 1;
|
|
17
|
+
return { filter, perPage, currentPage };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 将 body 中的 id / ids 归一为 id 数组。
|
|
22
|
+
* @param {{ id?: string|number, ids?: Array<string|number> }} body
|
|
23
|
+
* @returns {Array<string|number>}
|
|
24
|
+
*/
|
|
25
|
+
const normalizeIds = ({ id, ids } = {}) => {
|
|
26
|
+
if (Array.isArray(ids) && ids.length > 0) {
|
|
27
|
+
return ids;
|
|
28
|
+
}
|
|
29
|
+
if (id !== undefined && id !== null && id !== '') {
|
|
30
|
+
return [id];
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 从可能为对象的 locale 字段取出语言 code。
|
|
37
|
+
* @param {string|{id?: string, value?: string, name?: string}|null} locale
|
|
38
|
+
* @returns {string|null}
|
|
39
|
+
*/
|
|
40
|
+
const normalizeLocaleValue = locale => {
|
|
41
|
+
if (locale == null) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (typeof locale === 'string') {
|
|
45
|
+
return locale;
|
|
46
|
+
}
|
|
47
|
+
if (typeof locale === 'object') {
|
|
48
|
+
return locale.value || locale.id || locale.name || null;
|
|
49
|
+
}
|
|
50
|
+
return String(locale);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
module.exports = {
|
|
54
|
+
normalizeListQuery,
|
|
55
|
+
normalizeIds,
|
|
56
|
+
normalizeLocaleValue,
|
|
57
|
+
FLAT_FILTER_KEYS
|
|
58
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kne/fastify-intl-admin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fastify 业务插件:国际化语言类型与语言库管理(lang-type / lang-lib)",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"init": "husky",
|
|
8
|
+
"build:md": "npx @kne/md-doc",
|
|
9
|
+
"start:md": "npx @kne/md-doc --watch",
|
|
10
|
+
"prettier": "prettier --config .prettierrc --write '{libs/**/*,index}.{js,jsx,ts,tsx,json,css,scss}'",
|
|
11
|
+
"lint-staged": "npx lint-staged",
|
|
12
|
+
"test": "mocha test/**/*.test.js",
|
|
13
|
+
"test:coverage": "nyc --reporter=html --reporter=text mocha test/**/*.test.js"
|
|
14
|
+
},
|
|
15
|
+
"lint-staged": {
|
|
16
|
+
"{libs/**/*,index}.{js,jsx,ts,tsx,json,css,scss}": [
|
|
17
|
+
"prettier --config .prettierrc --write",
|
|
18
|
+
"git add"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"index.js",
|
|
23
|
+
"libs"
|
|
24
|
+
],
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/kne-union/fastify-intl-admin.git"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"fastify",
|
|
31
|
+
"i18n",
|
|
32
|
+
"intl",
|
|
33
|
+
"lang",
|
|
34
|
+
"translation",
|
|
35
|
+
"admin"
|
|
36
|
+
],
|
|
37
|
+
"author": "linzp",
|
|
38
|
+
"license": "ISC",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/kne-union/fastify-intl-admin/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/kne-union/fastify-intl-admin#readme",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"lodash": "^4.17.21"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@kne/fastify-namespace": "*",
|
|
48
|
+
"@kne/fastify-sequelize": "*",
|
|
49
|
+
"fastify-plugin": ">=5"
|
|
50
|
+
},
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@fastify/env": "^5.0.2",
|
|
53
|
+
"@kne/fastify-namespace": "*",
|
|
54
|
+
"@kne/fastify-sequelize": "^3.0.6",
|
|
55
|
+
"chai": "^6.2.2",
|
|
56
|
+
"fastify": "^5.4.0",
|
|
57
|
+
"fastify-plugin": "^5.0.1",
|
|
58
|
+
"husky": "^9.0.11",
|
|
59
|
+
"mocha": "^11.7.5",
|
|
60
|
+
"nodemon": "^3.1.3",
|
|
61
|
+
"nyc": "^17.1.0",
|
|
62
|
+
"prettier": "^3.2.5",
|
|
63
|
+
"sinon": "^21.0.0",
|
|
64
|
+
"sqlite3": "^5.1.7"
|
|
65
|
+
}
|
|
66
|
+
}
|