feat(i18n): support contextual translation keys

This commit is contained in:
kuaifan 2026-07-20 07:25:10 +00:00
parent df6c1fc04d
commit df4658cddf
36 changed files with 426 additions and 352 deletions

View File

@ -44,6 +44,8 @@ description: 从 `pro` 分支发布 DooTask 前端新版本:翻译 → 版本
多语言数据流:`language/original-{web,api}.txt`(原文/简体中文)→ 经翻译写入 `language/translate.json`(含 9 种语言)→ 生成 `public/language/{web,api}/*`
上下文翻译键用于保持短文案的界面宽度,同时区分不同语义,格式固定为 `[lower_snake_case].原文`,例如 `[weekday].一``[task_unit].个`。上下文前缀只参与翻译查找,不参与显示;运行时完整键缺失时会回退普通原文键。普通 key 的 `zh` 留空,上下文 key 的 `zh` 必须填写去掉前缀后的原文。
**1.1 检测差异**
```shell
@ -55,11 +57,11 @@ php .claude/skills/dootask-release/scripts/language.php diff
- `regexErrorCount > 0`translate.json **已有条目**的占位符与某语言值不一致 → **停止**,报告 `regexErrors`,交用户修复(这是历史数据问题,不要自行猜测修改)
- `redundantCount > 0`translate.json 里有、但原文已删除的条目 → 仅作提示apply 时会自动剔除,不致命)
- `needsCount == 0`:无新文案 → **跳到 1.4 直接生成**
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`)→ 进入 1.2
- `needsCount > 0``needs` 数组即待翻译清单,每项 `key` 已转成占位符形式(如 `(%T1)`;上下文 key 还会带自动填充的 `zh` → 进入 1.2
**1.2 翻译**
`needs` 里的每个 `key`,翻成 8 种语言(`zh` 留空、`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`
`needs` 里的每个 `key`,翻成 8 种语言(`key` 原样保留):`zh-CHT` `en` `ko` `ja` `de` `fr` `id` `ru`普通 key 的 `zh` 留空;上下文 key 的 `zh` 使用 `needs` 已给出的原文,不得留空或改写。
要求:贴合「项目任务管理系统」语境;占位符 `(%T1)`/`(%M1)` 等原样保留、不可增删改,位置可随目标语言语序调整:
@ -69,7 +71,7 @@ php .claude/skills/dootask-release/scripts/language.php diff
| (%T1)提交的「(%M2)」待你审批 | '(%M2)' submitted by (%T1) is waiting for your approval |
把结果写成一个 JSON 数组文件(建议放 `/tmp/dootask-release-translated.json`,避免污染工作区),每个元素含全部 10 个字段,顺序为:
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru``zh``""`
`key, zh, zh-CHT, en, ko, ja, de, fr, id, ru`
```json
[
@ -77,13 +79,21 @@ php .claude/skills/dootask-release/scripts/language.php diff
]
```
上下文短词示例:
```json
[
{"key":"[task_unit].个","zh":"个","zh-CHT":"個","en":"tasks","ko":"개 작업","ja":"タスク","de":"Aufgaben","fr":"tâches","id":"tugas","ru":"задач"}
]
```
**1.3 合并进 translate.json**
```shell
php .claude/skills/dootask-release/scripts/language.php apply /tmp/dootask-release-translated.json
```
脚本会校验字段完整性与占位符完整性、追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。参数化 key 必须使用 `(%T1)`/`(%M1)` 形式,禁止输入 raw `(*)`/`(**)`;编号须按出现顺序从 1 连续递增,各语言值可调整占位符顺序,但占位符的类型、编号和数量必须与 key 完全一致。输入 JSON 内相同或规范化后相同的 key 会被拒绝;已存在于 `translate.json` 的非待补项也会被拒绝,避免覆盖歧义。任一条不合格会明确报错并停止,按提示修正翻译后重试。
脚本会校验字段完整性、占位符完整性与上下文键格式,追加新条目、剔除冗余项,并按项目原生格式写回 `translate.json`。参数化 key 必须使用 `(%T1)`/`(%M1)` 形式,禁止输入 raw `(*)`/`(**)`;编号须按出现顺序从 1 连续递增,各语言值可调整占位符顺序,但占位符的类型、编号和数量必须与 key 完全一致。上下文 key 必须使用 `[lower_snake_case].原文`,且 `zh` 必须与原文部分完全一致。输入 JSON 内相同或规范化后相同的 key 会被拒绝;已存在于 `translate.json` 的非待补项也会被拒绝,避免覆盖歧义。任一条不合格会明确报错并停止,按提示修正翻译后重试。
**1.4 生成前端/后端语言文件**

View File

@ -54,6 +54,28 @@ function normalize_key(string $key): string
return preg_replace(["/\(%T\d+\)/", "/\(%M\d+\)/"], ["(*)", "(**)"], $key);
}
function context_source_key(string $key): ?string
{
if (preg_match('/^\[([a-z][a-z0-9_]*)]\.([\s\S]+)$/', $key, $matches)) {
return $matches[2];
}
return null;
}
function validate_original_contexts(array $originals): array
{
$errors = [];
foreach ($originals as $index => $key) {
if (preg_match('/^\[[^\]]+]\./', $key) && context_source_key($key) === null) {
$errors[] = [
'location' => "originals[index $index]",
'message' => "上下文翻译键格式错误:$key",
];
}
}
return $errors;
}
function validate_entry(array $obj, string $location, bool $requireTranslations): array
{
$formatErrors = [];
@ -72,6 +94,17 @@ function validate_entry(array $obj, string $location, bool $requireTranslations)
}
$key = $obj['key'];
if (preg_match('/^\[[^\]]+]\./', $key)) {
$sourceKey = context_source_key($key);
if ($sourceKey === null) {
$formatErrors[] = ['location' => "$location.key", 'message' => "上下文翻译键格式错误:$key"];
} elseif (($obj['zh'] ?? null) !== $sourceKey) {
$formatErrors[] = [
'location' => "$location.zh",
'message' => "上下文翻译键必须填写原文:$sourceKey",
];
}
}
if (preg_match('/\(\*{1,2}\)/', $key)) {
$formatErrors[] = [
'location' => "$location.key",
@ -134,7 +167,7 @@ function build_translations(array $originals): array
$translations = [];
$redundants = [];
$regexErrors = [];
$formatErrors = [];
$formatErrors = validate_original_contexts($originals);
if (!file_exists("translate.json")) {
fwrite(STDERR, "translate.json not exists\n");
exit(1);
@ -241,7 +274,12 @@ if ($cmd === 'diff') {
$label = strlen($m[1]) > 1 ? "M" : "T";
return "(%" . $label . $c++ . ")";
}, $key);
$needsOut[] = ['key' => $converted];
$need = ['key' => $converted];
$contextSource = context_source_key($converted);
if ($contextSource !== null) {
$need['zh'] = $contextSource;
}
$needsOut[] = $need;
}
echo json_encode([
@ -293,10 +331,10 @@ if ($cmd === 'apply') {
print_validation_errors($incomingFormatErrors, $incomingRegexErrors);
exit(1);
}
// 规范化:固定字段顺序 + zh 置空
// 规范化:固定字段顺序;上下文翻译键保留 zh 作为实际显示原文
$item = [];
foreach ($GLOBALS['LANG_FIELDS'] as $f) {
$item[$f] = $f === 'zh' ? '' : $raw[$f];
$item[$f] = $f === 'zh' && context_source_key($raw['key']) === null ? '' : $raw[$f];
}
$originalKey = normalize_key($item['key']);
if (!isset($originalSet[$originalKey])) {

View File

@ -68,6 +68,7 @@ Laravel 13 (LaravelS/Swoole, PHP 8.4) + Vue 2 (Vite) + Electron。开源任务/
- 新增用户可见文本须追加原文(简体中文)到:前端 `language/original-web.txt`,后端 `language/original-api.txt`(去重)
- 前端翻译用 `$L("文本")`,动态值用 `(*)` 占位:`$L('共(*)条', n)`——禁止拼接翻译
- 单字或短词因上下文不同可能产生歧义时,使用 `[lower_snake_case].原文` 上下文键,例如 `$L('[weekday].一')``$L('[task_unit].个')`;前缀只参与翻译查找,界面仍显示原文。此类键须完整登记到 `original-web.txt`,且 `translate.json``zh` 必须填写去除前缀后的原文;普通键的 `zh` 仍留空
## ai-kb 同步规则

View File

@ -851,21 +851,7 @@ Pro版
颜色
黄色
默认
(*)秒
(*)是一款轻量级的开源在线项目任务管理工具提供各类文档协作工具、在线思维导图、在线流程图、项目管理、任务分发、即时IM文件管理等工具。
流转到【(*)】时,[任务负责人] 和 [项目管理员] 可以修改状态。
文件(*)格式不正确,请上传(*)格式的图片。
@ -1346,7 +1332,6 @@ APP 推送
接龙
参与接龙
发起接龙
发起接龙,参与接龙目前共(*)人
请输入接龙主题
请输入接龙内容
@ -1354,7 +1339,6 @@ APP 推送
重复内容将不再计入接龙结果
返回编辑
继续发送
接龙结果
选择群组发起接龙
来自
@ -1371,7 +1355,6 @@ APP 推送
单选
请选择后投票
立即投票
再次发送
再次发送投票?
结束投票
@ -1758,7 +1741,6 @@ WiFi签到延迟时长为±1分钟。
签到半径设置
半径
经度
纬度
地图类型
@ -2064,7 +2046,6 @@ OKR群组
邀请地址不存在或已被删除!
会话名称
结果
命令
接口地址
@ -2509,7 +2490,6 @@ AI任务分析
个人视角
部门任务总览
(*) 人
最久 (*) 天
今天 (*) 项到期
还有 (*) 项
@ -2622,13 +2602,11 @@ AgoraIO 声网
任务状态
已还原
下个周期
LDAP 用户禁止修改邮箱
共(*)个
标签名称最多只能设置(*)个字
添加失败
未开启通知权限
使用端到端加密传输数据。
删除前,请确认以下事项:
已清楚风险,确定删除
@ -2658,3 +2636,23 @@ LDAP 用户禁止修改邮箱
(*) 负责的部门、项目、任务和文件将移交给交接人;同时退出所有群(如果是群主则转让给交接人)
正在编辑帐号【ID:(*), (*)】的信息。
打包下载(*)
[calendar_view].日
[calendar_view].周
[calendar_view].月
[day_unit].天
[distance_unit].米
[example_label].例
[initiator_label].由
[recurrence_prefix].每
[self_sender].你
[task_unit].个
[task_unit].项
[todo_target].给
[vote_unit].票
[weekday].日
[weekday].一
[weekday].二
[weekday].三
[weekday].四
[weekday].五
[weekday].六

View File

@ -10847,162 +10847,6 @@
"id": "Default",
"ru": "По умолчанию"
},
{
"key": "一",
"zh": "",
"zh-CHT": "一",
"en": "One",
"ko": "하나",
"ja": "一",
"de": "Eins",
"fr": "Un",
"id": "Satu",
"ru": "Один"
},
{
"key": "三",
"zh": "",
"zh-CHT": "三",
"en": "Three",
"ko": "셋",
"ja": "三",
"de": "Drei",
"fr": "Trois",
"id": "Tiga",
"ru": "Три"
},
{
"key": "二",
"zh": "",
"zh-CHT": "二",
"en": "Two",
"ko": "둘",
"ja": "二",
"de": "Zwei",
"fr": "Deux",
"id": "Dua",
"ru": "Два"
},
{
"key": "五",
"zh": "",
"zh-CHT": "五",
"en": "Five",
"ko": "다섯",
"ja": "五",
"de": "Fünf",
"fr": "Cinq",
"id": "Lima",
"ru": "Пять"
},
{
"key": "你",
"zh": "",
"zh-CHT": "你",
"en": "You",
"ko": "당신",
"ja": "あなた",
"de": "Du",
"fr": "Vous",
"id": "Anda",
"ru": "Вы"
},
{
"key": "六",
"zh": "",
"zh-CHT": "六",
"en": "Six",
"ko": "여섯",
"ja": "六",
"de": "Sechs",
"fr": "Six",
"id": "Enam",
"ru": "Шесть"
},
{
"key": "周",
"zh": "",
"zh-CHT": "周",
"en": "Week",
"ko": "주",
"ja": "週",
"de": "Woche",
"fr": "Semaine",
"id": "Minggu",
"ru": "Неделя"
},
{
"key": "四",
"zh": "",
"zh-CHT": "四",
"en": "Four",
"ko": "넷",
"ja": "四",
"de": "Vier",
"fr": "Quatre",
"id": "Empat",
"ru": "Четыре"
},
{
"key": "天",
"zh": "",
"zh-CHT": "天",
"en": "Day",
"ko": "일",
"ja": "日",
"de": "Tag",
"fr": "Jour",
"id": "Hari",
"ru": "День"
},
{
"key": "或",
"zh": "",
"zh-CHT": "或",
"en": "Or",
"ko": "또는",
"ja": "または",
"de": "Oder",
"fr": "Ou",
"id": "Atau",
"ru": "Или"
},
{
"key": "日",
"zh": "",
"zh-CHT": "日",
"en": "Day",
"ko": "일",
"ja": "日",
"de": "Tag",
"fr": "Jour",
"id": "Hari",
"ru": "День"
},
{
"key": "月",
"zh": "",
"zh-CHT": "月",
"en": "Month",
"ko": "월",
"ja": "月",
"de": "Monat",
"fr": "Mois",
"id": "Bulan",
"ru": "Месяц"
},
{
"key": "秒",
"zh": "",
"zh-CHT": "秒",
"en": "Second",
"ko": "초",
"ja": "秒",
"de": "Sekunde",
"fr": "Seconde",
"id": "Detik",
"ru": "Секунда"
},
{
"key": "(%T1)秒",
"zh": "",
@ -11015,18 +10859,6 @@
"id": "(%T1) detik",
"ru": "(%T1) секунд"
},
{
"key": "给",
"zh": "",
"zh-CHT": "給",
"en": "Give",
"ko": "주다",
"ja": "与える",
"de": "Geben",
"fr": "Donner",
"id": "Beri",
"ru": "Дать"
},
{
"key": "不在有效时间内,有效时间为:(%T1)",
"zh": "",
@ -15647,18 +15479,6 @@
"id": "Mulai Balas Berantai",
"ru": "Инициировать цепочку ответов"
},
{
"key": "由",
"zh": "",
"zh-CHT": "由",
"en": "By",
"ko": "작성자",
"ja": "作成者",
"de": "Von",
"fr": "Par",
"id": "Oleh",
"ru": "От"
},
{
"key": "发起接龙,参与接龙目前共(%T1)人",
"zh": "",
@ -15743,18 +15563,6 @@
"id": "Lanjutkan Pengiriman",
"ru": "Продолжить отправку"
},
{
"key": "例",
"zh": "",
"zh-CHT": "例",
"en": "Example",
"ko": "예시",
"ja": "例",
"de": "Beispiel",
"fr": "Exemple",
"id": "Contoh",
"ru": "Пример"
},
{
"key": "接龙结果",
"zh": "",
@ -15947,18 +15755,6 @@
"id": "Voting Sekarang",
"ru": "Голосовать сейчас"
},
{
"key": "票",
"zh": "",
"zh-CHT": "票",
"en": "Votes",
"ko": "표",
"ja": "票",
"de": "Stimmen",
"fr": "Votes",
"id": "Suara",
"ru": "Голоса"
},
{
"key": "再次发送",
"zh": "",
@ -29327,18 +29123,6 @@
"id": "Nama Sesi",
"ru": "Название сессии"
},
{
"key": "值",
"zh": "",
"zh-CHT": "值",
"en": "Value",
"ko": "값",
"ja": "値",
"de": "Wert",
"fr": "Valeur",
"id": "Nilai",
"ru": "Значение"
},
{
"key": "结果",
"zh": "",
@ -29699,18 +29483,6 @@
"id": "Radius",
"ru": "Радиус"
},
{
"key": "米",
"zh": "",
"zh-CHT": "米",
"en": "meters",
"ko": "미터",
"ja": "メートル",
"de": "Meter",
"fr": "mètres",
"id": "meter",
"ru": "метры"
},
{
"key": "经度",
"zh": "",
@ -36863,18 +36635,6 @@
"id": "Siklus berikutnya",
"ru": "Следующий цикл"
},
{
"key": "每",
"zh": "",
"zh-CHT": "每",
"en": "Every",
"ko": "매",
"ja": "毎",
"de": "Alle",
"fr": "Tous les",
"id": "Setiap",
"ru": "Каждые"
},
{
"key": "共(%T1)个",
"zh": "",
@ -36899,18 +36659,6 @@
"id": "Izin notifikasi belum diaktifkan",
"ru": "Разрешение на уведомления не включено"
},
{
"key": "个",
"zh": "",
"zh-CHT": "個",
"en": "items",
"ko": "개",
"ja": "個",
"de": "Elemente",
"fr": "éléments",
"id": "item",
"ru": "шт."
},
{
"key": "使用端到端加密传输数据。",
"zh": "",
@ -37283,18 +37031,6 @@
"id": "(%T1) orang",
"ru": "(%T1) чел."
},
{
"key": "项",
"zh": "",
"zh-CHT": "項",
"en": "items",
"ko": "항목",
"ja": "項目",
"de": "Elemente",
"fr": "éléments",
"id": "item",
"ru": "элементов"
},
{
"key": "最久 (%T1) 天",
"zh": "",
@ -38326,5 +38062,245 @@
"fr": "Télécharger (%T1) en paquet",
"id": "Unduh paket (%T1)",
"ru": "Скачать пакет (%T1)"
},
{
"key": "[calendar_view].日",
"zh": "日",
"zh-CHT": "日",
"en": "Day",
"ko": "일",
"ja": "日",
"de": "Tag",
"fr": "Jour",
"id": "Hari",
"ru": "День"
},
{
"key": "[calendar_view].周",
"zh": "周",
"zh-CHT": "週",
"en": "Week",
"ko": "주",
"ja": "週",
"de": "Woche",
"fr": "Semaine",
"id": "Minggu",
"ru": "Неделя"
},
{
"key": "[calendar_view].月",
"zh": "月",
"zh-CHT": "月",
"en": "Month",
"ko": "월",
"ja": "月",
"de": "Monat",
"fr": "Mois",
"id": "Bulan",
"ru": "Месяц"
},
{
"key": "[day_unit].天",
"zh": "天",
"zh-CHT": "天",
"en": "days",
"ko": "일",
"ja": "日",
"de": "Tage",
"fr": "jours",
"id": "hari",
"ru": "дн."
},
{
"key": "[distance_unit].米",
"zh": "米",
"zh-CHT": "米",
"en": "meters",
"ko": "미터",
"ja": "メートル",
"de": "Meter",
"fr": "mètres",
"id": "meter",
"ru": "метры"
},
{
"key": "[example_label].例",
"zh": "例",
"zh-CHT": "例",
"en": "Example",
"ko": "예시",
"ja": "例",
"de": "Beispiel",
"fr": "Exemple",
"id": "Contoh",
"ru": "Пример"
},
{
"key": "[initiator_label].由",
"zh": "由",
"zh-CHT": "由",
"en": "By",
"ko": "작성자",
"ja": "作成者",
"de": "Von",
"fr": "Par",
"id": "Oleh",
"ru": "От"
},
{
"key": "[recurrence_prefix].每",
"zh": "每",
"zh-CHT": "每",
"en": "Every",
"ko": "매",
"ja": "毎",
"de": "Alle",
"fr": "Tous les",
"id": "Setiap",
"ru": "Каждые"
},
{
"key": "[self_sender].你",
"zh": "你",
"zh-CHT": "你",
"en": "You",
"ko": "나",
"ja": "あなた",
"de": "Du",
"fr": "Vous",
"id": "Anda",
"ru": "Вы"
},
{
"key": "[task_unit].个",
"zh": "个",
"zh-CHT": "個",
"en": "tasks",
"ko": "개",
"ja": "件",
"de": "Aufgaben",
"fr": "tâches",
"id": "tugas",
"ru": "задач"
},
{
"key": "[task_unit].项",
"zh": "项",
"zh-CHT": "項",
"en": "tasks",
"ko": "개",
"ja": "件",
"de": "Aufgaben",
"fr": "tâches",
"id": "tugas",
"ru": "задач"
},
{
"key": "[todo_target].给",
"zh": "给",
"zh-CHT": "給",
"en": "For",
"ko": "대상",
"ja": "対象",
"de": "Für",
"fr": "Pour",
"id": "Untuk",
"ru": "Для"
},
{
"key": "[vote_unit].票",
"zh": "票",
"zh-CHT": "票",
"en": "votes",
"ko": "표",
"ja": "票",
"de": "Stimmen",
"fr": "votes",
"id": "suara",
"ru": "голосов"
},
{
"key": "[weekday].日",
"zh": "日",
"zh-CHT": "日",
"en": "Sun",
"ko": "일",
"ja": "日",
"de": "So",
"fr": "Dim",
"id": "Min",
"ru": "Вс"
},
{
"key": "[weekday].一",
"zh": "一",
"zh-CHT": "一",
"en": "Mon",
"ko": "월",
"ja": "月",
"de": "Mo",
"fr": "Lun",
"id": "Sen",
"ru": "Пн"
},
{
"key": "[weekday].二",
"zh": "二",
"zh-CHT": "二",
"en": "Tue",
"ko": "화",
"ja": "火",
"de": "Di",
"fr": "Mar",
"id": "Sel",
"ru": "Вт"
},
{
"key": "[weekday].三",
"zh": "三",
"zh-CHT": "三",
"en": "Wed",
"ko": "수",
"ja": "水",
"de": "Mi",
"fr": "Mer",
"id": "Rab",
"ru": "Ср"
},
{
"key": "[weekday].四",
"zh": "四",
"zh-CHT": "四",
"en": "Thu",
"ko": "목",
"ja": "木",
"de": "Do",
"fr": "Jeu",
"id": "Kam",
"ru": "Чт"
},
{
"key": "[weekday].五",
"zh": "五",
"zh-CHT": "五",
"en": "Fri",
"ko": "금",
"ja": "金",
"de": "Fr",
"fr": "Ven",
"id": "Jum",
"ru": "Пт"
},
{
"key": "[weekday].六",
"zh": "六",
"zh-CHT": "六",
"en": "Sat",
"ko": "토",
"ja": "土",
"de": "Sa",
"fr": "Sam",
"id": "Sab",
"ru": "Сб"
}
]
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -42,8 +42,10 @@ function initLanguage() {
if (typeof languageList[language] === "undefined") {
continue
}
const result = window.LANGUAGE_DATA[language][index] || key
languageCache.set(`${key}-${language}`, result);
const result = window.LANGUAGE_DATA[language][index]
if (result || utils.stripContextLanguageKey(key) === key) {
languageCache.set(`${key}-${language}`, result || key);
}
}
}
})
@ -122,19 +124,30 @@ function switchLanguage(inputString) {
return inputString
}
const fallbackString = utils.stripContextLanguageKey(inputString);
const lookupStrings = fallbackString === inputString ? [inputString] : [inputString, fallbackString];
if (arguments.length > 1) {
const templateKey = `${inputString}-${languageName}`;
if (languageTemplateCache.has(templateKey)) {
const {text, translateArguments} = languageTemplateCache.get(templateKey);
if (!text) {
return utils.replaceArgumentsLanguage(inputString, arguments);
for (const lookupString of lookupStrings) {
const templateKey = `${lookupString}-${languageName}`;
const template = languageTemplateCache.get(templateKey);
if (!template?.text) {
continue;
}
return text.replace(/\(%[TM](\d+)\)/g, (_, index) => {
return template.text.replace(/\(%[TM](\d+)\)/g, (_, index) => {
const value = utils.getArgumentLanguage(arguments[index]);
return translateArguments.has(index) ? switchLanguage(String(value)) : value;
return template.translateArguments.has(index) ? switchLanguage(String(value)) : value;
});
}
inputString = utils.replaceArgumentsLanguage(inputString, arguments)
inputString = utils.replaceArgumentsLanguage(fallbackString, arguments)
} else {
for (const lookupString of lookupStrings) {
const result = languageCache.get(`${lookupString}-${languageName}`);
if (result) {
return result;
}
}
inputString = fallbackString;
}
// 读取缓存

View File

@ -1,4 +1,6 @@
export default {
contextKeyPattern: /^\[([a-z][a-z0-9_]*)]\.([\s\S]+)$/,
/**
* 语言类型
*/
@ -46,6 +48,16 @@ export default {
return text.replace(/\(%[TM]\d+\)/g, "(*)");
},
/**
* 移除翻译上下文前缀例如 [weekday]. ->
*/
stripContextLanguageKey(text) {
if (typeof text !== "string") {
return text;
}
return text.match(this.contextKeyPattern)?.[2] || text;
},
/**
* 获取语言
* @returns {string}

View File

@ -280,7 +280,7 @@
</FormItem>
<FormItem prop="clear_day" :label="$L('消息保留')">
<Input v-model="mybotModifyData.clear_day" :maxlength="3" type="number" :placeholder="$L('默认90天')">
<div slot="append">{{ $L('天') }}</div>
<div slot="append">{{ $L('[day_unit].天') }}</div>
</Input>
</FormItem>
<FormItem prop="webhook_url" label="Webhook">

View File

@ -21,9 +21,9 @@
</ButtonGroup>
</div>
<ButtonGroup class="calendar-view">
<Button @click="setView('day')" :type="options.view == 'day' ? 'primary' : 'default'">{{ $L('日') }}</Button>
<Button @click="setView('week')" :type="options.view == 'week' ? 'primary' : 'default'">{{ $L('周') }}</Button>
<Button @click="setView('month')" :type="options.view == 'month' ? 'primary' : 'default'">{{ $L('月') }}</Button>
<Button @click="setView('day')" :type="options.view == 'day' ? 'primary' : 'default'">{{ $L('[calendar_view].日') }}</Button>
<Button @click="setView('week')" :type="options.view == 'week' ? 'primary' : 'default'">{{ $L('[calendar_view].周') }}</Button>
<Button @click="setView('month')" :type="options.view == 'month' ? 'primary' : 'default'">{{ $L('[calendar_view].月') }}</Button>
</ButtonGroup>
</div>
</div>

View File

@ -11,13 +11,13 @@
<table class="check-table">
<thead>
<tr>
<th>{{$L('日')}}</th>
<th>{{$L('一')}}</th>
<th>{{$L('二')}}</th>
<th>{{$L('三')}}</th>
<th>{{$L('四')}}</th>
<th>{{$L('五')}}</th>
<th>{{$L('六')}}</th>
<th>{{$L('[weekday].日')}}</th>
<th>{{$L('[weekday].一')}}</th>
<th>{{$L('[weekday].二')}}</th>
<th>{{$L('[weekday].三')}}</th>
<th>{{$L('[weekday].四')}}</th>
<th>{{$L('[weekday].五')}}</th>
<th>{{$L('[weekday].六')}}</th>
</tr>
</thead>
<tbody>

View File

@ -6,7 +6,7 @@
<div class="card-label">{{$L('未完成')}}</div>
<div class="card-data">
<span class="card-num">{{blocks.uncompleted || 0}}</span>
<span class="card-sub">{{$L('项')}}</span>
<span class="card-sub">{{$L('[task_unit].项')}}</span>
</div>
<div class="card-link">{{$L('查看任务')}} </div>
</li>
@ -14,7 +14,7 @@
<div class="card-label">{{$L('已超期')}}</div>
<div class="card-data">
<span class="card-num num-red">{{blocks.overdue || 0}}</span>
<span class="card-sub">{{$L('项')}}<template v-if="blocks.overdue_owner_count"> · {{$L('涉及 (*) ', blocks.overdue_owner_count)}}</template></span>
<span class="card-sub">{{$L('[task_unit].项')}}<template v-if="blocks.overdue_owner_count"> · {{$L('涉及 (*) ', blocks.overdue_owner_count)}}</template></span>
</div>
<div class="card-link link-red">{{$L('查看任务')}} </div>
</li>
@ -22,7 +22,7 @@
<div class="card-label">{{$L('(*) 天内到期', 3)}}</div>
<div class="card-data">
<span class="card-num num-orange">{{blocks.due_soon || 0}}</span>
<span class="card-sub">{{$L('项')}}</span>
<span class="card-sub">{{$L('[task_unit].项')}}</span>
</div>
<div class="card-link link-orange">{{$L('查看任务')}} </div>
</li>
@ -30,7 +30,7 @@
<div class="card-label">{{$L('本周完成')}}</div>
<div class="card-data">
<span class="card-num num-green">{{blocks.week_completed || 0}}</span>
<span class="card-sub">{{$L('项')}}<template v-if="weekDiffText"> · {{weekDiffText}}</template></span>
<span class="card-sub">{{$L('[task_unit].项')}}<template v-if="weekDiffText"> · {{weekDiffText}}</template></span>
</div>
</li>
</ul>

View File

@ -31,7 +31,7 @@
<span>{{ dialog.name }}</span>
</div>
<div class="initiate">
<span>{{ $L('由') }}</span>
<span>{{ $L('[initiator_label].由') }}</span>
<UserAvatar :userid="createId" :size="22" :showName="true" tooltipDisabled/>
<span> {{ $L('发起') }}</span>
</div>

View File

@ -31,7 +31,7 @@
<span>{{ dialog.name }}</span>
</div>
<div class="initiate">
<span>{{ $L('由') }}</span>
<span>{{ $L('[initiator_label].由') }}</span>
<UserAvatar :userid="createId" :size="22" :showName="true"/>
<span> {{ $L('发起,参与接龙目前共(*)人', num) }}</span>
</div>
@ -46,7 +46,7 @@
</div>
<ul ref="wordChainListRef">
<li v-for="(item) in list" v-if="item.type == 'case' && (dialogDroupWordChain.type == 'create' || item.text)">
<span>{{ $L('例') }}</span>
<span>{{ $L('[example_label].例') }}</span>
<Input v-model="item.text" :placeholder="$L('可填写接龙格式')" :disabled="dialogDroupWordChain.type != 'create'" />
</li>
<li v-for="(item,index) in list.filter(h=>h.type != 'case')">

View File

@ -28,7 +28,7 @@
{{$L(source.msg.action === 'remove' ? '取消待办' : (source.msg.action === 'done' ? '完成' : '设待办'))}}
"{{$A.getMsgSimpleDesc(source.msg.data)}}"
<div v-if="source.msg.action === 'add' && formatTodoUser(source.msg.data).length > 0" class="todo-users">
<span>{{$L('给')}}</span>
<span>{{$L('[todo_target].给')}}</span>
<template v-for="(item, index) in formatTodoUser(source.msg.data)">
<div v-if="index < 3" class="todo-user"><UserAvatar :userid="item" :show-name="true" :show-icon="false"/></div>
<div v-else-if="index == 3" class="todo-user">+{{formatTodoUser(source.msg.data).length - 3}}</div>

View File

@ -29,7 +29,7 @@
<li v-for="item in (msg.list || [])">
<div class="vote-option-title">{{ item.text }}</div>
<div class="ticket-num">
<span>{{ getVoteProgress(msg, item.id).num }}{{ $L('票') }}</span>
<span>{{ getVoteProgress(msg, item.id).num }}{{ $L('[vote_unit].票') }}</span>
<span>{{ getVoteProgress(msg, item.id).progress + '%' }}</span>
</div>
<Progress :percent="Number(getVoteProgress(msg,item.id).progress)" :stroke-width="5" hide-info/>

View File

@ -3,7 +3,7 @@
<pre v-html="$A.formatTextMsg(msg.text, userId)"></pre>
<ul :class="{'expand': unfoldWordChainData.indexOf(msgId) !== -1 }">
<li v-for="(item) in (msg.list || []).filter(h=>h.type == 'case')">
{{ $L('例') }} {{ item.text }}
{{ $L('[example_label].例') }} {{ item.text }}
</li>
<li v-for="(item, index) in (msg.list || []).filter(h=>h.type != 'case' && h.text)">
<span class="expand" v-if="index == 2 && msg.list.length > 4" @click="unfoldWordChain">

View File

@ -472,7 +472,7 @@
<template v-if="dialogData.bot == userId">
<FormItem v-if="typeof modifyData.clear_day !== 'undefined'" prop="clear_day" :label="$L('消息保留')">
<Input v-model="modifyData.clear_day" :maxlength="3" type="number">
<div slot="append">{{$L('天')}}</div>
<div slot="append">{{$L('[day_unit].天')}}</div>
</Input>
</FormItem>
<FormItem v-if="typeof modifyData.webhook_url !== 'undefined'" prop="webhook_url" label="Webhook">

View File

@ -429,7 +429,7 @@
<div class="form-tip">{{$L('任务完成(*)天后自动归档。', settingData.archive_days || 'n')}}</div>
<div class="setting-auto-day">
<Input v-model="settingData.archive_days" type="number">
<span slot="append">{{$L('天')}}</span>
<span slot="append">{{$L('[day_unit].天')}}</span>
</Input>
</div>
</template>

View File

@ -492,7 +492,7 @@ export default {
if (days <= 0) {
return item.name + ' (' + this.$L('无时间限制') + ')';
}
return item.name + ' (' + days + this.$L('天') + ')';
return item.name + ' (' + days + this.$L('[day_unit].天') + ')';
},
async choosePriority(item) {

View File

@ -247,7 +247,7 @@ export default {
if (days <= 0) {
return item.name + ' (' + this.$L('无时间限制') + ')';
}
return item.name + ' (' + days + this.$L('天') + ')';
return item.name + ' (' + days + this.$L('[day_unit].天') + ')';
},
choosePriority(item, focus = true) {

View File

@ -529,7 +529,7 @@
<template #append>
<Select v-model="delayTaskForm.type" style="width:auto">
<Option value="hour">{{$L('小时')}}</Option>
<Option value="day">{{$L('天')}}</Option>
<Option value="day">{{$L('[day_unit].天')}}</Option>
</Select>
</template>
</Input>
@ -1448,8 +1448,8 @@ export default {
}
}
}, [
h('span', {slot: 'prepend'}, this.$L('每')),
h('span', {slot: 'append'}, this.$L('天'))
h('span', {slot: 'prepend'}, this.$L('[recurrence_prefix].每')),
h('span', {slot: 'append'}, this.$L('[day_unit].天'))
])
])
},

View File

@ -77,21 +77,21 @@
<div class="card-label">{{getTitle('overdue')}}</div>
<div class="card-data">
<span class="card-num num-red">{{dashboardTask.overdue_count}}</span>
<span class="card-sub">{{$L('项')}}<template v-if="overdueMaxDays > 0"> · {{$L('最久 (*) ', overdueMaxDays)}}</template></span>
<span class="card-sub">{{$L('[task_unit].项')}}<template v-if="overdueMaxDays > 0"> · {{$L('最久 (*) ', overdueMaxDays)}}</template></span>
</div>
</li>
<li :class="{'card-off': dashboardTask.today_count === 0}" @click="scrollTo('today')">
<div class="card-label">{{getTitle('today')}}</div>
<div class="card-data">
<span class="card-num num-orange">{{dashboardTask.today_count}}</span>
<span class="card-sub">{{$L('项')}}<template v-if="todayNearest"> · {{$L('最近')}} {{todayNearest}}</template></span>
<span class="card-sub">{{$L('[task_unit].项')}}<template v-if="todayNearest"> · {{$L('最近')}} {{todayNearest}}</template></span>
</div>
</li>
<li :class="{'card-off': dashboardTask.todo_count === 0}" @click="scrollTo('todo')">
<div class="card-label">{{getTitle('todo')}}</div>
<div class="card-data">
<span class="card-num">{{dashboardTask.todo_count}}</span>
<span class="card-sub">{{$L('项')}}</span>
<span class="card-sub">{{$L('[task_unit].项')}}</span>
</div>
<div v-if="upcomingTask.count > 0" class="duo-upcoming" @click.stop="scrollTo('upcoming')">
<div class="side-num">{{upcomingTask.count}}</div>
@ -102,7 +102,7 @@
<div class="card-label">{{getTitle('assist')}}</div>
<div class="card-data">
<span class="card-num">{{assistTask.length}}</span>
<span class="card-sub">{{$L('项')}}<template v-if="assistTodayCount > 0"> · {{$L('今天 (*) 项到期', assistTodayCount)}}</template></span>
<span class="card-sub">{{$L('[task_unit].项')}}<template v-if="assistTodayCount > 0"> · {{$L('今天 (*) 项到期', assistTodayCount)}}</template></span>
</div>
</li>
</ul>

View File

@ -111,7 +111,7 @@
</template>
<template v-else>
<template v-if="dialog.type=='group' && dialog.last_msg && getLastMsgSenderId(dialog.last_msg)">
<div v-if="getLastMsgSenderId(dialog.last_msg) == userId" class="last-self">{{$L('')}}</div>
<div v-if="getLastMsgSenderId(dialog.last_msg) == userId" class="last-self">{{$L('[self_sender].')}}</div>
<UserAvatar v-else :userid="getLastMsgSenderId(dialog.last_msg)" :show-name="true" :show-icon="false"/>
</template>
<div class="last-text">

View File

@ -245,7 +245,7 @@
<div class="radius-control-body">
<Input :value="mapSelectPoint.radius" @on-change="onRadiusChange" @on-blur="onRadiusBlur">
<span slot="prepend">{{ $L('半径') }}</span>
<span slot="append">{{ $L('米') }}</span>
<span slot="append">{{ $L('[distance_unit].米') }}</span>
</Input>
<div class="location-info">
<div class="info-item">
@ -258,7 +258,7 @@
</div>
<div class="info-item">
<span class="info-label">{{ $L('半径') }}</span>
<span class="info-value">{{ mapSelectPoint.radius || '-' }} {{ $L('米') }}</span>
<span class="info-value">{{ mapSelectPoint.radius || '-' }} {{ $L('[distance_unit].米') }}</span>
</div>
</div>
<div class="radius-control-tip">

View File

@ -116,7 +116,7 @@
<ETooltip v-if="formDatum.auto_archived=='open'" placement="right" :disabled="$isEEUIApp || windowTouch">
<div class="setting-auto-day">
<Input v-model="formDatum.archived_day" type="number">
<span slot="append">{{$L('天')}}</span>
<span slot="append">{{$L('[day_unit].天')}}</span>
</Input>
</div>
<div slot="content">{{$L('任务完成(*)天后自动归档。', formDatum.archived_day || 'n')}}</div>
@ -163,7 +163,7 @@
<div style="width: 110px;">
<Input type="number" number v-model="formDatum.task_user_limit" @on-keyup="$A.inputNumberLimit($event, 1, 2000)">
<template #append>
<span>{{$L('个')}}</span>
<span>{{$L('[task_unit].个')}}</span>
</template>
</Input>
</div>

View File

@ -65,6 +65,8 @@ const PARAMETER_TEST_RE = /\(%[TM]\d+\)/;
const RAW_PARAMETER_TEST_RE = /\(\*{1,2}\)/;
const NAMED_PARAMETER_RE = /\{[A-Za-z_][A-Za-z0-9_.-]*\}/g;
const CHINESE_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
const CONTEXT_KEY_RE = /^\[([a-z][a-z0-9_]*)]\.([\s\S]+)$/;
const CONTEXT_KEY_PREFIX_RE = /^\[[^\]]+]\./;
function normalizePath(file) {
return file.split(sep).join("/");
@ -109,6 +111,10 @@ function normalizeParameterKey(key) {
return key.replace(/\(%T\d+\)/g, "(*)").replace(/\(%M\d+\)/g, "(**)");
}
function contextSourceKey(key) {
return key.match(CONTEXT_KEY_RE)?.[2] || null;
}
function placeholders(value) {
return [...value.matchAll(PARAMETER_RE)].map(match => match[0]);
}
@ -1016,6 +1022,12 @@ const originalDuplicates = [...originalLocations]
.filter(([, lines]) => lines.length > 1)
.map(([key, lines]) => ({ key, lines }));
const originalKeySet = new Set(originalKeys);
const originalContextErrors = originalLineItems.flatMap(item => {
if (!CONTEXT_KEY_PREFIX_RE.test(item.text)) return [];
const source = contextSourceKey(item.text);
if (!source) return [`language/original-web.txt:${item.line}:上下文翻译键格式错误「${item.text}`];
return source.trim() === "" ? [`language/original-web.txt:${item.line}:上下文翻译键原文不能为空`] : [];
});
let translationRows = [];
const translationFormatErrors = [];
@ -1043,6 +1055,14 @@ for (const [index, row] of translationRows.entries()) {
if (typeof row[field] !== "string") translationFormatErrors.push(`${location}.${field}:必须是字符串`);
}
if (typeof row.key !== "string") continue;
if (CONTEXT_KEY_PREFIX_RE.test(row.key)) {
const source = contextSourceKey(row.key);
if (!source) {
translationFormatErrors.push(`${location}.key上下文翻译键格式错误「${row.key}`);
} else if (row.zh !== source) {
translationFormatErrors.push(`${location}.zh上下文翻译键必须填写原文「${source}`);
}
}
if (RAW_PARAMETER_TEST_RE.test(row.key)) rawTranslateKeys.push({ key: row.key, location });
const keyParameters = placeholders(row.key);
if (row.key.replace(PARAMETER_RE, "").includes("(%")) {
@ -1089,6 +1109,7 @@ const missingFrontendTranslations = originalKeys
.filter(key => !translationsByNormalizedKey.has(key))
.map(key => ({ key, line: originalLocations.get(key)[0] }));
const skipGeneratedIndexComparison = originalDuplicates.length > 0
|| originalContextErrors.length > 0
|| normalizedDuplicates.length > 0
|| missingFrontendTranslations.length > 0
|| translationFormatErrors.length > 0
@ -1105,6 +1126,7 @@ const blockerCounts = {
"Vue 模板中文硬编码": frontend.templateTexts.length,
"命名变量占位符": namedParameters.length,
"original-web.txt 重复键": originalDuplicates.length,
"上下文翻译键格式错误": originalContextErrors.length,
"translate.json raw (*)/(**) key": rawTranslateKeys.length,
"translate.json 结构错误": translationFormatErrors.length,
"translate.json 参数错误": translationParameterErrors.length,
@ -1136,6 +1158,10 @@ if (originalDuplicates.length) {
console.log("\n-- original-web.txt 重复键 --");
for (const item of originalDuplicates) console.log(`${item.lines.join(", ")}${item.key}`);
}
if (originalContextErrors.length) {
console.log("\n-- 上下文翻译键格式错误 --");
for (const error of originalContextErrors) console.log(` ${error}`);
}
if (rawTranslateKeys.length) {
console.log("\n-- translate.json raw key请改用 (%Tn)/(%Mn)--");
for (const item of rawTranslateKeys) console.log(` ${item.location}${item.key}`);