td-web-cli 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/.prettierignore +2 -0
- package/.prettierrc +7 -0
- package/dist/api/index.js +135 -0
- package/dist/api/interface.js +5 -0
- package/dist/index.js +67 -0
- package/dist/logs/20260123.txt +156 -0
- package/dist/logs/20260126.txt +5169 -0
- package/dist/modules/i18n/excel2json/index.js +302 -0
- package/dist/modules/i18n/extractEntry/index.js +3 -0
- package/dist/modules/i18n/index.js +71 -0
- package/dist/modules/i18n/json2excel/index.js +3 -0
- package/dist/modules/i18n/jsonMerge/index.js +3 -0
- package/dist/utils/index.js +240 -0
- package/package.json +29 -0
- package/src/api/index.ts +188 -0
- package/src/api/interface.ts +6 -0
- package/src/config/setting.json +47 -0
- package/src/index.ts +78 -0
- package/src/modules/i18n/excel2json/index.ts +380 -0
- package/src/modules/i18n/extractEntry/index.ts +3 -0
- package/src/modules/i18n/index.ts +78 -0
- package/src/modules/i18n/json2excel/index.ts +3 -0
- package/src/modules/i18n/jsonMerge/index.ts +3 -0
- package/src/utils/index.ts +352 -0
- package/tsconfig.json +12 -0
package/src/api/index.ts
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
|
2
|
+
import { normalizeError } from '../utils/index.js';
|
|
3
|
+
|
|
4
|
+
// GET 请求封装
|
|
5
|
+
export const getData = async <T>(
|
|
6
|
+
url: string,
|
|
7
|
+
params: Record<string, unknown> = {},
|
|
8
|
+
headers: Record<string, string> = {}
|
|
9
|
+
): Promise<T> => {
|
|
10
|
+
const res = await axios.get<T>(url, { params, headers });
|
|
11
|
+
return res.data;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// POST 请求封装
|
|
15
|
+
export const postData = async <T, R>(
|
|
16
|
+
url: string,
|
|
17
|
+
params: T,
|
|
18
|
+
headers: Record<string, string> = {}
|
|
19
|
+
): Promise<R> => {
|
|
20
|
+
const res = await axios.post<T, AxiosResponse<R>>(url, params, {
|
|
21
|
+
headers,
|
|
22
|
+
});
|
|
23
|
+
return res.data;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// 流式传输 POST 请求(带取消功能)
|
|
27
|
+
export const postStream = async <T>(
|
|
28
|
+
url: string,
|
|
29
|
+
params: T,
|
|
30
|
+
headers: Record<string, string> = {},
|
|
31
|
+
onData: (chunk: string) => void,
|
|
32
|
+
onError?: (error: Error) => void,
|
|
33
|
+
onComplete?: () => void,
|
|
34
|
+
signal?: AbortSignal
|
|
35
|
+
): Promise<void> => {
|
|
36
|
+
const config: AxiosRequestConfig = {
|
|
37
|
+
headers,
|
|
38
|
+
responseType: 'stream',
|
|
39
|
+
signal, // 添加 AbortSignal 支持
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await axios.post(url, params, config);
|
|
44
|
+
|
|
45
|
+
// 处理流数据
|
|
46
|
+
const stream = response.data;
|
|
47
|
+
let buffer = '';
|
|
48
|
+
|
|
49
|
+
// 监听 abort 事件
|
|
50
|
+
const onAbort = (): void => {
|
|
51
|
+
stream.destroy(new Error('请求已中止'));
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
55
|
+
|
|
56
|
+
stream.on('data', (chunk: Buffer) => {
|
|
57
|
+
buffer += chunk.toString();
|
|
58
|
+
|
|
59
|
+
// 处理可能的多个消息在一个chunk中
|
|
60
|
+
const parts = buffer.split('\n');
|
|
61
|
+
buffer = parts.pop() || ''; // 保留未完成的部分
|
|
62
|
+
|
|
63
|
+
parts.forEach((part) => {
|
|
64
|
+
if (part.trim()) {
|
|
65
|
+
onData(part.trim());
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
stream.on('end', () => {
|
|
71
|
+
signal?.removeEventListener('abort', onAbort);
|
|
72
|
+
if (buffer.trim()) {
|
|
73
|
+
onData(buffer.trim());
|
|
74
|
+
}
|
|
75
|
+
onComplete?.();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
stream.on('error', (err: Error) => {
|
|
79
|
+
signal?.removeEventListener('abort', onAbort);
|
|
80
|
+
// 如果是主动取消的请求,不触发 onError
|
|
81
|
+
if (err.message !== '请求已中止') {
|
|
82
|
+
onError?.(err);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
} catch (error) {
|
|
86
|
+
// 如果是取消的请求,不触发 onError
|
|
87
|
+
if (!axios.isCancel(error)) {
|
|
88
|
+
onError?.(normalizeError(error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// SSE 传输 POST 请求(带取消功能)
|
|
94
|
+
export const postSSE = async <T>(
|
|
95
|
+
url: string,
|
|
96
|
+
params: T,
|
|
97
|
+
headers: Record<string, string> = {},
|
|
98
|
+
onMessage: (event: string, data: string) => void,
|
|
99
|
+
onError?: (error: Error) => void,
|
|
100
|
+
signal?: AbortSignal
|
|
101
|
+
): Promise<void> => {
|
|
102
|
+
const config: AxiosRequestConfig = {
|
|
103
|
+
headers: {
|
|
104
|
+
...headers,
|
|
105
|
+
Accept: 'text/event-stream',
|
|
106
|
+
},
|
|
107
|
+
responseType: 'stream',
|
|
108
|
+
signal, // 添加 AbortSignal 支持
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const response = await axios.post(url, params, config);
|
|
113
|
+
const stream = response.data;
|
|
114
|
+
let buffer = '';
|
|
115
|
+
|
|
116
|
+
// 监听 abort 事件
|
|
117
|
+
const onAbort = (): void => {
|
|
118
|
+
stream.destroy(new Error('请求已中止'));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
122
|
+
|
|
123
|
+
stream.on('data', (chunk: Buffer) => {
|
|
124
|
+
buffer += chunk.toString();
|
|
125
|
+
|
|
126
|
+
// 处理SSE格式(以\n\n分隔的多个事件)
|
|
127
|
+
const events = buffer.split('\n\n');
|
|
128
|
+
buffer = events.pop() || '';
|
|
129
|
+
|
|
130
|
+
events.forEach((eventStr) => {
|
|
131
|
+
if (eventStr.trim()) {
|
|
132
|
+
const event: Record<string, string> = {};
|
|
133
|
+
eventStr.split('\n').forEach((line) => {
|
|
134
|
+
const sepIndex = line.indexOf(':');
|
|
135
|
+
if (sepIndex !== -1) {
|
|
136
|
+
const key = line.slice(0, sepIndex).trim();
|
|
137
|
+
// 注意 trimStart 保留 value 中的空格
|
|
138
|
+
event[key] = line.slice(sepIndex + 1).trimStart();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (event.event || event.data) {
|
|
143
|
+
onMessage(event.event || 'message', event.data || '');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
stream.on('error', (err: Error) => {
|
|
150
|
+
signal?.removeEventListener('abort', onAbort);
|
|
151
|
+
// 如果是主动取消的请求,不触发 onError
|
|
152
|
+
if (err.message !== '请求已中止') {
|
|
153
|
+
onError?.(err);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
stream.on('end', () => {
|
|
158
|
+
signal?.removeEventListener('abort', onAbort);
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// 如果是取消的请求,不触发 onError
|
|
162
|
+
if (!axios.isCancel(error)) {
|
|
163
|
+
onError?.(normalizeError(error));
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// PUT 请求封装
|
|
169
|
+
export const putData = async <T, R>(
|
|
170
|
+
url: string,
|
|
171
|
+
params: T,
|
|
172
|
+
headers: Record<string, string> = {}
|
|
173
|
+
): Promise<R> => {
|
|
174
|
+
const res = await axios.put<T, AxiosResponse<R>>(url, params, {
|
|
175
|
+
headers,
|
|
176
|
+
});
|
|
177
|
+
return res.data;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// DELETE 请求封装
|
|
181
|
+
export const deleteData = async <T>(
|
|
182
|
+
url: string,
|
|
183
|
+
params: Record<string, unknown> = {},
|
|
184
|
+
headers: Record<string, string> = {}
|
|
185
|
+
): Promise<T> => {
|
|
186
|
+
const res = await axios.delete<T>(url, { params, headers });
|
|
187
|
+
return res.data;
|
|
188
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"i18n": {
|
|
3
|
+
"defaultKey": "en",
|
|
4
|
+
"langs": {
|
|
5
|
+
"cn": ["简体中文"],
|
|
6
|
+
"zh": ["繁体中文"],
|
|
7
|
+
"en": ["英语"],
|
|
8
|
+
"it": ["意大利语"],
|
|
9
|
+
"brpt": ["巴西葡语"],
|
|
10
|
+
"uk": ["乌克兰语"],
|
|
11
|
+
"ru": ["俄语"],
|
|
12
|
+
"es": ["欧洲西语"],
|
|
13
|
+
"hu": ["匈牙利语"],
|
|
14
|
+
"pl": ["波兰语"],
|
|
15
|
+
"tr": ["土耳其语"],
|
|
16
|
+
"de": ["德语"],
|
|
17
|
+
"fr": ["法语"],
|
|
18
|
+
"ro": ["罗马尼亚语"],
|
|
19
|
+
"ko": ["韩语"],
|
|
20
|
+
"cs": ["捷克语"],
|
|
21
|
+
"laes": ["拉美西语"],
|
|
22
|
+
"pt": ["欧洲葡语"],
|
|
23
|
+
"nl": ["荷兰语"]
|
|
24
|
+
},
|
|
25
|
+
"longCodes": {
|
|
26
|
+
"cn": "zh-CN",
|
|
27
|
+
"zh": "zh-TW",
|
|
28
|
+
"en": "en-US",
|
|
29
|
+
"it": "it-IT",
|
|
30
|
+
"brpt": "pt-BR",
|
|
31
|
+
"uk": "uk-UA",
|
|
32
|
+
"ru": "ru-RU",
|
|
33
|
+
"es": "es-ES",
|
|
34
|
+
"hu": "hu-HU",
|
|
35
|
+
"pl": "pl-PL",
|
|
36
|
+
"tr": "tr-TR",
|
|
37
|
+
"de": "de-DE",
|
|
38
|
+
"fr": "fr-FR",
|
|
39
|
+
"ro": "ro-RO",
|
|
40
|
+
"ko": "ko-KR",
|
|
41
|
+
"cs": "cs-CZ",
|
|
42
|
+
"laes": "es-MX",
|
|
43
|
+
"pt": "pt-PT",
|
|
44
|
+
"nl": "nl-NL"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CLI入口脚本
|
|
5
|
+
* 通过交互式选择执行不同模块功能
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Command } from 'commander';
|
|
9
|
+
import { select, Separator } from '@inquirer/prompts';
|
|
10
|
+
import { i18n } from './modules/i18n/index.js';
|
|
11
|
+
import { logger, loggerError } from './utils/index.js';
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 主程序入口函数
|
|
17
|
+
* 解析命令行参数,交互式选择模块并执行对应功能
|
|
18
|
+
*/
|
|
19
|
+
async function main() {
|
|
20
|
+
try {
|
|
21
|
+
logger.info('td-web-cli程序启动');
|
|
22
|
+
|
|
23
|
+
// 解析命令行参数
|
|
24
|
+
program.parse(process.argv);
|
|
25
|
+
logger.info(`命令行参数解析完成:${process.argv.slice(2).join(' ')}`);
|
|
26
|
+
|
|
27
|
+
// 定义可用模块选项
|
|
28
|
+
const moduleChoices = [
|
|
29
|
+
{
|
|
30
|
+
name: '国际化',
|
|
31
|
+
value: 'i18n',
|
|
32
|
+
description: '国际化相关功能',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// 交互式选择模块
|
|
37
|
+
const answer = await select({
|
|
38
|
+
message: '请选择要执行的模块:',
|
|
39
|
+
choices: [
|
|
40
|
+
...moduleChoices,
|
|
41
|
+
new Separator(), // 分割线,便于未来扩展更多模块
|
|
42
|
+
],
|
|
43
|
+
default: 'i18n', // 默认选项
|
|
44
|
+
pageSize: 10, // 最大显示选项数
|
|
45
|
+
loop: true, // 选项循环滚动
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// 查找选择模块的名称,方便日志输出
|
|
49
|
+
const selectedModule = moduleChoices.find((item) => item.value === answer);
|
|
50
|
+
|
|
51
|
+
if (!selectedModule) {
|
|
52
|
+
logger.warn('未选择有效模块,程序已退出');
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
logger.info(`用户选择模块:${selectedModule.name}`);
|
|
57
|
+
|
|
58
|
+
// 根据选择执行对应模块
|
|
59
|
+
switch (answer) {
|
|
60
|
+
case 'i18n':
|
|
61
|
+
logger.info(`${selectedModule.name}模块开始执行`);
|
|
62
|
+
await i18n(program);
|
|
63
|
+
logger.info(`${selectedModule.name}模块执行完成`);
|
|
64
|
+
break;
|
|
65
|
+
default:
|
|
66
|
+
logger.warn(`${selectedModule.name}模块暂未实现,程序已退出`);
|
|
67
|
+
process.exit(0);
|
|
68
|
+
}
|
|
69
|
+
} catch (error: unknown) {
|
|
70
|
+
// 记录错误日志,方便排查
|
|
71
|
+
loggerError(error, logger);
|
|
72
|
+
console.error('程序执行时发生异常,已记录日志,程序已退出');
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 启动主程序
|
|
78
|
+
main();
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { input } from '@inquirer/prompts';
|
|
3
|
+
import XLSX from 'xlsx';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import {
|
|
7
|
+
getTimestamp,
|
|
8
|
+
logger,
|
|
9
|
+
loggerError,
|
|
10
|
+
normalizeError,
|
|
11
|
+
CheckResult,
|
|
12
|
+
languageToolCheck,
|
|
13
|
+
getLanguageTool,
|
|
14
|
+
} from '../../../utils/index.js';
|
|
15
|
+
|
|
16
|
+
type Row = (string | number | null | undefined)[];
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 国际化配置类型定义
|
|
20
|
+
* defaultKey: 默认语言key
|
|
21
|
+
* langs: 语言映射,key为语言标识,value为语言名称数组(支持多名称匹配)
|
|
22
|
+
* longCodes: 语言长代码映射,key为语言标识,value为语言长代码
|
|
23
|
+
*/
|
|
24
|
+
interface I18nConfig {
|
|
25
|
+
defaultKey: string;
|
|
26
|
+
langs: Record<string, string[]>;
|
|
27
|
+
longCodes: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读取并解析配置文件
|
|
32
|
+
* @param configPath 配置文件路径
|
|
33
|
+
* @returns I18nConfig 配置对象
|
|
34
|
+
* @throws 配置文件不存在或格式错误时抛出异常
|
|
35
|
+
*/
|
|
36
|
+
function loadConfig(configPath: string): I18nConfig {
|
|
37
|
+
if (!fs.existsSync(configPath)) {
|
|
38
|
+
throw new Error(`配置文件不存在:${configPath}`);
|
|
39
|
+
}
|
|
40
|
+
const content = fs.readFileSync(configPath, { encoding: 'utf-8' });
|
|
41
|
+
const json = JSON.parse(content);
|
|
42
|
+
if (!json.i18n) {
|
|
43
|
+
throw new Error('配置文件格式错误,缺少i18n');
|
|
44
|
+
}
|
|
45
|
+
if (!json.i18n.defaultKey) {
|
|
46
|
+
throw new Error('配置文件格式错误,缺少i18n.defaultKey');
|
|
47
|
+
}
|
|
48
|
+
if (!json.i18n.langs) {
|
|
49
|
+
throw new Error('配置文件格式错误,缺少i18n.langs');
|
|
50
|
+
}
|
|
51
|
+
if (!json.i18n.longCodes) {
|
|
52
|
+
throw new Error('配置文件格式错误,缺少i18n.longCodes');
|
|
53
|
+
}
|
|
54
|
+
return json.i18n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 匹配excel表头列名对应的语言key,支持大小写不敏感匹配
|
|
59
|
+
* 先匹配语言key本身,再匹配语言名称数组(包含关系)
|
|
60
|
+
* @param colName 表头列名
|
|
61
|
+
* @param langs 语言映射
|
|
62
|
+
* @returns 匹配到的语言key,未匹配返回null
|
|
63
|
+
*/
|
|
64
|
+
function matchLangKey(
|
|
65
|
+
colName: string,
|
|
66
|
+
langs: Record<string, string[]>
|
|
67
|
+
): string | null {
|
|
68
|
+
if (!colName) return null;
|
|
69
|
+
const colNameLower = colName.toLowerCase();
|
|
70
|
+
|
|
71
|
+
// 先尝试匹配语言key
|
|
72
|
+
for (const langKey of Object.keys(langs)) {
|
|
73
|
+
if (langKey.toLowerCase() === colNameLower) {
|
|
74
|
+
return langKey;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 再尝试匹配语言名称(包含关系)
|
|
79
|
+
for (const [langKey, names] of Object.entries(langs)) {
|
|
80
|
+
if (
|
|
81
|
+
names.some((name) => name && colNameLower.includes(name.toLowerCase()))
|
|
82
|
+
) {
|
|
83
|
+
return langKey;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 去除字符串首尾的单引号或双引号
|
|
92
|
+
* @param str 输入字符串
|
|
93
|
+
* @returns 去除引号后的字符串
|
|
94
|
+
*/
|
|
95
|
+
function trimQuotes(str: string): string {
|
|
96
|
+
if (
|
|
97
|
+
(str.startsWith('"') && str.endsWith('"')) ||
|
|
98
|
+
(str.startsWith("'") && str.endsWith("'"))
|
|
99
|
+
) {
|
|
100
|
+
return str.slice(1, -1);
|
|
101
|
+
}
|
|
102
|
+
return str;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* 批量检测词条文本,返回所有检测结果
|
|
107
|
+
* @param texts 词条数组
|
|
108
|
+
* @param language 语言代码
|
|
109
|
+
* @returns 检测结果数组,顺序对应输入texts
|
|
110
|
+
*
|
|
111
|
+
* 说明:
|
|
112
|
+
* 这里将所有词条用换行符拼接成一个字符串,一次性调用语言检测接口,
|
|
113
|
+
* 以减少请求次数和提升性能。
|
|
114
|
+
* 返回结果数组中只包含一个元素,即合并检测的结果。
|
|
115
|
+
*/
|
|
116
|
+
async function batchCheckTexts(
|
|
117
|
+
texts: string[],
|
|
118
|
+
language: string
|
|
119
|
+
): Promise<(CheckResult | null)[]> {
|
|
120
|
+
const results: (CheckResult | null)[] = [];
|
|
121
|
+
try {
|
|
122
|
+
// 将词条用换行符拼接,避免词条间干扰,推荐换行分隔
|
|
123
|
+
const joinedText = texts.join('\n');
|
|
124
|
+
const res = await languageToolCheck(joinedText, language);
|
|
125
|
+
results.push(res);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
loggerError(error, logger);
|
|
128
|
+
results.push(null);
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* excel转json功能主函数
|
|
135
|
+
* 读取用户输入的excel路径,解析内容,根据配置生成多语言json文件
|
|
136
|
+
* 并对配置文件中所有语言对应的词条进行语言检测
|
|
137
|
+
* @param program Commander命令行实例
|
|
138
|
+
*/
|
|
139
|
+
export async function excel2json(program: Command) {
|
|
140
|
+
// 配置文件默认路径
|
|
141
|
+
const configPath = path.join(process.cwd(), 'src/config/setting.json');
|
|
142
|
+
let i18nConfig: I18nConfig;
|
|
143
|
+
|
|
144
|
+
// 加载配置文件
|
|
145
|
+
try {
|
|
146
|
+
logger.info(`开始加载配置文件:${configPath}`);
|
|
147
|
+
i18nConfig = loadConfig(configPath);
|
|
148
|
+
logger.info('配置文件加载成功');
|
|
149
|
+
} catch (error: unknown) {
|
|
150
|
+
const msg = `读取配置文件失败:${normalizeError(error).stack},程序已退出`;
|
|
151
|
+
logger.error(msg);
|
|
152
|
+
console.error('程序执行时发生异常,已记录日志,程序已退出');
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 尝试调用接口获取支持的语言列表,更新 longCodes
|
|
157
|
+
try {
|
|
158
|
+
logger.info('尝试获取在线支持的语言列表...');
|
|
159
|
+
const languageTools = await getLanguageTool();
|
|
160
|
+
logger.info(`成功获取语言列表,覆盖配置文件中的 longCodes`);
|
|
161
|
+
|
|
162
|
+
// 构建新的 longCodes 映射
|
|
163
|
+
const newLongCodes: Record<string, string> = {};
|
|
164
|
+
// 语言标识对应语言名称列表,方便匹配
|
|
165
|
+
const langNameToKey: Record<string, string> = {};
|
|
166
|
+
for (const [key, names] of Object.entries(i18nConfig.langs)) {
|
|
167
|
+
names.forEach((name) => {
|
|
168
|
+
langNameToKey[name.toLowerCase()] = key;
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
for (const lang of languageTools) {
|
|
173
|
+
// 尝试根据语言名称匹配配置中的语言key
|
|
174
|
+
const lowerName = lang.name.toLowerCase();
|
|
175
|
+
const matchedKey =
|
|
176
|
+
langNameToKey[lowerName] ||
|
|
177
|
+
Object.keys(i18nConfig.langs).find(
|
|
178
|
+
(k) => k.toLowerCase() === lowerName
|
|
179
|
+
);
|
|
180
|
+
if (matchedKey) {
|
|
181
|
+
newLongCodes[matchedKey] = lang.longCode;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 替换旧的 longCodes,保留未匹配的旧值
|
|
186
|
+
i18nConfig.longCodes = { ...i18nConfig.longCodes, ...newLongCodes };
|
|
187
|
+
} catch (error) {
|
|
188
|
+
logger.warn(
|
|
189
|
+
`获取在线语言列表失败,使用本地配置 longCodes,错误:${normalizeError(error).stack}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 交互式输入excel文件路径并校验
|
|
194
|
+
const answer = await input({
|
|
195
|
+
message: '请输入excel文件路径:',
|
|
196
|
+
validate: (value) => {
|
|
197
|
+
const cleaned = value.trim().replace(/^['"]|['"]$/g, '');
|
|
198
|
+
if (cleaned.length === 0) return '路径不能为空';
|
|
199
|
+
if (!fs.existsSync(cleaned)) return '文件不存在,请输入有效路径';
|
|
200
|
+
if (!/\.(xls|xlsx)$/i.test(cleaned))
|
|
201
|
+
return '请输入有效的excel文件路径(.xls或.xlsx)';
|
|
202
|
+
return true;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// 规范化路径,支持相对路径转绝对路径,去除首尾引号
|
|
207
|
+
const excelPath = path.resolve(
|
|
208
|
+
process.cwd(),
|
|
209
|
+
answer.trim().replace(/^['"]|['"]$/g, '')
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
logger.info(`开始读取excel文件:${excelPath}`);
|
|
214
|
+
|
|
215
|
+
// 读取excel文件
|
|
216
|
+
const workbook = XLSX.readFile(excelPath);
|
|
217
|
+
const firstSheetName = workbook.SheetNames[0];
|
|
218
|
+
if (!firstSheetName) {
|
|
219
|
+
logger.error('excel文件没有任何工作表,程序已退出');
|
|
220
|
+
console.error('程序执行时发生异常,已记录日志,程序已退出');
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// 读取第一个工作表的数据,按行读取,header=1表示返回二维数组
|
|
225
|
+
const sheet = workbook.Sheets[firstSheetName];
|
|
226
|
+
const rows: Row[] = XLSX.utils.sheet_to_json(sheet, { header: 1 });
|
|
227
|
+
|
|
228
|
+
if (rows.length < 2) {
|
|
229
|
+
logger.error('工作表数据不足,至少需要两行(表头+数据),程序已退出');
|
|
230
|
+
console.error('程序执行时发生异常,已记录日志,程序已退出');
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
logger.info('开始解析表头');
|
|
235
|
+
// 处理表头行,去除空格,转成字符串
|
|
236
|
+
const headerRow = rows[0].map((cell) => (cell ? String(cell).trim() : ''));
|
|
237
|
+
|
|
238
|
+
// 根据表头匹配语言列,建立列索引到语言key的映射
|
|
239
|
+
const colIndexToLangKey: Record<number, string> = {};
|
|
240
|
+
headerRow.forEach((colName, idx) => {
|
|
241
|
+
const langKey = matchLangKey(colName, i18nConfig.langs);
|
|
242
|
+
if (langKey) {
|
|
243
|
+
colIndexToLangKey[idx] = langKey;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 获取默认语言列索引
|
|
248
|
+
const defaultLang = i18nConfig.defaultKey;
|
|
249
|
+
const defaultColIndex = Object.entries(colIndexToLangKey).find(
|
|
250
|
+
([, langKey]) => langKey === defaultLang
|
|
251
|
+
)?.[0];
|
|
252
|
+
|
|
253
|
+
if (defaultColIndex === undefined) {
|
|
254
|
+
logger.error(`找不到默认语言列:${defaultLang},程序已退出`);
|
|
255
|
+
console.error('程序执行时发生异常,已记录日志,程序已退出');
|
|
256
|
+
process.exit(1);
|
|
257
|
+
}
|
|
258
|
+
const defaultColNum = Number(defaultColIndex);
|
|
259
|
+
|
|
260
|
+
// 初始化所有语言词条对象(包括默认语言)
|
|
261
|
+
const langTranslations: Record<string, Record<string, string>> = {};
|
|
262
|
+
Object.values(colIndexToLangKey).forEach((langKey) => {
|
|
263
|
+
langTranslations[langKey] = {};
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
logger.info('开始解析数据行');
|
|
267
|
+
// 遍历数据行,提取所有语言词条
|
|
268
|
+
// key统一用默认语言列的值,其他语言对应的列为翻译内容
|
|
269
|
+
const langKeysMap: Record<string, string[]> = {}; // 语言key => 词条数组
|
|
270
|
+
Object.keys(langTranslations).forEach((langKey) => {
|
|
271
|
+
langKeysMap[langKey] = [];
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
for (let i = 1; i < rows.length; i++) {
|
|
275
|
+
const row = rows[i];
|
|
276
|
+
const keyCell = row[defaultColNum];
|
|
277
|
+
if (keyCell === undefined || keyCell === null || keyCell === '') continue;
|
|
278
|
+
|
|
279
|
+
let key = String(keyCell).trim();
|
|
280
|
+
key = trimQuotes(key); // 去除引号
|
|
281
|
+
|
|
282
|
+
// 跳过空key,避免写入无效数据
|
|
283
|
+
if (key.length === 0) continue;
|
|
284
|
+
|
|
285
|
+
// 默认语言的词条即key本身
|
|
286
|
+
langTranslations[defaultLang][key] = key;
|
|
287
|
+
langKeysMap[defaultLang].push(key);
|
|
288
|
+
|
|
289
|
+
// 其他语言词条
|
|
290
|
+
for (const [colIdxStr, langKey] of Object.entries(colIndexToLangKey)) {
|
|
291
|
+
const colIdx = Number(colIdxStr);
|
|
292
|
+
if (langKey === defaultLang) continue;
|
|
293
|
+
const valCell = row[colIdx];
|
|
294
|
+
if (valCell !== undefined && valCell !== null && valCell !== '') {
|
|
295
|
+
const valStr = String(valCell);
|
|
296
|
+
langTranslations[langKey][key] = valStr;
|
|
297
|
+
langKeysMap[langKey].push(valStr);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// 对所有语言词条批量进行语言检测(包括默认语言)
|
|
303
|
+
for (const [langKey, texts] of Object.entries(langKeysMap)) {
|
|
304
|
+
const longCode = i18nConfig.longCodes[langKey];
|
|
305
|
+
if (!longCode) {
|
|
306
|
+
logger.warn(`语言(${langKey})未配置 longCode,跳过检测`);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (texts.length === 0) {
|
|
310
|
+
logger.info(`语言(${langKey})无词条,跳过检测`);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
logger.info(
|
|
315
|
+
`开始对语言(${langKey})词条进行语言检测,词条数量:${texts.length}`
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const checkResults = await batchCheckTexts(texts, longCode);
|
|
319
|
+
|
|
320
|
+
if (!checkResults || checkResults.length === 0 || !checkResults[0]) {
|
|
321
|
+
logger.error(`语言(${langKey})词条检测失败`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const result = checkResults[0];
|
|
326
|
+
if (result.matches.length === 0) {
|
|
327
|
+
logger.info(`语言(${langKey})词条检测无错误`);
|
|
328
|
+
} else {
|
|
329
|
+
logger.info(
|
|
330
|
+
`语言(${langKey})词条检测发现问题,词条数量: ${result.matches.length}`
|
|
331
|
+
);
|
|
332
|
+
for (const match of result.matches) {
|
|
333
|
+
logger.info(
|
|
334
|
+
`- 错误: ${match.message}\n 出错句子: ${match.sentence}\n 建议替换: ${match.replacements
|
|
335
|
+
.map((r) => r.value)
|
|
336
|
+
.join(', ')}`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 输出目录:excel文件所在目录下的“lang_时间戳”文件夹
|
|
343
|
+
const excelDir = path.dirname(excelPath);
|
|
344
|
+
const timestamp = getTimestamp();
|
|
345
|
+
const outputRoot = path.join(excelDir, `lang_${timestamp}`);
|
|
346
|
+
if (!fs.existsSync(outputRoot)) {
|
|
347
|
+
fs.mkdirSync(outputRoot, { recursive: true });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
logger.info(`开始生成语言文件,输出目录:${outputRoot}`);
|
|
351
|
+
|
|
352
|
+
// 按语言生成对应的json文件,默认语言的key=value不生成文件
|
|
353
|
+
for (const [langKey, translations] of Object.entries(langTranslations)) {
|
|
354
|
+
if (Object.keys(translations).length === 0) continue;
|
|
355
|
+
|
|
356
|
+
if (langKey === defaultLang) {
|
|
357
|
+
logger.info(`跳过默认语言(${langKey})的json文件生成`);
|
|
358
|
+
continue; // 跳过默认语言文件生成
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const langDir = path.join(outputRoot, langKey);
|
|
362
|
+
if (!fs.existsSync(langDir)) {
|
|
363
|
+
fs.mkdirSync(langDir, { recursive: true });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const filePath = path.join(langDir, 'translate.json');
|
|
367
|
+
fs.writeFileSync(filePath, JSON.stringify(translations, null, 2), {
|
|
368
|
+
encoding: 'utf-8',
|
|
369
|
+
});
|
|
370
|
+
logger.info(`已生成语言文件:${filePath}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
logger.info('全部转换完成', true);
|
|
374
|
+
} catch (error: unknown) {
|
|
375
|
+
// 记录错误日志,方便排查
|
|
376
|
+
loggerError(error, logger);
|
|
377
|
+
console.error('程序执行时发生异常,已记录日志,程序已退出');
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
}
|