@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,625 @@
|
|
|
1
|
+
const fp = require('fastify-plugin');
|
|
2
|
+
const omit = require('lodash/omit');
|
|
3
|
+
const { normalizeListQuery, normalizeIds } = require('../utils/list-query');
|
|
4
|
+
const { parseFilename, parseContent, groupRowsToFiles } = require('../utils/i18n-file');
|
|
5
|
+
|
|
6
|
+
module.exports = fp(async (fastify, options) => {
|
|
7
|
+
const { models } = fastify[options.name];
|
|
8
|
+
const { Op } = fastify.sequelize.Sequelize;
|
|
9
|
+
const { ensureHttpErrors } = require('../utils/http-errors');
|
|
10
|
+
const httpErrors = ensureHttpErrors(fastify);
|
|
11
|
+
|
|
12
|
+
const createdUserInclude = () => ({
|
|
13
|
+
model: options.getUserModel(),
|
|
14
|
+
as: 'createdUser'
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const detail = async (authenticatePayload, { id }) => {
|
|
18
|
+
const record = await models.langLib.findByPk(id);
|
|
19
|
+
if (!record) {
|
|
20
|
+
throw httpErrors.notFound('语言词条不存在');
|
|
21
|
+
}
|
|
22
|
+
return record;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const assertOpenLocales = async locales => {
|
|
26
|
+
const langTypes = await models.langType.findAll({
|
|
27
|
+
where: {
|
|
28
|
+
code: { [Op.in]: locales },
|
|
29
|
+
status: 'open'
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
const openCodes = new Set(langTypes.map(item => item.code));
|
|
33
|
+
const invalid = locales.filter(code => !openCodes.has(code));
|
|
34
|
+
if (invalid.length > 0) {
|
|
35
|
+
throw httpErrors.badRequest(`以下语言未启用或不存在: ${invalid.join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getDefaultLocaleCode = async () => {
|
|
40
|
+
const record = await models.langType.findOne({ where: { isDefault: true } });
|
|
41
|
+
return record ? record.code : '';
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const attachSourceTargets = async rows => {
|
|
45
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
46
|
+
if (list.length === 0) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const defaultLocale = await getDefaultLocaleCode();
|
|
50
|
+
const toJson = row => (typeof row.toJSON === 'function' ? row.toJSON() : Object.assign({}, row));
|
|
51
|
+
|
|
52
|
+
if (!defaultLocale) {
|
|
53
|
+
return list.map(row => Object.assign(toJson(row), { sourceTarget: '', defaultLocale: '' }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const keyMap = new Map();
|
|
57
|
+
list.forEach(row => {
|
|
58
|
+
const namespace = row.namespace;
|
|
59
|
+
const code = row.code;
|
|
60
|
+
if (!namespace || !code) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const key = `${namespace}::${code}`;
|
|
64
|
+
if (!keyMap.has(key)) {
|
|
65
|
+
keyMap.set(key, { namespace, code });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const sourceMap = new Map();
|
|
70
|
+
const pairs = Array.from(keyMap.values());
|
|
71
|
+
if (pairs.length > 0) {
|
|
72
|
+
const namespaces = [...new Set(pairs.map(item => item.namespace))];
|
|
73
|
+
const codes = [...new Set(pairs.map(item => item.code))];
|
|
74
|
+
// 用 in 查询再按 namespace+code 精确匹配,避免 Op.or 复合条件在部分环境下不生效
|
|
75
|
+
const sources = await models.langLib.findAll({
|
|
76
|
+
where: {
|
|
77
|
+
locale: defaultLocale,
|
|
78
|
+
namespace: { [Op.in]: namespaces },
|
|
79
|
+
code: { [Op.in]: codes }
|
|
80
|
+
},
|
|
81
|
+
attributes: ['namespace', 'code', 'target']
|
|
82
|
+
});
|
|
83
|
+
sources.forEach(item => {
|
|
84
|
+
const key = `${item.namespace}::${item.code}`;
|
|
85
|
+
if (keyMap.has(key)) {
|
|
86
|
+
sourceMap.set(key, item.target != null ? String(item.target) : '');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return list.map(row => {
|
|
92
|
+
const json = toJson(row);
|
|
93
|
+
const key = `${json.namespace}::${json.code}`;
|
|
94
|
+
const sourceTarget = json.locale === defaultLocale ? (json.target != null ? String(json.target) : '') : sourceMap.get(key) || '';
|
|
95
|
+
return Object.assign(json, { sourceTarget, defaultLocale });
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const list = async (authenticatePayload, query) => {
|
|
100
|
+
const { filter, perPage, currentPage } = normalizeListQuery(query);
|
|
101
|
+
const where = {};
|
|
102
|
+
if (filter.locale) {
|
|
103
|
+
where.locale = filter.locale;
|
|
104
|
+
}
|
|
105
|
+
if (filter.reviewStatus) {
|
|
106
|
+
where.reviewStatus = filter.reviewStatus;
|
|
107
|
+
}
|
|
108
|
+
if (filter.status) {
|
|
109
|
+
where.status = filter.status;
|
|
110
|
+
}
|
|
111
|
+
if (filter.namespace) {
|
|
112
|
+
where.namespace = filter.namespace;
|
|
113
|
+
}
|
|
114
|
+
if (filter.code) {
|
|
115
|
+
where.code = filter.code;
|
|
116
|
+
}
|
|
117
|
+
if (filter.keyword) {
|
|
118
|
+
const keyword = `%${filter.keyword}%`;
|
|
119
|
+
where[Op.or] = [{ namespace: { [Op.iLike]: keyword } }, { code: { [Op.iLike]: keyword } }, { target: { [Op.iLike]: keyword } }];
|
|
120
|
+
}
|
|
121
|
+
const { rows, count } = await models.langLib.findAndCountAll({
|
|
122
|
+
where,
|
|
123
|
+
include: [createdUserInclude()],
|
|
124
|
+
offset: perPage * (currentPage - 1),
|
|
125
|
+
limit: perPage,
|
|
126
|
+
order: [
|
|
127
|
+
['namespace', 'ASC'],
|
|
128
|
+
['code', 'ASC'],
|
|
129
|
+
['locale', 'ASC']
|
|
130
|
+
]
|
|
131
|
+
});
|
|
132
|
+
return {
|
|
133
|
+
pageData: await attachSourceTargets(rows),
|
|
134
|
+
totalCount: count
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const namespaces = async () => {
|
|
139
|
+
const rows = await models.langLib.findAll({
|
|
140
|
+
attributes: ['namespace'],
|
|
141
|
+
order: [['namespace', 'ASC']]
|
|
142
|
+
});
|
|
143
|
+
const seen = new Set();
|
|
144
|
+
const pageData = [];
|
|
145
|
+
for (const item of rows) {
|
|
146
|
+
const namespace = item.namespace;
|
|
147
|
+
if (!namespace || seen.has(namespace)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
seen.add(namespace);
|
|
151
|
+
pageData.push({ namespace });
|
|
152
|
+
}
|
|
153
|
+
pageData.sort((a, b) => {
|
|
154
|
+
if (a.namespace === 'global') {
|
|
155
|
+
return -1;
|
|
156
|
+
}
|
|
157
|
+
if (b.namespace === 'global') {
|
|
158
|
+
return 1;
|
|
159
|
+
}
|
|
160
|
+
return String(a.namespace).localeCompare(String(b.namespace));
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
pageData,
|
|
164
|
+
totalCount: pageData.length
|
|
165
|
+
};
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const addPackage = async (authenticatePayload, body) => {
|
|
169
|
+
const namespace = String(body.namespace || '').trim();
|
|
170
|
+
const code = String(body.code || '').trim();
|
|
171
|
+
const locales = Array.isArray(body.locales) ? body.locales.map(item => String(item).trim()).filter(Boolean) : [];
|
|
172
|
+
const aiGenerate = !!body.aiGenerate;
|
|
173
|
+
const targets = body.targets || {};
|
|
174
|
+
const sourceTarget = body.sourceTarget != null ? String(body.sourceTarget) : '';
|
|
175
|
+
const defaultLocale = (body.defaultLocale ? String(body.defaultLocale).trim() : '') || (await getDefaultLocaleCode());
|
|
176
|
+
|
|
177
|
+
if (!namespace) {
|
|
178
|
+
throw httpErrors.badRequest('命名空间不能为空');
|
|
179
|
+
}
|
|
180
|
+
if (!code) {
|
|
181
|
+
throw httpErrors.badRequest('词条编码不能为空');
|
|
182
|
+
}
|
|
183
|
+
if (locales.length === 0) {
|
|
184
|
+
throw httpErrors.badRequest('至少选择一种语言');
|
|
185
|
+
}
|
|
186
|
+
if (aiGenerate && !sourceTarget.trim()) {
|
|
187
|
+
throw httpErrors.badRequest('AI 生成时默认值不能为空');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await assertOpenLocales(locales);
|
|
191
|
+
|
|
192
|
+
const existing = await models.langLib.findAll({
|
|
193
|
+
where: {
|
|
194
|
+
namespace,
|
|
195
|
+
code,
|
|
196
|
+
locale: { [Op.in]: locales }
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
if (existing.length > 0) {
|
|
200
|
+
const existsLocales = existing.map(item => item.locale).join(', ');
|
|
201
|
+
throw httpErrors.badRequest(`以下语言词条已存在: ${existsLocales}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let resolvedTargets = Object.assign({}, targets);
|
|
205
|
+
if (aiGenerate) {
|
|
206
|
+
const aiLocales = defaultLocale ? locales.filter(item => item !== defaultLocale) : locales;
|
|
207
|
+
// 仅默认语言:直接写入,不走 AI
|
|
208
|
+
if (aiLocales.length === 0) {
|
|
209
|
+
resolvedTargets = {};
|
|
210
|
+
locales.forEach(locale => {
|
|
211
|
+
resolvedTargets[locale] = sourceTarget;
|
|
212
|
+
});
|
|
213
|
+
} else {
|
|
214
|
+
if (typeof options.generateTargets !== 'function') {
|
|
215
|
+
throw httpErrors.badRequest('未配置 generateTargets,无法使用 AI 生成');
|
|
216
|
+
}
|
|
217
|
+
const generated =
|
|
218
|
+
(await options.generateTargets({
|
|
219
|
+
namespace,
|
|
220
|
+
code,
|
|
221
|
+
locales: aiLocales,
|
|
222
|
+
sourceTarget,
|
|
223
|
+
defaultLocale
|
|
224
|
+
})) || {};
|
|
225
|
+
resolvedTargets = Object.assign({}, generated);
|
|
226
|
+
if (defaultLocale && locales.includes(defaultLocale)) {
|
|
227
|
+
resolvedTargets[defaultLocale] = sourceTarget;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const userId = authenticatePayload && authenticatePayload.id;
|
|
233
|
+
const rows = locales.map(locale => {
|
|
234
|
+
// 默认语言词条(含 AI 源文)直接通过,无需审核
|
|
235
|
+
const isDefaultLocale = !!(defaultLocale && locale === defaultLocale);
|
|
236
|
+
const onlyDefaultLocales = aiGenerate && defaultLocale && locales.every(item => item === defaultLocale);
|
|
237
|
+
return {
|
|
238
|
+
namespace,
|
|
239
|
+
code,
|
|
240
|
+
locale,
|
|
241
|
+
target: resolvedTargets[locale] != null ? String(resolvedTargets[locale]) : '',
|
|
242
|
+
reviewStatus: aiGenerate && !isDefaultLocale && !onlyDefaultLocales ? 'pending' : 'approved',
|
|
243
|
+
reviewRemark: null,
|
|
244
|
+
status: 'open',
|
|
245
|
+
userId
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
if (typeof models.langLib.bulkCreate === 'function') {
|
|
250
|
+
return models.langLib.bulkCreate(rows);
|
|
251
|
+
}
|
|
252
|
+
const created = [];
|
|
253
|
+
for (const row of rows) {
|
|
254
|
+
created.push(await models.langLib.create(row));
|
|
255
|
+
}
|
|
256
|
+
return created;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const save = async (authenticatePayload, { id, ...data }) => {
|
|
260
|
+
const record = await detail(authenticatePayload, { id });
|
|
261
|
+
const payload = omit(data, ['namespace', 'code', 'locale', 'createdAt', 'updatedAt', 'id', 'reviewStatus', 'reviewRemark']);
|
|
262
|
+
if (Object.prototype.hasOwnProperty.call(data, 'target') && data.target !== record.target) {
|
|
263
|
+
payload.target = data.target;
|
|
264
|
+
const defaultLocale = await getDefaultLocaleCode();
|
|
265
|
+
// 默认语言改文无需审核,直接通过;其他语言改文重置为待审
|
|
266
|
+
if (defaultLocale && record.locale === defaultLocale) {
|
|
267
|
+
payload.reviewStatus = 'approved';
|
|
268
|
+
payload.reviewRemark = null;
|
|
269
|
+
} else {
|
|
270
|
+
payload.reviewStatus = 'pending';
|
|
271
|
+
payload.reviewRemark = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
await record.update(payload);
|
|
275
|
+
return record;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const remove = async (authenticatePayload, body) => {
|
|
279
|
+
const ids = normalizeIds(body);
|
|
280
|
+
if (ids.length === 0) {
|
|
281
|
+
throw httpErrors.badRequest('请指定要删除的词条');
|
|
282
|
+
}
|
|
283
|
+
const records = await models.langLib.findAll({
|
|
284
|
+
where: { id: { [Op.in]: ids } }
|
|
285
|
+
});
|
|
286
|
+
if (records.length === 0) {
|
|
287
|
+
throw httpErrors.notFound('语言词条不存在');
|
|
288
|
+
}
|
|
289
|
+
for (const record of records) {
|
|
290
|
+
await record.destroy();
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const setStatus = async (authenticatePayload, body) => {
|
|
295
|
+
const ids = normalizeIds(body);
|
|
296
|
+
if (ids.length === 0) {
|
|
297
|
+
throw httpErrors.badRequest('请指定要更新的词条');
|
|
298
|
+
}
|
|
299
|
+
if (!['open', 'closed'].includes(body.status)) {
|
|
300
|
+
throw httpErrors.badRequest('状态不合法');
|
|
301
|
+
}
|
|
302
|
+
const records = await models.langLib.findAll({
|
|
303
|
+
where: { id: { [Op.in]: ids } }
|
|
304
|
+
});
|
|
305
|
+
if (records.length === 0) {
|
|
306
|
+
throw httpErrors.notFound('语言词条不存在');
|
|
307
|
+
}
|
|
308
|
+
for (const record of records) {
|
|
309
|
+
await record.update({ status: body.status });
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const review = async (authenticatePayload, body) => {
|
|
314
|
+
const ids = normalizeIds(body);
|
|
315
|
+
if (ids.length === 0) {
|
|
316
|
+
throw httpErrors.badRequest('请指定要审核的词条');
|
|
317
|
+
}
|
|
318
|
+
if (!['approved', 'rejected'].includes(body.reviewStatus)) {
|
|
319
|
+
throw httpErrors.badRequest('审核状态不合法');
|
|
320
|
+
}
|
|
321
|
+
const records = await models.langLib.findAll({
|
|
322
|
+
where: { id: { [Op.in]: ids } }
|
|
323
|
+
});
|
|
324
|
+
if (records.length === 0) {
|
|
325
|
+
throw httpErrors.notFound('语言词条不存在');
|
|
326
|
+
}
|
|
327
|
+
for (const record of records) {
|
|
328
|
+
await record.update({
|
|
329
|
+
reviewStatus: body.reviewStatus,
|
|
330
|
+
reviewRemark: body.reviewRemark != null ? String(body.reviewRemark) : record.reviewRemark
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const regenerate = async (authenticatePayload, body) => {
|
|
336
|
+
const ids = normalizeIds(body);
|
|
337
|
+
if (ids.length === 0) {
|
|
338
|
+
throw httpErrors.badRequest('请指定要重新生成的词条');
|
|
339
|
+
}
|
|
340
|
+
if (typeof options.generateTargets !== 'function') {
|
|
341
|
+
throw httpErrors.badRequest('未配置 generateTargets,无法重新生成');
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const records = await models.langLib.findAll({
|
|
345
|
+
where: { id: { [Op.in]: ids } }
|
|
346
|
+
});
|
|
347
|
+
if (records.length === 0) {
|
|
348
|
+
throw httpErrors.notFound('语言词条不存在');
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const regeneratableRecords = records.filter(item => item.reviewStatus === 'pending' || item.reviewStatus === 'approved');
|
|
352
|
+
if (regeneratableRecords.length === 0) {
|
|
353
|
+
throw httpErrors.badRequest('仅待审核或已通过词条支持重新生成');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const defaultLocale = await getDefaultLocaleCode();
|
|
357
|
+
if (!defaultLocale) {
|
|
358
|
+
throw httpErrors.badRequest('未设置默认语言,无法重新生成');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const groups = new Map();
|
|
362
|
+
for (const record of regeneratableRecords) {
|
|
363
|
+
if (record.locale === defaultLocale) {
|
|
364
|
+
throw httpErrors.badRequest('默认语言词条不支持重新生成');
|
|
365
|
+
}
|
|
366
|
+
const key = `${record.namespace}::${record.code}`;
|
|
367
|
+
if (!groups.has(key)) {
|
|
368
|
+
groups.set(key, []);
|
|
369
|
+
}
|
|
370
|
+
groups.get(key).push(record);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
for (const groupRecords of groups.values()) {
|
|
374
|
+
const { namespace, code } = groupRecords[0];
|
|
375
|
+
const source = await models.langLib.findOne({
|
|
376
|
+
where: { namespace, code, locale: defaultLocale }
|
|
377
|
+
});
|
|
378
|
+
const sourceTarget = source && source.target != null ? String(source.target) : '';
|
|
379
|
+
if (!sourceTarget.trim()) {
|
|
380
|
+
throw httpErrors.badRequest(`缺少默认语言源文案: ${namespace}/${code}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const locales = groupRecords.map(item => item.locale);
|
|
384
|
+
const generated =
|
|
385
|
+
(await options.generateTargets({
|
|
386
|
+
namespace,
|
|
387
|
+
code,
|
|
388
|
+
locales,
|
|
389
|
+
sourceTarget,
|
|
390
|
+
defaultLocale
|
|
391
|
+
})) || {};
|
|
392
|
+
|
|
393
|
+
for (const record of groupRecords) {
|
|
394
|
+
await record.update({
|
|
395
|
+
target: generated[record.locale] != null ? String(generated[record.locale]) : '',
|
|
396
|
+
reviewStatus: 'pending',
|
|
397
|
+
reviewRemark: null
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const copyToNamespace = async (authenticatePayload, body) => {
|
|
404
|
+
const ids = normalizeIds(body);
|
|
405
|
+
const namespace = String(body.namespace || '').trim();
|
|
406
|
+
if (!namespace) {
|
|
407
|
+
throw httpErrors.badRequest('目标命名空间不能为空');
|
|
408
|
+
}
|
|
409
|
+
if (ids.length === 0) {
|
|
410
|
+
throw httpErrors.badRequest('请指定要复制的词条');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const records = await models.langLib.findAll({
|
|
414
|
+
where: { id: { [Op.in]: ids } }
|
|
415
|
+
});
|
|
416
|
+
if (records.length === 0) {
|
|
417
|
+
throw httpErrors.notFound('语言词条不存在');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const userId = authenticatePayload && authenticatePayload.id;
|
|
421
|
+
const candidates = records.filter(item => item.namespace !== namespace);
|
|
422
|
+
if (candidates.length === 0) {
|
|
423
|
+
throw httpErrors.badRequest('目标命名空间与源命名空间相同,无需复制');
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const existing = await models.langLib.findAll({
|
|
427
|
+
where: {
|
|
428
|
+
namespace,
|
|
429
|
+
code: { [Op.in]: [...new Set(candidates.map(item => item.code))] }
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
const existingKeys = new Set(existing.map(item => `${item.code}::${item.locale}`));
|
|
433
|
+
|
|
434
|
+
const rows = [];
|
|
435
|
+
let skippedCount = records.length - candidates.length;
|
|
436
|
+
for (const item of candidates) {
|
|
437
|
+
const key = `${item.code}::${item.locale}`;
|
|
438
|
+
if (existingKeys.has(key)) {
|
|
439
|
+
skippedCount += 1;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
rows.push({
|
|
443
|
+
namespace,
|
|
444
|
+
code: item.code,
|
|
445
|
+
locale: item.locale,
|
|
446
|
+
target: item.target,
|
|
447
|
+
reviewStatus: item.reviewStatus,
|
|
448
|
+
reviewRemark: item.reviewRemark,
|
|
449
|
+
status: item.status,
|
|
450
|
+
userId
|
|
451
|
+
});
|
|
452
|
+
existingKeys.add(key);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (rows.length === 0) {
|
|
456
|
+
throw httpErrors.badRequest('目标命名空间下词条已存在,没有可复制项');
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let created;
|
|
460
|
+
if (typeof models.langLib.bulkCreate === 'function') {
|
|
461
|
+
created = await models.langLib.bulkCreate(rows);
|
|
462
|
+
} else {
|
|
463
|
+
created = [];
|
|
464
|
+
for (const row of rows) {
|
|
465
|
+
created.push(await models.langLib.create(row));
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
createdCount: created.length,
|
|
471
|
+
skippedCount
|
|
472
|
+
};
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 运行时语言包:返回扁平 { [code]: target },供 @kne/react-intl Fetch 直接使用。
|
|
477
|
+
* 仅包含开启且审核通过的词条。
|
|
478
|
+
*/
|
|
479
|
+
const messages = async (authenticatePayload, query = {}) => {
|
|
480
|
+
const locale = query.locale != null ? String(query.locale).trim() : '';
|
|
481
|
+
const namespace = query.namespace != null ? String(query.namespace).trim() : 'global';
|
|
482
|
+
if (!locale) {
|
|
483
|
+
throw httpErrors.badRequest('locale 不能为空');
|
|
484
|
+
}
|
|
485
|
+
if (!namespace) {
|
|
486
|
+
throw httpErrors.badRequest('namespace 不能为空');
|
|
487
|
+
}
|
|
488
|
+
const rows = await models.langLib.findAll({
|
|
489
|
+
where: {
|
|
490
|
+
locale,
|
|
491
|
+
namespace,
|
|
492
|
+
status: 'open',
|
|
493
|
+
reviewStatus: 'approved'
|
|
494
|
+
},
|
|
495
|
+
attributes: ['code', 'target'],
|
|
496
|
+
order: [['code', 'ASC']]
|
|
497
|
+
});
|
|
498
|
+
const result = {};
|
|
499
|
+
for (const row of rows) {
|
|
500
|
+
if (row.code == null || row.code === '') {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
result[row.code] = row.target != null ? String(row.target) : '';
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const exportI18n = async (authenticatePayload, query = {}) => {
|
|
509
|
+
const namespace = query.namespace != null ? String(query.namespace).trim() : '';
|
|
510
|
+
const where = {};
|
|
511
|
+
if (namespace) {
|
|
512
|
+
where.namespace = namespace;
|
|
513
|
+
}
|
|
514
|
+
const rows = await models.langLib.findAll({
|
|
515
|
+
where,
|
|
516
|
+
order: [
|
|
517
|
+
['namespace', 'ASC'],
|
|
518
|
+
['locale', 'ASC'],
|
|
519
|
+
['code', 'ASC']
|
|
520
|
+
]
|
|
521
|
+
});
|
|
522
|
+
return {
|
|
523
|
+
pageData: groupRowsToFiles(rows)
|
|
524
|
+
};
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const importOneFile = async (authenticatePayload, { filename, content }) => {
|
|
528
|
+
let namespace;
|
|
529
|
+
let locale;
|
|
530
|
+
let entries;
|
|
531
|
+
try {
|
|
532
|
+
({ namespace, locale } = parseFilename(filename));
|
|
533
|
+
entries = parseContent(content);
|
|
534
|
+
} catch (e) {
|
|
535
|
+
throw httpErrors.badRequest(e.message || 'i18n 文件解析失败');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const userId = authenticatePayload && authenticatePayload.id;
|
|
539
|
+
let createdCount = 0;
|
|
540
|
+
let updatedCount = 0;
|
|
541
|
+
let skippedCount = 0;
|
|
542
|
+
|
|
543
|
+
for (const { code, target } of entries) {
|
|
544
|
+
const existing = await models.langLib.findOne({
|
|
545
|
+
where: { namespace, locale, code }
|
|
546
|
+
});
|
|
547
|
+
if (existing) {
|
|
548
|
+
await existing.update({
|
|
549
|
+
target,
|
|
550
|
+
status: 'open',
|
|
551
|
+
reviewStatus: 'approved',
|
|
552
|
+
reviewRemark: null
|
|
553
|
+
});
|
|
554
|
+
updatedCount += 1;
|
|
555
|
+
} else {
|
|
556
|
+
await models.langLib.create({
|
|
557
|
+
namespace,
|
|
558
|
+
code,
|
|
559
|
+
locale,
|
|
560
|
+
target,
|
|
561
|
+
status: 'open',
|
|
562
|
+
reviewStatus: 'approved',
|
|
563
|
+
reviewRemark: null,
|
|
564
|
+
userId
|
|
565
|
+
});
|
|
566
|
+
createdCount += 1;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
filename,
|
|
572
|
+
namespace,
|
|
573
|
+
locale,
|
|
574
|
+
createdCount,
|
|
575
|
+
updatedCount,
|
|
576
|
+
skippedCount
|
|
577
|
+
};
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const importI18n = async (authenticatePayload, body = {}) => {
|
|
581
|
+
const files = Array.isArray(body.files) && body.files.length > 0 ? body.files : body.filename != null ? [{ filename: body.filename, content: body.content }] : [];
|
|
582
|
+
|
|
583
|
+
if (files.length === 0) {
|
|
584
|
+
throw httpErrors.badRequest('请上传至少一个 i18n 文件');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const results = [];
|
|
588
|
+
let createdCount = 0;
|
|
589
|
+
let updatedCount = 0;
|
|
590
|
+
let skippedCount = 0;
|
|
591
|
+
|
|
592
|
+
for (const file of files) {
|
|
593
|
+
const result = await importOneFile(authenticatePayload, file);
|
|
594
|
+
results.push(result);
|
|
595
|
+
createdCount += result.createdCount;
|
|
596
|
+
updatedCount += result.updatedCount;
|
|
597
|
+
skippedCount += result.skippedCount;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
createdCount,
|
|
602
|
+
updatedCount,
|
|
603
|
+
skippedCount,
|
|
604
|
+
pageData: results
|
|
605
|
+
};
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
Object.assign(fastify[options.name].services, {
|
|
609
|
+
langLib: {
|
|
610
|
+
list,
|
|
611
|
+
namespaces,
|
|
612
|
+
detail,
|
|
613
|
+
addPackage,
|
|
614
|
+
save,
|
|
615
|
+
remove,
|
|
616
|
+
setStatus,
|
|
617
|
+
review,
|
|
618
|
+
regenerate,
|
|
619
|
+
copyToNamespace,
|
|
620
|
+
messages,
|
|
621
|
+
exportI18n,
|
|
622
|
+
importI18n
|
|
623
|
+
}
|
|
624
|
+
});
|
|
625
|
+
});
|