一个skills轻松搞定项目中i18n多语言
以cursor为例
整个skills分为三个部分:
- i18n-scan 入口配置,校验工具
- i18n-fix 规范设置,修复工具
- i18n-transform 最终执行工具
目录结构如下:

首先来看一下i18n-scan
目录结构

SKILL.md文件
--- name: i18n-fix description: 修复代码中影响国际化转换的问题,根据具体问题类型选择对应的修复模式。当前支持的修复类型:template-fix(模板字符串修复)。当用户说"处理模板字符串"、"修复模板字符串中文"、"修复 unprocessedCode",或明确指定某种 fix 模式时使用。 --- # i18n Fix 修复代码中阻碍国际化转换的问题。每种修复类型对应一个独立的 reference,包含完整的规则和执行流程。 ## 支持的修复类型 | 修复类型 | 触发场景 | 详细规则 | |---|---|---| | **template-fix** | 源码扫描输出存在 unprocessedCode / "处理模板字符串" | `references/template-fix.md` | > 后续新增修复类型时,在此表追加一行,并在 `references/` 下新建对应文件即可。 ## 执行步骤 ### Step 1:识别修复类型 根据用户描述、问题上下文或明确指定,匹配上表中的修复类型: - 描述中提到模板字符串 / unprocessedCode → **template-fix** - 用户直接说"用 template-fix" → **template-fix** - 无法匹配时,列出支持的修复类型让用户选择 ### Step 2:读取对应 reference,执行修复 根据 Step 1 的结果,读取对应的 reference 文件,按其中的规则和流程逐项处理。 各修复类型的详细步骤见: → `references/template-fix.md`(模板字符串修复) ### Step 3:输出修复摘要 修复完成后向用户报告: - 修改了哪些文件的哪些行 - 是否存在规则未覆盖、需要人工处理的情况
references/template-fix.md 文件
# 模板字符串修复:详细规则与执行流程 ## 核心目标 将含中文的模板字符串中的复杂嵌套表达式提取为局部 `const` 变量,让 must 工具可以自动识别整段模板字符串中的中文并替换。 ## 处理规则 ### 规则 1:只提取含中文的模板字符串 跳过不含中文字符的模板字符串(如纯 URL 拼接、纯数字拼接等)。 ### 规则 2:提取嵌套表达式为 const 将 `${}` 中的**复杂表达式**(链式访问、函数调用、运算)提取到模板字符串**正上方**的 `const` 声明: ```typescript // 处理前 const msg = `${data?.players1?.playersSeatNumber}号签位和${data?.players2?.playersSeatNumber}号签位`; // 处理后 const playersOneSeatNumber = data?.players1?.playersSeatNumber; const playersTwoSeatNumber = data?.players2?.playersSeatNumber; const msg = `${playersOneSeatNumber}号签位和${playersTwoSeatNumber}号签位`; ``` ### 规则 3:变量命名转换 提取变量名时: - 取表达式**最后两个有语义的属性名**拼接(忽略 `data`、`item`、`res` 等纯容器名) - **数字后缀转英文**:`1` → `One`,`2` → `Two`,`3` → `Three` ... `9` → `Nine` - 使用 camelCase ``` data?.players1?.playersSeatNumber → playersOneSeatNumber data?.players2?.playersSeatNumber → playersTwoSeatNumber item?.config?.maxCount → configMaxCount res?.data?.totalNum → dataTotalNum name.toString() → nameStr(函数调用,取调用前的变量名 + 语义后缀) ',' + name → nameWithPrefix(运算,描述语义) ``` ### 规则 4:简单变量不提取 `${}` 内已经是单一变量(无链式、无运算、无函数调用)则保持不变: ```typescript // 不需要处理(${ name } 已是简单变量) `你好${name}` ``` ### 规则 5:提取位置 `const` 声明放在**包含该模板字符串的最近一个语句的正上方**,不要插入到函数参数列表内部。 ## 执行流程 1. **读取待处理位置** - 来源:i18n-scan 源码扫描输出的 unprocessedCode(文件路径 + 行号 + 原始代码) - 或用户直接提供 2. **逐项处理** - 读取对应文件,定位到指定行 - 识别模板字符串中的 `${}` 表达式 - 按规则 2–5 提取变量,原地修改文件 3. **完成后告知用户** - 列出修改了哪些文件的哪些行 - 提示可重新运行 i18n-scan 源码扫描确认 unprocessedCode 已清零 ## 示例 **输入(i18n-scan 源码扫描输出):** ``` src/utils/api/uploadService.ts line 60:16 `你好${name.toString()}` line 61:16 `你好${',' + name}` ``` **处理结果:** ```typescript // line 60 处理前 const greeting = `你好${name.toString()}`; // line 60 处理后 const nameStr = name.toString(); const greeting = `你好${nameStr}`; // line 61 处理前 const greeting2 = `你好${',' + name}`; // line 61 处理后 const nameWithPrefix = ',' + name; const greeting2 = `你好${nameWithPrefix}`; ```
再来看一下i18n-scan
目录结构

SKILL.md文件
--- name: i18n-scan description: 检查当前项目的国际化状态,分两个维度:① 源码扫描——找出源文件中尚未国际化的中文(支持限定目录);② locale 校验——检查 zh-HK / en 翻译文件是否与 zh-CN 完整对齐。两个维度均为只读操作。当用户说"检查国际化状态"、"有哪些中文没处理"、"预览中文"、"扫描哪些文件需要国际化"、"检查翻译 key"、"对比多语言文件"、"翻译文件有没有对齐"、"全面检查一下国际化"时使用。 --- # i18n Scan 两种只读扫描,覆盖国际化的不同阶段: | 维度 | 扫描对象 | 回答的问题 | |---|---|---| | **源码扫描** | `.tsx / .ts` 源文件 | 哪些文件还有中文没被处理? | | **locale 校验** | `.json` 语言资源文件 | 三语言翻译文件是否完整对齐? | ## 执行步骤 ### Step 0:环境检查 **检查 must 工具是否已安装并配置:** ```bash # 1. 检查 package.json 中是否有 @ali/parrot-tool-must 依赖 grep "@ali/parrot-tool-must" package.json # 2. 检查 .must.config.js 配置文件是否存在 ls -la .must.config.js ``` **检查结果处理:** - **must 未安装** (`grep` 无输出): 询问用户: ``` ⚠️ 未检测到 must 工具依赖 i18n 转换需要 @ali/parrot-tool-must 工具支持。是否安装? 安装命令:npm install --save-dev @ali/parrot-tool-must ``` 等待用户确认后执行安装。 - **配置文件缺失** (`ls` 报错): 询问用户: ``` ⚠️ 未检测到 .must.config.js 配置文件 需要创建 must 工具配置文件才能使用 i18n 功能。是否创建? 配置文件将包含: - 源码目录配置 - i18next 方法定制 - key 生成规则 - 排除规则等 ``` 若用户同意,读取 `.cursor/skills/i18n-scan/references/must.config.template.js` 模板,复制到项目根目录并命名为 `.must.config.js`。提示用户可根据项目实际情况调整配置(如 `name`、`exclude` 路径等)。 - **均已就绪**:直接进入 Step 1 ### Step 1:判断运行哪个维度 | 用户意图 | 运行维度 | |---|---| | "哪些文件有中文没处理" / "预览中文" / "扫描源码" | 只运行源码扫描 | | "翻译文件有没有对齐" / "检查 key 是否完整" | 只运行 locale 校验 | | "全面检查国际化" / "检查国际化状态" | 两个维度都运行 | ### Step 2:运行源码扫描(按需,需通过 Step 0 检查) 从本文件所在目录(`.cursor/skills/i18n-scan/`)执行: ```bash # 扫描整个 src 目录 node scripts/preview.js # 限定目录(path 相对于项目根目录) node scripts/preview.js --path=./src/pages/Home ``` **产出:** - 按命名空间分组的待转换文件清单 - unprocessedCode:含复杂模板字符串的文件和行号,需人工介入 详细的命名空间推断规则和输出格式参考: → `references/preview.md` ### Step 3:运行 locale 校验(按需) 从本文件所在目录(`.cursor/skills/i18n-scan/`)执行: ```bash node scripts/check.js ``` **产出:** - 每个命名空间的 ✅ / ❌ 状态 - 缺少或多余的 key 列表 - 嵌套结构不一致的位置 - key 顺序提示(`·`,弱提示,不影响通过) 详细的输出解读和修复方式参考: → `references/check.md` ### Step 4:汇总并告知用户 将两个维度的扫描结果整理后输出给用户,不做任何修复: - **源码扫描**:严格按照 `references/preview.md` Step 4 中描述的格式输出——按命名空间分组,列出公共目录、涉及文件数、推断方式的汇总表,并在表格后追加 unprocessedCode 列表(若有) - **locale 校验**:严格按照 `references/check.md` Step 2 中描述的格式输出——直接呈现脚本原始输出内容,包括每个命名空间的 ✅ / ❌ 状态、缺失 / 多余 key 明细、结构不一致位置,以及 `·` 顺序提示 后续如何处理由用户决定。
references结构下:
check.md文件
# locale 校验:详细执行步骤 ## Step 1:运行检查脚本 从 `.cursor/skills/i18n-scan/` 目录执行: ```bash node scripts/check.js ``` 脚本以 **zh-CN 为基准**,逐一对比 zh-HK 和 en,进行四类检查: - **① 重复 key 检测**(前置扫描,独立于 ✅/❌ 判定):同一文件内是否存在同名 key - **② key 完整性**:叶子节点 key 是否全部一致(缺少 / 多余) - **③ 嵌套结构一致性**:`{ "a": { "b": "x" } }` 和 `{ "a.b": "x" }` 视为结构不同 - **④ key 顺序提示**(弱提示,不影响 ✅/❌ 判定):排列顺序是否与 zh-CN 一致 ## Step 2:解读输出 ``` 🔍 i18n key 一致性检查 — 全部 40 个命名空间 基准语言:zh-CN 对比语言:zh-HK / en ──────────────────────────────────────────────────────────── ✅ home ✅ liveList · [en] 2 个 key 顺序与 zh-CN 不一致 ⚠️ 重复 key(zh-CN/orderSchedule.json):"schedule_updating" ⚠️ 重复 key(zh-HK/orderSchedule.json):"schedule_updating" ⚠️ 重复 key(en/orderSchedule.json):"schedule_updating" ✅ orderSchedule ❌ eventMain [en] 多余 1 个 key(不存在于 zh-CN): - eventcard.competition_application_under_review [en] 结构不一致(2 处): - "applying" 在 zh-CN 是嵌套对象,在 en 中为平铺 key ──────────────────────────────────────────────────────────── 共检查 40 个命名空间:✅ 39 个一致,❌ 1 个存在差异,· 1 处顺序提示 ``` ### 重复 key 警告(`⚠️`)说明 `⚠️` 是独立的前置警告,**不影响命名空间的 ✅/❌ 判定**。原因:`JSON.parse()` 遇到重复 key 时会静默取最后一个值覆盖前者,导致检查脚本感知不到重复的存在,key 对齐检查仍然可能通过。 > **典型场景**:两次 i18n-transform 写入同一命名空间时,若发生追加而非合并,会引入重复 key。 重复 key 的危险性: - 前一个 key 的翻译值被静默丢弃,可能导致翻译不符合预期 - 文件可读性差,维护时容易引发混乱 - 部分 JSON linter / 编辑器会直接报错 ## Step 3:修复差异 **重复 key(`⚠️`,需优先处理):** 打开对应文件,搜索重复的 key 名,保留正确的一条,删除多余的。三语言文件需同步处理。 **缺少 key(最常见):** 在 `src/i18n/locales/<lang>/<namespace>.json` 中补充翻译。 **多余 key:** - 有实际代码引用 → 不能删除,需在 zh-CN(及 zh-HK)补上对应翻译 - 无实际引用 → 直接从目标语言文件中删除 **结构不一致:** 统一改为与 zh-CN 相同的嵌套格式,将平铺点分 key 改写为嵌套对象。 **顺序提示(`·`):** 按 zh-CN 中的 key 排列顺序,调整目标语言文件中对应 key 的位置,便于 diff 对比维护。 **文件不存在:** 参考 i18n-transform skill 的 **Step 3(新命名空间初始化)**,创建对应语言的 JSON 文件并注册命名空间。 ## Step 4:修复后重新验证 ```bash node scripts/check.js ``` 确认全部输出为 ✅、无 `⚠️` 重复 key 警告、且无 `·` 顺序提示后完成。
must-config-template.md文件
# must.config.js 配置模板说明 这是 `@ali/parrot-tool-must` 工具的配置文件模板,用于 i18next 国际化转换。 ## 主要配置项 ### 基础配置 - **name**: 应用名称(需根据项目修改) - **sourcePath**: 源码目录,默认 `./src` - **fileType**: 扫描的文件类型,默认 `ts`(包含 .ts 和 .tsx) ### 排除规则 - **exclude**: 函数,返回 true 表示跳过该文件 - 示例:排除 app.ts、document.tsx 等不需要国际化的文件 - 支持字符串匹配和正则表达式 ### macro 配置(i18next 定制) - **path**: 语言文件位置,默认 `src/i18n/locales` - **method**: i18next 调用方法,默认 `i18next.t("$key$")` - **import**: 自动添加的 import 语句 - **keyGenerator**: key 生成规则 - 从文件路径提取目录名 - 从翻译文本提取有意义的英文单词(前3个) - 用 `.` 连接,如 `common.upload_success` ### 过滤规则 - **matchCopy**: 判断文案是否需要转换 - 过滤非中文文本 - 过滤 `console.log`、`Error`、`noI18n()` 等调用内的文案 ## 使用方式 将此模板复制到项目根目录,命名为 `.must.config.js`,然后根据项目调整: 1. 修改 `name` 为实际应用名 2. 调整 `exclude` 中的排除路径 3. 根据需要调整 key 生成规则 ## 参考 完整配置说明参考 must 工具官方文档。
must.config.template.js文件
const path = require('path');
/**
* must 工具配置模板
* 用于 i18next 国际化转换
*/
// 无意义的英文单词,不参与 key 生成
const NOT_MEANING_WORD = [
'a',
'an',
'the',
'of',
'to',
'in',
'on',
'at',
'for',
'with',
'and',
'or',
'but',
'is',
'are',
'was',
'were',
'be',
'been',
'has',
'have',
'had',
'do',
'does',
'did',
'will',
'would',
'can',
'could',
'should',
'may',
'might',
'must',
'shall',
'it',
'this',
'that',
'these',
'those',
'i',
'you',
'he',
'she',
'we',
'they',
'by',
'from',
'as',
'into',
'out',
'up',
'down',
];
// key 分隔符
const KEY_SPLITTER = '.';
// 中文正则
const CHINESE_REG = /[^\x00-\xff]/;
// 函数边界类型(停止向上查找的节点类型)
const BOUNDARY_TYPES = new Set([
'Program',
'FunctionDeclaration',
'FunctionExpression',
'ArrowFunctionExpression',
'ClassMethod',
'ObjectMethod',
]);
/**
* 判断当前 AST 节点是否在指定函数调用内
* 用于过滤不需要国际化的字符串,如 console.log('中文')
*/
function isInExcludedCall(astPath, excludeSet) {
let cur = astPath.parentPath;
while (cur) {
const { type, node } = cur;
if (BOUNDARY_TYPES.has(type)) return false;
if (type === 'CallExpression' || type === 'NewExpression') {
const { callee } = node;
if (callee.type === 'MemberExpression') return excludeSet.has(callee.object?.name);
if (callee.type === 'Identifier') return excludeSet.has(callee.name);
return false;
}
cur = cur.parentPath;
}
return false;
}
module.exports = {
// 工作目录
cwd: './',
// 应用名(根据项目修改)
name: 'my-app',
// 提取的源码目录
sourcePath: './src',
// 排除的文件/目录列表(根据项目调整)
exclude: path => {
const excludePaths = [
'/src/app.ts',
'/src/document.tsx',
// 添加其他不需要国际化的文件路径
];
if (excludePaths.some(p => (p instanceof RegExp ? p.test(path) : path.includes(p))))
return true;
return false;
},
// 提取文件类型
fileType: 'ts',
macro: {
// 适配 i18next 的 placeholder 模板
placeholder: variable => {
return `{{${variable}}}`;
},
// 语言配置文件数据位置
path: 'src/i18n/locales',
// 定制 i18next 函数方法
method: 'i18next.t("$key$")',
// 定制 i18next 函数引入
import: 'import i18next from "i18next";',
// 是否将原始文案展示为注释
implement: false,
// key 生成器规则
keyGenerator: (copy, filePath, config) => {
const enCopy = copy['en-US'];
// 提取有意义的英文单词
const words = enCopy.split(/[^a-zA-Z]/i);
const validWords = words.filter(w => /^[\w|\d]+$/i.test(w)).map(w => w.toLowerCase());
// 过滤无意义词
const namedWords = validWords.filter(w => !NOT_MEANING_WORD.includes(w));
// 如果过滤后为空,但过滤前有值,则回退到过滤前的结果
let finalWords = namedWords.length > 0 ? namedWords : validWords;
if (finalWords.length === 0) {
finalWords = ['unnamed_content'];
}
const keyArray = [];
const relativeFilePath = filePath.replace(`${config.cwd}/`, '');
const parsedPath = path.parse(relativeFilePath);
const dirPart = parsedPath.dir
.split('/')
.filter(p => !!p && p !== 'index' && p !== 'src')
.slice(-1) // 只取最后一层目录
.map(p => p.toLowerCase());
// 添加目录名(如果有)
if (dirPart.length > 0) {
keyArray.push(...dirPart);
}
// 添加翻译文本(取前3个有意义单词,用下划线连接)
keyArray.push(finalWords.slice(0, 3).join('_'));
return keyArray.join(KEY_SPLITTER);
},
},
// 匹配需要转换的文案(过滤规则)
matchCopy: (text, astPath) => {
// 过滤非中文
if (!CHINESE_REG.test(text) || text === '、') return null;
// 过滤不需要转换的函数调用
if (
isInExcludedCall(
astPath,
new Set([
'noI18n', // 显式标记不需要国际化
'console', // 控制台输出
'Error', // 错误信息
]),
)
)
return null;
return text;
},
// 是否上传到美杜莎(内部系统)
isNeedUploadCopyToMedusa: false,
};
preview.md文件
# 源码扫描:详细执行步骤 ## Step 1:运行预览脚本 从 `.cursor/skills/i18n-scan/` 目录执行: ```bash # 全量扫描 node scripts/preview.js # 指定目录(path 相对于项目根目录) node scripts/preview.js --path=./src/pages/Home ``` ## Step 2:解析输出 从脚本输出中提取: - **changedFiles**:会被自动修改的文件列表 - **unprocessedCode**:含模板字符串中文、需手动处理的文件和行号 ## Step 3:为每个文件推断命名空间 **规则优先级(从高到低):** 1. **查文件内已有的 `i18next.t()` 调用** - 提取第一个参数中冒号前的部分:`i18next.t('home:xxx')` → `home` 2. **查同层目录其他文件的 `i18next.t()` 调用** - 取出现频率最高的命名空间 3. **按路径规则推断**(以上均未找到时) | 文件路径 | 命名空间规则 | 示例 | |---|---|---| | `src/pages/<Page>/...` | 页面名首字母小写 | `src/pages/Home/` → `home` | | `src/pages/<A>/<B>/...` | 取第二段,首字母小写 | `src/pages/cMatch/MyMatch/` → `myMatch` | | `src/components/`、`src/hooks/`、`src/utils/`、`src/common/` 等非 pages 目录 | 统一使用 `common` | — | > **⚠️ 路径规则是最后兜底**:同一目录下若已有其他文件使用了某个命名空间(如 `match/common/` 下的文件都用 `matchCommon`),优先级 2 会直接取到正确结果。 ## Step 4:输出汇总 **按命名空间分组**,推算同组文件的**最长公共目录**: - 取各文件所在目录,逐段对比,取共同最长前缀 - **`common` 命名空间例外**:若文件来自 `src/utils`、`src/hooks`、`src/common` 等不同目录,前缀退化到 `src` 没有意义,改为各写一行,不合并 ``` | 命名空间 | 公共目录 | 涉及文件数 | 推断方式 | |---|---|---|---| | home | src/pages/Home | 2 | 路径推断 | | matchCommon | src/pages/match/common | 3 | 已有用法 | | common | src/utils | 2 | 非 pages 目录(分开列) | | common | src/hooks | 1 | 非 pages 目录(分开列) | ``` 若存在 **unprocessedCode**,在表格后追加: ``` ⚠️ 以下文件含模板字符串中文,需先使用 [i18n-fix] skill 处理后再重新扫描: - src/utils/api/uploadService.ts(line 60, 61) ``` 处理完模板字符串后,重新执行 Step 1–4 更新结果。
scripts结构下:
preview.js文件
/** * JSX 国际化预览脚本(只读,不修改源文件) * 运行后返回哪些文件会被修改,不展示具体 diff * * 原理:debug: true 时工具把替换结果写入 .debug.xxx,只有真正有内容替换才会生成, * 遍历源文件检查 .debug 文件是否存在即可判断该文件是否会被修改,最后清理 debug 文件。 */ const path = require('path'); const fs = require('fs-extra'); const { extract } = require('@ali/parrot-tool-must'); const { parseArgs } = require('./utils/parse-args'); const baseConfig = require(path.join(__dirname, '../../../../.must.config.js')); // 始终以项目根目录为工作目录,与执行位置无关 const cwd = path.resolve(__dirname, '../../../..'); /** * 预览哪些文件会被国际化修改 * @param {string[]} files 文件绝对路径列表 * @return {Promise<{changedFiles: string[], unprocessedCode: object}>} */ async function preview(files, config) { try { const { unprocessedCode } = await extract.js.parse(files, config); // debug 文件存在 = 该文件有可自动替换的中文 const changedFiles = files.filter(filePath => { const p = path.parse(filePath); return fs.existsSync(path.join(p.dir, `${p.name}.debug${p.ext}`)); }); // unprocessedCode 的文件含有模板字符串中文,需手动处理,也属于受影响文件 const unprocessedFiles = Object.keys(unprocessedCode || {}); // 合并去重(同一文件可能既有自动替换又有模板字符串) const allChanged = [...new Set([...changedFiles, ...unprocessedFiles])]; const relativeUnprocessed = {}; Object.entries(unprocessedCode || {}).forEach(([filePath, items]) => { relativeUnprocessed[path.relative(cwd, filePath)] = items; }); return { changedFiles: allChanged.map(f => path.relative(cwd, f)), unprocessedCode: relativeUnprocessed, }; } finally { // 清理 debug 文件 files.forEach(filePath => { const p = path.parse(filePath); fs.removeSync(path.join(p.dir, `${p.name}.debug${p.ext}`)); }); } } async function run() { const params = parseArgs(); const targetPath = params.path || params.dir; const config = { ...baseConfig, cwd, // 覆盖 baseConfig.cwd('./'),避免 must 工具在 skills 目录下创建多余的 src 目录 ...(targetPath ? { sourcePath: targetPath } : {}), debug: true, }; const files = extract.findFiles({ ...config, cwd }); const { changedFiles, unprocessedCode } = await preview(files, config); console.log(`共 ${changedFiles.length} 个文件会被修改:`); changedFiles.forEach(f => console.log(` - ${f}`)); if (Object.keys(unprocessedCode).length > 0) { console.log('\n以下模板字符串需手动处理:'); Object.entries(unprocessedCode).forEach(([filePath, items]) => { console.log(` ${filePath}`); items.forEach(({ line, column, code }) => { console.log(` line ${line}:${column} ${code}`); }); }); } } run();
check.js文件
/** * i18n 三语言 key 一致性检查脚本 * * 以 zh-CN 为基准,检查 zh-HK 和 en 是否与其完全一致,包含三类检查: * 1. key 一致性:缺少或多余的叶子 key * 2. 结构一致性:{ a: { b: "x" } } 与 { "a.b": "x" } 被视为结构不同 * 3. 顺序提示(弱提示):叶子 key 的排列顺序与 zh-CN 不一致 * * 用法(从本文件所在目录执行): * node scripts/check.js # 检查全部命名空间 */ const path = require('path'); const fs = require('fs'); const LOCALES_DIR = path.resolve(__dirname, '../../../../src/i18n/locales'); const LANGS = ['zh-CN', 'zh-HK', 'en']; /** * 递归遍历 JSON,同时收集: * - leaves:所有叶子节点的路径(点分隔,用于 key 一致性检查,保留插入顺序) * - branches:所有中间对象节点的路径(用于结构一致性检查) * * 注意:路径中的点来自 JSON 层级拼接,与 key 名称中本身含有的点共用同一符号。 * 只要两个文件使用相同拼接规则,对比结果就是准确的。 */ function extractStructure(obj, prefix = '') { const leaves = []; const branches = []; for (const [k, v] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${k}` : k; if (v !== null && typeof v === 'object' && !Array.isArray(v)) { branches.push(fullKey); const child = extractStructure(v, fullKey); leaves.push(...child.leaves); branches.push(...child.branches); } else { leaves.push(fullKey); } } return { leaves, branches }; } /** * 检查两个有序 key 列表的相对顺序是否一致(只对比共同存在的 key) * 返回顺序不一致的 key 数量,0 表示顺序相同 */ function checkOrder(baseLeaves, langLeaves) { const langSet = new Set(langLeaves); const baseSet = new Set(baseLeaves); // 取各自文件中共同 key 的排列序列 const commonInBase = baseLeaves.filter(k => langSet.has(k)); const commonInLang = langLeaves.filter(k => baseSet.has(k)); if (commonInBase.join('\0') === commonInLang.join('\0')) return 0; // 计算有多少个 key 位置与 zh-CN 不同 let diffCount = 0; for (let i = 0; i < commonInBase.length; i++) { if (commonInBase[i] !== commonInLang[i]) diffCount++; } return diffCount; } /** * 扫描 JSON 原始文本中的重复 key,按对象作用域分别检测 * 用栈跟踪嵌套层级,仅在同一父对象内出现两次才视为重复 * 返回重复 key 数组,无重复则返回空数组 */ function findDuplicateKeys(rawText) { const duplicates = []; // 每个栈帧对应一个 JSON 对象作用域,存储该作用域内已见过的 key const stack = [new Set()]; for (const line of rawText.split('\n')) { const trimmed = line.trim(); if (!trimmed) continue; // 先处理 } —— 离开当前作用域 if (trimmed.startsWith('}')) { if (stack.length > 1) stack.pop(); } // 检测当前行的 key(仅匹配行首的 "key": 形式) const keyMatch = trimmed.match(/^"([^"]+)"\s*:/); if (keyMatch) { const key = keyMatch[1]; const currentSet = stack[stack.length - 1]; if (currentSet.has(key)) { if (!duplicates.includes(key)) duplicates.push(key); } else { currentSet.add(key); } } // 后处理 { —— 进入新作用域 if (trimmed.endsWith('{')) { stack.push(new Set()); } } return duplicates; } /** * 读取并解析指定语言 + 命名空间的 JSON 文件,失败返回 null * 同时检测文件中是否存在重复 key(JSON.parse 会静默合并,需要提前扫描) */ function readJSON(lang, namespace) { const filePath = path.join(LOCALES_DIR, lang, `${namespace}.json`); if (!fs.existsSync(filePath)) return null; try { const raw = fs.readFileSync(filePath, 'utf-8'); const duplicates = findDuplicateKeys(raw); if (duplicates.length > 0) { console.error( ` ⚠️ 重复 key(${lang}/${namespace}.json):${duplicates.map(k => `"${k}"`).join(', ')}`, ); } return JSON.parse(raw); } catch (e) { console.error(` ⚠️ 解析失败:${filePath}\n ${e.message}`); return null; } } /** * 检查单个命名空间 * @returns {{ ok: boolean, issues: string[], orderHints: string[] }} */ function checkNamespace(namespace) { const issues = []; const orderHints = []; const baseJSON = readJSON('zh-CN', namespace); if (baseJSON === null) { return { ok: false, issues: [`zh-CN/${namespace}.json 不存在`], orderHints, }; } const base = extractStructure(baseJSON); const baseLeaves = new Set(base.leaves); const baseBranches = new Set(base.branches); for (const lang of ['zh-HK', 'en']) { const langJSON = readJSON(lang, namespace); if (langJSON === null) { issues.push(`[${lang}] 文件不存在:${lang}/${namespace}.json`); continue; } const lang_ = extractStructure(langJSON); const langLeaves = new Set(lang_.leaves); const langBranches = new Set(lang_.branches); // ── 1. key 一致性(叶子节点) ────────────────────────────── const missingLeaves = [...baseLeaves].filter(k => !langLeaves.has(k)); const extraLeaves = [...langLeaves].filter(k => !baseLeaves.has(k)); if (missingLeaves.length > 0) { issues.push( `[${lang}] 缺少 ${missingLeaves.length} 个 key(存在于 zh-CN):\n` + missingLeaves.map(k => ` - ${k}`).join('\n'), ); } if (extraLeaves.length > 0) { issues.push( `[${lang}] 多余 ${extraLeaves.length} 个 key(不存在于 zh-CN):\n` + extraLeaves.map(k => ` - ${k}`).join('\n'), ); } // ── 2. 结构一致性(对象节点 vs 平铺点分 key) ───────────── const structDiffs = []; // zh-CN 是嵌套对象,lang 用了平铺 key(branch 消失) for (const b of baseBranches) { if (!langBranches.has(b)) { structDiffs.push(`"${b}" 在 zh-CN 是嵌套对象,在 ${lang} 中为平铺 key`); } } // lang 是嵌套对象,zh-CN 用了平铺 key(lang 多出 branch) for (const b of langBranches) { if (!baseBranches.has(b)) { structDiffs.push(`"${b}" 在 ${lang} 是嵌套对象,在 zh-CN 中为平铺 key`); } } if (structDiffs.length > 0) { issues.push( `[${lang}] 结构不一致(${structDiffs.length} 处):\n` + structDiffs.map(d => ` - ${d}`).join('\n'), ); } // ── 3. 顺序提示(弱提示,不影响 ok 状态) ───────────────── const diffCount = checkOrder(base.leaves, lang_.leaves); if (diffCount > 0) { orderHints.push(`[${lang}] ${diffCount} 个 key 顺序与 zh-CN 不一致`); } } return { ok: issues.length === 0, issues, orderHints }; } /** * 从 zh-CN 目录获取所有命名空间名称 */ function getAllNamespaces() { const dir = path.join(LOCALES_DIR, 'zh-CN'); if (!fs.existsSync(dir)) { console.error(`目录不存在:${dir}`); return []; } return fs .readdirSync(dir) .filter(f => f.endsWith('.json')) .map(f => f.replace('.json', '')) .sort(); } function run() { const namespaces = getAllNamespaces(); if (namespaces.length === 0) { console.log('未找到任何命名空间文件'); return; } const scope = `全部 ${namespaces.length} 个命名空间`; console.log(`\n🔍 i18n key 一致性检查 — ${scope}`); console.log(`基准语言:zh-CN 对比语言:zh-HK / en`); console.log(`检查内容:key 完整性 + 嵌套结构一致性 + 顺序提示`); console.log('─'.repeat(60)); let okCount = 0; const failList = []; let totalOrderHints = 0; for (const ns of namespaces) { const { ok, issues, orderHints } = checkNamespace(ns); if (ok) { const orderSuffix = orderHints.length > 0 ? ` · ${orderHints.join(' ')}` : ''; console.log(` ✅ ${ns}${orderSuffix}`); okCount++; totalOrderHints += orderHints.length; } else { console.log(` ❌ ${ns}`); issues.forEach(issue => { issue.split('\n').forEach(line => console.log(` ${line}`)); }); if (orderHints.length > 0) { orderHints.forEach(hint => console.log(` · ${hint}`)); totalOrderHints += orderHints.length; } failList.push(ns); } } console.log('─'.repeat(60)); const orderNote = totalOrderHints > 0 ? `,· ${totalOrderHints} 处顺序提示` : ''; console.log( `\n共检查 ${namespaces.length} 个命名空间:` + `✅ ${okCount} 个一致` + (failList.length > 0 ? `,❌ ${failList.length} 个存在差异` : '') + orderNote, ); if (failList.length > 0) { process.exit(1); } } run();
utils/parse-args.js文件
/** * 解析命令行参数,支持 --key=value 格式 */ function parseArgs() { const args = process.argv.slice(2); const params = {}; args.forEach(arg => { if (arg.startsWith('--')) { const [key, value] = arg.substring(2).split('='); params[key] = value; } }); return params; } module.exports = { parseArgs };
最后是执行的i18n-transform
目录结构:

SKILL.md文件
--- name: i18n-transform description: 将指定目录下的中文文案提取并转换为 i18next 调用,同时自动处理转换后可能产生的问题(模块顶层初始化、中文比较、TypeScript 类型支持、重复翻译合并),最终补全 zh-HK / en 翻译资源。当用户说"转换中文"、"提取 i18n"、"运行 must 转换"、"国际化某个目录"时使用。必须指定 --path 和 --namespace 参数。可携带翻译对照表(zh-CN / zh-HK / en 三列 Markdown 表格)用于自动补全多语言,未携带则仅输出缺失清单供人工处理。 --- # i18n Transform ## 前置检查 ### 1. 环境检查(必须通过) **在执行任何转换操作前,必须先调用 i18n-scan skill 的 Step 0 进行环境检查:** 从 `.cursor/skills/i18n-scan/` 目录执行环境检查: ```bash # 1. 检查 must 工具是否安装 grep "@ali/parrot-tool-must" package.json # 2. 检查配置文件是否存在 ls -la .must.config.js ``` **若检查失败(must 未安装或配置缺失):** 1. 停止执行当前 i18n-transform 流程 2. 提示用户环境未就绪: ``` ❌ 环境未就绪,无法执行 i18n 转换 缺失:<must 工具 / 配置文件> 请先完成环境配置,或使用 i18n-scan skill 自动检查和配置环境。 ``` 3. 等待用户解决环境问题后重新开始 **环境检查通过后,继续执行下方"翻译对照表"和后续步骤。** ### 2. 翻译对照表 本 skill 支持接收一份 **翻译对照表**,用于 Step 8 自动补全 zh-HK / en 翻译资源。格式为三列 Markdown 表格: ``` | zh-CN | zh-HK | en | |---|---|---| | 上传成功 | 上傳成功 | Upload successful | | 昨天 | 昨天 | Yesterday | ``` **若用户未提供对照表:** 在环境检查通过后主动询问用户: > "是否提供翻译对照表?提供后可在 Step 8 自动补全 zh-HK / en 翻译,否则仅输出缺失清单供人工处理。" 若用户明确表示不提供,则跳过询问直接执行,Step 8 按无对照表模式处理。 ## 执行步骤 ### Step 1:运行转换脚本 从本文件所在目录(`.claude/skills/i18n-transform/`)执行: ```bash node scripts/transform.js --path=<目标目录> --namespace=<命名空间> ``` **参数说明:** - `--path`:要转换的目录或文件路径,相对于项目根目录,例如 `./src/utils` 或 `./src/pages/Home` - `--namespace`:i18next 命名空间,例如 `common`、`home`、`matchCommon` **示例:** ```bash node scripts/transform.js --path=./src/utils --namespace=common node scripts/transform.js --path=./src/pages/Home --namespace=home ``` 脚本会: 1. 提取目标目录下所有文件中的中文文案 2. 生成 i18next 调用(`i18next.t("<namespace>:<key>")`)并写回源文件 3. 将提取的 zh-CN 语言资源合并写入 `src/i18n/locales/zh-CN/<namespace>.json` 4. 在终端输出:提取 key 数、`failList`、以及需要手动处理的 `unprocessedCode` ### Step 2:检查 unprocessedCode 脚本输出中若有 `unprocessedCode`,说明存在模板字符串无法自动转换,需要先使用 **i18n-fix** skill 处理后再重新执行 Step 1。 ``` ⚠️ 以下模板字符串需手动处理(unprocessedCode): src/utils/api/uploadService.ts line 60:16 `你好${name.toString()}` ``` ### Step 3:新命名空间初始化(仅新命名空间) 检查 `src/i18n/locales/en/` 下是否已存在该命名空间的 JSON 文件: ```bash ls src/i18n/locales/en/<namespace>.json ``` 若文件不存在,说明是新命名空间,需创建 en / zh-HK 语言文件并在页面入口注册命名空间。详细操作参考: → `references/post-transform-issues.md` **Step 0** ### Step 4:TypeScript 类型支持(仅新命名空间) 检查 `src/global.d.ts` 是否已包含该命名空间: ```bash grep -n "<namespace>" src/global.d.ts ``` 若无结果,说明是新命名空间,需追加类型声明。详细操作参考: → `references/post-transform-issues.md` **Step 1** ### Step 5:修复模块顶层初始化问题 脚本转换会将顶层常量对象/数组中的中文替换为 `i18next.t()`,但这会在模块加载时报错(命名空间尚未加载)。 使用以下命令扫描可能有问题的位置: ```bash git diff --name-only | xargs grep -n "i18next.t(" ``` **判断是否需要 getter:** 仅当代码位于文件顶层(所有函数/类之外)且在对象属性或数组元素中时才需要。 > **⚠️ 注意 enum 场景**:扫描时若发现 `enum` 成员被替换为 `i18next.t()` 调用(TypeScript 编译错误),直接还原为原始字符串字面量并标注 `TODO-i18n`,引用该 enum 的顶层展示 Map/Options 按正常 getter 模式处理即可。 详细修复模式(含 enum 子场景)参考: → `references/post-transform-issues.md` **Step 2** ### Step 6:处理中文比较问题 must 工具会将所有中文字符串替换为 `i18next.t()`,但部分中文字符串用于逻辑比较(如 `===`)或作为对象 key 访问,转换后会产生运行时错误。 使用以下命令扫描: ```bash # 相等性比较 git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "===|==|!==|!=" # 对象属性访问 git diff --name-only | xargs grep -n "\[i18next\.t(" # 字符串方法(⚠️ 容易遗漏) git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "\.indexOf\(|\.includes\(|\.startsWith\(|\.endsWith\(|\.match\(|\.search\(|\.split\(|\.replace\(|\.replaceAll\(|\.localeCompare\(" ``` **处理策略(按数据来源区分):** - 前端定义的数据 → 添加唯一 `key` 字段,改用 key 比较 - 后端返回的中文 → 还原为中文字符串比较,渲染时使用动态翻译 - 对象属性访问 → 还原为中文字符串 详细修复模式参考: → `references/post-transform-issues.md` **Step 3** #### 6a:清理被还原的孤立翻译 key **每当将 `i18next.t()` 还原为中文字符串时,脚本写入 JSON 的对应 key 就变成了孤立 key(代码中已无引用),必须从语言文件中手动删除。** 操作方式:对上一步每条"已还原"的记录,找到其对应的 i18next key,从 `src/i18n/locales/zh-CN/<namespace>.json` 中删除该条目。 示例: ``` 还原:name3.split(i18next.t('common:api.unnamed_content')) → name3.split('|') → 需从 common.json 中删除孤立 key:api.unnamed_content ``` #### 6b:输出修复摘要(必须输出) 处理完成后,**必须**向用户输出本步骤完整摘要,格式如下: ``` 📋 Step 6 中文比较问题处理结果: 已处理(共 N 处): - src/pages/Home/index.tsx:84 [相等性比较] name === i18next.t(...) → 改用 value 比较 - src/utils/common.ts:120 [对象属性访问] publicIcon[i18next.t(...)] → publicIcon['自定义'] - src/utils/api/foo.ts:79 [字符串方法-还原] name3.split(i18next.t(...)) → name3.split(noI18n('|')) └─ 已从 common.json 删除孤立 key: api.unnamed_content ⚠️ 以上所有处理均需人工验证(共 N 处): - src/pages/Home/index.tsx:84 请确认 value 比较逻辑覆盖所有分支,无遗漏边界情况 - src/utils/common.ts:120 请确认对象 key 确实来自服务端中文,且 noI18n 包裹位置正确 - src/utils/api/foo.ts:79 请确认还原后逻辑正确,孤立 key 已从翻译文件中删除 ⚠️ 还需人工处理(共 N 处): - src/pages/Home/index.tsx:210 [字符串方法] text.split(i18next.t(...)) 数据来源不明,已标注 TODO-i18n - src/utils/api/bar.ts:45 [相等性比较] 前端数据结构复杂,建议人工确认 key 字段是否完整覆盖 ``` > **⚠️ 强制规范**: > > 1. **绝不能**输出"⚠️ 需人工跟进(共 0 处):无"。凡是本步骤做过任何修改(含自动修复),都必须在"以上所有处理均需人工验证"中逐条列出,要求人工逐一核查。 > 2. 自动修复不等于正确——数据来源判断、边界情况、孤立 key 清理均可能出错,必须人工兜底。 > 3. 代码中已标注 `TODO-i18n` 的位置必须告知用户,方便后续跟进。 若本步骤扫描结果为空(无任何中文比较),输出"✅ Step 6:未发现中文比较问题"即可。 ### Step 7:合并重复翻译 转换后 `<namespace>.json` 中可能存在多个相同中文值,将它们提取到 `common` 对象统一管理。 ```bash cat src/i18n/locales/zh-CN/<namespace>.json | grep -oP '": "\K[^"]+' | sort | uniq -d ``` ⚠️ 批量替换时务必使用精确匹配,避免误替换语义不同的 key。 详细步骤参考: → `references/post-transform-issues.md` **Step 4** ### Step 8:补全 zh-HK / en 翻译资源 从 `.cursor/skills/i18n-scan/` 目录运行 locale 校验,关注所有命名空间的结果(重点处理当前命名空间): ```bash node scripts/check.js ``` 检查结果分三类,处理优先级从高到低: **① 结构不一致(❌,须修复)** 将目标语言文件中平铺的点分 key 改写为与 zh-CN 相同的嵌套对象格式。 **② key 顺序提示(·,建议修复)** 按 zh-CN 中的 key 排列顺序,调整目标语言文件中对应 key 的位置,保持三语言文件 diff 对比友好。 **③ 缺失 key(最常见)** - **有翻译对照表**:按对照表逐 key 查找并写入,找不到的收集为"无法补全"列表,最后二次运行 check 验证,输出补全摘要 - **无翻译对照表**:将缺失 key 及其 zh-CN 值整理后告知用户,不做任何翻译 详细流程及输出格式参考: → `references/fill-translations.md` 全部处理完成后再次运行 i18n-scan locale 校验,确认当前命名空间输出 ✅ 且无 `·` 顺序提示。详细步骤参考: → `.cursor/skills/i18n-scan/references/check.md` --- ### Step 9:全面巡检与收尾 Step 8 完成后,运行 i18n-scan 两个维度做全量收尾检查: ```bash # 1. 源码扫描:确认是否还有未转换的中文 node .cursor/skills/i18n-scan/scripts/preview.js --path=<本次转换的目录> # 2. locale 校验:确认所有命名空间对齐 node .cursor/skills/i18n-scan/scripts/check.js ``` #### 9a:处理源码扫描结果 **⚠️ 重要原则:禁止 AI 自行判断"不需要转换"** 只要源码扫描发现中文,**必须**以警告形式向用户报告,不得自行判断为"不需要转换"就跳过。即使是以下场景也必须报告: - enum 注释中的中文 - 模板字符串中的中文(即使是纯 URL) - 代码注释中的中文 - 任何其他形式的中文 **报告格式(强制要求):** ``` ⚠️ 源码扫描发现残留中文(共 N 处): 必须人工确认是否需要处理: 1. src/apis/superMatch/group.type.ts - line 4:7 /** 单打 */ [enum 注释] - line 6:7 /** 双打 */ [enum 注释] - line 223 '不限' [enum 值/后端标识] 2. src/pages/match/TennisAllVideo/index.tsx - line 177:12 // 网球分享打开报告页 [代码注释] 说明: - enum 注释:用于代码文档,通常不需要国际化,但如果展示给用户则需要处理 - enum 值:如果是后端返回的数据标识,不能修改;如果是前端展示文本,需要国际化 - 代码注释:通常不需要国际化,除非是展示给用户的注释 - 模板字符串:即使是 URL,如果包含中文参数或注释也需要确认 👉 请确认以上内容是否需要国际化处理。 ``` **若源码扫描仍发现待转换的中文,判断是否重新执行:** 重新执行前,记录本轮已处理的文件列表,与上一轮对比: - **新增文件出现**:正常情况,继续执行 Step 1 - **与上一轮完全相同的文件再次出现**:说明这些文件存在无法自动处理的特殊情况,**停止重试**,以警告形式列出文件和具体行号、中文内容 - **已连续执行 3 轮仍有剩余**:无论是否有进展,**停止重试**,以警告形式输出当前剩余文件清单 #### 9b:处理 locale 校验结果 **若 locale 校验存在 ❌ 差异:** 重新执行 Step 8 处理,不计入重试轮次。 #### 9c:输出最终摘要 **当 locale 校验全部通过后,输出最终摘要:** ``` ✅ i18n-transform 完成 转换目录:<path> 命名空间:<namespace> 执行轮次:N 轮 locale 校验:✅ 全部命名空间对齐 源码扫描结果: ✅ 无残留中文 或 ⚠️ 发现 N 处中文待确认(详见上方警告列表) - <文件1>:N 处 - <文件2>:N 处 ⚠️ Step 6 需人工验证(如有): - <文件>:<行号> <修复内容> - 请人工验证逻辑正确性 ``` > **强制规范**: > > 1. **绝不能**输出"源码扫描:✅ 无残留中文"而实际上存在中文(即使是注释或特殊场景) > 2. 所有残留中文必须以警告形式列出,附上文件路径、行号、具体内容和可能的类型标注 > 3. 由用户最终决定是否需要处理,AI 只负责报告,不做判断
references结构下:
fill-translations.md文件
# 补全 zh-HK / en 翻译资源 ## 有翻译对照表 ### 匹配规则 对照表通常只含**纯中文文本**映射,不含插值变量。因此匹配前需先判断: **① zh-CN 值含有 `{{...}}` 插值变量** - 对照表中不会存在此类条目,**直接归入"无法补全"列表**,不尝试匹配 - 示例:`"你好{{name}}"` / `"今日{{totalNum}}场比赛"` → 无法从对照表补全,需人工翻译 **② zh-CN 值为纯文本** - 在对照表中按 zh-CN 值精确查找 - 找到 → 写入;未找到 → 归入"无法补全"列表,**不自动翻译** ### en 翻译的插值空格规范 当人工补充含插值变量的 en 翻译时(不在本 skill 自动处理范围内,仅作提醒),须遵守: - 变量与相邻文字之间加空格:`"{{totalNum}} matches today"` ✅,`"{{totalNum}}matches today"` ❌ - 冒号等标点后、变量前加空格:`"Next opponent: {{name}}"` ✅,`"Next opponent:{{name}}"` ❌ ### 输出摘要 写入完成后再次运行 i18n-check 验证,输出摘要: ``` 📋 Step 8 翻译补全结果(命名空间:<namespace>): ✅ 已从对照表补全(共 N 个 key): - zh-HK: api.uploaded_successfully → "上傳成功" - en: api.uploaded_successfully → "Upload successful" ⚠️ 无法补全,需人工翻译(共 N 个 key): - api.hello_name zh-CN: "你好{{name}}" [含插值变量,对照表不含此类条目] - utils.pdf_document zh-CN: "PDF文档" [对照表中未找到] ✅ i18n-check 二次验证:<namespace> 通过 / 仍有 N 处差异(见上方缺失项) ``` ## 无翻译对照表 将缺失 key 及其 zh-CN 值整理后告知用户,不做任何翻译: ``` 📋 Step 8 缺失翻译清单(命名空间:<namespace>): [zh-HK 缺少] - api.uploaded_successfully zh-CN: "上传成功" - utils.yesterday zh-CN: "昨天" [en 缺少] - api.uploaded_successfully zh-CN: "上传成功" - utils.yesterday zh-CN: "昨天" 请补充后重新运行 i18n-check 验证。 ```
post-transform-issues.md文件
# 转换后问题处理指引 ## Step 0:新命名空间初始化 **仅在命名空间为新增时执行**。判断方式:检查 `src/i18n/locales/en/` 下是否已存在该文件: ```bash ls src/i18n/locales/en/<namespace>.json ``` ### 0a:创建 en 和 zh-HK 语言文件 转换脚本已自动创建 `zh-CN/<namespace>.json`,还需手动创建另外两个语言的空文件: ```bash echo '{}' > src/i18n/locales/en/<namespace>.json echo '{}' > src/i18n/locales/zh-HK/<namespace>.json ``` ### 0b:在页面入口文件注册命名空间 在 `--path` 对应目录的入口文件(通常是 `index.tsx`)的**组件定义之前**添加 `loadNamespaceInternal` 调用: ```typescript import zhCN from '@/i18n/locales/zh-CN/<namespace>.json'; import zhHK from '@/i18n/locales/zh-HK/<namespace>.json'; import en from '@/i18n/locales/en/<namespace>.json'; import { loadNamespaceInternal } from '@/i18n/utils'; // 注册命名空间(必须在组件定义之前执行) loadNamespaceInternal('<namespace>', { 'zh-CN': zhCN, 'zh-HK': zhHK, en, }); ``` **注意事项:** - `loadNamespaceInternal` 必须在所有组件 / Hook / 函数定义之前调用(模块顶层) - 命名空间名称须与 `--namespace` 参数完全一致 - 若 `--path` 目录没有页面入口文件(如 `src/utils`、`src/hooks` 等非 pages 目录),跳过 0b --- ## Step 1:TypeScript 类型支持 **仅在命名空间为新增时执行**。判断方式:检查 `src/global.d.ts` 中是否已有该命名空间的 import 和类型声明。 ```bash grep -n "<namespace>" src/global.d.ts ``` 若无结果,在 `src/global.d.ts` 中追加: ```typescript import <namespace> from "./i18n/locales/zh-CN/<namespace>.json"; declare module "i18next" { interface CustomTypeOptions { resources: { // ...已有命名空间保持不变... <namespace>: typeof <namespace>; // 追加此行 }; } } ``` --- ## Step 2:修复模块顶层初始化问题 ### 问题识别 ```bash git diff --name-only | xargs grep -n "i18next.t(" ``` **需要 getter 的判断条件(同时满足):** - 在文件顶层(所有函数/类之外) - 在对象字面量的属性值或数组元素中 **不需要 getter 的场景:** - 函数/组件/Hook 内部定义的常量 - `useState` 初始值 - 直接赋值给顶层变量(`const x = i18next.t(...)`,这种写法本身会报错,需整体移入函数内) ### 修复方式 **模式 1:顶层对象属性** ```typescript // ❌ 转换后 export const roleName = { PLAYER: i18next.t('home:userpart.contestants'), }; // ✅ 修复 export const roleName = { get PLAYER() { return i18next.t('home:userpart.contestants'); }, }; ``` **模式 2:顶层嵌套对象** ```typescript // ❌ 转换后 const EntranceMap = { item: { name: i18next.t('home:service.entry') }, }; // ✅ 修复(只在 name 上加 getter) const EntranceMap = { item: { get name() { return i18next.t('home:service.entry'); }, }, }; ``` **模式 3:顶层数组元素** ```typescript // ❌ 转换后 const tabList = [ { label: i18next.t('home:tab.all'), value: 'all' }, ]; // ✅ 修复 const tabList = [ { get label() { return i18next.t('home:tab.all'); }, value: 'all' }, ]; ``` **模式 4:配置文件(Taro `*.config.ts` / ICE `build.config.ts` 等)** `export default {}` 不支持 getter,且此类文件已被 `.must.config.js` 排除。若 `git diff` 仍显示配置文件被修改,执行以下还原流程: ```bash # 1. 确认被修改的配置文件 git diff --name-only | grep -E '\.config\.(ts|js)$' # 2. 查看新增了哪些 i18next key git diff <config-file> # 3. 还原配置文件 git checkout -- <config-file> ``` 还原后,从 `src/i18n/locales/zh-CN/<namespace>.json` 中手动删除第 2 步提取到的 key,并告知用户哪些文件被还原、哪些 key 被移除。 **模式 5:TypeScript enum 成员被错误替换** must 工具会把字符串 enum 成员值也替换为 `i18next.t()` 调用,但 TypeScript 要求 string enum 成员只能是字符串字面量,**这是编译错误**。发现时直接还原并标注 TODO-i18n,交由人工处理: ```typescript // ❌ must 错误转换 export enum GenderLimitEnum { MALE = i18next.t('common:supermatch.male'), } // ✅ 还原,标注 TODO 交人工判断 export enum GenderLimitEnum { // TODO-i18n: enum 值为中文常量,建议改为英文字面量(如 MALE = 'MALE')以避免与翻译耦合 MALE = '男', } ``` enum 还原后,引用它的顶层展示 Map / Options 中的**展示文本**按模式 1/3 正常加 getter 即可。 --- ## Step 3:处理中文比较问题 ### 搜索命令 ```bash # 1. 相等性比较 git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "===|==|!==|!=" # 2. 对象属性访问 git diff --name-only | xargs grep -n "\[i18next\.t(" # 3. 字符串方法(⚠️ 最容易遗漏) git diff --name-only | xargs grep -n "i18next\.t(" | grep -E "\.indexOf\(|\.includes\(|\.startsWith\(|\.endsWith\(|\.match\(|\.search\(|\.split\(|\.replace\(|\.replaceAll\(|\.localeCompare\(" ``` ### 处理策略 > **⚠️ 核心原则:代码追溯,而非猜测** > > 处理中文比较问题的关键是**通过代码追溯准确判断数据来源**,而不是依赖字段名或用途猜测。 --- #### 步骤 1:系统性追溯数据来源 对于每个包含 `i18next.t()` 的比较语句,按以下优先级追溯: **1. 查看 TypeScript 类型定义** ```bash # 跳转到变量的类型定义 # 方法:Cmd/Ctrl + 点击变量名 → 查看类型 → 跳转到类型定义 ``` **判断标准:** | 类型定义位置 | 数据来源判断 | |---|---| | `src/services/types.ts` 或 API 相关文件 | **后端数据** | | `src/constants/` 或组件内部定义 | **前端数据** | | 类型注释中有 `@param` / `@returns` 说明 | 查看注释描述 | **示例:后端数据** ```typescript // 在 src/services/types.ts 中 export type MatchInfo = { /** 轮次描述 */ roundDesc: string; /** 比赛状态 */ status: MatchStatusType; }; // ↑ 在 types.ts 中定义 = 后端接口返回的数据结构 // 使用时 if (matchInfo?.roundDesc === i18next.t('...')) { } // ❌ 错误:后端数据不应与翻译文本比较 ``` **示例:前端数据** ```typescript // 在组件内或 constants.ts 中 const EntranceList = [ { key: 'schedule', get name() { return i18next.t('...'); } } ]; // ↑ 本地定义 = 前端数据 // 使用时 if (item.name === i18next.t('...')) { } // ❌ 错误:应比较 item.key ``` **2. 追溯变量赋值来源** ```bash # 在当前文件中搜索变量的赋值语句 # 或使用 "Go to Definition" 跳转到定义处 ``` **判断标准:** ```typescript // ✅ 后端来源特征 const data = await fetchAPI(); // API 调用 const value = response.model.xxx; // API 响应取值 const { status } = props; // 从 props 接收(可能来自上层 API) // ✅ 前端来源特征 const data = SomeMap[key]; // 从本地 Map/Object 取值 const value = SomeEnum.VALUE; // 枚举值 const list = items.map(x => ({ ... })); // 前端数据转换 ``` **3. 搜索该字段的其他使用场景** ```bash # 在整个项目中搜索该字段名 grep -rn "\.roundDesc" src/ --include="*.ts" --include="*.tsx" ``` 观察该字段在项目中的其他使用方式: - 若其他地方用 `noI18n()` 包裹 → 后端数据 - 若其他地方直接渲染 `<div>{xxx}</div>` → 后端数据 - 若其他地方用作 `i18next.t()` 的参数 → 可能是前端数据 **4. 查看 API 接口文件或接口文档** ```bash # 搜索 mtop API 定义 grep -rn "api: 'mtop\." src/services/ ``` 查看 API 返回的字段结构,确认该字段是否来自后端响应。 --- #### 步骤 2:根据数据来源选择处理策略 **场景 A:前端数据源的比较 → 改用稳定 key/value 比较** > 核心原则:绝不用翻译文本(`i18next.t()` 的返回值)做比较,翻译文本会随语言切换而变化。 **A1(优先):数据项已有稳定的 `value` / `type` / `id` 字段 → 直接比较该字段** ```typescript // 场景:subItem 来自前端数组,本身含有唯一 value 字段 // ❌ 转换后(用翻译文本比较) return subItem.label === i18next.t('ns:foo.not_used') ? i18next.t('ns:foo.no_eagle_eye') : subItem.label; // ✅ 修复(直接比较 value,语言无关) // 若外层还有类型区分(如 item.type),同时加上类型判断确保精确 return key === 'hawkeyeType' && subItem.value === 0 ? i18next.t('ns:foo.no_eagle_eye') : subItem.label; ``` **A2:数据项无稳定字段 → 添加 `key` 字段** ```typescript // ❌ 用翻译文本比较 if (name === i18next.t('home:service.schedule')) { } // ✅ 给数据结构添加唯一 key,改用 key 比较 const EntranceMap = { schedule: { key: 'schedule', get name() { return i18next.t('...'); } } }; if (item.key === 'schedule') { } ``` > ⚠️ 分析数据来源时,务必查看数据的定义处(如 `contants.ts`)。若 `label` 字段已是 `get label() { return i18next.t(...) }` 的 getter 形式,说明数据是**前端已国际化的数据**,使用场景 A。 **场景 B:后端数据源的比较 → 还原中文字符串,并用 `noI18n()` 包裹** `noI18n` 是项目的中文豁免标记函数(定义于 `src/i18n/utils.ts`),被它包裹的字符串会被 must 扫描工具自动跳过,同时向代码审查者表明此处中文是经过评估后刻意保留的。 ```typescript import { noI18n } from '@/i18n/utils'; // ❌ must 错误转换 if (status === i18next.t('home:status.in_progress')) { } // ✅ 还原(后端返回中文),用 noI18n 包裹标记意图 if (status === noI18n('进行中')) { } ``` **后端中文数据的渲染处理:** 当后端中文需要展示到页面时,使用**动态翻译**模式: ```typescript // 后端返回的中文字段 const genderLimit = '混双'; // 来自 API // ✅ 渲染时使用动态翻译(fallback 到原始值) <div> {i18next.t(`common:dynamic.${genderLimit}`, { defaultValue: genderLimit })} </div> // 在 common:dynamic 中预先配置好后端可能返回的所有中文值: // common/dynamic.json: // { // "男": "Male", // "女": "Female", // "混双": "Mixed Doubles", // "决赛": "Final", // "小组循环赛": "Round Robin" // } ``` **场景 C:对象属性用翻译文本作 key → 还原中文字符串,用 `noI18n()` 包裹** ```typescript // ❌ must 转换后 const icon = publicIcon[i18next.t('namespace:custom')]; // ✅ 还原(key 来自后端中文字段,必须保持中文) const icon = publicIcon[noI18n('自定义')]; // 或者当 key 是变量时 const icon = publicIcon[item.category]; // item.category 是后端返回的中文 ``` > **⚠️ 注意:** 在对象定义处也要检查中文 key 是否需要动态翻译。如果这个对象用于展示,考虑改造为: > ```typescript > const publicIconConfig = { > custom: { key: '自定义', icon: 'xxx' } > }; > // 渲染时:i18next.t(`common:dynamic.${iconItem.key}`) > ``` **场景 D:字符串方法与 i18next.t() 组合** - 有现成布尔属性可替代 → 直接修复 - 数据来源不明确或逻辑复杂 → 标注 TODO: ```typescript // TODO-i18n: 字符串方法与 i18next.t() 组合,需人工判断数据来源并修复 // 数据来源:[描述变量来自哪里] // 建议方案:[A/B/C/D 场景,具体修复思路] ``` --- #### 步骤 3:特殊场景处理技巧 **技巧 1:当字段在项目中被广泛使用时,建立"参考列表"** 如果追溯过程中发现某些后端字段被大量使用(如 `status`、`roundDesc`),可在当前会话中建立临时参考: ```bash # 搜索已确认的 noI18n() 使用 grep -rn "noI18n(" src/ --include="*.ts" --include="*.tsx" -A 1 # 输出示例: # src/pages/xxx.tsx: if (roundDesc === noI18n('决赛')) # ↑ 说明 roundDesc 已被确认为后端字段 ``` 基于已有的 `noI18n()` 使用,可推断相同字段名在其他地方也是后端数据。 **技巧 2:利用 Git Diff 对比转换前后的变化** ```bash # 查看某个文件转换前的内容 git show HEAD:path/to/file.tsx | grep "字段名" ``` 若转换前该字段是普通中文字符串(非 `i18next.t()`),很可能是后端数据。 **技巧 3:不确定时保守处理** 若追溯后仍无法 100% 确定数据来源,采用保守策略: ```typescript // TODO-i18n: 数据来源待确认 // 已追溯:[描述追溯过程] // 当前判断:[前端/后端/不确定] // 建议:人工验证后再修复,或询问项目负责人 if (xxx === i18next.t('...')) { } ``` 标注 TODO 后继续处理其他明确的问题,避免误改。 --- #### 步骤 4:验证修复结果 修复后,验证逻辑是否正确: **后端数据场景:** ```bash # 1. 搜索是否有遗漏的翻译文本比较 grep -rn "i18next\.t(" <修复的文件> | grep -E "===|!==|==" # 2. 确认 noI18n() 已正确导入 grep -n "import.*noI18n" <修复的文件> ``` **前端数据场景:** ```bash # 确认数据结构已添加稳定的 key 字段 grep -n "key:" <数据定义文件> ``` --- ## Step 4:合并重复翻译到 common 对象 ### 查找重复值 ```bash cat src/i18n/locales/zh-CN/<namespace>.json | grep -oP '": "\K[^"]+' | sort | uniq -d ``` ### 处理步骤 1. 将重复值提取到翻译文件顶部的 `common` 对象 2. 搜索旧 key 的所有引用,使用**精确匹配**替换为 `<namespace>:common.<key>` 3. 验证无误替换:对照翻译文件确认代码中的 key 都存在 **⚠️ 精确匹配,避免过度替换:** ```bash # ✅ 正确:key 后跟引号,确保完整匹配 sed -i '' 's/module\.confirm"/common.confirm"/g' file.tsx # ❌ 错误:前缀匹配会误替换 module.confirm_xxx sed -i '' 's/module\.confirm/common.confirm/g' file.tsx ``` 替换后验证: ```bash # 检查是否有未在翻译文件中定义的 key grep -rn "<namespace>:common\." <目标目录> --include="*.tsx" --include="*.ts" ```
scripts结构下:
transform.js文件
const path = require('path');
const fs = require('fs-extra');
const { extract } = require('@ali/parrot-tool-must');
const { parseArgs } = require('./utils/parse-args');
const { mergeLocales } = require('./utils/merge-locales');
const { writeZhCNLocale } = require('./utils/write-locales');
const baseConfig = require(path.join(__dirname, '../../../../.must.config.js'));
// 始终以项目根目录为工作目录,与执行位置无关
const cwd = path.resolve(__dirname, '../../../..');
async function run() {
const params = parseArgs();
const targetPath = params.path || params.dir;
const namespace = params.namespace || params.ns;
if (!targetPath || !namespace) {
console.error('❌ 缺少必要参数!');
console.log('用法: node scripts/transform.js --path=<目标目录> --namespace=<命名空间>');
process.exit(1);
}
const config = {
...baseConfig,
cwd, // 覆盖 baseConfig.cwd('./'),确保 must 工具以项目根目录为基准,不在 skills 目录下创建文件
sourcePath: targetPath,
macro: {
...baseConfig.macro,
method: `i18next.t("${namespace}:$key$")`,
},
};
const localesDir = path.resolve(cwd, config.macro.path);
const stringsDir = mergeLocales(localesDir, [namespace]);
try {
const start = Date.now();
const files = extract.findFiles({ ...config, cwd });
const result = await extract.js.parse(files, config);
const { strings, failList, unprocessedCode } = result;
if (strings?.['zh-CN']) {
writeZhCNLocale(strings['zh-CN'], namespace, cwd);
}
console.log('\n========== 转换结果 ==========');
console.log(`命名空间: ${namespace}`);
console.log(`目标目录: ${targetPath}`);
console.log(`提取 key 数: ${Object.keys(strings?.['zh-CN'] || {}).length}`);
if (failList?.length > 0) {
console.log(`\n⚠️ failList(${failList.length} 项):`);
failList.forEach(f => console.log(` - ${f}`));
}
if (Object.keys(unprocessedCode || {}).length > 0) {
console.log('\n⚠️ 以下模板字符串需手动处理(unprocessedCode):');
Object.entries(unprocessedCode).forEach(([filePath, items]) => {
console.log(` ${path.relative(cwd, filePath)}`);
items.forEach(({ line, column, code }) => {
console.log(` line ${line}:${column} ${code}`);
});
});
}
const elapsed = Date.now() - start;
console.log(`\n耗时: ${elapsed}ms`);
} catch (error) {
console.error('❌ 转换失败:', error);
process.exit(1);
} finally {
fs.removeSync(stringsDir);
}
}
run();
utils/merge-locales.js文件
/** * locales 多文件合并工具 * 把 {localesDir}/{lang}/*.json 合并成工具期望的 {localesDir}/strings/{lang}.json 单文件 * 供 generateKeyCheckObject 读取历史 key 做冲突检测 */ const path = require('path'); const fs = require('fs-extra'); /** * 递归把嵌套对象扁平化为点分隔的 key * { "a": { "b": "val" } } → { "a.b": "val" } */ function flattenObject(obj, prefix = '', result = {}) { Object.keys(obj).forEach(key => { const fullKey = prefix ? `${prefix}.${key}` : key; if (obj[key] !== null && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { flattenObject(obj[key], fullKey, result); } else { result[fullKey] = obj[key]; } }); return result; } /** * 把 localesDir 下各语言目录的 JSON 文件合并成 strings/{lang}.json * @param {string} localesDir 语言包根目录绝对路径 * @param {string[]} [includes] 指定合并哪些文件(不含扩展名),不传则合并全部 * @return {string} stringsDir */ function mergeLocales(localesDir, includes) { const stringsDir = path.resolve(localesDir, 'strings'); fs.ensureDirSync(stringsDir); const langs = fs.readdirSync(localesDir).filter(name => { return fs.statSync(path.resolve(localesDir, name)).isDirectory() && name !== 'strings'; }); langs.forEach(lang => { const langDir = path.resolve(localesDir, lang); const merged = {}; fs.readdirSync(langDir) .filter(f => { if (!f.endsWith('.json')) return false; if (includes && includes.length > 0) { return includes.includes(path.basename(f, '.json')); } return true; }) .forEach(file => { const data = fs.readJsonSync(path.resolve(langDir, file)); Object.assign(merged, flattenObject(data)); }); fs.writeJsonSync(path.resolve(stringsDir, `${lang}.json`), merged, { spaces: 2, }); }); return stringsDir; } module.exports = { flattenObject, mergeLocales };
utlis/parse-args.js文件
/** * 解析命令行参数,支持 --key=value 格式 */ function parseArgs() { const args = process.argv.slice(2); const params = {}; args.forEach(arg => { if (arg.startsWith('--')) { const [key, value] = arg.substring(2).split('='); params[key] = value; } }); return params; } module.exports = { parseArgs };
utlis/write-locales.js文件
const path = require('path');
const fs = require('fs-extra');
const { flattenObject } = require('./merge-locales');
/**
* 将单个扁平 key(如 "supermatch.unlimited")写入 JSON 对象。
* 策略:若 key 第一段在 obj 中已是嵌套对象,则递归写入其内部;
* 否则保持扁平 dotted-key 形式,与原来格式保持一致。
* @param {object} obj 目标 JSON 对象(原地修改)
* @param {string} flatKey 扁平 dotted key
* @param {string} value 翻译值
*/
function smartInsert(obj, flatKey, value) {
const dotIdx = flatKey.indexOf('.');
if (dotIdx === -1) {
if (!(flatKey in obj)) obj[flatKey] = value;
return;
}
const firstSeg = flatKey.slice(0, dotIdx);
const restKey = flatKey.slice(dotIdx + 1);
const child = obj[firstSeg];
if (child !== undefined && child !== null && typeof child === 'object' && !Array.isArray(child)) {
// 父节点已是嵌套对象 → 递归插入,保持嵌套风格
smartInsert(child, restKey, value);
} else {
// 父节点不存在或不是对象 → 保持扁平 dotted-key 风格
if (!(flatKey in obj)) obj[flatKey] = value;
}
}
/**
* 将提取的 zh-CN strings 合并写入 src/i18n/locales/zh-CN/<namespace>.json
* 已有 key 不覆盖,只追加新 key。
* 写入时智能匹配已有的 JSON 结构:
* - 若 key 前缀在已有 JSON 中已是嵌套对象(a: {b: ...}),则插入到嵌套对象内
* - 否则使用扁平 dotted-key 形式(a.b: "")
* @param {object} strings zh-CN 扁平 key-value 对象
* @param {string} namespace
* @param {string} cwd 项目根目录
*/
function writeZhCNLocale(strings, namespace, cwd) {
const localesZhCN = path.resolve(cwd, 'src/i18n/locales/zh-CN');
fs.ensureDirSync(localesZhCN);
const targetFile = path.join(localesZhCN, `${namespace}.json`);
const existing = fs.existsSync(targetFile) ? fs.readJsonSync(targetFile) : {};
const existingFlat = flattenObject(existing);
const newEntries = Object.entries(strings).filter(([key]) => !(key in existingFlat));
for (const [key, value] of newEntries) {
smartInsert(existing, key, value);
}
fs.writeJsonSync(targetFile, existing, { spaces: 2 });
console.log(`✅ zh-CN 语言资源已写入: ${targetFile}`);
}
module.exports = { writeZhCNLocale };
项目中的配置结构

中文配置举例

{ "bottombtn.confirm_cancellation_application": "是否确认撤销申请" }
英文配置举例

{ "bottombtn.confirm_cancellation_application": "Confirm Application Cancellation?", }
页面使用:
import i18next from 'i18next'; return ( <div>这里的翻译文案是: i18next.t('applicationForm:bottombtn.confirm_cancellation_application')</div> )

浙公网安备 33010602011771号