release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
This commit is contained in:
commit
a5d1dec495
1580 files changed
+320938
No files matched your search
@@ -0,0 +1,184 @@
|
||||
//! 文案边界(示例契约):core / presenter 的**状态**怎么进 UI。
|
||||
//!
|
||||
//! ## 现状(为什么需要这份契约)
|
||||
//!
|
||||
//! UI 文案硬编码英文,而 `core` / `presenter` 产出的状态串是中文,于是界面上已经出现
|
||||
//! `Launch failed · exit0 静默失败` 这种**一句话里中英混排**的东西,加载器徽标也在 `原版` / `Fabric`
|
||||
//! 之间混用。根因不是「译文不够」,是**边界没划**:谁产出文案、谁负责翻译,没有规则。
|
||||
//!
|
||||
//! ## 三条规则
|
||||
//!
|
||||
//! **R1 · UI 侧不产生文案,只翻译 key。**
|
||||
//! UI 不许把片段拼成句子(`"Launch failed · " + reason`),也不许写中文字面量。
|
||||
//! 一句话 = 一个 key([`crate::catalog`]),带变量的用 `{name}` 交给
|
||||
//! [`crate::catalog::Catalog::render`]。拼接一旦发生,任何一套语言都拼不对(语序、标点、
|
||||
//! 量词都不一样),而且混排会立刻出现在截图里。
|
||||
//!
|
||||
//! **R2 · core 的中文串不进 UI。**
|
||||
//! core / presenter 的中文(`原版`、`静默失败(exit 0 但游戏没起来)`、
|
||||
//! `缺少库文件 foo.jar(…)`)是**日志 / CLI / 诊断报告**的文案,不是界面文案。
|
||||
//! 界面只能通过两条合法通道拿到「要显示什么」:
|
||||
//! 1. **结构化出口**(首选):枚举 / DTO 字段 → 映射到 [`crate::Key`]。
|
||||
//! 例:`presenter::dto::LoaderKind::as_str()` 的稳定 id(`vanilla` / `fabric`)→
|
||||
//! [`crate::Key::LoaderVanilla`];`LogOutcome` 三态 → `LaunchOutcome*` 三 key。
|
||||
//! 好处是**新增一个枚举变体时编译器会逼你补映射**(`match` 非穷尽即报错)。
|
||||
//! 2. **过渡表**([`classify`]):只覆盖「已知会漏到界面的少数几个串」,逐条硬编码。
|
||||
//! 它是**债**,不是终点——新出现的未知串一律走第三条。
|
||||
//!
|
||||
//! **R3 · 认不出的串,不许画原文。**
|
||||
//! [`CoreText::Untranslated`] 就是那种情况:UI 只许显示 [`crate::Key::CoreTextUntranslated`]
|
||||
//! 这个**故意难看**的兜底(`[system text not translated]`),并把原文写进日志。
|
||||
//! 这条比「先画上去以后再翻」严格,是因为后者永远不会被回头修——难看才会被修。
|
||||
//! 同时它是一件**上游缺陷的证据**:说明 core 欠一个结构化出口,应当报 finding 而不是在 UI 贴创可贴。
|
||||
//!
|
||||
//! ## 品牌专名(唯一的豁免)
|
||||
//!
|
||||
//! `Fabric` / `Forge` / `NeoForge` / `Quilt` / `OptiFine` / `LiteLoader` 是专有名词,**不翻译**
|
||||
//! (翻成「锻造」就没人认识了)。它们可以原样显示,但必须经 [`CoreText::Brand`] 判定——
|
||||
//! 也就是说「原样显示」是一个**显式决定**,不是「反正没译文就画原文」的默认行为。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:本表里列到的串(映射正确、未知串不被放行);
|
||||
//! - **抓不住**:core 明天新加一句中文——本 crate 不依赖 core(零依赖纪律),没有跨 crate 的一致性检查。
|
||||
//! 那条只能靠「core 侧只给枚举」的纪律 + 后续在 `crates/presenter` 里加一条守门测试
|
||||
//! (例如:`*_view` 的字符串字段不许含 CJK 字符)。
|
||||
|
||||
use crate::catalog::Key;
|
||||
|
||||
/// 一段来自 `core` / `presenter` 的显示文本该怎么处理。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum CoreText {
|
||||
/// 品牌专名:原样显示(不翻译,见模块文档的豁免)。
|
||||
Brand(&'static str),
|
||||
/// 有对应 key:按当前语言取词。
|
||||
Key(Key),
|
||||
/// 不认识:只许显示 [`Key::CoreTextUntranslated`],原文进日志。
|
||||
Untranslated,
|
||||
}
|
||||
|
||||
/// 已知会漏到界面的中文状态串 → key。
|
||||
///
|
||||
/// 每一条都得在 `crates/presenter` / `crates/core` 里找得到出处(注释里给位置),
|
||||
/// 不许凭想象加——凭空加的条目会让 [`classify`] 假装覆盖了实际没覆盖的东西。
|
||||
const LEGACY_STATUS: &[(&str, Key)] = &[
|
||||
// `presenter::dto::instances::LoaderKind::label()`(全仓唯一那份显示词表)
|
||||
("原版", Key::LoaderVanilla),
|
||||
// `presenter::host::job.rs`:`LogOutcome::SilentFail` 的展示文字
|
||||
("静默失败(exit 0 但游戏没起来)", Key::LaunchOutcomeSilent),
|
||||
// `presenter::mock_data.rs` 里的演示数据
|
||||
("exit0 静默失败", Key::LaunchOutcomeSilent),
|
||||
];
|
||||
|
||||
/// 品牌专名(原样显示;**不得**翻译或拼进句子,UI 拼装仍然禁止)。
|
||||
const BRANDS: &[&str] = &[
|
||||
"Fabric",
|
||||
"Forge",
|
||||
"NeoForge",
|
||||
"Quilt",
|
||||
"OptiFine",
|
||||
"LiteLoader",
|
||||
];
|
||||
|
||||
/// 契约总入口:core / presenter 的显示文本 → 该画什么。
|
||||
///
|
||||
/// 这是**示例契约**:形状(三分类 + 未知不放行)比表里的具体条目重要,接线 mission 按这个形状
|
||||
/// 把每条 UI 路径改过来,条目随上游结构化出口的落地逐条删掉。
|
||||
#[must_use]
|
||||
pub fn classify(text: &str) -> CoreText {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return CoreText::Untranslated;
|
||||
}
|
||||
|
||||
if let Some((_, key)) = LEGACY_STATUS.iter().find(|(legacy, _)| *legacy == trimmed) {
|
||||
return CoreText::Key(*key);
|
||||
}
|
||||
|
||||
if let Some(brand) = BRANDS.iter().find(|brand| **brand == trimmed) {
|
||||
return CoreText::Brand(brand);
|
||||
}
|
||||
|
||||
// `Fabric 0.16.5` 这类「品牌 + 版本号」也是合法显示:品牌之后只跟一个版本号时放行。
|
||||
// 注意这里是**保守**放行:只有「一个词 + 空格 + 版本号」的形状才算,含中文的串绝不会进来。
|
||||
if let Some((head, tail)) = trimmed.split_once(' ') {
|
||||
if let Some(brand) = BRANDS.iter().find(|brand| **brand == head) {
|
||||
if !tail.is_empty() && !tail.contains(char::is_whitespace) && !tail.chars().any(is_cjk)
|
||||
{
|
||||
return CoreText::Brand(brand);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CoreText::Untranslated
|
||||
}
|
||||
|
||||
/// 是否 CJK 字符(`classify` 的保守放行判据用)。
|
||||
fn is_cjk(ch: char) -> bool {
|
||||
// 0x2E80..=0x9FFF 已含 CJK 部首 / 假名 / 统一表意文字与 0x3000 段的中文标点,不再单列。
|
||||
matches!(ch as u32, 0x2E80..=0x9FFF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_chinese_status_strings_map_to_keys() {
|
||||
// 抓得住:过渡表条目的映射写错(把「原版」映到别的 key),以及「同一段意思两个 key」的漂移。
|
||||
// 抓不住:core 那边改了这个中文串的措辞——那时这里会落到 Untranslated(**故意的**,
|
||||
// 让漏译可见,而不是继续拿旧文案糊过去)。
|
||||
assert_eq!(classify("原版"), CoreText::Key(Key::LoaderVanilla));
|
||||
assert_eq!(
|
||||
classify("静默失败(exit 0 但游戏没起来)"),
|
||||
CoreText::Key(Key::LaunchOutcomeSilent)
|
||||
);
|
||||
assert_eq!(
|
||||
classify("exit0 静默失败"),
|
||||
CoreText::Key(Key::LaunchOutcomeSilent)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(" 原版 "),
|
||||
CoreText::Key(Key::LoaderVanilla),
|
||||
"前后空白要吃掉"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn brands_pass_through_and_are_never_translated() {
|
||||
// 抓得住:品牌名被当成待翻译文案(`Fabric` → 某个 key 的译文就没人认识它了),
|
||||
// 以及「品牌 + 版本号」这种合法徽标被判成未翻译。
|
||||
// 抓不住:品牌名与版本号之间该用什么分隔(M3 令牌 / 排版层的事,不是本契约的事)。
|
||||
assert_eq!(classify("Fabric"), CoreText::Brand("Fabric"));
|
||||
assert_eq!(classify("Fabric 0.16.5"), CoreText::Brand("Fabric"));
|
||||
assert_eq!(classify("NeoForge 21.1.72"), CoreText::Brand("NeoForge"));
|
||||
assert_eq!(classify("Fabric 中文"), CoreText::Untranslated);
|
||||
assert_eq!(classify("Fabric 0.16.5 beta"), CoreText::Untranslated);
|
||||
assert_eq!(classify("FabricX"), CoreText::Untranslated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_core_prose_is_refused_instead_of_printed_raw() {
|
||||
// 抓得住:R3——未知中文(core 的诊断prose、`describe_warning` 的拼句)被放行到界面,
|
||||
// 那正是 `Launch failed · exit0 静默失败` 这类混排的来源。
|
||||
// 抓不住:UI 拿到 Untranslated 之后有没有真的只显示兜底(页面侧测试 + 截图验收)。
|
||||
let leaks = [
|
||||
"缺少库文件 lwjgl.jar(C:\\Users\\x\\.minecraft\\libs\\lwjgl.jar)",
|
||||
"版本 JSON 没有 mainClass",
|
||||
"退出码为 0 但疑似静默失败(3 条错误行):游戏可能刚起来就被 mod 异常终止",
|
||||
"",
|
||||
" ",
|
||||
];
|
||||
for leak in leaks {
|
||||
assert_eq!(classify(leak), CoreText::Untranslated, "{leak}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_never_returns_a_raw_unknown_string() {
|
||||
// 抓得住:将来给 `CoreText` 加了「原样字符串」变体(那等于把 R3 废掉)。
|
||||
// 抓不住:枚举里已有变体被误用——那要靠调用点 review。
|
||||
let odd = "some untranslated english";
|
||||
assert_eq!(classify(odd), CoreText::Untranslated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! 消息目录:`Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 / 缺译诊断。
|
||||
//!
|
||||
//! ## 为什么 key 是枚举而不是字符串
|
||||
//!
|
||||
//! 字符串 key(`"nav.instances"`)拼错了编译器一声不响,运行时才知道——本仓的说法是「运行时静默回落」。
|
||||
//! 枚举 key 把这类错误前移到**编译期**:`Key::NavInstance`(真名是 `NavInstances`)直接
|
||||
//! `error[E0599]`,不可能跑到运行时。
|
||||
//!
|
||||
//! **这里没有守卫测试**:曾经写过一个 `compile_fail` 文档测试想守这条,但复审实测它对
|
||||
//! 「把 `Key` 换成字符串包装类型」这类变异仍然全绿——它守的是「这段代码编译不过」,而不是
|
||||
//! 「编译期校验还在」。说不清真假的守卫比没有更糟,所以删了;这条纪律只靠枚举这个事实本身。
|
||||
//!
|
||||
//! ## 为什么两套译文写在**同一行**
|
||||
//!
|
||||
//! ```ignore
|
||||
//! NavInstances => ("Instances", "实例"),
|
||||
//! ```
|
||||
//!
|
||||
//! 一行就是「一个 key 的全部语言」。于是「加了 key 忘了译文」在结构上不可能发生——这比「运行时发现
|
||||
//! 缺译再诊断」更强。缺译诊断([`Catalog::diagnose`])因此有两个真实用途:
|
||||
//!
|
||||
//! 1. **注入目录**:后续 mission 用 [`Catalog::from_entries`] 加第三套(繁体 / 更多语言)时,
|
||||
//! 表格是手写的,缺条目的风险回来了;
|
||||
//! 2. **回归守门**:万一有人把这张表改回「两套各写一遍」,[`Catalog::missing`] 的守卫测试立刻红。
|
||||
//!
|
||||
//! ## 按域拆开(M99 `SETTINGSFRAME`)
|
||||
//!
|
||||
//! 条目表原先整张写在本文件里,而**设置页正文接下来由四条 mission 并行接**(java / download /
|
||||
//! language+appearance / ai+about),每条都要往这张表里加一批 key——同一张表就是四条线的冲突点。
|
||||
//! 现在一个域一个文件(`catalog/<域>.rs`),本文件只留:`catalog!` 宏、装配链、[`Catalog`] 与对外 API。
|
||||
//!
|
||||
//! | 文件 | 域 | 谁会长它 |
|
||||
//! |---|---|---|
|
||||
//! | [`nav`] / [`instances`] / [`loader`] / [`launch`] / [`font`] / [`core_text`] | 页面与运行时文案 | 各自页面 |
|
||||
//! | [`download`] / [`logs`] | 下载页 / 日志页(M94 / M95) | 那两页 |
|
||||
//! | `settings/`(`general` / `appearance` / `java` / `download` / `ai`) | 设置页,**一个分组一个文件** | 设置页四条线 |
|
||||
//! | [`general`] | 设置页 General 分组的**真控件**文案(语言选择器三档 + 主题/启动页/关闭三行) | M106 `I18NFOLLOW`(挂在链尾,既有顺序不动) |
|
||||
//!
|
||||
//! ### 域文件的形状(加 key 时照抄)
|
||||
//!
|
||||
//! ```ignore
|
||||
//! macro_rules! <域>_entries {
|
||||
//! ($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
//! $next! { $($rest,)* {
|
||||
//! $($acc)*
|
||||
//! NavInstances => ("Instances", "实例"),
|
||||
//! } }
|
||||
//! };
|
||||
//! }
|
||||
//! pub(super) use <域>_entries;
|
||||
//! ```
|
||||
//!
|
||||
//! 它把本域条目**追加**到累积表 `$acc` 之后,再把「还没装配的域」原样交给 `$next`——最后交给
|
||||
//! [`finish`],由它把整张表喂给 [`catalog!`]。**为什么非得这么绕**:`macro_rules!` 不能在
|
||||
//! 宏参数里展开另一个宏(`catalog! { nav_entries!() }` 只会把 `nav_entries!()` 当成普通 token),
|
||||
//! 也不能用元变量当宏名(`macro_rules! $name` 非法),所以只能「回调链」式累积。代价是每个域文件
|
||||
//! 都要重抄上面那 6 行签名——这是 `macro_rules!` 的固有成本,不是可以省掉的样板。
|
||||
//!
|
||||
//! ### 拆开之后仍然只有一个真相源
|
||||
//!
|
||||
//! 装配顺序写在下面那条调用里,**它等于 `Key` 的声明顺序**;[`golden`] 把拆分前那份
|
||||
//! `key -> (en, zh-CN)` 全量逐字节钉住(连顺序一起钉),所以「搬丢一条 / 两条互抄 / 顺序被调换」
|
||||
//! 都会红。
|
||||
//!
|
||||
//! ## 缺译时给什么
|
||||
//!
|
||||
//! **不回落**(绝不拿另一种语言顶替,用户会看到「一半英文一半中文」的界面而不自知):
|
||||
//!
|
||||
//! - [`Catalog::get`] → `None`(严格调用方用它);
|
||||
//! - [`Catalog::text`] → [`MISSING`](`⟪missing⟫`,UI 上刺眼但一眼可辨);
|
||||
//! - [`Catalog::render`] → `⟪missing:KeyName⟫`(带上 key 名,方便照着日志补译)。
|
||||
|
||||
use crate::lang::Lang;
|
||||
|
||||
/// 缺译占位符(`text()` 的返回;故意刺眼——宁可难看,也不许静默顶替)。
|
||||
pub const MISSING: &str = "⟪missing⟫";
|
||||
|
||||
/// 生成 `Key` 枚举 + 两套常量表(一行 = 一个 key 的全部语言)。
|
||||
macro_rules! catalog {
|
||||
($($variant:ident => ($en:literal, $zh:literal)),+ $(,)?) => {
|
||||
/// 可翻译文案的 key。**不存在时编译失败**(枚举变体名即契约)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Key {
|
||||
$(
|
||||
#[doc = concat!("en: `", $en, "` / zh-CN: `", $zh, "`")]
|
||||
$variant,
|
||||
)+
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// 全部 key(缺译诊断与完整性测试的遍历源)。
|
||||
pub const ALL: &'static [Key] = &[$(Key::$variant),+];
|
||||
|
||||
/// 变体名(日志 / 缺译诊断里用来指认 key;不是文案)。
|
||||
#[must_use]
|
||||
pub const fn name(self) -> &'static str {
|
||||
match self {
|
||||
$(Key::$variant => stringify!($variant),)+
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const EN: &[(Key, &str)] = &[$((Key::$variant, $en)),+];
|
||||
const ZH_CN: &[(Key, &str)] = &[$((Key::$variant, $zh)),+];
|
||||
};
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- 域装配
|
||||
|
||||
/// 链的终点:把累积起来的所有条目交给 [`catalog!`],生成 `Key` 与两张表。
|
||||
macro_rules! finish {
|
||||
({ $($acc:tt)* }) => { catalog! { $($acc)* } };
|
||||
}
|
||||
|
||||
mod account;
|
||||
mod agent;
|
||||
mod content;
|
||||
mod core_text;
|
||||
mod download;
|
||||
mod font;
|
||||
mod font_install;
|
||||
mod general;
|
||||
mod instances;
|
||||
mod launch;
|
||||
mod loader;
|
||||
mod logs;
|
||||
mod nav;
|
||||
mod settings;
|
||||
mod update;
|
||||
|
||||
/// 拆分前的 `key -> (en, zh-CN)` **逐字快照**(金表)。
|
||||
///
|
||||
/// ## 它是什么
|
||||
///
|
||||
/// M99 把 `catalog.rs` 的单张条目表按域拆到 `catalog/<域>.rs` 之后,这 72 行就是「拆分前那一份」
|
||||
/// 的原样导出:由拆分前的 `Catalog::of(En)` / `Catalog::of(ZhCn)` 逐条 `println!` 出来,
|
||||
/// 再原封不动抄进来(生成方式见 `tests.rs` 里 `split_keeps_every_translation` 的注释)。
|
||||
/// M108 在它上面改了 3 条(`NavDownload` / `DownloadTitle` / `DownloadSubtitle`)、追加了 1 条
|
||||
/// (`NavDownloadExpanded`);M105/M106/M107 各自追加的条目也都在,整表现在是 138 条。
|
||||
///
|
||||
/// ## 为什么不是「数条数」
|
||||
///
|
||||
/// 条数相等抓不住任何一种真会发生的坏法:某条译文在搬移时被漏掉、另一条被复制两次,条数照旧。
|
||||
/// 这张表比的是**全量、逐字节、按声明顺序**的三元组,于是下面四种坏法都会红:
|
||||
///
|
||||
/// | 坏法 | 红在哪 |
|
||||
/// |---|---|
|
||||
/// | 某个 key 整条被搬丢 | 条数不等 / 该行 key 名对不上 |
|
||||
/// | 两条 key 的译文互抄(同 key 被覆盖到别的译文) | 该行 en 或 zh 不等 |
|
||||
/// | 某条译文改了一个字符 | 该行不等(`assert_eq!` 带原文) |
|
||||
/// | 域的顺序被调换(`Key` 的 `Ord` 与导出表顺序都靠它) | 该行 key 名对不上 |
|
||||
///
|
||||
/// **抓不住**:译文本身翻得对不对(金表是「拆分前」的快照,它只能证明「没变」)。
|
||||
///
|
||||
/// ## M108 更新了三条、新增了一条(版本管理改名)
|
||||
///
|
||||
/// 用户令(2026-11-04)把这页的显示名从「下载 / Downloads」改成「版本管理 / Version management」:
|
||||
/// [`Key::NavDownload`] / [`Key::DownloadTitle`] / [`Key::DownloadSubtitle`]
|
||||
/// 三条译文按令改掉,并**新增** [`Key::NavDownloadExpanded`](240 展开态的全标签,
|
||||
/// 88 轨放不下 18 字符的英文,见 `catalog/nav.rs` 的「两条 key」一节)。
|
||||
///
|
||||
/// 其余各行仍逐字节等于拆分前那份快照——也就是说这张表现在证明的是「除这几条按令改过的,
|
||||
/// 其余一字未动」,不再是「整表一字未动」。
|
||||
///
|
||||
/// ## M120 动了实例页那一族(1 条改译文 + 1 条删除 + 28 条新增)
|
||||
///
|
||||
/// 实例页的文案以前是英文基线字面量,M120 全搬进 `catalog/instances.rs`。那一族在装配链
|
||||
/// **第 2 位**(紧跟 nav),所以这批行是**插在中间**的,不是文件末尾追加:
|
||||
///
|
||||
/// | 动作 | 条目 |
|
||||
/// |---|---|
|
||||
/// | 改译文 | [`Key::InstancesEmptyBody`](换成详情列真正画的那句) |
|
||||
/// | 删除 | `InstancesLastPlayed`(M117 加的整句,从未接线;页面是「标签 + 值」的元信息卡) |
|
||||
/// | 新增 | `InstancesTitle` … `InstancesStageWindow`(页头 / 动作 / 空态 / 元信息标签与值 / 隔离档位 / 运行状态 / 阶段,共 28 条) |
|
||||
///
|
||||
/// 位置跟着族走这个事实本身就是判据:金表是 `Key::ALL` 的逐 index 镜像,
|
||||
/// 「为把新 key 排到文件末尾而去重排别人的族」会让这里整片红。
|
||||
#[cfg(test)]
|
||||
mod golden;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use account::account_entries;
|
||||
use agent::agent_entries;
|
||||
use content::content_entries;
|
||||
use core_text::core_text_entries;
|
||||
use download::{download_entries, version_entries};
|
||||
use font::font_entries;
|
||||
use font_install::font_install_entries;
|
||||
use general::general_entries;
|
||||
use instances::instances_entries;
|
||||
use launch::launch_entries;
|
||||
use loader::loader_entries;
|
||||
use logs::logs_entries;
|
||||
use nav::nav_entries;
|
||||
use settings::{
|
||||
settings_accent_entries, settings_ai_entries, settings_appearance_entries,
|
||||
settings_download_entries, settings_general_entries, settings_java_entries,
|
||||
};
|
||||
use update::update_entries;
|
||||
|
||||
// 装配顺序 = `Key` 的声明顺序。**与拆分前逐条一致**,动它等于动 `Key` 的 `Ord` 与导出表顺序,
|
||||
// 金表(`catalog/golden.rs`)会红。设置页那五个域按页面上的分组顺序排,中间三个现在是空的。
|
||||
nav_entries! {
|
||||
instances_entries,
|
||||
loader_entries,
|
||||
launch_entries,
|
||||
font_entries,
|
||||
settings_general_entries,
|
||||
settings_appearance_entries,
|
||||
settings_java_entries,
|
||||
settings_download_entries,
|
||||
settings_ai_entries,
|
||||
core_text_entries,
|
||||
download_entries,
|
||||
logs_entries,
|
||||
agent_entries,
|
||||
general_entries,
|
||||
// M107:版本管理页的侧栏 / 搜索文案**追加在链尾**(金表只追加不重排,理由见
|
||||
// `catalog/download.rs` 里 `version_entries` 的文档)。M104 的 `agent_entries` 与 M106 的
|
||||
// `general_entries` 已经在 main 上占了链尾,所以本域排在它们**之后**:
|
||||
// `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version。
|
||||
version_entries,
|
||||
// M109:内容库页。与 M107 同一条纪律——本域排在 main 已占住的链尾**之后**,
|
||||
// 于是 `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,再 version,最后 content。
|
||||
content_entries,
|
||||
// M114:账户页。同理**追加在 main 已占住的链尾之后**(先 content 再 account),
|
||||
// 金表只追加不重排。
|
||||
account_entries,
|
||||
// M159:自更新(设置页「更新」分组 + 顶部提示条)。同样只追加在链尾之后。
|
||||
update_entries,
|
||||
// M168:中文字体下载状态行(设置页 General 组)。同样只追加在链尾之后——
|
||||
// `catalog/font.rs` 那四条老文案在链中间,往那里追加会平移其后所有 key(金表整片红)。
|
||||
font_install_entries,
|
||||
// M250:强调色一族(设置页 Appearance 组的第一个控件)。同理追加在链尾——`appearance`
|
||||
// 那个域在链中间,往那里追加会平移其后所有 key(金表整片红)。
|
||||
settings_accent_entries,
|
||||
finish,
|
||||
{}
|
||||
}
|
||||
|
||||
/// 一套语言的目录(不可变;`Copy`,随手传值)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Catalog {
|
||||
lang: Lang,
|
||||
entries: &'static [(Key, &'static str)],
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// 内置目录。
|
||||
#[must_use]
|
||||
pub const fn of(lang: Lang) -> Catalog {
|
||||
let entries: &'static [(Key, &'static str)] = match lang {
|
||||
Lang::En => EN,
|
||||
Lang::ZhCn => ZH_CN,
|
||||
};
|
||||
Catalog { lang, entries }
|
||||
}
|
||||
|
||||
/// 用外部表构造目录(给后续 mission 的第三套语言 / 测试注入用)。
|
||||
#[must_use]
|
||||
pub const fn from_entries(lang: Lang, entries: &'static [(Key, &'static str)]) -> Catalog {
|
||||
Catalog { lang, entries }
|
||||
}
|
||||
|
||||
/// 这套目录是哪种语言。
|
||||
#[must_use]
|
||||
pub const fn lang(&self) -> Lang {
|
||||
self.lang
|
||||
}
|
||||
|
||||
/// 严格取词:缺译就是 `None`(**不回落**到别的语言)。
|
||||
#[must_use]
|
||||
pub fn get(&self, key: Key) -> Option<&'static str> {
|
||||
self.entries
|
||||
.iter()
|
||||
.find(|(entry, _)| *entry == key)
|
||||
.map(|(_, text)| *text)
|
||||
}
|
||||
|
||||
/// 这套目录的全部条目(声明顺序)。给导出 / 校对工具与完整性守门测试用。
|
||||
#[must_use]
|
||||
pub const fn entries(&self) -> &'static [(Key, &'static str)] {
|
||||
self.entries
|
||||
}
|
||||
|
||||
/// 取词(UI 便利路径):缺译返回 [`MISSING`]。
|
||||
#[must_use]
|
||||
pub fn text(&self, key: Key) -> &'static str {
|
||||
self.get(key).unwrap_or(MISSING)
|
||||
}
|
||||
|
||||
/// 带变量的句子:把 `{name}` 换掉。
|
||||
///
|
||||
/// 参数没给全时**占位符原样留在文案里**(`Launch failed (exit {code})`),不 panic、不猜空串——
|
||||
/// 缺参在截图上一眼可见,比静默变成 `exit ` 好查。
|
||||
#[must_use]
|
||||
pub fn render(&self, key: Key, args: &[(&str, &str)]) -> String {
|
||||
let Some(template) = self.get(key) else {
|
||||
return format!("⟪missing:{}⟫", key.name());
|
||||
};
|
||||
|
||||
let mut out = template.to_owned();
|
||||
for (name, value) in args {
|
||||
out = out.replace(&format!("{{{name}}}"), value);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 缺译的 key(自带语言里从来是空;注入目录与回归守门才用得上)。
|
||||
#[must_use]
|
||||
pub fn missing(&self) -> Vec<Key> {
|
||||
Key::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| self.get(*key).is_none())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 人话诊断(`None` = 没有缺译)。一条缺译一行,附带英文原文当参考。
|
||||
#[must_use]
|
||||
pub fn diagnose(&self) -> Option<String> {
|
||||
let missing = self.missing();
|
||||
if missing.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut out = format!("{} 缺 {} 条译文:", self.lang.tag(), missing.len());
|
||||
for key in missing {
|
||||
let reference = Catalog::of(Lang::En)
|
||||
.get(key)
|
||||
.map_or_else(|| "<en 也缺>".to_owned(), |text| format!("\"{text}\""));
|
||||
out.push_str(&format!("\n - {} · en={reference}", key.name()));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// 抽取文案里的 `{name}` 占位符(守门测试用:同一 key 的两套译文占位符必须一致)。
|
||||
#[must_use]
|
||||
pub fn placeholders(text: &str) -> Vec<&str> {
|
||||
let mut found = Vec::new();
|
||||
let mut rest = text;
|
||||
while let Some(open) = rest.find('{') {
|
||||
let after = &rest[open + 1..];
|
||||
let Some(close) = after.find('}') else {
|
||||
break;
|
||||
};
|
||||
found.push(&after[..close]);
|
||||
rest = &after[close + 1..];
|
||||
}
|
||||
found
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! 账户页文案(M114 三轨:微软正版 / 第三方皮肤站 / 本地档案)。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域**追加在装配链末尾**,
|
||||
//! 所以金表(`catalog::golden`)里这批 key 也排在最后。
|
||||
//!
|
||||
//! ## 口径(不许改写成别的说法)
|
||||
//!
|
||||
//! 本地档案的说明必须带「仅本地/隔离网络测试用,无法连接启用正版验证的服务器」——
|
||||
//! 这是 README 已定的口径(`AccountTrackOfflineBody` / `AccountOfflineWarning` 两处都写全),
|
||||
//! 不许写成「兼容旧启动器迁移」这种暗示能进正版服的说法。
|
||||
//!
|
||||
//! 文案里**没有任何 token 字样**:账户页从不展示凭据(M114 审核红线),
|
||||
//! 「密码只用于这一次登录」这条提示是唯一提到密码的地方。
|
||||
|
||||
macro_rules! account_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 页头 / 列表
|
||||
AccountTitle => ("Accounts", "账户"),
|
||||
AccountSubtitle => ("Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
|
||||
AccountListTitle => ("All accounts", "全部账户"),
|
||||
AccountAddedOn => ("Added {date}", "添加于 {date}"),
|
||||
AccountActiveChip => ("Active", "当前"),
|
||||
AccountSwitch => ("Use for launch", "切换为启动账户"),
|
||||
AccountRelogin => ("Sign in again", "重新登录"),
|
||||
AccountRemove => ("Remove", "删除"),
|
||||
AccountKindMicrosoft => ("Microsoft", "微软正版"),
|
||||
AccountKindAuthlib => ("Third-party skin server", "第三方皮肤站"),
|
||||
AccountKindOffline => ("Local profile", "本地档案"),
|
||||
AccountServer => ("Server {server}", "服务器 {server}"),
|
||||
// ── 空态
|
||||
AccountEmptyTitle => ("No accounts yet", "还没有账户"),
|
||||
AccountEmptyBody => ("Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
|
||||
AccountEmptyAction => ("Add an account", "添加账户"),
|
||||
// ── 三条轨道的入口卡
|
||||
AccountTrackMicrosoftTitle => ("Microsoft account", "微软正版"),
|
||||
AccountTrackMicrosoftBody => ("Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
|
||||
AccountTrackAuthlibTitle => ("Third-party skin server", "第三方皮肤站"),
|
||||
AccountTrackAuthlibBody => ("An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
|
||||
AccountTrackOfflineTitle => ("Local profile", "本地档案"),
|
||||
AccountTrackOfflineBody => ("Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
|
||||
AccountTrackOpen => ("Sign in", "登录"),
|
||||
AccountTrackCreate => ("Create profile", "创建档案"),
|
||||
AccountDialogClose => ("Close", "关闭"),
|
||||
// ── 本地档案表单
|
||||
AccountOfflineNameLabel => ("Profile name", "档案名"),
|
||||
AccountOfflineNameHint => ("3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
|
||||
AccountOfflineSubmit => ("Create profile", "创建档案"),
|
||||
AccountOfflineWarning => ("Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
|
||||
// ── 微软登录面板(设备码 / 授权码)
|
||||
AccountDeviceCodeLabel => ("Enter this code", "输入这个用户码"),
|
||||
AccountDeviceCodeCopy => ("Copy code", "复制用户码"),
|
||||
AccountDeviceLinkCopy => ("Copy link", "复制链接"),
|
||||
AccountDeviceCountdown => ("{time} left", "剩 {time}"),
|
||||
AccountDeviceExpired => ("This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
|
||||
AccountDeviceRetry => ("Try again", "重试"),
|
||||
AccountDeviceCancel => ("Cancel", "取消"),
|
||||
AccountDeviceBrowser => ("Sign in with browser", "用浏览器登录"),
|
||||
// M178 复审 r1 P2-5:这是**主按钮**(设备码流),不能读起来像备选。
|
||||
AccountDeviceSignIn => ("Sign in with your Microsoft account", "使用 Microsoft 账户登录"),
|
||||
AccountDeviceFallbackHint => ("No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"),
|
||||
// ── 第三方站表单
|
||||
AccountServerLabel => ("Server address", "服务器地址"),
|
||||
AccountServerHint => ("API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
||||
AccountServerProbe => ("Check server", "校验服务器"),
|
||||
AccountLoginNameLabel => ("Email or username", "邮箱或用户名"),
|
||||
AccountPasswordLabel => ("Password", "密码"),
|
||||
AccountPasswordHint => ("Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
|
||||
AccountAuthlibSubmit => ("Sign in", "登录"),
|
||||
AccountAuthlibIdleHint => ("The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
|
||||
// ── 皮肤区
|
||||
AccountSkinTitle => ("Skin", "皮肤"),
|
||||
AccountSkinNone => ("No skin available", "没有可用皮肤"),
|
||||
AccountSkinHint => ("Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
|
||||
// ── 导航轨底部头像
|
||||
AccountRailSignedOut => ("Not signed in", "未登录"),
|
||||
AccountDeviceHint => ("Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
|
||||
AccountDeviceBrowserHint => ("Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
|
||||
AccountServerChecking => ("Checking the server…", "正在校验服务器…"),
|
||||
AccountServerOk => ("Server {server} is ready", "服务器 {server} 可用"),
|
||||
AccountLoginFailed => ("Sign-in failed", "登录失败"),
|
||||
AccountCurrentTitle => ("Current account", "当前账户"),
|
||||
// ── 失败原因码(M114 r2:后端只给码,文案在这里;原文只进日志)
|
||||
AccountFailureCode => ("Code: {code}", "代码:{code}"),
|
||||
AccountFailureDenied => ("The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
|
||||
AccountFailureWhitelist => ("Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
|
||||
AccountFailureUnreachable => ("Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
|
||||
AccountFailureNotAuthlib => ("This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
|
||||
AccountFailureBadCredentials => ("The server rejected the username or password.", "服务器不认这个用户名或密码。"),
|
||||
AccountFailureNoProfile => ("The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
|
||||
AccountFailureStore => ("Could not save the account to disk.", "账户没能写到磁盘上。"),
|
||||
AccountFailureOther => ("The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
|
||||
// M132:缺 client id 是**配置项缺失**,原因就是用户能执行的下一步,所以这一条
|
||||
// 必须自己说清「缺什么、怎么补」,不许退回上面那句「完整原因在日志里」。
|
||||
// 纪律:只写环境变量名与 `<id>` 占位,**不许出现任何真实 client id**;
|
||||
// 也不许写成「未支持 / unsupported」——登录链路是完整的。
|
||||
AccountLoginNotConfigured => ("Microsoft sign-in is not configured", "微软登录未配置"),
|
||||
AccountFailureMsaNotConfigured => (
|
||||
"This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.",
|
||||
"本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"
|
||||
),
|
||||
AccountDeviceRequesting => ("Contacting Microsoft…", "正在联系微软…"),
|
||||
AccountLoginSucceeded => ("Signed in as {name}", "已登录 {name}"),
|
||||
// ── 账户页横幅(添加/删除/切换的结果;页面上真实可见,不是 toast)
|
||||
AccountNoticeAdded => ("Added {name}", "已添加 {name}"),
|
||||
AccountNoticeRemoved => ("Removed {name}", "已删除 {name}"),
|
||||
AccountNoticeSwitched => ("{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
|
||||
AccountNoticeOfflineNameInvalid => ("Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
|
||||
AccountNoticeAccountExists => ("An account with that name already exists.", "同名账户已经存在。"),
|
||||
AccountNoticeStoreFailed => ("Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
|
||||
AccountNoticeNotFound => ("That account no longer exists.", "这个账户已经不在了。"),
|
||||
AccountNoticeOther => ("The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
|
||||
AccountNoticeDismiss => ("Dismiss", "关闭"),
|
||||
AccountNoticeTitleFailed => ("Account action failed", "账户操作失败"),
|
||||
AccountNoticeTitleDone => ("Account updated", "账户已更新"),
|
||||
// ── 重新登录的引导
|
||||
AccountHintAuthlibReauth => ("Enter the password again for {server}.", "请输入 {server} 的密码。"),
|
||||
AccountHintOfflineNoRelogin => ("Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
|
||||
// ── 浏览器登录回退到设备码的说明(M178)
|
||||
// 口径:这不是「登录失败」——设备码接着跑。第一句说清「已改用设备码」,
|
||||
// 需要改配置的那一条**必须给可照抄的下一步**(哪个门户、加什么值、端口不参与匹配),
|
||||
// 且**不许**出现 AAD 的原始报文(`invalid_request` / `AADSTS…` / JSON)——
|
||||
// 用户 2026-11-08 实测撞到的就是那张原始错误页。
|
||||
AccountBrowserFallbackNotRegistered => (
|
||||
"This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.",
|
||||
"这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。"
|
||||
),
|
||||
AccountBrowserFallbackUnverified => (
|
||||
"Could not confirm that browser sign-in is usable, so it switched to a device code.",
|
||||
"没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。"
|
||||
),
|
||||
AccountBrowserFallbackFailed => (
|
||||
"Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.",
|
||||
"浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use account_entries;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Catalog, Key, Lang};
|
||||
|
||||
/// **M178**:浏览器登录回退到设备码时那一句,必须「说清为什么 + 怎么修」,且**不许**把
|
||||
/// AAD 的原始报文搬上屏(用户实测撞到的正是那张 `invalid_request ... redirect_uri` 错误页)。
|
||||
///
|
||||
/// 抓得住:文案退回「登录失败,完整原因在日志里」、删掉可照抄的重定向 URI、或有人把
|
||||
/// AAD 原始错误码/JSON 塞进词条。
|
||||
/// 抓不住:读起来顺不顺(那靠 review 与截图)。
|
||||
#[test]
|
||||
fn the_browser_fallback_copy_explains_itself_without_raw_aad_text() {
|
||||
for &lang in Lang::ALL {
|
||||
let catalog = Catalog::of(lang);
|
||||
let not_registered = catalog.text(Key::AccountBrowserFallbackNotRegistered);
|
||||
let unverified = catalog.text(Key::AccountBrowserFallbackUnverified);
|
||||
let failed = catalog.text(Key::AccountBrowserFallbackFailed);
|
||||
// 「已改用设备码」这条结论三句里都得有(用户要知道画面为什么变了)。
|
||||
for copy in [not_registered, unverified, failed] {
|
||||
assert!(
|
||||
copy.contains("device code") || copy.contains("设备码"),
|
||||
"{}: {copy}",
|
||||
lang.tag()
|
||||
);
|
||||
// 原文不上屏:这两条是 AAD 的机器码,出现即语言/脱敏纪律失效。
|
||||
assert!(!copy.contains("invalid_request"), "{}: {copy}", lang.tag());
|
||||
assert!(!copy.contains("AADSTS"), "{}: {copy}", lang.tag());
|
||||
assert!(!copy.contains('{'), "{}: {copy}", lang.tag());
|
||||
}
|
||||
// 「没登记回环」这一条必须给**可照抄**的下一步(门户 + 值 + 端口不参与匹配)。
|
||||
assert!(
|
||||
not_registered.contains("http://localhost/auth"),
|
||||
"{not_registered}"
|
||||
);
|
||||
assert!(
|
||||
not_registered.to_lowercase().contains("redirect uri")
|
||||
|| not_registered.contains("重定向 URI"),
|
||||
"{not_registered}"
|
||||
);
|
||||
assert!(
|
||||
not_registered.to_lowercase().contains("port") || not_registered.contains("端口"),
|
||||
"{not_registered}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **M132**:缺 client id 这条是本页唯一「说清原因」的失败文案,它的纪律比别的正文更硬。
|
||||
///
|
||||
/// 抓得住:文案退回「登录失败 / 完整原因在日志里」、退化成「未支持」、或者只说缺 id 却
|
||||
/// 不给要设的变量名;也能抓住有人把某个真实 client id 写进词条。
|
||||
/// 抓不住:读起来顺不顺(那靠 review 与截图)。
|
||||
#[test]
|
||||
fn the_missing_client_id_copy_says_what_to_configure_in_both_languages() {
|
||||
for &lang in Lang::ALL {
|
||||
let catalog = Catalog::of(lang);
|
||||
let title = catalog.text(Key::AccountLoginNotConfigured);
|
||||
let body = catalog.text(Key::AccountFailureMsaNotConfigured);
|
||||
let all = format!("{title}\n{body}");
|
||||
|
||||
assert!(body.contains("ULL_MSA_CLIENT_ID"), "{}: {all}", lang.tag());
|
||||
assert!(
|
||||
body.contains("<id>"),
|
||||
"示例只许用占位:{}: {all}",
|
||||
lang.tag()
|
||||
);
|
||||
// 面板上是「标题 + 正文」一起:结论(未配置)给标题,怎么做给正文。
|
||||
assert!(
|
||||
all.to_lowercase().contains("not configured") || all.contains("未配置"),
|
||||
"要说清「未配置」:{}: {all}",
|
||||
lang.tag()
|
||||
);
|
||||
assert!(!body.to_lowercase().contains("unsupported"), "{all}");
|
||||
assert!(
|
||||
!body.contains("未支持") && !body.contains("未实现"),
|
||||
"{all}"
|
||||
);
|
||||
// 不许退回「完整原因在日志里」那句(那正是用户报的 bug)
|
||||
assert!(!body.contains("launcher log"), "{all}");
|
||||
assert!(!body.contains("启动器日志"), "{all}");
|
||||
// 硬约束:`ULL_MSA_CLIENT_ID=` 后面只许跟 `<id>` 占位——写进来一个真 id 就红。
|
||||
assert_eq!(
|
||||
body.matches("ULL_MSA_CLIENT_ID=").count(),
|
||||
body.matches("ULL_MSA_CLIENT_ID=<id>").count(),
|
||||
"示例里出现了真 id:{all}"
|
||||
);
|
||||
}
|
||||
for &lang in Lang::ALL {
|
||||
let catalog = Catalog::of(lang);
|
||||
let title = catalog.text(Key::AccountLoginNotConfigured);
|
||||
assert!(
|
||||
title.to_lowercase().contains("not configured") || title.contains("未配置"),
|
||||
"{}: {title}",
|
||||
lang.tag()
|
||||
);
|
||||
assert!(
|
||||
!title.contains('=') && !title.contains("ULL_MSA_CLIENT_ID"),
|
||||
"标题只报结论,变量名在正文里:{title}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! AI 助手栏 / AI 独立页的文案(M104 `AGENTRAIL`)。
|
||||
//!
|
||||
//! 这两处**共用同一套会话视图**(`crates/app-iced/src/pages/agent/`),所以文案也只在这里出现一次:
|
||||
//! 栏上的折叠/拆出、页上的收回、两处都要的发送/中断/空会话引导。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后,
|
||||
//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。
|
||||
//!
|
||||
//! **新增 key 时按顺序追加在最后**:金表是「拆分前那份表的逐字快照」,重排既有行会让它红。
|
||||
|
||||
macro_rules! agent_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── AI 助手(右栗 + 独立页共用)
|
||||
AgentTitle => ("Assistant", "助手"),
|
||||
// M119 r1 复审 P2-1:这条一度被改成中性的「会话」,理由是「跑什么后端由端点标签说」——
|
||||
// 但端点标签在壳里根本不存在,于是默认配置下用户看到的是一个像是接上了模型的助手。
|
||||
// 现在把诚实的话写回来,而且它是**常驻**的(标题下就一行,不点也能看到)。
|
||||
AgentSubtitle => ("Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
|
||||
AgentCollapse => ("Collapse", "收起"),
|
||||
AgentDetach => ("Pop out", "拆出"),
|
||||
AgentDock => ("Dock back", "收回"),
|
||||
AgentOpen => ("Open assistant", "打开助手"),
|
||||
AgentStripLabel => ("AI", "助手"),
|
||||
AgentSend => ("Send", "发送"),
|
||||
AgentStop => ("Stop", "中断"),
|
||||
// 输入框上再说一次:用户真要动手的位置就在这儿
|
||||
AgentPlaceholder => ("Message the simulated assistant…", "给模拟助手发消息…"),
|
||||
AgentEmptyTitle => ("Nothing in this session yet", "这个会话还没有内容"),
|
||||
// 空会话是最容易被误读的一屏(没有人说过话,很容易以为是「连上了但还没问」)
|
||||
AgentEmptyBody => ("Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
|
||||
AgentNotePending => ("Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
|
||||
AgentNoteStopped => ("Stopped by you.", "你中断了这次回复。"),
|
||||
AgentUnread => ("{count} unread", "{count} 条未读"),
|
||||
AgentResize => ("Drag to resize the assistant rail", "拖动调整助手栏宽度"),
|
||||
AgentWidthLabel => ("Width {width}", "宽度 {width}"),
|
||||
AgentRoleYou => ("You", "你"),
|
||||
AgentRoleAssistant => ("Assistant", "助手"),
|
||||
AgentRoleNotice => ("Notice", "提示"),
|
||||
// ── M119:失败态与写操作确认(接线后才有;`AgentRail::error` / `rail.confirm`)
|
||||
//
|
||||
// 为什么必须成 key:任务要求「presenter 出错时 UI 显示**具名**错误」,
|
||||
// 而 M106 立的规矩是界面文案一律走 catalog——没有键就只能硬编码中英。
|
||||
AgentErrorTitle => ("Assistant error", "助手出错"),
|
||||
AgentErrorUnavailable => ("Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
|
||||
AgentErrorNotImplemented => ("This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
|
||||
AgentConfirmRequest => ("The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
|
||||
AgentConfirmAllow => ("Allow", "允许"),
|
||||
AgentConfirmDeny => ("Deny", "拒绝"),
|
||||
// 头部常驻小标记(P2-1):与副标题/占位符互为冗余,三个位置都能看到「这是模拟」
|
||||
AgentSimulated => ("Simulated", "模拟"),
|
||||
// ── M119 r2:三处文案的**真实态**配对(模拟态用上面那三条已有的 key)
|
||||
//
|
||||
// 复审 r2 的 P2-1:上一轮只有标题胶囊由 `simulated` 驱动,副标题/占位符/空态正文是
|
||||
// 无条件取词,而那三条译文里写着「模拟 / 没接真实模型」——接上真 transport 之后
|
||||
// 这三句会**反方向**地留着。现在配成对:`simulated == true` 走模拟态,
|
||||
// `false` 走这三条中性话(`pages/agent/tests/disclaimer.rs` 钉住四处一起变)。
|
||||
AgentSubtitleReal => ("Conversation", "会话"),
|
||||
AgentPlaceholderReal => ("Message the assistant…", "给助手发消息…"),
|
||||
AgentEmptyBodyReal => ("Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use agent_entries;
|
||||
@@ -0,0 +1,363 @@
|
||||
//! 内容库文案(M109 `CONTENTDL`)。
|
||||
//!
|
||||
//! 一句话一个 key:带变量的句子用 `{name}`,页面只做 [`crate::Catalog::render`],不拼片段。
|
||||
//!
|
||||
//! ## 三条口径
|
||||
//!
|
||||
//! 1. **只说能做的事**:整合包那条写的是「要等导入管线」,不是「稍后自动支持」;
|
||||
//! 2. **站名也进词表**:`Modrinth` / `CurseForge` 的**中文译文必须与英文不同**
|
||||
//! (`catalog/tests.rs` 的 `chinese_catalog_is_actually_chinese` 会红),所以中文写成「Modrinth 源」;
|
||||
//! 3. **禁用态要能解释自己**:`ContentIncompatible*` / `ContentModpackNotReady` 就是禁用按钮旁边那几句话——
|
||||
//! 没有它们,禁用就等于「点了没反应」。
|
||||
//!
|
||||
//! 域文件的形状见 `crate::catalog` 的「域装配」一节。
|
||||
|
||||
macro_rules! content_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// --- content library page (M109) ---
|
||||
ContentTitle => ("Content", "内容库"),
|
||||
ContentSubtitle => (
|
||||
"Modpacks, mods, resource packs and shaders",
|
||||
"整合包、Mod、资源包与光影"
|
||||
),
|
||||
ContentSectionVersions => ("Version management", "版本管理"),
|
||||
ContentSectionContent => ("Content library", "内容库"),
|
||||
ContentKindModpack => ("Modpacks", "整合包"),
|
||||
ContentKindMod => ("Mods", "Mod"),
|
||||
ContentKindResourcepack => ("Resource packs", "资源包"),
|
||||
ContentKindShader => ("Shaders", "光影"),
|
||||
ContentSourceModrinth => ("Modrinth", "Modrinth 源"),
|
||||
ContentSourceCurseforge => ("CurseForge", "CurseForge 源"),
|
||||
ContentSortRelevance => ("Relevance", "相关度"),
|
||||
ContentSortDownloads => ("Downloads", "下载量"),
|
||||
ContentSortNewest => ("Newest", "最新发布"),
|
||||
ContentSortUpdated => ("Recently updated", "最近更新"),
|
||||
ContentSearchPlaceholder => ("Search content…", "搜索内容…"),
|
||||
ContentSearch => ("Search", "搜索"),
|
||||
ContentFilterVersion => ("Game version", "游戏版本"),
|
||||
ContentFilterLoader => ("Loader", "加载器"),
|
||||
ContentFilterAny => ("Any", "不限"),
|
||||
ContentCount => ("{count} results", "{count} 个结果"),
|
||||
ContentLoading => ("Loading content…", "正在加载内容…"),
|
||||
ContentLoadingHint => (
|
||||
"Results come from the network; nothing is cached offline yet.",
|
||||
"结果来自网络,暂时没有离线缓存。"
|
||||
),
|
||||
ContentEmptyTitle => ("Nothing matched", "没有匹配的内容"),
|
||||
ContentEmptyBody => (
|
||||
"Try another keyword, or clear a filter.",
|
||||
"换个关键词,或清掉一个筛选条件。"
|
||||
),
|
||||
ContentFailedTitle => ("Could not load content", "内容加载失败"),
|
||||
ContentFailedBody => ("Check the network, then retry.", "检查网络后重试。"),
|
||||
ContentOfflineTitle => ("No connection", "未联网"),
|
||||
ContentOfflineBody => (
|
||||
"Content search needs the network. Check your connection, then retry.",
|
||||
"内容检索需要联网。检查网络后重试。"
|
||||
),
|
||||
ContentRetry => ("Retry", "重试"),
|
||||
ContentLoadMore => ("Load more", "加载更多"),
|
||||
ContentNoMore => ("No more results", "没有更多了"),
|
||||
ContentDetailEmpty => (
|
||||
"Pick an item to see what it supports.",
|
||||
"选一个项目看它支持什么。"
|
||||
),
|
||||
ContentDetailDownloads => ("{count} downloads", "下载量 {count}"),
|
||||
ContentDetailUpdated => ("Updated {date}", "更新于 {date}"),
|
||||
ContentDetailLoaders => ("Loaders: {list}", "加载器:{list}"),
|
||||
ContentDetailVersions => ("Game versions: {list}", "游戏版本:{list}"),
|
||||
ContentDetailFetching => ("Loading versions…", "正在取版本列表…"),
|
||||
ContentDetailFetchFailed => (
|
||||
"Could not load versions: {reason}",
|
||||
"版本列表取不到:{reason}"
|
||||
),
|
||||
ContentDetailVersionCount => ("{count} versions", "{count} 个版本"),
|
||||
ContentDetailVersionLine => ("{number} · {kind} · {date}", "{number}({kind})· {date}"),
|
||||
ContentInstall => ("Install into instance", "装入实例"),
|
||||
ContentTargetInstance => ("Target instance", "目标实例"),
|
||||
ContentNoInstances => ("No instance to install into", "没有可装入的实例"),
|
||||
ContentIncompatibleVersion => (
|
||||
"Needs {version}; this instance runs {actual}",
|
||||
"需要 {version},该实例运行的是 {actual}"
|
||||
),
|
||||
ContentIncompatibleLoader => ("Not built for {loader}", "不支持 {loader}"),
|
||||
ContentModpackNotReady => (
|
||||
"Installing modpacks from the library into an instance is not available yet",
|
||||
"从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"
|
||||
),
|
||||
ContentSourceUnavailable => (
|
||||
"CurseForge needs an API key: add one in Settings",
|
||||
"CurseForge 需要在设置里配置密钥才能搜索"
|
||||
),
|
||||
ContentSourceNotWired => (
|
||||
"Installing from CurseForge is not available yet",
|
||||
"CurseForge 来源的安装尚未开放"
|
||||
),
|
||||
// --- M191:安装失败的两档分组与「只重试这些」---
|
||||
// 两档**各一条**:不兼容要换版本/加载器,取不到要检查网络,合并成一句会让用户
|
||||
// 对着一条重试必然失败的项反复点重试。
|
||||
ContentInstallFailedTitle => (
|
||||
"Install did not finish — {count} items failed",
|
||||
"安装未完成 —— {count} 项没装上"
|
||||
),
|
||||
ContentInstallRetryIncompatible => (
|
||||
"Not available for this version/loader",
|
||||
"不兼容所选版本/加载器"
|
||||
),
|
||||
ContentInstallRetryUnreachable => (
|
||||
"Could not fetch (network or missing project)",
|
||||
"取不到(网络 / 项目不存在)"
|
||||
),
|
||||
ContentInstallRetryOnly => (
|
||||
"Retry just these {count}",
|
||||
"只重试这 {count} 个"
|
||||
),
|
||||
ContentInstallRetryHint => (
|
||||
"Retry asks again only for the items above; files that already landed stay put.",
|
||||
"重试只重新解析上面这几项,已落盘的文件不动。"
|
||||
),
|
||||
// --- M113: pack import / export(PACKUI)---
|
||||
PackTitle => ("Modpack import & export", "整合包导入 / 导出"),
|
||||
PackImportTitle => ("Import a modpack", "导入整合包"),
|
||||
PackDropHint => (
|
||||
"Drop a .mrpack, .zip or a MultiMC/Prism instance folder here",
|
||||
"把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"
|
||||
),
|
||||
PackDropActive => ("Release to probe the pack", "松手开始探测"),
|
||||
PackNoDialog => (
|
||||
"This build has no system file picker: drag the file in, or paste its path below.",
|
||||
"本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"
|
||||
),
|
||||
PackPathLabel => ("Pack path", "整合包路径"),
|
||||
PackPathPlaceholder => (
|
||||
"D:\\packs\\Better MC.mrpack",
|
||||
"D:\\整合包\\更好的MC.mrpack"
|
||||
),
|
||||
PackImportAction => ("Import", "导入"),
|
||||
PackCancel => ("Cancel import", "取消导入"),
|
||||
PackDismiss => ("Dismiss", "关闭"),
|
||||
PackTargetLabel => ("Install into", "装到实例"),
|
||||
PackTargetNew => ("New instance", "新建实例"),
|
||||
PackInstanceName => ("Instance name", "实例名"),
|
||||
PackStageLine => ("Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
|
||||
PackStageProbe => ("Reading the manifest", "读取清单"),
|
||||
PackStageExtract => ("Unpacking overrides", "解包覆盖内容"),
|
||||
PackStageFetch => ("Fetching missing files", "补下载文件"),
|
||||
PackStageCommit => ("Writing files", "落地写盘"),
|
||||
PackStageRegister => ("Registering the instance", "登记实例"),
|
||||
PackProbeTitle => (
|
||||
"This pack is almost a known format",
|
||||
"这个包差一点就是已知格式"
|
||||
),
|
||||
PackProbeLooksLike => (
|
||||
"Looks like {format}, but missing: {missing}",
|
||||
"看起来像 {format},但缺:{missing}"
|
||||
),
|
||||
PackProbeUnknown => (
|
||||
"No known format claims this pack",
|
||||
"没有任何已知格式认领这个包"
|
||||
),
|
||||
PackProbeSaw => ("Found in the pack: {saw}", "包里看到:{saw}"),
|
||||
PackProbeNothing => ("nothing recognisable", "没有可识别的东西"),
|
||||
PackStatusSuccess => ("Imported", "已导入"),
|
||||
PackStatusPartial => ("Imported with gaps", "导入完成,但有缺口"),
|
||||
PackStatusFailed => ("Import failed — rolled back", "导入失败 —— 已整包回滚"),
|
||||
PackReportWritten => ("Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
|
||||
PackReportMissing => (
|
||||
"Could not fetch (tolerated): {count}",
|
||||
"有 {count} 个文件没搞到(可容忍)"
|
||||
),
|
||||
PackReportConflicts => (
|
||||
"Conflicts: {count}",
|
||||
"冲突:{count} 个"
|
||||
),
|
||||
PackReportRejected => (
|
||||
"The client does not support these: {count}",
|
||||
"这些组件客户端不支持:{count} 个"
|
||||
),
|
||||
PackReportRejectedNote => ("not supported by the client", "客户端不支持"),
|
||||
PackCancelled => (
|
||||
"Cancel requested — the pipeline is stopping now; the real outcome follows.",
|
||||
"已请求取消 —— 后台正在停手,真实结果随后给出。"
|
||||
),
|
||||
PackStatusCancelled => ("Cancelled — rolled back", "已取消 —— 已回滚"),
|
||||
PackCancelIncomplete => (
|
||||
"Cancelled — rollback incomplete: the target directory may differ from before the import.",
|
||||
"已取消 —— 回滚没做完:目标目录可能与导入前不同。"
|
||||
),
|
||||
PackCancelTooLate => (
|
||||
"The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.",
|
||||
"取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。"
|
||||
),
|
||||
PackOverwriteAction => ("Re-import, the pack wins", "以包为准重新导入"),
|
||||
PackOverwriteNote => (
|
||||
"Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.",
|
||||
"「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"
|
||||
),
|
||||
PackMissingOffline => ("offline import", "离线导入"),
|
||||
PackMissingNoSource => (
|
||||
"no download source available yet",
|
||||
"暂时拿不到下载地址"
|
||||
),
|
||||
PackMissingFetchFailed => ("download failed: {detail}", "下载失败:{detail}"),
|
||||
PackFatalTitle => (
|
||||
"Rolled back — nothing was left behind",
|
||||
"已回滚 —— 目标目录零残留"
|
||||
),
|
||||
PackFatalHint => ("What to try next", "下一步可以试"),
|
||||
PackConflictUserModified => ("you changed it", "你改过"),
|
||||
PackConflictUserDeleted => ("you deleted it", "你删过"),
|
||||
PackConflictPreExisting => ("was already there", "本来就在"),
|
||||
PackChoiceKeepUser => ("kept yours", "保留我的"),
|
||||
PackChoiceOverwrite => ("pack version used", "用包里的"),
|
||||
PackConflictHashes => (
|
||||
"pack {pack} · base {base} · disk {disk}",
|
||||
"包内 {pack} · 基线 {base} · 磁盘 {disk}"
|
||||
),
|
||||
PackConflictDetail => ("Hashes", "三方哈希"),
|
||||
PackPickTitle => (
|
||||
"Before importing: {count} files already exist with different content",
|
||||
"导入前:有 {count} 个文件已存在且内容不同"
|
||||
),
|
||||
PackPickSummary => (
|
||||
"Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}",
|
||||
"保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"
|
||||
),
|
||||
PackPickKeepAll => ("Keep all mine", "全部保留我的"),
|
||||
PackPickOverwriteAll => ("Overwrite all", "全部覆盖"),
|
||||
PackPickRowKeep => ("Keep", "保留"),
|
||||
PackPickRowOverwrite => ("Overwrite", "覆盖"),
|
||||
PackPickPage => ("Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
|
||||
PackPickPrev => ("Previous", "上一页"),
|
||||
PackPickNext => ("Next", "下一页"),
|
||||
PackPickConfirm => ("Import with these choices", "按这些选择导入"),
|
||||
PackPickCancel => ("Cancel import", "取消导入"),
|
||||
PackPickDefaultNote => (
|
||||
"Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.",
|
||||
"没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"
|
||||
),
|
||||
PackPickUnknownRemote => (
|
||||
"{count} remote entries only learn their path after download; they follow “keep”.",
|
||||
"还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"
|
||||
),
|
||||
PackPickSourceOverrides => ("in the pack (overrides/)", "包内 overrides/"),
|
||||
PackPickSourceClientOverrides => ("in the pack (client-overrides/)", "包内 client-overrides/"),
|
||||
PackPickSourceManifest => ("manifest files[]", "清单 files[]"),
|
||||
PackPickDisk => ("yours: {size}", "你的:{size}"),
|
||||
PackPickIncoming => ("pack: {size}", "包内:{size}"),
|
||||
PackFormatModrinth => ("Modrinth .mrpack", "Modrinth 的 .mrpack"),
|
||||
PackFormatCurseforge => ("CurseForge manifest", "CurseForge 清单"),
|
||||
PackFormatMultimc => ("MultiMC / Prism instance", "MultiMC·Prism 实例"),
|
||||
PackExportTitle => ("Export this instance", "导出这个实例"),
|
||||
PackExportOpen => ("Export…", "导出…"),
|
||||
PackExportFormat => ("Format", "格式"),
|
||||
PackExportName => ("Pack name", "包名"),
|
||||
PackExportVersion => ("Version", "版本"),
|
||||
PackExportOutput => ("Save to", "保存到"),
|
||||
PackExportOutputPlaceholder => (
|
||||
"empty = suggested path",
|
||||
"留空 = 自动给一个路径"
|
||||
),
|
||||
PackExportPrecheck => ("Dry run", "预检"),
|
||||
PackExportConfirm => ("Write the pack", "确认写盘"),
|
||||
PackExportAbort => ("Back", "返回"),
|
||||
PackExportMultimcNote => (
|
||||
"MultiMC/Prism can only be imported, not exported (by design).",
|
||||
"MultiMC·Prism 只能导入、不能导出(有意省略)。"
|
||||
),
|
||||
PackExportDone => ("Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
|
||||
PackExportFailed => ("Export failed: {reason}", "导出失败:{reason}"),
|
||||
PackExportPending => ("Scanning the instance…", "正在扫描实例…"),
|
||||
PackPreviewHead => (
|
||||
"Will inline {inline} files · cannot export: {skipped}",
|
||||
"将内联 {inline} 个文件 · 无法导出 {skipped} 项"
|
||||
),
|
||||
PackPreviewRemote => (
|
||||
"{count} files can be referenced remotely",
|
||||
"{count} 个文件可走远程引用"
|
||||
),
|
||||
PackPreviewInlineBytes => (
|
||||
"{bytes} copied into overrides/",
|
||||
"{bytes} 会原样拷进 overrides/"
|
||||
),
|
||||
PackPreviewInlineTitle => (
|
||||
"Copied into overrides/ (first {shown}):",
|
||||
"原样拷进 overrides/(只列前 {shown} 个):"
|
||||
),
|
||||
PackPreviewSkippedTitle => (
|
||||
"Not in the pack: {count}",
|
||||
"没进包:{count} 项"
|
||||
),
|
||||
PackPreviewNotes => ("Known losses", "已知损失"),
|
||||
PackPreviewTarget => ("Will be written to {path}", "将写到 {path}"),
|
||||
PackPreviewMore => (
|
||||
"…and {count} more files (not shown here)",
|
||||
"…还有 {count} 个文件(这里不展开)"
|
||||
),
|
||||
PackPreviewComplete => ("Nothing is left out", "没有遗漏任何文件"),
|
||||
PackPreviewIncomplete => (
|
||||
"Some files will not be in the pack",
|
||||
"有文件不会进包"
|
||||
),
|
||||
PackReasonModrinthSha1 => ("Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
|
||||
PackReasonCurseFingerprint => (
|
||||
"CurseForge match (fingerprint)",
|
||||
"CurseForge 命中(文件指纹)"
|
||||
),
|
||||
PackReasonNoRemoteMatch => (
|
||||
"unknown to both catalogs, inlined",
|
||||
"两个目录都不认识,已内联"
|
||||
),
|
||||
PackReasonCurseOnly => (
|
||||
"only CurseForge knows it, this format cannot use it, inlined",
|
||||
"只有 CF 认识,目标格式用不上,已内联"
|
||||
),
|
||||
PackReasonModrinthOnly => (
|
||||
"only Modrinth knows it, the CF manifest cannot use it, inlined",
|
||||
"只有 Modrinth 认识,CF 清单用不上,已内联"
|
||||
),
|
||||
PackReasonMissingSha512 => (
|
||||
"the catalogue entry has no sha512 hash, inlined",
|
||||
"命中缺 sha512,已内联"
|
||||
),
|
||||
PackReasonMissingCurseIds => (
|
||||
"the catalogue entry has no CurseForge ids, inlined",
|
||||
"命中缺 projectID/fileID,已内联"
|
||||
),
|
||||
PackReasonDisabled => (
|
||||
"disabled file, this format cannot express it (.disabled kept)",
|
||||
"禁用文件,目标格式表达不了(保留 .disabled)"
|
||||
),
|
||||
PackReasonSymlink => ("symbolic link, skipped", "符号链接,已跳过"),
|
||||
PackReasonUnsafePath => (
|
||||
"path fails the pack path rules, skipped",
|
||||
"路径过不了包内规整,已跳过"
|
||||
),
|
||||
PackReasonUnreadable => ("cannot read it, skipped", "读不出来,已跳过"),
|
||||
// --- M161:导入结果的两种失败**不许共用一句话** ---
|
||||
// 「预检没通过(一个字节都没写)」与「写进去之后回滚完成」是两件事:
|
||||
// 前者用户去修包/修配置再试,后者说明已经动过目标目录。此前两者共用
|
||||
// `PackStatusFailed`("Import failed — rolled back"),预检失败时那句话是假的。
|
||||
PackStatusFailedNotStarted => (
|
||||
"Import failed — nothing was written",
|
||||
"导入失败 —— 还没写任何文件"
|
||||
),
|
||||
PackStatusFailedIncomplete => (
|
||||
"Import failed — rollback did not finish",
|
||||
"导入失败 —— 回滚没做完"
|
||||
),
|
||||
PackFatalTitleNotStarted => (
|
||||
"Preflight failed — nothing was written",
|
||||
"预检未通过 —— 一个字节都没写"
|
||||
),
|
||||
PackFatalTitleIncomplete => (
|
||||
"Rollback did not finish — the target folder may differ",
|
||||
"回滚没做完 —— 目标目录可能与导入前不同"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use content_entries;
|
||||
@@ -0,0 +1,15 @@
|
||||
//! core / presenter 只给了中文串、UI 又不许画原文时的兜底文案(口径见 [`crate::bridge`])。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! core_text_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── core / presenter 只给了中文串、UI 又不许画原文时的兜底(见 `bridge`)
|
||||
CoreTextUntranslated => ("[system text not translated]", "[未翻译的系统文案]"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use core_text_entries;
|
||||
@@ -0,0 +1,156 @@
|
||||
//! 版本管理页文案(M94 建页时叫「下载页」,M108 起显示名改成「版本管理」)。
|
||||
//!
|
||||
//! 一句话一个 key:带变量的句子用 `{name}`,页面只做 [`crate::Catalog::render`],不拼片段
|
||||
//! ([`crate::bridge`] 的规矩 1)。
|
||||
//!
|
||||
//! 文案口径(M94 r1 / P2-2):**只说这个页面真能做到的动作**(切分类、刷新、看日志),
|
||||
//! 不写「稍后会自动重试」这类 UI 没有的能力,也不拿文案去补功能。
|
||||
//!
|
||||
//! **「下载」字样不许再指向这一页**(M108 用户令):这一页管的是「版本与加载器」,用户点它
|
||||
//! 不是为了下载点什么。唯一的例外是 [`Key::DownloadStageDownloading`]——那是安装流程里**真在
|
||||
//! 传文件**的那一步,说的不是页面身份而是当时的动作,留着才对(守门见 `pages/tests.rs` 的
|
||||
//! `the_download_route_is_presented_as_version_management`,它把本域每条 key 的两种语言都扫一遍)。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! download_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// --- version management page (M94; renamed in M108) ---
|
||||
DownloadTitle => ("Version management", "版本管理"),
|
||||
// 例外说明(M108 复审 p3):副标题承诺「安装、升级、修复」,但页面目前只有
|
||||
// 「选版本 → 安装」一条路,「修复」与「加载器选择」都还没有入口
|
||||
// (`InstallView::of` 连 `InstallProgressView.loader` 都没读)。
|
||||
// 这是用户令(2026-11-04)定的口径,能力由 M105 / M113 补齐;
|
||||
// 补齐之前不许拿这句话当已实现功能宣传。
|
||||
DownloadSubtitle => (
|
||||
"Manage versions and loaders: install, upgrade, repair",
|
||||
"管理版本与加载器:安装、升级、修复"
|
||||
),
|
||||
DownloadRefresh => ("Refresh", "刷新"),
|
||||
DownloadInstall => ("Install", "安装"),
|
||||
DownloadCancel => ("Cancel install", "取消安装"),
|
||||
DownloadInstalled => ("Installed", "已安装"),
|
||||
DownloadCount => ("{count} versions", "{count} 个版本"),
|
||||
DownloadRowSupporting => ("{kind} · released {date}", "{kind} · 发布于 {date}"),
|
||||
DownloadSelectHint => ("Pick a version, then install", "选一个版本再安装"),
|
||||
DownloadLoading => ("Loading versions…", "正在加载版本列表…"),
|
||||
DownloadLoadingHint => (
|
||||
"If this stays here, press Refresh in the top right.",
|
||||
"如果一直停在这里,点右上角的刷新。"
|
||||
),
|
||||
DownloadEmptyTitle => ("No versions to show", "没有可显示的版本"),
|
||||
DownloadEmptyBody => (
|
||||
"Pick another category, or refresh the list.",
|
||||
"换一个分类,或刷新一次列表。"
|
||||
),
|
||||
DownloadCategoryAll => ("All", "全部"),
|
||||
DownloadCategoryRelease => ("Release", "正式版"),
|
||||
DownloadCategorySnapshot => ("Snapshot", "预览版"),
|
||||
DownloadCategoryAncient => ("Ancient", "远古版"),
|
||||
DownloadStagePlanning => ("Planning", "解析版本"),
|
||||
DownloadStageDownloading => ("Downloading files", "下载文件"),
|
||||
DownloadStageInstalling => ("Building the version", "生成版本"),
|
||||
DownloadStageRegistering => ("Registering the instance", "登记实例"),
|
||||
DownloadStageDone => ("Done", "已完成"),
|
||||
DownloadStageFailed => ("Install failed", "安装失败"),
|
||||
DownloadStageCancelled => ("Install cancelled", "安装已取消"),
|
||||
DownloadProgress => (
|
||||
"{done}/{total} files",
|
||||
"{done}/{total} 文件"
|
||||
),
|
||||
DownloadProgressUnknown => ("Waiting for the first file…", "等第一个文件…"),
|
||||
// 速率与剩余时间是两个独立短语,各占一个 key(`DownloadRate` 只负责把它们排成一行)
|
||||
DownloadRate => ("{speed} · {eta}", "{speed},{eta}"),
|
||||
DownloadEtaSecs => ("{secs}s left", "剩 {secs}s"),
|
||||
DownloadEtaMins => ("{mins}m{secs}s left", "剩 {mins}m{secs}s"),
|
||||
DownloadFailedHint => (
|
||||
"The Logs page has the full reason.",
|
||||
"完整原因在日志页。"
|
||||
),
|
||||
DownloadOpenLogs => ("Open logs", "打开日志"),
|
||||
DownloadDismiss => ("Dismiss", "关闭"),
|
||||
// --- 安装选项(M105)---
|
||||
DownloadOptionsTitle => ("Install options", "安装选项"),
|
||||
DownloadOptionsLoaderVersion => ("Loader version", "加载器版本"),
|
||||
DownloadOptionsLoaderVersionsLoading => (
|
||||
"Fetching loader versions…",
|
||||
"正在获取加载器版本…"
|
||||
),
|
||||
DownloadOptionsLoaderVersionsFailed => (
|
||||
"Could not fetch loader versions: {reason}",
|
||||
"取不到加载器版本:{reason}"
|
||||
),
|
||||
DownloadOptionsLoaderVersionsEmpty => (
|
||||
"No loader builds published for {version}",
|
||||
"官方没有为 {version} 发布加载器版本"
|
||||
),
|
||||
DownloadOptionsInstanceName => ("Instance name", "实例名"),
|
||||
DownloadOptionsInstanceNameHint => (
|
||||
"Empty = use the version id",
|
||||
"留空就用版本号"
|
||||
),
|
||||
DownloadOptionsIncompatible => (
|
||||
"{loader} has no builds for {version}",
|
||||
"{loader} 没有适配 {version} 的构建"
|
||||
),
|
||||
DownloadOptionsNeedsAtLeast => (
|
||||
"{loader} needs Minecraft {min} or newer",
|
||||
"{loader} 需要 Minecraft {min} 或更新"
|
||||
),
|
||||
// M222:下载页只做「原版 + 加载器」一层,Forge 父链只有命令行能走。中性说明,
|
||||
// 不是错误(不禁用确认键、不套错误色)——所以它不走 `problem` 那条通道。
|
||||
DownloadOptionsLiteLoaderVanillaOnly => (
|
||||
"Vanilla parent only — Forge goes through the CLI",
|
||||
"只叠原版;叠 Forge 请走命令行"
|
||||
),
|
||||
DownloadCancelled => (
|
||||
"Install cancelled — finished files are kept, the instance was not created",
|
||||
"安装已取消 —— 已下好的文件保留,实例未登记"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use download_entries;
|
||||
|
||||
/// **M107 版本管理页**的侧栏 / 搜索文案。
|
||||
///
|
||||
/// 为什么单独一个宏、并**接在装配链最后**:`catalog/golden.rs` 是「`Key` 声明顺序 × 译文」的逐字节
|
||||
/// 快照,M107 的纪律是**只追加不重排**。把新 key 插在 `download_entries` 中间会让 `Key::ALL`
|
||||
/// 的顺序在 `DownloadDismiss` 与 `LogsSubtitleIdle` 之间断开,金表就得整体重排(那正是要避免的
|
||||
/// 风险)。追加在链尾 ⇒ 新 key 落在 `Key::ALL` 末尾 ⇒ 金表末尾追加同样几行即可。
|
||||
macro_rules! version_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// --- version management page (M107) ---
|
||||
VersionRailTitle => ("Version type", "版本类型"),
|
||||
VersionMajorTitle => ("Major version", "主版本"),
|
||||
VersionMajorAll => ("All versions", "全部版本"),
|
||||
VersionSearchPlaceholder => ("Search version numbers", "搜版本号"),
|
||||
VersionSearchClear => ("Clear search", "清空搜索"),
|
||||
VersionSearchHits => ("{count} matches", "{count} 个匹配"),
|
||||
VersionSearchEmptyTitle => ("No matching versions", "没有匹配的版本"),
|
||||
VersionSearchEmptyBody => (
|
||||
"Try a shorter piece of the number — 1.21, 24w, a1.",
|
||||
"试试更短的片段 — 1.21、24w、a1。"
|
||||
),
|
||||
VersionSearchElsewhere => (
|
||||
"{count} versions match, but they are in another type.",
|
||||
"有 {count} 个版本匹配,但它们在别的类型里。"
|
||||
),
|
||||
VersionSearchElsewhereAction => ("Show all types", "看全部类型"),
|
||||
// --- M107 r2:复数口径与「在别处」的标题(追加在域尾 ⇒ `Key::ALL` 顺序只增不动) ---
|
||||
VersionSearchHitsOne => ("1 match", "1 个匹配"),
|
||||
VersionSearchElsewhereTitle => ("Found in another type", "在别的类型里找到了"),
|
||||
VersionSearchElsewhereOne => (
|
||||
"1 version matches, but it is in another type.",
|
||||
"有 1 个版本匹配,但它在别的类型里。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use version_entries;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! 中文字体文案(D28):中文环境缺字体时的下载提示 / 失败回退。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! font_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 中文字体(D28:中文环境缺字体时的下载提示 / 失败回退)
|
||||
FontCjkNeedsDownload => (
|
||||
"No Chinese font found on this system. Download an open-source (OFL) Chinese font?",
|
||||
"系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"
|
||||
),
|
||||
FontCjkDownloading => ("Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
|
||||
FontCjkDownloadFailed => (
|
||||
"Chinese font download failed; the interface stays in English.",
|
||||
"中文字体下载失败,界面暂时保持英文。"
|
||||
),
|
||||
FontCjkReady => ("Chinese font ready: {family}", "中文字体已就绪:{family}"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use font_entries;
|
||||
@@ -0,0 +1,72 @@
|
||||
//! 中文字体**下载状态行**的文案(M168):设置页 General 组那一行的四态文字 + 三类失败原因。
|
||||
//!
|
||||
//! ## 为什么单开一个域文件(而不是塞进 `font.rs`)
|
||||
//!
|
||||
//! `catalog/font.rs` 那四条(`FontCjkNeedsDownload` / `FontCjkDownloading` / `FontCjkDownloadFailed`
|
||||
//! / `FontCjkReady`)是 M92「只发信号」时代的文案,位置在装配链**中间**。往那个域后面追加会把
|
||||
//! 其后所有 key 在 `Key::ALL` 里整体平移一位 ⇒ 金表(逐 index 逐字节)整片红。
|
||||
//!
|
||||
//! 本域挂在**链尾**(`update_entries` 之后),于是 `Key::ALL` 的既有顺序一个不动,金表只需在
|
||||
//! 末尾追加(M104 / M106 / M107 / M109 / M114 / M159 都是这条做法)。
|
||||
//!
|
||||
//! ## 四条状态各一句话(tower 定死的口径)
|
||||
//!
|
||||
//! | 状态 | 文案说什么 |
|
||||
//! |---|---|
|
||||
//! | 下载中 | 「正在获取中文字体…(16.9 MB)」——**没有**假进度条,有真实量(字节数)才写 |
|
||||
//! | 完成 | 「中文字体已就绪:⟨族名⟩」(复用 `FontCjkReady`) |
|
||||
//! | 失败 | 「获取失败(⟨三类原因之一⟩)。界面照常可用,用的是系统字体;再切一次语言可以重试」 |
|
||||
//! | 无需下载 | 系统已有字体 / 当前语言不需要,两条分开说(对用户是两件事) |
|
||||
//!
|
||||
//! 「探测不出」也要有话说:那是「不知道」,既不是「没有」也不是「不需要」——写清楚「未下载」,
|
||||
//! 免得用户以为我们偷偷下过一份。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! font_install_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 中文字体下载状态行(M168:设置页 General 组)
|
||||
FontCjkStatusLabel => ("Chinese font", "中文字体"),
|
||||
FontCjkStatusNotNeeded => (
|
||||
"Not needed for this interface language",
|
||||
"当前界面语言不需要中文字体"
|
||||
),
|
||||
FontCjkStatusSystem => ("Ready (system font: {family})", "已就绪(系统字体:{family})"),
|
||||
FontCjkStatusDownloading => (
|
||||
"Fetching a Chinese font… ({size})",
|
||||
"正在获取中文字体…({size})"
|
||||
),
|
||||
FontCjkStatusFailed => (
|
||||
"Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.",
|
||||
"获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"
|
||||
),
|
||||
// 超时也归这一档(M168 复审 P2-2):用户要分清「太慢/没网」与「内容不对」,
|
||||
// 但不需要知道我们内部把它叫 Timeout 还是 Network。
|
||||
FontCjkReasonNetwork => (
|
||||
"network unreachable or too slow (timed out)",
|
||||
"网络不通或太慢(超时)"
|
||||
),
|
||||
FontCjkReasonIntegrity => (
|
||||
"the mirror sent content that failed verification",
|
||||
"镜像内容校验不通过"
|
||||
),
|
||||
FontCjkReasonDisk => ("cannot write to the data directory", "写不进数据目录"),
|
||||
FontCjkStatusUnknown => (
|
||||
"Not determined yet; nothing was downloaded",
|
||||
"尚未判定;没有下载任何字体"
|
||||
),
|
||||
FontCjkStatusUnprobeable => (
|
||||
"System fonts cannot be probed, so nothing was downloaded",
|
||||
"无法探测系统字体,因此没有下载"
|
||||
),
|
||||
FontCjkStatusHint => (
|
||||
"Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.",
|
||||
"只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use font_install_entries;
|
||||
@@ -0,0 +1,65 @@
|
||||
//! 设置页 General 分组**真控件**的文案(M106 `I18NFOLLOW`):语言选择器 + 其余三行。
|
||||
//!
|
||||
//! ## 与 `catalog/settings/general.rs` 的分工
|
||||
//!
|
||||
//! `catalog/settings/general.rs`(M99 建的)只有两条**标题**([`SettingsLanguage`] /
|
||||
//! [`SettingsLanguageHint`]),是设置页五域装配的一部分;本文件是 M106 接真控件时新增的
|
||||
//! **选择器文案**:三档选项名、选中后的「界面当前使用」回显,以及 General 分组里另外三行
|
||||
//! (主题 / 启动页 / 关闭按钮)。
|
||||
//!
|
||||
//! 为什么要分两个文件:`catalog.rs` 的装配链是**追加**式的,新域挂在链尾就不会动到既有
|
||||
//! `Key` 的顺序(= `catalog/golden.rs` 那张逐字节金表)。两个文件同属 General 分组,读代码时
|
||||
//! 按「标题 vs 选项」区分即可;真要合并也是一个文件搬十行的活,不急着在接线的 mission 里做。
|
||||
//!
|
||||
//! ## 选项名为什么不是「语言自称」
|
||||
//!
|
||||
//! 常见做法是三档都写 `简体中文` / `English` 这样的自称。这里没有:`catalog/tests.rs` 有一条
|
||||
//! `chinese_catalog_is_actually_chinese`(英文表与中文表逐 key 必须不同),自称会让「中文表里
|
||||
//! 写着 English」被判成漏译。所以选项名**跟着界面语言走**:中文界面「跟随系统 / 简体中文 / 英文」,
|
||||
//! 英文界面 "Follow system / Chinese (Simplified) / English"——这也是多数启动器的做法。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! general_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── General 分组(语言选择器 + 其余三行)
|
||||
SettingsLanguageFollowSystem => ("Follow system", "跟随系统"),
|
||||
SettingsLanguageZhCn => ("Chinese (Simplified)", "简体中文"),
|
||||
SettingsLanguageEn => ("English", "英文"),
|
||||
SettingsLanguageEffective => ("Interface right now: {language}", "界面当前使用:{language}"),
|
||||
SettingsTheme => ("Theme", "主题"),
|
||||
SettingsThemeFollowSystem => ("Follow system", "跟随系统"),
|
||||
// M229:主题三态改成「Ink / Silver / 跟随系统」——前两档就是两块品牌色板
|
||||
// (Ink = 深色主方案 / Silver = 浅色主方案),落盘仍是 `appearance.theme_mode` 的
|
||||
// `dark` / `light` / `auto` 三个值,不新增配置键。
|
||||
SettingsThemeInk => ("Ink", "Ink(深色)"),
|
||||
SettingsThemeSilver => ("Silver", "Silver(浅色)"),
|
||||
SettingsThemeHint => (
|
||||
"Ink is the dark scheme, Silver the light one. Follow system picks between them as Windows switches.",
|
||||
"Ink 是深色主方案,Silver 是浅色主方案;跟随系统会随 Windows 的深浅在这两块之间切换。"
|
||||
),
|
||||
SettingsStartupPage => ("Startup page", "启动页"),
|
||||
SettingsStartupInstances => ("Instances", "实例"),
|
||||
SettingsCloseButton => ("Close button", "关闭按钮"),
|
||||
SettingsCloseToTray => ("Exit to tray", "退出到托盘"),
|
||||
// 设置页页头(M106):标题复用 `NavSettings`,这三条是副标题与两个动作。
|
||||
SettingsSubtitle => ("Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
|
||||
SettingsResetSection => ("Reset section", "重置本组"),
|
||||
SettingsSave => ("Save", "保存"),
|
||||
// 第五条导航项(M106 给导航轨补的最后一条)。放在本域而不是 `catalog/nav.rs`:
|
||||
// 金表按**位置**逐字节比,插进 nav 域会把既有 4 条导航行整体平移一位。
|
||||
NavAi => ("Assistant", "助手"),
|
||||
// 设置页左列的分组名(M106)。`Group.name` 是**id**(测试按它认组),显示名走这里。
|
||||
SettingsGroupGeneral => ("General", "通用"),
|
||||
SettingsGroupAppearance => ("Appearance", "外观"),
|
||||
SettingsGroupJava => ("Java", "Java 运行时"),
|
||||
SettingsGroupDownloads => ("Downloads", "下载"),
|
||||
// `Route::Agent` 的回退卡正文(M106:`app/view.rs` 里最后一条硬编码英文)
|
||||
AgentFallbackBody => ("The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) use general_entries;
|
||||
@@ -0,0 +1,185 @@
|
||||
//! 金表的**表数据**(`Key::ALL` 顺序 × (en, zh-CN) 逐字快照,第一列是 `Key::name()`)。「为什么
|
||||
//! 有这张表、它抓得住什么」全在 `catalog.rs` 的 `mod golden;` 里;本文件只放数据。
|
||||
pub(super) const GOLDEN: &[(&str, &str, &str)] = &[
|
||||
("NavInstances", "Instances", "实例"),
|
||||
("NavDownload", "Versions", "版本管理"),
|
||||
("NavDownloadExpanded", "Version management", "版本管理"),
|
||||
("NavLogs", "Logs", "日志"),
|
||||
("NavSettings", "Settings", "设置"),
|
||||
("InstancesNew", "New instance", "新建实例"),
|
||||
("InstancesImport", "Import", "导入"),
|
||||
("InstancesBack", "Back to instances", "返回实例列表"),
|
||||
("InstancesPlay", "Play", "启动"),
|
||||
("InstancesEmptyTitle", "No instance selected", "未选择实例"),
|
||||
("InstancesEmptyBody", "Pick an instance on the left to see its version, loader and launch options.", "在左侧选择一个实例,查看它的版本、加载器与启动选项。"),
|
||||
("InstancesTitle", "Instances", "实例"),
|
||||
("InstancesSubtitle", "Local versions and their mod loaders", "本地版本与它们的模组加载器"),
|
||||
("InstancesAllTitle", "ALL INSTANCES", "全部实例"),
|
||||
("InstancesStop", "Stop", "停止"),
|
||||
("InstancesDuplicate", "Duplicate", "复制"),
|
||||
("InstancesOpenFolder", "Open folder", "打开文件夹"),
|
||||
("InstancesDelete", "Delete", "删除"),
|
||||
("InstancesNarrowNote", "Below 840 px the list and the detail share one column, so opening an instance replaces this screen (full-page switch). Widen the window to see both panes again.", "窗口窄于 840 像素时,列表与详情共用一列:打开一个实例会整页替换这一屏(整页切换)。把窗口拉宽就能重新同时看见两栏。"),
|
||||
("InstancesMetaVersion", "VERSION", "版本"),
|
||||
("InstancesMetaLoader", "LOADER", "加载器"),
|
||||
("InstancesMetaMemory", "MEMORY", "内存"),
|
||||
("InstancesMetaJava", "JAVA", "Java"),
|
||||
("InstancesMetaMods", "MODS", "模组"),
|
||||
("InstancesMetaIsolation", "ISOLATION", "隔离方式"),
|
||||
("InstancesMetaVersionId", "VERSION ID", "版本标识"),
|
||||
("InstancesMetaFiles", "FILES", "文件"),
|
||||
("InstancesMetaLastPlayed", "LAST PLAYED", "上次游玩"),
|
||||
("InstancesPresentOnDisk", "Present on disk", "已在磁盘上"),
|
||||
("InstancesMissing", "Missing", "缺失"),
|
||||
("InstancesNeverPlayed", "Never", "从未游玩"),
|
||||
("InstancesIsolationIsolated", "Isolated", "独立目录"),
|
||||
("InstancesIsolationShared", "Shared", "共用目录"),
|
||||
("InstancesRunning", "Running · pid {pid}", "运行中 · pid {pid}"),
|
||||
("InstancesFailed", "Launch failed · {reason}", "启动失败 · {reason}"),
|
||||
("InstancesCrashed", "the game crashed (exit {code})", "游戏崩溃(退出码 {code})"),
|
||||
("InstancesSilentFailed", "exit 0, but never started", "退出码 0,没起来"),
|
||||
("InstancesStageDownload", "Downloading", "下载中"),
|
||||
("InstancesStageVerify", "Verifying", "校验中"),
|
||||
("InstancesStageLaunch", "Starting", "启动中"),
|
||||
("InstancesStageWindow", "Waiting for window", "等待游戏窗口"),
|
||||
("InstancesLaunchTitle", "LAUNCH OPTIONS", "启动参数"),
|
||||
("InstancesLaunchJvm", "JVM arguments", "JVM 参数"),
|
||||
("InstancesLaunchJvmHint", "Space separated, quotes allowed. Empty = follow the global extra JVM arguments (both are sent; on duplicates the instance wins).", "空格分隔、支持引号。留空 = 跟随全局附加 JVM 参数(两者叠加下发,同名选项实例赢)。"),
|
||||
("InstancesLaunchGame", "Game arguments", "游戏参数"),
|
||||
("InstancesLaunchGameHint", "Appended last, so they override earlier game arguments. Empty = nothing appended.", "追加在命令行最后,可覆盖前面的游戏参数。留空 = 不追加。"),
|
||||
("InstancesLaunchMemory", "Max memory (MiB)", "最大内存(MiB)"),
|
||||
("InstancesLaunchMemoryHint", "Empty = follow the global default ({default} MiB).", "留空 = 跟随全局默认({default} MiB)。"),
|
||||
("InstancesLaunchJava", "Java executable", "Java 可执行文件"),
|
||||
("InstancesLaunchJavaHint", "Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"),
|
||||
("InstancesLaunchBackend", "Graphics backend", "图形后端"),
|
||||
("InstancesLaunchBackendGated", "Requires MC {min}+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 {min} 及以上(本实例 {version}),这个版本不会下发该参数。"),
|
||||
("InstancesLaunchEffective", "Effective: {value}", "生效:{value}"),
|
||||
("InstancesBackendDefault", "Default", "默认"),
|
||||
("InstancesBackendOpengl", "OpenGL", "OpenGL 后端"),
|
||||
("InstancesBackendVulkan", "Vulkan", "Vulkan 后端"),
|
||||
("InstancesLaunchSave", "Save", "保存"),
|
||||
("InstancesLaunchReset", "Reset to global", "恢复跟随全局"),
|
||||
("AdoptTitle", "Import an existing version", "导入已有版本"),
|
||||
("AdoptSubtitle", "Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"),
|
||||
("AdoptScanning", "Scanning versions/…", "正在扫描 versions/…"),
|
||||
("AdoptEmpty", "No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"),
|
||||
("AdoptSelectLabel", "Pick a version", "选择版本"),
|
||||
("AdoptReady", "Ready", "可导入"),
|
||||
("AdoptBlocked", "{count} problem(s)", "{count} 个问题"),
|
||||
("AdoptRegistered", "Already used by {name}", "已被 {name} 使用"),
|
||||
("AdoptTargetLabel", "Where should the game run?", "游戏在哪个目录里跑"),
|
||||
("AdoptTargetInstanceDir", "New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"),
|
||||
("AdoptTargetInstanceDirHint", "mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"),
|
||||
("AdoptTargetShared", "Share the game folder", "共用游戏目录"),
|
||||
("AdoptTargetSharedHint", "mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"),
|
||||
("AdoptTargetVersionDir", "Use the version folder", "用版本目录"),
|
||||
("AdoptTargetVersionDirHint", "player data grows inside versions/<id>", "玩家数据会长在 versions/<id> 里"),
|
||||
("AdoptNameLabel", "Instance name (empty = version id)", "实例名(留空 = 版本 id)"),
|
||||
("AdoptPreview", "Check", "预检"),
|
||||
("AdoptConfirm", "Import", "导入"),
|
||||
("AdoptBack", "Back", "返回"),
|
||||
("AdoptClose", "Close", "关闭"),
|
||||
("AdoptPlanTitle", "Ready to import", "可以导入"),
|
||||
("AdoptPlanRunDir", "Game folder", "运行目录"),
|
||||
("AdoptPlanIsolation", "Isolation", "隔离方式"),
|
||||
("AdoptFailureTitle", "Import failed", "导入失败"),
|
||||
("AdoptProblemCount", "Every reason is listed below:", "全部原因如下:"),
|
||||
("AdoptInstanceName", "Instance name", "实例名"),
|
||||
("AdoptDoneTitle", "Imported", "已导入"),
|
||||
("AdoptDoneHint", "It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
|
||||
("InstancesModsTitle", "MODS", "模组"),
|
||||
("InstancesModsCount", "{enabled} enabled · {disabled} disabled", "{enabled} 启用 · {disabled} 禁用"),
|
||||
("InstancesModsLoading", "Reading mods/…", "正在读取 mods/…"),
|
||||
("InstancesModsEmpty", "No mods here yet — put jars into mods/ and they show up.", "这里还没有模组——把模组文件放进 mods/ 就会出现在这里。"),
|
||||
("InstancesModsMissingDir", "This instance has no mods/ directory yet.", "这个实例还没有 mods/ 目录。"),
|
||||
("InstancesModsRunning", "The game is running — toggles are read-only until it exits.", "游戏正在运行——退出前开关只读。"),
|
||||
("InstancesModsOldBadge", "legacy file", "旧版本残留"),
|
||||
("InstancesModsSkipped", "{count} entries skipped", "跳过 {count} 项"),
|
||||
("InstancesModsFailed", "Could not read mods/: {reason}", "读取 mods/ 失败:{reason}"),
|
||||
("InstancesModsToggleFailed", "Could not toggle {file}: {reason}", "启停 {file} 失败:{reason}"),
|
||||
("InstancesModsReasonRunning", "the game is running", "游戏正在运行"),
|
||||
("InstancesModsReasonInstance", "the instance is gone", "实例已不存在"),
|
||||
("InstancesModsReasonName", "the file name is not usable", "文件名不可用"),
|
||||
("InstancesModsReasonDirectory", "there is no usable mods/ directory", "没有可用的 mods/ 目录"),
|
||||
("InstancesModsReasonMissing", "the file is gone", "文件已不在"),
|
||||
("InstancesModsReasonNotFile", "that is not a regular file", "那不是普通文件"),
|
||||
("InstancesModsReasonCollision", "a file with that name already exists", "同名文件已存在"),
|
||||
("InstancesModsReasonRename", "the rename failed", "改名失败"),
|
||||
("InstancesSavesTitle", "SAVES", "存档"),
|
||||
("InstancesSavesCount", "{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||
("InstancesSavesLoading", "Reading saves/…", "正在读取 saves/…"),
|
||||
("InstancesSavesEmpty", "No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||
("InstancesSavesMissingDir", "This instance has no saves/ or screenshots/ directory yet.", "这个实例还没有 saves/ 或 screenshots/ 目录。"),
|
||||
("InstancesSavesWorlds", "WORLDS", "世界"),
|
||||
("InstancesSavesScreenshots", "SCREENSHOTS", "截图"),
|
||||
("InstancesSavesNoWorlds", "No worlds yet.", "还没有世界。"),
|
||||
("InstancesSavesNoScreenshots", "No screenshots yet.", "还没有截图。"),
|
||||
("InstancesSavesWorldIcon", "has icon", "有图标"),
|
||||
("InstancesSavesTimeUnknown", "time unknown", "时间未知"),
|
||||
("InstancesSavesSkipped", "{count} entries skipped", "跳过 {count} 项"),
|
||||
("InstancesSavesOpenFolder", "Open saves folder", "打开存档文件夹"),
|
||||
("InstancesSavesRetry", "Retry", "重试"),
|
||||
("InstancesSavesFailed", "Could not read saves/: {reason}", "读取 saves/ 失败:{reason}"),
|
||||
("InstancesSavesOpenFailed", "Could not open the folder: {reason}", "打开文件夹失败:{reason}"),
|
||||
("InstancesSavesReasonDir", "the directory is not usable", "目录不可用"),
|
||||
("InstancesSavesReasonInstance", "the instance is gone", "实例已不存在"),
|
||||
("InstancesSavesReasonOpen", "the file manager did not start", "文件管理器没起来"),
|
||||
("InstancesSavesRename", "Rename", "重命名"),
|
||||
("InstancesSavesBackup", "Back up", "备份"),
|
||||
("InstancesSavesDelete", "Delete", "删除"),
|
||||
("InstancesSavesRenamePlaceholder", "New name for this world", "这个世界的新名字"),
|
||||
("InstancesSavesRenameConfirm", "Rename", "改名"),
|
||||
("InstancesSavesCancel", "Cancel", "取消"),
|
||||
("InstancesSavesBusy", "Working…", "正在处理…"),
|
||||
("InstancesSavesDeleteTitle", "Delete this world?", "删除这个世界?"),
|
||||
("InstancesSavesDeleteBody", "{dir} and everything inside it will be deleted. This cannot be undone.", "{dir} 和它里面的全部内容都会被删除,且不能撤销。"),
|
||||
("InstancesSavesDeleteConfirm", "Delete world", "删除世界"),
|
||||
("InstancesSavesRenamed", "Renamed {from} → {to}.", "已把 {from} 改名为 {to}。"),
|
||||
("InstancesSavesDeleted", "Deleted {dir}.", "已删除 {dir}。"),
|
||||
("InstancesSavesBackedUp", "Backed up {dir} to {path} ({size}).", "已把 {dir} 备份到 {path}({size})。"),
|
||||
("InstancesSavesBackupSkipped", "{count} symlinks skipped", "跳过 {count} 个符号链接"),
|
||||
("InstancesSavesRenameFailed", "Could not rename {file}: {reason}", "重命名 {file} 失败:{reason}"),
|
||||
("InstancesSavesDeleteFailed", "Could not delete {file}: {reason}", "删除 {file} 失败:{reason}"),
|
||||
("InstancesSavesBackupFailed", "Could not back up {file}: {reason}", "备份 {file} 失败:{reason}"),
|
||||
("InstancesSavesReasonUnsafeName", "the name is not usable", "名字不可用"),
|
||||
("InstancesSavesReasonSymlink", "that path is a symbolic link (not followed)", "那个路径是符号链接(不跟随)"),
|
||||
("InstancesSavesReasonMissing", "the world is gone", "那个世界已不在"),
|
||||
("InstancesSavesReasonNotDir", "that is not a directory", "那不是目录"),
|
||||
("InstancesSavesReasonConflict", "a world with that name already exists", "同名世界已存在"),
|
||||
("InstancesSavesReasonBoundary", "that path is outside saves/", "那个路径落在 saves/ 之外"),
|
||||
("InstancesSavesReasonTooLarge", "the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
|
||||
("InstancesSavesReasonWrite", "the write failed", "写盘失败"),
|
||||
("InstancesSavesReasonRunning", "the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
|
||||
("InstancesSavesPreviewNone", "no preview", "未生成预览"),
|
||||
("InstancesSavesPreviewTooLarge", "too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"),
|
||||
("InstancesSavesPreviewUnreadable", "the file could not be read", "读不出这张图"),
|
||||
("InstancesSavesPreviewDecode", "not a decodable image", "解不出这张图(可能已损坏或格式不支持)"),
|
||||
];
|
||||
|
||||
/// 金表的**全量**行(本文件 → [`tail`] → [`tail_ai`] → [`tail2`],顺序即 `Key::ALL`)。
|
||||
///
|
||||
/// 拆成四段只为守 400 行硬线(M157/M159/M197 往金表追加文案后越线);对调用方是**一条**序列,
|
||||
/// `catalog/tests.rs` 的逐字节断言不用关心分段。
|
||||
///
|
||||
/// ⚠️ 加段时**必须**在这里也 chain 上:条数与内容都只能从这一条序列导出(见 [`len`])。
|
||||
pub(super) fn rows() -> impl Iterator<Item = &'static (&'static str, &'static str, &'static str)> {
|
||||
GOLDEN
|
||||
.iter()
|
||||
.chain(tail::ROWS.iter())
|
||||
.chain(tail_ai::ROWS.iter())
|
||||
.chain(tail2::ROWS.iter())
|
||||
}
|
||||
|
||||
/// 金表总行数(`Key::ALL` 的条数)。
|
||||
///
|
||||
/// **定义成 `rows().count()`,不是各段 `len()` 相加**:r3 复审实测过一条假绿——把
|
||||
/// [`rows`] 里的 `chain(tail2::ROWS)` 删掉,`catalog/tests.rs` 仍全绿,因为条数来自一处手工
|
||||
/// 聚合(仍有 tail2)而内容只遍历 `rows()`(少了 tail2),于是 213/459 行无人看守。
|
||||
/// 现在条数与内容同源:漏挂一段 ⇒ 条数当场对不上 ⇒ 用例必红。
|
||||
pub(super) fn len() -> usize {
|
||||
rows().count()
|
||||
}
|
||||
|
||||
mod tail;
|
||||
mod tail2;
|
||||
mod tail_ai;
|
||||
@@ -0,0 +1,214 @@
|
||||
//! 金表的**后半段的前半截**(`InstancesLaunchReset` 之后、到 M197 的 Java 域结束)。
|
||||
//!
|
||||
//! 为什么拆:`golden.rs` 在 M157 加了 19 行启动参数文案后到 416 行,越过 400 行硬线;
|
||||
//! 拆分口径与仓库其它 `-p1/-p2` 一致——**纯搬移、一行不改**,顺序即 `Key::ALL` 的后半段。
|
||||
//! 拼装与断言在 `golden.rs` 的 [`rows`] 与 `catalog/tests.rs`。
|
||||
//!
|
||||
//! **M197 起本段只到 Java 域结束**:M197 往 `settings_java_entries` 追加了 68 行,加上 dev 侧
|
||||
//! 其它 mission 后加的行会涨到 470+(越过 400 行硬线),于是把 **M174 助手域起的那半截**
|
||||
//! 纯搬移到 [`super::tail_ai`](链序:`golden` → `tail` → `tail_ai` → `tail2`)。
|
||||
//! 搬移只改位置:其余每一行逐字节原样(含其它 mission 后加的行)。
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("LoaderVanilla", "Vanilla", "原版"),
|
||||
("LaunchOutcomeNormal", "Finished normally", "正常结束"),
|
||||
("LaunchOutcomeCrash", "The game crashed", "游戏崩溃"),
|
||||
("LaunchOutcomeSilent", "Suspected silent failure: exit code 0, but the game never really started", "疑似静默失败:退出码 0,但游戏没真正跑起来"),
|
||||
("LaunchFailedWithCode", "Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
|
||||
("LaunchStageVersionManifest", "Reading the version file", "读版本文件"),
|
||||
("LaunchStageInheritance", "Resolving the inheritance chain", "解析继承链"),
|
||||
("LaunchStageInstanceConfig", "Reading the instance settings", "读实例设置"),
|
||||
("LaunchStageIsolation", "Preparing the isolated game folder", "准备隔离游戏目录"),
|
||||
("LaunchStageIdentity", "Preparing the account", "准备账户"),
|
||||
("LaunchStageSpawn", "Starting the game process", "启动游戏进程"),
|
||||
("LaunchStageFailed", "Launch failed · {stage}: {detail}", "启动失败 · {stage}:{detail}"),
|
||||
("LaunchToastEnded", "{outcome} · {seconds}s", "{outcome} · {seconds} 秒"),
|
||||
("LaunchMsaNotConfigured", "Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.", "微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"),
|
||||
("FontCjkNeedsDownload", "No Chinese font found on this system. Download an open-source (OFL) Chinese font?", "系统里没找到中文字体。要下载一套开源(OFL)中文字体吗?"),
|
||||
("FontCjkDownloading", "Downloading Chinese font… {percent}%", "正在下载中文字体… {percent}%"),
|
||||
("FontCjkDownloadFailed", "Chinese font download failed; the interface stays in English.", "中文字体下载失败,界面暂时保持英文。"),
|
||||
("FontCjkReady", "Chinese font ready: {family}", "中文字体已就绪:{family}"),
|
||||
("SettingsLanguage", "Language", "界面语言"),
|
||||
("SettingsLanguageHint", "Switches the interface language. Chinese text needs a Chinese font.", "切换界面语言。显示中文需要中文字体。"),
|
||||
("SettingsLogsFolder", "Log folder", "日志文件夹"),
|
||||
("SettingsLogsFolderHint", "What explains a crash lives here: the launcher's own runtime log (launcher.log, rolled over at 1 MiB with one old copy kept) and one jsonl per game session under logs/game/. Sessions from older versions (data/agent/sessions) are still read, not moved.", "解释一次崩溃要看的东西都在这:启动器自己的运行日志(launcher.log,满 1 MiB 滚动一次、只留一份旧档)与每次游戏会话的 jsonl(logs/game/)。旧版本留下的会话(data/agent/sessions)仍然读得出来,不会被搬动。"),
|
||||
("SettingsLogsCopy", "Copy path", "复制路径"),
|
||||
("SettingsLogsUnknown", "Data directory not resolved — restart the launcher from its own folder", "数据根没解析出来——从启动器所在目录重新启动试试"),
|
||||
("SettingsAppearanceCorner", "Window corners", "窗口圆角"),
|
||||
("SettingsAppearanceCornerSystem", "Follow system", "跟随系统"),
|
||||
("SettingsAppearanceCornerSquare", "Square", "直角"),
|
||||
("SettingsAppearanceCornerHint", "On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.", "Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"),
|
||||
("SettingsAppearanceCornerSuspended", "Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.", "当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"),
|
||||
// M180:外观分组补齐(`catalog/settings/appearance.rs` 的第二批,M116 圆角那 5 条之后)。
|
||||
// 位置就是装配链里那个域的位置——`settings_appearance_entries` 在 `settings_java_entries` 之前,
|
||||
// 所以这 40 行必须落在 `SettingsAppearanceCornerSuspended` 与 `SettingsJavaScan` 之间
|
||||
// (顺序 = `Key::ALL`;为排到文件尾而重排别人的族会让 `split_keeps_every_translation` 整片红)。
|
||||
("SettingsAppearanceWindowOpacity", "Window transparency", "整窗透明度"),
|
||||
("SettingsAppearanceWindowOpacityHint", "Applies to the whole window. Floor {floor}; 1.00 = fully opaque.", "作用于整个窗口。下限 {floor};1.00 = 完全不透明。"),
|
||||
("SettingsAppearancePanelOpacity", "Panel transparency", "面板透明度"),
|
||||
("SettingsAppearancePanelOpacityHint", "Only the panel/card layer. Floor {floor}; independent of window transparency.", "只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。"),
|
||||
("SettingsAppearancePanelOpacityForcedValue", "100% (forced from {requested})", "100%(强制,原请求值 {requested})"),
|
||||
("SettingsAppearancePanelOpacityForcedHint", "Software renderer: panels and glass stay fully opaque (window transparency still works).", "软件渲染:面板与玻璃效果恒为不透明(整窗透明度仍然有效)。"),
|
||||
("SettingsAppearanceBackgroundImage", "Background image", "背景图"),
|
||||
("SettingsAppearanceOff", "Off", "关"),
|
||||
("SettingsAppearanceOn", "On", "开"),
|
||||
("SettingsAppearanceImagePath", "Image path", "图片路径"),
|
||||
("SettingsAppearancePaletteLevel", "Colours: {level}", "配色:{level}"),
|
||||
("SettingsAppearanceBgFill", "Fill", "填充方式"),
|
||||
("SettingsAppearanceBgFillCover", "Cover", "铺满"),
|
||||
("SettingsAppearanceBgFillContain", "Contain", "完整显示"),
|
||||
("SettingsAppearanceBgFillTile", "Tile", "平铺"),
|
||||
("SettingsAppearanceBgDim", "Dim", "暗化"),
|
||||
("SettingsAppearanceBgDimHint", "Blends the image toward the panel colour so text stays readable. Max {max}.", "把背景图往面板底色上退,让文字读得清。上限 {max}。"),
|
||||
("SettingsAppearancePaletteSource", "Colour source", "配色来源"),
|
||||
("SettingsAppearancePaletteBackground", "Background", "取自背景图"),
|
||||
("SettingsAppearancePaletteWallpaper", "Wallpaper", "取自壁纸"),
|
||||
("SettingsAppearancePalettePreset", "Preset", "内置色卡"),
|
||||
("SettingsAppearanceBgReadFailed", "Background image could not be read: {reason}", "背景图读不出来:{reason}"),
|
||||
("SettingsAppearanceBgDecodeFailed", "Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)", "背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)"),
|
||||
("SettingsAppearanceBgTooLarge", "Background image is too large to decode: {width}×{height} (limit {limit} px per edge)", "背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)"),
|
||||
("SettingsAppearanceBgSmallTile", "Small image ({width}×{height}): tiles are scaled up so the window stays covered (max {max} tiles, no gaps).", "图片很小({width}×{height}):图块会被放大,保证窗口铺满(最多 {max} 块,不留缝)。"),
|
||||
("SettingsAppearancePaletteBgUndecodable", "Background image could not be read; colours fall back.", "背景图读不出来,配色回落到下一级。"),
|
||||
("SettingsAppearancePaletteBgTooSmall", "Background image is too small to sample; colours fall back.", "背景图太小,取不了色,配色回落到下一级。"),
|
||||
("SettingsAppearancePaletteBgBadDimensions", "Background image data looks inconsistent; colours fall back.", "背景图数据前后不一致,配色回落到下一级。"),
|
||||
("SettingsAppearancePaletteBgNoOpaquePixels", "Background image is almost fully transparent; colours fall back.", "背景图几乎全透明,配色回落到下一级。"),
|
||||
("SettingsAppearancePaletteBgLowChroma", "Background image is too monochrome to tint from; colours fall back.", "背景图太单调(彩度不足),取不出配色,回落到下一级。"),
|
||||
("SettingsAppearancePaletteBgContrast", "Background image colours would be unreadable; colours fall back.", "按背景图取的颜色读不清,配色回落到下一级。"),
|
||||
("SettingsAppearancePaletteWallpaperUndecodable", "Desktop wallpaper could not be read.", "桌面壁纸读不出来。"),
|
||||
("SettingsAppearancePaletteWallpaperTooSmall", "Desktop wallpaper is too small to sample.", "桌面壁纸太小,取不了色。"),
|
||||
("SettingsAppearancePaletteWallpaperBadDimensions", "Desktop wallpaper data looks inconsistent.", "桌面壁纸数据前后不一致。"),
|
||||
("SettingsAppearancePaletteWallpaperNoOpaquePixels", "Desktop wallpaper is almost fully transparent.", "桌面壁纸几乎全透明。"),
|
||||
("SettingsAppearancePaletteWallpaperLowChroma", "Desktop wallpaper is too monochrome to tint from.", "桌面壁纸太单调(彩度不足),取不出配色。"),
|
||||
("SettingsAppearancePaletteWallpaperContrast", "Desktop wallpaper colours would be unreadable; colours fall back.", "按桌面壁纸取的颜色读不清,配色回落到下一级。"),
|
||||
("SettingsAppearancePalettePresetContrast", "The built-in card needed a small contrast correction.", "内置色卡做了一点对比度修正。"),
|
||||
("SettingsAppearancePaletteAdjusted", "A few colours were nudged for readability.", "有几个颜色为了可读性做了微调。"),
|
||||
// M175:设置页「Java」分组(`catalog/settings/java.rs` 的 `settings_java_entries`)。
|
||||
// 位置就是装配链里那个域的位置:`settings_java_entries` 在 `settings_appearance_entries` 之后、
|
||||
// `settings_download_entries`(空)与 `settings_ai_entries`(M174)之前,所以这几行必须落在
|
||||
// `SettingsAppearanceCornerSuspended` 与 `SettingsGroupAssistant` 之间(顺序 = `Key::ALL`)。
|
||||
("SettingsJavaScan", "Scan now", "立即扫描"),
|
||||
("SettingsJavaScanningStart", "Scanning…", "正在扫描…"),
|
||||
("SettingsJavaScanning", "Scanning… {done}/{total}", "正在扫描… {done}/{total}"),
|
||||
("SettingsJavaCancelling", "Cancelling…", "正在取消…"),
|
||||
("SettingsJavaCancel", "Cancel", "取消"),
|
||||
("SettingsJavaCancelled", "The scan was cancelled; the list below is from the previous run.", "扫描已取消;下面的列表还是上一次的结果。"),
|
||||
("SettingsJavaScanDone", "{count} JDK found", "找到 {count} 个 JDK"),
|
||||
("SettingsJavaNeverScanned", "Not scanned yet", "还没扫描过"),
|
||||
("SettingsJavaNeverScannedBody", "The list is still empty. The launcher scans once by itself when the settings page opens; press Scan now to look again right away.", "列表还是空的。打开设置页时启动器会自己扫一次;也可以点「立即扫描」立刻再扫一遍。"),
|
||||
("SettingsJavaEmptyTitle", "No Java runtime found", "未检测到 Java 运行时"),
|
||||
("SettingsJavaEmptyBody", "Nothing matched. Add the path of a JDK you already unpacked below, or install one with ull java install 21 on the command line.", "没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 ull java install 21 装一个。"),
|
||||
("SettingsJavaDefault", "Default", "默认"),
|
||||
("SettingsJavaSetDefault", "Use as default", "设为默认"),
|
||||
("SettingsJavaFits", "Meets the current instance requirement", "满足当前实例的要求"),
|
||||
("SettingsJavaTooOld", "Below the requirement: this instance needs Java {minimum}+", "低于要求:当前实例需要 Java {minimum} 及以上"),
|
||||
("SettingsJavaDefaultSet", "Default JDK is now {path}", "默认 JDK 已设为 {path}"),
|
||||
("SettingsJavaSourceManaged", "installed by this launcher", "本启动器安装"),
|
||||
("SettingsJavaSourceEnv", "JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"),
|
||||
("SettingsJavaSourcePath", "on PATH", "PATH 上"),
|
||||
("SettingsJavaSourceSystem", "system install", "系统安装"),
|
||||
("SettingsJavaSourceManual", "added by you", "手动添加"),
|
||||
("SettingsJavaAdd", "Add a JDK path", "手动添加 JDK 路径"),
|
||||
("SettingsJavaAddPlaceholder", "C:\\Program Files\\Java\\jdk-21", "例:C:\\Program Files\\Java\\jdk-21"),
|
||||
("SettingsJavaAddHint", "A JDK folder or its java executable. We run java -version on it; anything that cannot answer is refused.", "JDK 目录或它的 java 可执行文件。会真跑一次 java -version;答不出话的一律拒绝。"),
|
||||
("SettingsJavaAddAction", "Add", "添加"),
|
||||
("SettingsJavaAdded", "Added {path}", "已添加 {path}"),
|
||||
("SettingsJavaMemory", "Default memory (MB)", "默认内存(MB)"),
|
||||
("SettingsJavaMemoryHint", "Whole megabytes, {min}–{max}. Instances without their own override follow this value.", "整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"),
|
||||
("SettingsJavaLayerGlobal", "In effect: {mb} MB, from the global default.", "生效:{mb} MB,来自全局默认值。"),
|
||||
("SettingsJavaLayerInstance", "In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.", "生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"),
|
||||
("SettingsJavaJvm", "Extra JVM arguments", "额外 JVM 参数"),
|
||||
("SettingsJavaJvmPlaceholder", "-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"),
|
||||
("SettingsJavaJvmHint", "Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.", "按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"),
|
||||
("SettingsJavaJvmSaved", "Saved value: {value}", "已保存的值:{value}"),
|
||||
("SettingsJavaJvmSavedNone", "No extra JVM arguments are saved yet.", "还没有保存任何额外参数。"),
|
||||
("SettingsJavaSave", "Save", "保存"),
|
||||
("SettingsJavaSaved", "Saved and read back from disk. The next launch uses the new value.", "已保存,并从磁盘读回确认。下次启动就用新值。"),
|
||||
("SettingsJavaUnsaved", "There are unsaved changes.", "有未保存的改动。"),
|
||||
("SettingsJavaRefused", "Not saved: {reason}", "未保存:{reason}"),
|
||||
("SettingsJavaSkipNotDirty", "there was nothing to change, so no file was written", "没有任何改动,因此没有写文件"),
|
||||
("SettingsJavaSkipReadOnly", "the settings file is read-only: a newer launcher wrote it, or it is marked untrusted", "配置文件只读:它被更新版本的启动器写过,或被标记为不可信"),
|
||||
("SettingsJavaSkipUnreadable", "the settings file is untrusted, so it is treated as read-only", "配置文件不可信,因此按只读处理"),
|
||||
("SettingsJavaNoStorage", "No settings file is available in this run, so nothing can be scanned or saved.", "这一次运行没有可用的设置文件,扫描与保存都做不了。"),
|
||||
("SettingsJavaReasonMemoryEmpty", "the memory field is empty", "内存没填"),
|
||||
("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"),
|
||||
("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"),
|
||||
("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"),
|
||||
("SettingsJavaReasonJvmTooLong", "the JVM arguments are longer than the settings file allows (1024 characters)", "JVM 参数超过配置文件允许的长度(1024 字符)"),
|
||||
("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"),
|
||||
("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"),
|
||||
("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"),
|
||||
("SettingsJavaReasonAddNoVersion", "java -version did not answer there, so it is not a usable JDK", "在那里跑不出 java -version,不是一份能用的 JDK"),
|
||||
("SettingsJavaReasonAddTooLong", "the path is too long to be a JDK location", "路径太长,不像是一个 JDK 位置"),
|
||||
("SettingsJavaReasonAddWriteFailed", "the JDK is fine, but the list file could not be written (disk or permission)", "这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"),
|
||||
("SettingsJavaReasonAddDuplicate", "that JDK is already in the list", "这份 JDK 已经在列表里了"),
|
||||
("SettingsJavaReasonWriteFailed", "the settings file could not be written (disk full, no permission, or the value was rejected)", "设置写不进去(磁盘满、没权限,或这个值被拒绝)"),
|
||||
("SettingsJavaTooNew", "Above the requirement: this instance can only use Java {maximum} or older (the old loader cannot read newer class files)", "高于要求:当前实例只能用 Java {maximum} 及以下(老加载器吃不了新 class 文件)"),
|
||||
// M197:Java 分组的「下载一个 JDK」与三个设置键(`catalog/settings/java.rs` 里同一批追加)。
|
||||
("SettingsJavaDownloadTitle", "Download a JDK", "下载一个 JDK"),
|
||||
("SettingsJavaDownloadHint", "Downloads go through the built-in mirror table: in China the campus mirrors are tried first (Temurin over Tsinghua TUNA), and GitHub-hosted archives are fetched through gh-proxy. Every archive is verified against its SHA-256 before it is unpacked.", "下载走内置镜像表:国内先试院校镜像(Temurin 走清华 TUNA),GitHub 上的归档套 gh-proxy。落盘前一律按 SHA-256 校验。"),
|
||||
("SettingsJavaDownloadLts", "LTS", "长期支持版(LTS)"),
|
||||
("SettingsJavaDownloadBaseline", "Recommended baseline", "推荐基准版本"),
|
||||
("SettingsJavaDownloadVendors", "Vendors: {vendors}", "发行版:{vendors}"),
|
||||
("SettingsJavaDownloadNoVendor", "No vendor publishes this major version on this platform.", "这个平台上没有发行版发这个大版本。"),
|
||||
("SettingsJavaDownloadAction", "Install", "安装"),
|
||||
("SettingsJavaDownloadReinstall", "Install again", "重装"),
|
||||
("SettingsJavaDownloadInstalled", "Already installed: {path}", "已安装:{path}"),
|
||||
("SettingsJavaDownloadNeeded", "The current instance needs {requirement}.", "当前实例需要 {requirement}。"),
|
||||
("SettingsJavaDownloadFits", "Usable by the current instance", "当前实例可用"),
|
||||
("SettingsJavaDownloadTooLow", "The current instance needs Java {minimum} or newer, so installing this one will not start it.", "当前实例需要 Java {minimum} 及以上 —— 装这一档也起不来。"),
|
||||
("SettingsJavaDownloadTooHigh", "The current instance can only use Java {maximum} or older, so installing this one will not start it.", "当前实例只能用 Java {maximum} 及以下 —— 装这一档也起不来。"),
|
||||
("SettingsJavaDownloadRecommended", "Recommended for the current instance", "为当前实例推荐这一档"),
|
||||
("SettingsJavaDownloadBusy", "Installing Java {major}…", "正在安装 Java {major}…"),
|
||||
("SettingsJavaDownloadPhasePreparing", "Looking up the release…", "正在查版本…"),
|
||||
("SettingsJavaDownloadPhaseDownloading", "Downloading {percent}% ({done} of {total})", "下载中 {percent}%({done} / {total})"),
|
||||
("SettingsJavaDownloadPhaseVerifying", "Verifying the archive…", "正在校验归档…"),
|
||||
("SettingsJavaDownloadPhaseExtracting", "Unpacking…", "正在解压…"),
|
||||
("SettingsJavaDownloadSource", "Source: {source}", "来源:{source}"),
|
||||
("SettingsJavaDownloadRetry", "{source} failed ({reason}); retry {attempt} in {seconds}s", "{source} 失败({reason});{seconds} 秒后重试第 {attempt} 次"),
|
||||
("SettingsJavaDownloadSwitch", "Switching source: {from} → {to}", "换源:{from} → {to}"),
|
||||
("SettingsJavaDownloadDone", "Java {major} ({vendor}) is ready at {root} · source {source}", "Java {major}({vendor})已就绪:{root} · 来源 {source}"),
|
||||
("SettingsJavaDownloadDoneReused", "Java {major} ({vendor}) was already installed at {root}; nothing was downloaded.", "Java {major}({vendor})之前已经装好:{root},这一次没有下载。"),
|
||||
("SettingsJavaDownloadFailed", "Java {major} could not be installed; the last source tried was {source}. The full reason is in the log.", "Java {major} 没装成;最后试到的源是 {source}。完整原因在日志里。"),
|
||||
("SettingsJavaDownloadFailedNoSource", "Java {major} could not be installed: the version lookup itself failed. The full reason is in the log.", "Java {major} 没装成:查版本这一步就失败了。完整原因在日志里。"),
|
||||
("SettingsJavaDownloadFailedHint", "Check the network, or change the vendor order below.", "先查网络,或者改下面的发行版顺序。"),
|
||||
("SettingsJavaDownloadTriedHeader", "Sources tried:", "试过的源:"),
|
||||
("SettingsJavaDownloadTried", "{source}: {reason}", "{source}:{reason}"),
|
||||
("SettingsJavaDownloadRefused", "Not started: {reason}", "没开始:{reason}"),
|
||||
("SettingsJavaReasonDownloadBusy", "another JDK install is already running", "已经有一个 JDK 在装了"),
|
||||
("SettingsJavaReasonDownloadUnknownMajor", "that major version is not in the catalog", "目录里没有这个大版本"),
|
||||
("SettingsJavaReasonDownloadNoVendor", "no vendor publishes that major version here", "没有发行版在这里发这个大版本"),
|
||||
("SettingsJavaReasonRootUnresolved", "the JDK install folder cannot be resolved on this system", "这台机器上解析不出 JDK 安装目录"),
|
||||
("SettingsJavaSourceReasonNotFound", "this mirror does not have that file (404)", "这个镜像没有这个文件(404)"),
|
||||
("SettingsJavaSourceReasonServer", "the server answered with a 5xx / 429 error", "服务器报错 5xx / 429"),
|
||||
("SettingsJavaSourceReasonTimeout", "the request timed out", "请求超时"),
|
||||
("SettingsJavaSourceReasonNetwork", "the connection failed", "连不上"),
|
||||
("SettingsJavaSourceReasonIntegrity", "the checksum did not match", "校验不符"),
|
||||
("SettingsJavaSourceReasonIo", "a local disk error", "本地磁盘错误"),
|
||||
("SettingsJavaSourceReasonOther", "the request was rejected", "请求被拒"),
|
||||
("SettingsJavaInstallRoot", "JDK install folder", "JDK 安装目录"),
|
||||
("SettingsJavaInstallRootPlaceholder", "D:\\Java", "例:D:\\Java"),
|
||||
("SettingsJavaInstallRootHint", "Leave it empty to follow the data mode: portable keeps JDKs inside the launcher folder, standard uses the system Java folder. A path here overrides both, and JDKs land in <root>/<vendor>/<version>/.", "留空 = 跟数据目录模式走:portable 放在启动器目录里,standard 放在系统 Java 目录。填了它覆盖前两者,JDK 落在 <root>/<发行方>/<版本>/。"),
|
||||
("SettingsJavaInstallRootEffective", "In effect: {mode} · {path}", "生效:{mode} · {path}"),
|
||||
("SettingsJavaInstallRootLayerSetting", "this comes from java.installRoot", "这一项来自 java.installRoot"),
|
||||
("SettingsJavaInstallRootLayerDataMode", "this comes from core.data_mode ({mode})", "这一项来自 core.data_mode({mode})"),
|
||||
("SettingsJavaInstallRootUnresolved", "The system Java folder cannot be resolved here. Type a folder above, or set core.data_mode back to portable.", "这台机器上解析不出系统标准 Java 目录。在上面填一个目录,或把 core.data_mode 改回 portable。"),
|
||||
("SettingsJavaVendorPriority", "Vendor order", "发行版顺序"),
|
||||
("SettingsJavaVendorOrderEffective", "In effect: {order}", "生效顺序:{order}"),
|
||||
("SettingsJavaVendorOrderLayerSetting", "this comes from java.vendorPriority", "这一项来自 java.vendorPriority"),
|
||||
("SettingsJavaVendorOrderLayerRegion", "this comes from the built-in order for your region ({region})", "这一项来自内置的地区默认序({region})"),
|
||||
("SettingsJavaRegionDomestic", "China", "国内"),
|
||||
("SettingsJavaRegionGlobal", "rest of the world", "国外"),
|
||||
("SettingsJavaVendorUp", "Up", "上移"),
|
||||
("SettingsJavaVendorDown", "Down", "下移"),
|
||||
("SettingsJavaVendorReset", "Reset to the region default", "恢复地区默认序"),
|
||||
("SettingsJavaVendorSupports", "ships {majors}", "发 {majors}"),
|
||||
("SettingsJavaAutoInstall", "Recommend a JDK when none is found", "缺 JDK 时推荐一档"),
|
||||
("SettingsJavaAutoInstallHint", "On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Nothing is downloaded on its own — you press Install. Off: nothing is marked and you pick everything yourself.", "开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。不会自己开始下载 —— 点「安装」才会下。关:什么也不标,全部自己挑。"),
|
||||
("SettingsJavaAutoInstallOn", "on", "开"),
|
||||
("SettingsJavaAutoInstallOff", "off", "关"),
|
||||
("SettingsJavaPrefsSave", "Save", "保存"),
|
||||
("SettingsJavaPrefsSaved", "Saved and read back from disk: all three keys are in the settings file.", "已保存并从磁盘读回:三个键都进配置文件了。"),
|
||||
("SettingsJavaPrefsUnsaved", "There are unsaved changes in this section.", "这一段有未保存的改动。"),
|
||||
("SettingsJavaReasonInstallRootTooLong", "the folder path is too long", "目录路径太长"),
|
||||
// M201:设置页 Downloads 组的文本行提交按钮(装配链里本域紧随 `settings_java_entries`)。
|
||||
("SettingsDownloadsApply", "Apply", "应用"),
|
||||
];
|
||||
@@ -0,0 +1,243 @@
|
||||
//! 金表的第三段(`tail.rs` 之后,M161 拆:`tail.rs` 到 M157 已有 411 行、加自更新那几条就 423)。
|
||||
//!
|
||||
//! 口径与 `golden.rs` / `tail.rs` 完全一致:**纯搬移、一行不改**,顺序即 `Key::ALL` 的续段。
|
||||
//! 拼装见 `golden.rs` 的 [`super::rows`]。
|
||||
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
("PackImportAction", "Import", "导入"),
|
||||
("PackCancel", "Cancel import", "取消导入"),
|
||||
("PackDismiss", "Dismiss", "关闭"),
|
||||
("PackTargetLabel", "Install into", "装到实例"),
|
||||
("PackTargetNew", "New instance", "新建实例"),
|
||||
("PackInstanceName", "Instance name", "实例名"),
|
||||
("PackStageLine", "Step {index}/{total} · {stage}", "步骤 {index}/{total} · {stage}"),
|
||||
("PackStageProbe", "Reading the manifest", "读取清单"),
|
||||
("PackStageExtract", "Unpacking overrides", "解包覆盖内容"),
|
||||
("PackStageFetch", "Fetching missing files", "补下载文件"),
|
||||
("PackStageCommit", "Writing files", "落地写盘"),
|
||||
("PackStageRegister", "Registering the instance", "登记实例"),
|
||||
("PackProbeTitle", "This pack is almost a known format", "这个包差一点就是已知格式"),
|
||||
("PackProbeLooksLike", "Looks like {format}, but missing: {missing}", "看起来像 {format},但缺:{missing}"),
|
||||
("PackProbeUnknown", "No known format claims this pack", "没有任何已知格式认领这个包"),
|
||||
("PackProbeSaw", "Found in the pack: {saw}", "包里看到:{saw}"),
|
||||
("PackProbeNothing", "nothing recognisable", "没有可识别的东西"),
|
||||
("PackStatusSuccess", "Imported", "已导入"),
|
||||
("PackStatusPartial", "Imported with gaps", "导入完成,但有缺口"),
|
||||
("PackStatusFailed", "Import failed — rolled back", "导入失败 —— 已整包回滚"),
|
||||
("PackReportWritten", "Files written: {count} · {bytes}", "{count} 个文件已写盘 · {bytes}"),
|
||||
("PackReportMissing", "Could not fetch (tolerated): {count}", "有 {count} 个文件没搞到(可容忍)"),
|
||||
("PackReportConflicts", "Conflicts: {count}", "冲突:{count} 个"),
|
||||
("PackReportRejected", "The client does not support these: {count}", "这些组件客户端不支持:{count} 个"),
|
||||
("PackReportRejectedNote", "not supported by the client", "客户端不支持"),
|
||||
("PackCancelled", "Cancel requested — the pipeline is stopping now; the real outcome follows.", "已请求取消 —— 后台正在停手,真实结果随后给出。"),
|
||||
("PackStatusCancelled", "Cancelled — rolled back", "已取消 —— 已回滚"),
|
||||
("PackCancelIncomplete", "Cancelled — rollback incomplete: the target directory may differ from before the import.", "已取消 —— 回滚没做完:目标目录可能与导入前不同。"),
|
||||
("PackCancelTooLate", "The cancel arrived too late: this import already finished. The instance is kept; same-name files may already be overwritten.", "取消来得太晚:这次导入已经跑完,实例会保留,同名文件可能已被覆盖。"),
|
||||
("PackOverwriteAction", "Re-import, the pack wins", "以包为准重新导入"),
|
||||
("PackOverwriteNote", "Keeping both (renamed) is not supported yet: for each existing file you pick either “keep” or “overwrite”.", "「两者都留(改名)」暂不支持:每个已存在的文件只能选「保留」或「覆盖」。"),
|
||||
("PackMissingOffline", "offline import", "离线导入"),
|
||||
("PackMissingNoSource", "no download source available yet", "暂时拿不到下载地址"),
|
||||
("PackMissingFetchFailed", "download failed: {detail}", "下载失败:{detail}"),
|
||||
("PackFatalTitle", "Rolled back — nothing was left behind", "已回滚 —— 目标目录零残留"),
|
||||
("PackFatalHint", "What to try next", "下一步可以试"),
|
||||
("PackConflictUserModified", "you changed it", "你改过"),
|
||||
("PackConflictUserDeleted", "you deleted it", "你删过"),
|
||||
("PackConflictPreExisting", "was already there", "本来就在"),
|
||||
("PackChoiceKeepUser", "kept yours", "保留我的"),
|
||||
("PackChoiceOverwrite", "pack version used", "用包里的"),
|
||||
("PackConflictHashes", "pack {pack} · base {base} · disk {disk}", "包内 {pack} · 基线 {base} · 磁盘 {disk}"),
|
||||
("PackConflictDetail", "Hashes", "三方哈希"),
|
||||
("PackPickTitle", "Before importing: {count} files already exist with different content", "导入前:有 {count} 个文件已存在且内容不同"),
|
||||
("PackPickSummary", "Keep {keep} · overwrite {overwrite} · overwriting discards {bytes}", "保留 {keep} · 覆盖 {overwrite} · 覆盖将丢弃 {bytes}"),
|
||||
("PackPickKeepAll", "Keep all mine", "全部保留我的"),
|
||||
("PackPickOverwriteAll", "Overwrite all", "全部覆盖"),
|
||||
("PackPickRowKeep", "Keep", "保留"),
|
||||
("PackPickRowOverwrite", "Overwrite", "覆盖"),
|
||||
("PackPickPage", "Items {from}-{to} of {total}", "第 {from}-{to} 项,共 {total} 项"),
|
||||
("PackPickPrev", "Previous", "上一页"),
|
||||
("PackPickNext", "Next", "下一页"),
|
||||
("PackPickConfirm", "Import with these choices", "按这些选择导入"),
|
||||
("PackPickCancel", "Cancel import", "取消导入"),
|
||||
("PackPickDefaultNote", "Anything you do not change stays as you have it: file content is only replaced where you pick “Overwrite”.", "没有改动的一律按「保留」:只有你点了「覆盖」的文件才会被包内内容替换。"),
|
||||
("PackPickUnknownRemote", "{count} remote entries only learn their path after download; they follow “keep”.", "还有 {count} 条远端条目要下载后才知道路径,一律按「保留」处置。"),
|
||||
("PackPickSourceOverrides", "in the pack (overrides/)", "包内 overrides/"),
|
||||
("PackPickSourceClientOverrides", "in the pack (client-overrides/)", "包内 client-overrides/"),
|
||||
("PackPickSourceManifest", "manifest files[]", "清单 files[]"),
|
||||
("PackPickDisk", "yours: {size}", "你的:{size}"),
|
||||
("PackPickIncoming", "pack: {size}", "包内:{size}"),
|
||||
("PackFormatModrinth", "Modrinth .mrpack", "Modrinth 的 .mrpack"),
|
||||
("PackFormatCurseforge", "CurseForge manifest", "CurseForge 清单"),
|
||||
("PackFormatMultimc", "MultiMC / Prism instance", "MultiMC·Prism 实例"),
|
||||
("PackExportTitle", "Export this instance", "导出这个实例"),
|
||||
("PackExportOpen", "Export…", "导出…"),
|
||||
("PackExportFormat", "Format", "格式"),
|
||||
("PackExportName", "Pack name", "包名"),
|
||||
("PackExportVersion", "Version", "版本"),
|
||||
("PackExportOutput", "Save to", "保存到"),
|
||||
("PackExportOutputPlaceholder", "empty = suggested path", "留空 = 自动给一个路径"),
|
||||
("PackExportPrecheck", "Dry run", "预检"),
|
||||
("PackExportConfirm", "Write the pack", "确认写盘"),
|
||||
("PackExportAbort", "Back", "返回"),
|
||||
("PackExportMultimcNote", "MultiMC/Prism can only be imported, not exported (by design).", "MultiMC·Prism 只能导入、不能导出(有意省略)。"),
|
||||
("PackExportDone", "Wrote {path} · {bytes}", "已写出 {path} · {bytes}"),
|
||||
("PackExportFailed", "Export failed: {reason}", "导出失败:{reason}"),
|
||||
("PackExportPending", "Scanning the instance…", "正在扫描实例…"),
|
||||
("PackPreviewHead", "Will inline {inline} files · cannot export: {skipped}", "将内联 {inline} 个文件 · 无法导出 {skipped} 项"),
|
||||
("PackPreviewRemote", "{count} files can be referenced remotely", "{count} 个文件可走远程引用"),
|
||||
("PackPreviewInlineBytes", "{bytes} copied into overrides/", "{bytes} 会原样拷进 overrides/"),
|
||||
("PackPreviewInlineTitle", "Copied into overrides/ (first {shown}):", "原样拷进 overrides/(只列前 {shown} 个):"),
|
||||
("PackPreviewSkippedTitle", "Not in the pack: {count}", "没进包:{count} 项"),
|
||||
("PackPreviewNotes", "Known losses", "已知损失"),
|
||||
("PackPreviewTarget", "Will be written to {path}", "将写到 {path}"),
|
||||
("PackPreviewMore", "…and {count} more files (not shown here)", "…还有 {count} 个文件(这里不展开)"),
|
||||
("PackPreviewComplete", "Nothing is left out", "没有遗漏任何文件"),
|
||||
("PackPreviewIncomplete", "Some files will not be in the pack", "有文件不会进包"),
|
||||
("PackReasonModrinthSha1", "Modrinth match (SHA-1)", "Modrinth 命中(SHA-1)"),
|
||||
("PackReasonCurseFingerprint", "CurseForge match (fingerprint)", "CurseForge 命中(文件指纹)"),
|
||||
("PackReasonNoRemoteMatch", "unknown to both catalogs, inlined", "两个目录都不认识,已内联"),
|
||||
("PackReasonCurseOnly", "only CurseForge knows it, this format cannot use it, inlined", "只有 CF 认识,目标格式用不上,已内联"),
|
||||
("PackReasonModrinthOnly", "only Modrinth knows it, the CF manifest cannot use it, inlined", "只有 Modrinth 认识,CF 清单用不上,已内联"),
|
||||
("PackReasonMissingSha512", "the catalogue entry has no sha512 hash, inlined", "命中缺 sha512,已内联"),
|
||||
("PackReasonMissingCurseIds", "the catalogue entry has no CurseForge ids, inlined", "命中缺 projectID/fileID,已内联"),
|
||||
("PackReasonDisabled", "disabled file, this format cannot express it (.disabled kept)", "禁用文件,目标格式表达不了(保留 .disabled)"),
|
||||
("PackReasonSymlink", "symbolic link, skipped", "符号链接,已跳过"),
|
||||
("PackReasonUnsafePath", "path fails the pack path rules, skipped", "路径过不了包内规整,已跳过"),
|
||||
("PackReasonUnreadable", "cannot read it, skipped", "读不出来,已跳过"),
|
||||
("PackStatusFailedNotStarted", "Import failed — nothing was written", "导入失败 —— 还没写任何文件"),
|
||||
("PackStatusFailedIncomplete", "Import failed — rollback did not finish", "导入失败 —— 回滚没做完"),
|
||||
("PackFatalTitleNotStarted", "Preflight failed — nothing was written", "预检未通过 —— 一个字节都没写"),
|
||||
("PackFatalTitleIncomplete", "Rollback did not finish — the target folder may differ", "回滚没做完 —— 目标目录可能与导入前不同"),
|
||||
("AccountTitle", "Accounts", "账户"),
|
||||
("AccountSubtitle", "Microsoft, third-party skin servers and local profiles", "微软正版、第三方皮肤站与本地档案"),
|
||||
("AccountListTitle", "All accounts", "全部账户"),
|
||||
("AccountAddedOn", "Added {date}", "添加于 {date}"),
|
||||
("AccountActiveChip", "Active", "当前"),
|
||||
("AccountSwitch", "Use for launch", "切换为启动账户"),
|
||||
("AccountRelogin", "Sign in again", "重新登录"),
|
||||
("AccountRemove", "Remove", "删除"),
|
||||
("AccountKindMicrosoft", "Microsoft", "微软正版"),
|
||||
("AccountKindAuthlib", "Third-party skin server", "第三方皮肤站"),
|
||||
("AccountKindOffline", "Local profile", "本地档案"),
|
||||
("AccountServer", "Server {server}", "服务器 {server}"),
|
||||
("AccountEmptyTitle", "No accounts yet", "还没有账户"),
|
||||
("AccountEmptyBody", "Add a Microsoft account, a third-party skin server account, or a local profile.", "添加微软正版账户、第三方皮肤站账户,或建一个本地档案。"),
|
||||
("AccountEmptyAction", "Add an account", "添加账户"),
|
||||
("AccountTrackMicrosoftTitle", "Microsoft account", "微软正版"),
|
||||
("AccountTrackMicrosoftBody", "Sign in with a device code or in your browser. Online servers require this kind of account.", "用设备码或浏览器登录。联机服务器必须用这种账户。"),
|
||||
("AccountTrackAuthlibTitle", "Third-party skin server", "第三方皮肤站"),
|
||||
("AccountTrackAuthlibBody", "An authlib-injector compatible server (LittleSkin and friends) brings its own skins and capes.", "兼容 authlib-injector 的皮肤站(如 LittleSkin)自带皮肤与披风。"),
|
||||
("AccountTrackOfflineTitle", "Local profile", "本地档案"),
|
||||
("AccountTrackOfflineBody", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
|
||||
("AccountTrackOpen", "Sign in", "登录"),
|
||||
("AccountTrackCreate", "Create profile", "创建档案"),
|
||||
("AccountDialogClose", "Close", "关闭"),
|
||||
("AccountOfflineNameLabel", "Profile name", "档案名"),
|
||||
("AccountOfflineNameHint", "3–16 letters, digits or underscore", "3–16 位字母、数字或下划线"),
|
||||
("AccountOfflineSubmit", "Create profile", "创建档案"),
|
||||
("AccountOfflineWarning", "Local / isolated-network testing only — it cannot join servers that require a genuine Minecraft account.", "仅本地/隔离网络测试用,无法连接启用正版验证的服务器。"),
|
||||
("AccountDeviceCodeLabel", "Enter this code", "输入这个用户码"),
|
||||
("AccountDeviceCodeCopy", "Copy code", "复制用户码"),
|
||||
("AccountDeviceLinkCopy", "Copy link", "复制链接"),
|
||||
("AccountDeviceCountdown", "{time} left", "剩 {time}"),
|
||||
("AccountDeviceExpired", "This code has expired. Start again for a fresh one.", "这个用户码已过期,重新开始换一个新的。"),
|
||||
("AccountDeviceRetry", "Try again", "重试"),
|
||||
("AccountDeviceCancel", "Cancel", "取消"),
|
||||
("AccountDeviceBrowser", "Sign in with browser", "用浏览器登录"),
|
||||
("AccountDeviceSignIn", "Sign in with your Microsoft account", "使用 Microsoft 账户登录"),
|
||||
("AccountDeviceFallbackHint", "No browser handy? This way shows a code you can type on any device.", "浏览器打不开就用这个:它会给一串用户码,在任何设备上输入即可。"),
|
||||
("AccountServerLabel", "Server address", "服务器地址"),
|
||||
("AccountServerHint", "API root, e.g. https://littleskin.cn/api/yggdrasil/", "API 根,例:https://littleskin.cn/api/yggdrasil/"),
|
||||
("AccountServerProbe", "Check server", "校验服务器"),
|
||||
("AccountLoginNameLabel", "Email or username", "邮箱或用户名"),
|
||||
("AccountPasswordLabel", "Password", "密码"),
|
||||
("AccountPasswordHint", "Used once for this sign-in. It is never stored.", "只在这次登录里用一次,不会保存。"),
|
||||
("AccountAuthlibSubmit", "Sign in", "登录"),
|
||||
("AccountAuthlibIdleHint", "The server is checked automatically when you sign in; Check server only does it up front.", "登录时会自动校验服务器;「校验服务器」只是提前跑一遍。"),
|
||||
("AccountSkinTitle", "Skin", "皮肤"),
|
||||
("AccountSkinNone", "No skin available", "没有可用皮肤"),
|
||||
("AccountSkinHint", "Skins come from the account's own server; nothing is generated locally.", "皮肤来自账户自己的服务器,本地不生成任何假皮肤。"),
|
||||
("AccountRailSignedOut", "Not signed in", "未登录"),
|
||||
("AccountDeviceHint", "Open the link in your browser and enter the code above.", "在浏览器里打开链接,输入上面的用户码。"),
|
||||
("AccountDeviceBrowserHint", "Finish the sign-in in the browser window we just opened; the link below also works.", "在我们刚打开的浏览器窗口里完成登录;下面的链接也能手动打开。"),
|
||||
("AccountServerChecking", "Checking the server…", "正在校验服务器…"),
|
||||
("AccountServerOk", "Server {server} is ready", "服务器 {server} 可用"),
|
||||
("AccountLoginFailed", "Sign-in failed", "登录失败"),
|
||||
("AccountCurrentTitle", "Current account", "当前账户"),
|
||||
("AccountFailureCode", "Code: {code}", "代码:{code}"),
|
||||
("AccountFailureDenied", "The server refused this sign-in (access denied).", "服务器拒绝了这次登录(拒绝访问)。"),
|
||||
("AccountFailureWhitelist", "Microsoft has not approved this launcher's app registration yet. An administrator has to accept the app, then try again.", "微软还没批准本启动器的应用注册:需要管理员同意该应用后再试。"),
|
||||
("AccountFailureUnreachable", "Could not reach the server. Check the network connection or the address, then try again.", "连不上服务器:检查网络或地址后重试。"),
|
||||
("AccountFailureNotAuthlib", "This address does not look like an authlib-injector server (the API root usually ends with /api/yggdrasil/).", "这个地址不像 authlib-injector 服务器(API 根通常以 /api/yggdrasil/ 结尾)。"),
|
||||
("AccountFailureBadCredentials", "The server rejected the username or password.", "服务器不认这个用户名或密码。"),
|
||||
("AccountFailureNoProfile", "The account has no Minecraft profile on this server yet. Create the character on the server first.", "这个账号在该服务器上还没有角色:先在皮肤站里创建角色。"),
|
||||
("AccountFailureStore", "Could not save the account to disk.", "账户没能写到磁盘上。"),
|
||||
("AccountFailureOther", "The sign-in failed. The full reason is in the launcher log.", "登录失败,完整原因在启动器日志里。"),
|
||||
("AccountLoginNotConfigured", "Microsoft sign-in is not configured", "微软登录未配置"),
|
||||
("AccountFailureMsaNotConfigured", "This build has no Azure public client id, so Microsoft sign-in cannot start (the sign-in flow itself is implemented). Set the environment variable ULL_MSA_CLIENT_ID=<id> before starting the launcher, or build with ULL_MSA_CLIENT_ID=<id>. Then restart the launcher: a running process does not pick up a new environment variable.", "本启动器没有配置 Azure 公共客户端 id,微软登录无法开始(登录功能已实现,只差这一项配置)。启动前设置环境变量 ULL_MSA_CLIENT_ID=<id>,或构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build。改完请重启启动器——已在运行的进程读不到新设的环境变量。"),
|
||||
("AccountDeviceRequesting", "Contacting Microsoft…", "正在联系微软…"),
|
||||
("AccountLoginSucceeded", "Signed in as {name}", "已登录 {name}"),
|
||||
("AccountNoticeAdded", "Added {name}", "已添加 {name}"),
|
||||
("AccountNoticeRemoved", "Removed {name}", "已删除 {name}"),
|
||||
("AccountNoticeSwitched", "{name} will be used for the next launch", "{name} 将作为下次启动的账户"),
|
||||
("AccountNoticeOfflineNameInvalid", "Profile names must be 3–16 letters, digits or underscores.", "档案名必须是 3–16 位字母、数字或下划线。"),
|
||||
("AccountNoticeAccountExists", "An account with that name already exists.", "同名账户已经存在。"),
|
||||
("AccountNoticeStoreFailed", "Could not save the account store. Check the disk and try again.", "账户库写不进去:检查磁盘后重试。"),
|
||||
("AccountNoticeNotFound", "That account no longer exists.", "这个账户已经不在了。"),
|
||||
("AccountNoticeOther", "The account action failed. The full reason is in the launcher log.", "账户操作失败,完整原因在启动器日志里。"),
|
||||
("AccountNoticeDismiss", "Dismiss", "关闭"),
|
||||
("AccountNoticeTitleFailed", "Account action failed", "账户操作失败"),
|
||||
("AccountNoticeTitleDone", "Account updated", "账户已更新"),
|
||||
("AccountHintAuthlibReauth", "Enter the password again for {server}.", "请输入 {server} 的密码。"),
|
||||
("AccountHintOfflineNoRelogin", "Local profiles have no server account to sign in again — create a new profile instead.", "本地档案没有可重新登录的服务器账户,请新建一个档案。"),
|
||||
("AccountBrowserFallbackNotRegistered", "This build's app registration has no loopback redirect URI, so browser sign-in cannot work; switched to a device code. To fix: add http://localhost/auth under Redirect URIs in the Azure app registration (the port is ignored), then restart the launcher.", "这个构建的应用注册里没有登记回环重定向 URI,浏览器登录用不了,已自动改用设备码。怎么修:在 Azure 应用注册的「重定向 URI」里加上 http://localhost/auth(端口不参与匹配),然后重启启动器。"),
|
||||
("AccountBrowserFallbackUnverified", "Could not confirm that browser sign-in is usable, so it switched to a device code.", "没能确认浏览器登录是否可用(预检没成功),已自动改用设备码。"),
|
||||
("AccountBrowserFallbackFailed", "Browser sign-in did not finish, so it switched to a device code. If the browser page said the redirect URI is not valid, add http://localhost/auth under Redirect URIs of the app registration.", "浏览器登录没有完成,已自动改用设备码。如果浏览器里提示 redirect_uri 无效,请把 http://localhost/auth 加进应用注册的「重定向 URI」。"),
|
||||
("SettingsGroupUpdate", "Updates", "更新"),
|
||||
("SettingsUpdateCurrent", "Current version", "当前版本"),
|
||||
("SettingsUpdateChannel", "Channel", "更新渠道"),
|
||||
("SettingsUpdateChannelStable", "Stable", "稳定版"),
|
||||
("SettingsUpdateAutoCheck", "Check for updates on startup", "启动时检查更新"),
|
||||
("SettingsUpdateCheckNow", "Check now", "立即检查"),
|
||||
("SettingsUpdateChecking", "Checking…", "正在检查…"),
|
||||
("SettingsUpdateNever", "Not checked yet", "还没检查过"),
|
||||
("SettingsUpdateUpToDate", "You are on the latest version ({version})", "已是最新({version})"),
|
||||
("SettingsUpdateAvailable", "Version {version} is available ({size})", "有新版本 {version}({size})"),
|
||||
("SettingsUpdateMissingArtifact", "Version {version} is out, but there is no build for this platform yet.", "{version} 已发布,但还没有本平台的安装包。"),
|
||||
("SettingsUpdateFailed", "Check failed: {reason}", "检查失败:{reason}"),
|
||||
("SettingsUpdateApply", "Download and install", "下载并安装"),
|
||||
("SettingsUpdateApplying", "Downloading and verifying…", "正在下载并校验…"),
|
||||
("SettingsUpdateReady", "Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
|
||||
("SettingsUpdateNotes", "Release notes: {notes}", "更新说明:{notes}"),
|
||||
("SettingsUpdateForced", "This update is required", "这是一次强制更新"),
|
||||
("SettingsUpdateLastChecked", "Last check", "上次检查"),
|
||||
("SettingsUpdateHint", "Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.", "更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"),
|
||||
("UpdateBannerTitleAvailable", "Version {version} is available", "发现新版本 {version}"),
|
||||
("UpdateBannerBodyAvailable", "Download size {size}", "下载体积 {size}"),
|
||||
("UpdateBannerTitleReady", "Update ready", "更新已就位"),
|
||||
("UpdateBannerBodyReady", "Version {version} will run after a restart.", "重启后运行版本 {version}。"),
|
||||
("UpdateBannerTitleFailed", "Update failed", "更新失败"),
|
||||
("UpdateBannerApplying", "Downloading the update…", "正在下载更新…"),
|
||||
("UpdateBannerInstall", "Download and install", "下载并安装"),
|
||||
("UpdateBannerDismiss", "Skip this version", "忽略这个版本"),
|
||||
("UpdateBannerRestartNow", "Restart now", "立即重启"),
|
||||
("UpdateBannerRetry", "Try again", "重试"),
|
||||
// M168:中文字体下载状态行(链尾追加,既有顺序不动)
|
||||
("FontCjkStatusLabel", "Chinese font", "中文字体"),
|
||||
("FontCjkStatusNotNeeded", "Not needed for this interface language", "当前界面语言不需要中文字体"),
|
||||
("FontCjkStatusSystem", "Ready (system font: {family})", "已就绪(系统字体:{family})"),
|
||||
("FontCjkStatusDownloading", "Fetching a Chinese font… ({size})", "正在获取中文字体…({size})"),
|
||||
("FontCjkStatusFailed", "Download failed ({reason}). The interface keeps working with system fonts; switch the language again to retry.", "获取失败({reason})。界面照常可用,用的是系统字体;再切一次语言可以重试。"),
|
||||
("FontCjkReasonNetwork", "network unreachable or too slow (timed out)", "网络不通或太慢(超时)"),
|
||||
("FontCjkReasonIntegrity", "the mirror sent content that failed verification", "镜像内容校验不通过"),
|
||||
("FontCjkReasonDisk", "cannot write to the data directory", "写不进数据目录"),
|
||||
("FontCjkStatusUnknown", "Not determined yet; nothing was downloaded", "尚未判定;没有下载任何字体"),
|
||||
("FontCjkStatusUnprobeable", "System fonts cannot be probed, so nothing was downloaded", "无法探测系统字体,因此没有下载"),
|
||||
("FontCjkStatusHint", "Downloaded once into the data directory and cached; the next start does not download it again. A mirror can be set with ui.font_cdn.", "只下这一次,缓存在数据目录里,下次启动不重下。镜像可用配置键 ui.font_cdn 指定。"),
|
||||
("SettingsAppearanceAccent", "Accent colour", "强调色"),
|
||||
("SettingsAppearanceAccentPresets", "Presets", "预设"),
|
||||
("SettingsAppearanceAccentFactory", "Factory", "出厂"),
|
||||
("SettingsAppearanceAccentHint", "Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error colours do not follow it.", "空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与错误色不随强调色变。"),
|
||||
("SettingsAppearanceAccentShort", "Accent needs 6 hex digits (RRGGBB); got {digits}.", "强调色要 6 位十六进制(RRGGBB),现在 {digits} 位。"),
|
||||
("SettingsAppearanceAccentLong", "Accent has {digits} digits; a colour takes exactly 6.", "强调色有 {digits} 位,一个颜色只要 6 位。"),
|
||||
("SettingsAppearanceAccentDigit", "Accent contains a character that is not a hex digit: {ch}", "强调色里有非十六进制字符:{ch}"),
|
||||
];
|
||||
@@ -0,0 +1,282 @@
|
||||
//! 金表的**第四段**(旧 `tail.rs` 从 M174 的助手域起的那一半;M197 拆出)。
|
||||
//!
|
||||
//! 为什么拆:`tail.rs` 在 M197 加了「下载一个 JDK / 三个设置键」这 70 多条文案后到 400+ 行
|
||||
//! (`guards.yml` 的 `file-size` 会红)。拆分口径与仓库其它 `-p1/-p2` / `tail2` 一致:
|
||||
//! **纯搬移、一行不改**,顺序即 `Key::ALL` 的续段——本段从 M174 的助手域开始,
|
||||
//! 一路到旧 `tail.rs` 末尾(后面还跟着 core_text / download / logs / agent / version /
|
||||
//! content / pack 各行,它们本来就在这个位置)。
|
||||
//!
|
||||
//! 拼装见 [`super::super::golden::rows`](链序:`golden` → `tail` → 本段 → `tail2`)。
|
||||
//!
|
||||
//! 正文是 **dev 侧 `tail.rs` 从 M174 助手域起** 的全部行(含其它 mission 后加的行),
|
||||
//! 逐字节原样搬来;本段只负责把它们放到 `Key::ALL` 的同一位置。
|
||||
pub(super) const ROWS: &[(&str, &str, &str)] = &[
|
||||
// M174:设置页「助手(AI)」分组(`catalog/settings/ai.rs` 的 `settings_ai_entries`)。
|
||||
// 位置就是装配链里那个域的位置:`settings_ai_entries` 在 `settings_download_entries` 之后、
|
||||
// `core_text_entries` 之前,所以这几行必须落在 `SettingsAppearanceCornerSuspended` 与
|
||||
// `CoreTextUntranslated` 之间(顺序 = `Key::ALL`)。
|
||||
("SettingsGroupAssistant", "Assistant", "助手"),
|
||||
("SettingsAiStatusLabel", "Status: {status}", "状态:{status}"),
|
||||
("SettingsAiStatusUnconfigured", "not configured", "未配置"),
|
||||
("SettingsAiStatusConfigured", "configured", "已配置"),
|
||||
("SettingsAiStatusVerified", "verified", "连接已通过"),
|
||||
("SettingsAiStatusTestFailed", "test failed", "测试失败"),
|
||||
("SettingsAiEnable", "Enable the assistant", "启用助手"),
|
||||
("SettingsAiEnableHint", "With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.", "关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。"),
|
||||
("SettingsAiProviderLabel", "Provider", "提供商"),
|
||||
("SettingsAiProviderOpenAi", "OpenAI compatible", "OpenAI 兼容"),
|
||||
("SettingsAiProviderAnthropic", "Anthropic", "Anthropic 原生"),
|
||||
("SettingsAiProviderOllama", "Ollama", "Ollama 原生"),
|
||||
("SettingsAiProviderDisabled", "Disabled", "已禁用"),
|
||||
("SettingsAiBaseUrl", "Endpoint (base_url)", "端点(base_url)"),
|
||||
("SettingsAiBaseUrlPlaceholder", "http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"),
|
||||
("SettingsAiBaseUrlHint", "The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.", "OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。"),
|
||||
("SettingsAiEffective", "Effective: {value}", "生效:{value}"),
|
||||
("SettingsAiModel", "Model name", "模型名称"),
|
||||
("SettingsAiModelPlaceholder", "deepseek-chat", "例:deepseek-chat"),
|
||||
("SettingsAiApiKey", "API key", "API 密钥"),
|
||||
("SettingsAiApiKeyPlaceholder", "Paste the key", "粘贴密钥"),
|
||||
("SettingsAiApiKeyConfigured", "A key is stored ({chars} characters). Leave this empty to keep it.", "已保存密钥({chars} 个字符)。留空则保持不变。"),
|
||||
("SettingsAiApiKeyMissing", "No key stored yet", "还没有保存密钥"),
|
||||
("SettingsAiShow", "Show", "显示"),
|
||||
("SettingsAiHide", "Hide", "隐藏"),
|
||||
("SettingsAiSave", "Save", "保存"),
|
||||
("SettingsAiTest", "Test connection", "测试连接"),
|
||||
("SettingsAiTesting", "Testing…", "正在测试…"),
|
||||
("SettingsAiSaved", "Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"),
|
||||
("SettingsAiRefused", "Not saved: {reason}", "未保存:{reason}"),
|
||||
("SettingsAiUnsaved", "There are unsaved changes.", "有未保存的改动。"),
|
||||
("SettingsAiNoStorage", "No settings file is available in this run, so nothing can be saved or tested.", "这一次运行没有可用的设置文件,保存与测试都做不了。"),
|
||||
("SettingsAiTestDisabled", "Fill in the missing field before testing: {missing}.", "先补上这一项再测试:{missing}。"),
|
||||
("SettingsAiMissingDisabled", "the assistant is disabled", "助手处于关闭状态"),
|
||||
("SettingsAiMissingBaseUrl", "the endpoint address", "端点地址"),
|
||||
("SettingsAiMissingModel", "the model name", "模型名称"),
|
||||
("SettingsAiMissingApiKey", "the API key", "API 密钥"),
|
||||
("SettingsAiReasonProvider", "the provider value is not recognised", "提供商取值不认识"),
|
||||
("SettingsAiReasonBaseUrl", "the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"),
|
||||
("SettingsAiReasonModel", "the model name is required", "必须填模型名称"),
|
||||
("SettingsAiTestPassed", "Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"),
|
||||
("SettingsAiTestAuth", "Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"),
|
||||
("SettingsAiTestModel", "The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"),
|
||||
("SettingsAiTestUnreachable", "The endpoint is unreachable.", "端点不可达。"),
|
||||
("SettingsAiTestTimeout", "Timed out while waiting for the endpoint.", "等端点回应超时了。"),
|
||||
("SettingsAiTestOther", "The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"),
|
||||
("SettingsAiTestNotConfigured", "Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"),
|
||||
("SettingsAiTestTarget", "Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"),
|
||||
("SettingsAiTestDetail", "Detail: {detail}", "详情:{detail}"),
|
||||
("CoreTextUntranslated", "[system text not translated]", "[未翻译的系统文案]"),
|
||||
("DownloadTitle", "Version management", "版本管理"),
|
||||
("DownloadSubtitle", "Manage versions and loaders: install, upgrade, repair", "管理版本与加载器:安装、升级、修复"),
|
||||
("DownloadRefresh", "Refresh", "刷新"),
|
||||
("DownloadInstall", "Install", "安装"),
|
||||
("DownloadCancel", "Cancel install", "取消安装"),
|
||||
("DownloadInstalled", "Installed", "已安装"),
|
||||
("DownloadCount", "{count} versions", "{count} 个版本"),
|
||||
("DownloadRowSupporting", "{kind} · released {date}", "{kind} · 发布于 {date}"),
|
||||
("DownloadSelectHint", "Pick a version, then install", "选一个版本再安装"),
|
||||
("DownloadLoading", "Loading versions…", "正在加载版本列表…"),
|
||||
("DownloadLoadingHint", "If this stays here, press Refresh in the top right.", "如果一直停在这里,点右上角的刷新。"),
|
||||
("DownloadEmptyTitle", "No versions to show", "没有可显示的版本"),
|
||||
("DownloadEmptyBody", "Pick another category, or refresh the list.", "换一个分类,或刷新一次列表。"),
|
||||
("DownloadCategoryAll", "All", "全部"),
|
||||
("DownloadCategoryRelease", "Release", "正式版"),
|
||||
("DownloadCategorySnapshot", "Snapshot", "预览版"),
|
||||
("DownloadCategoryAncient", "Ancient", "远古版"),
|
||||
("DownloadStagePlanning", "Planning", "解析版本"),
|
||||
("DownloadStageDownloading", "Downloading files", "下载文件"),
|
||||
("DownloadStageInstalling", "Building the version", "生成版本"),
|
||||
("DownloadStageRegistering", "Registering the instance", "登记实例"),
|
||||
("DownloadStageDone", "Done", "已完成"),
|
||||
("DownloadStageFailed", "Install failed", "安装失败"),
|
||||
("DownloadStageCancelled", "Install cancelled", "安装已取消"),
|
||||
("DownloadProgress", "{done}/{total} files", "{done}/{total} 文件"),
|
||||
("DownloadProgressUnknown", "Waiting for the first file…", "等第一个文件…"),
|
||||
("DownloadRate", "{speed} · {eta}", "{speed},{eta}"),
|
||||
("DownloadEtaSecs", "{secs}s left", "剩 {secs}s"),
|
||||
("DownloadEtaMins", "{mins}m{secs}s left", "剩 {mins}m{secs}s"),
|
||||
("DownloadFailedHint", "The Logs page has the full reason.", "完整原因在日志页。"),
|
||||
("DownloadOpenLogs", "Open logs", "打开日志"),
|
||||
("DownloadDismiss", "Dismiss", "关闭"),
|
||||
("DownloadOptionsTitle", "Install options", "安装选项"),
|
||||
("DownloadOptionsLoaderVersion", "Loader version", "加载器版本"),
|
||||
("DownloadOptionsLoaderVersionsLoading", "Fetching loader versions…", "正在获取加载器版本…"),
|
||||
("DownloadOptionsLoaderVersionsFailed", "Could not fetch loader versions: {reason}", "取不到加载器版本:{reason}"),
|
||||
("DownloadOptionsLoaderVersionsEmpty", "No loader builds published for {version}", "官方没有为 {version} 发布加载器版本"),
|
||||
("DownloadOptionsInstanceName", "Instance name", "实例名"),
|
||||
("DownloadOptionsInstanceNameHint", "Empty = use the version id", "留空就用版本号"),
|
||||
("DownloadOptionsIncompatible", "{loader} has no builds for {version}", "{loader} 没有适配 {version} 的构建"),
|
||||
("DownloadOptionsNeedsAtLeast", "{loader} needs Minecraft {min} or newer", "{loader} 需要 Minecraft {min} 或更新"),
|
||||
("DownloadOptionsLiteLoaderVanillaOnly", "Vanilla parent only — Forge goes through the CLI", "只叠原版;叠 Forge 请走命令行"),
|
||||
("DownloadCancelled", "Install cancelled — finished files are kept, the instance was not created", "安装已取消 —— 已下好的文件保留,实例未登记"),
|
||||
("LogsSubtitleIdle", "Nothing is running right now", "当前没有运行中的实例"),
|
||||
("LogsStatus", "{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||
("LogsOpenFolder", "Open folder", "打开目录"),
|
||||
("LogsClear", "Clear", "清空"),
|
||||
("LogsOutputTitle", "OUTPUT", "输出"),
|
||||
("LogsDiagnosisTitle", "DIAGNOSIS", "诊断"),
|
||||
("LogsConfidenceHigh", "HIGH CONFIDENCE", "高置信度"),
|
||||
("LogsConfidenceMedium", "MEDIUM CONFIDENCE", "中置信度"),
|
||||
("LogsConfidenceLow", "LOW CONFIDENCE", "低置信度"),
|
||||
("LogsSubtitleLive", "Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
|
||||
("LogsConfidence", "Confidence {percent}%", "置信度 {percent}%"),
|
||||
("LogsSuggestedFix", "Suggested fix", "建议动作"),
|
||||
("LogsSummaryLabel", "One-line summary", "一行摘要"),
|
||||
("LogsCopySummary", "Copy summary", "复制摘要"),
|
||||
("LogsEmptyTitle", "No output yet", "还没有输出"),
|
||||
("LogsEmptyBody", "Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.", "启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"),
|
||||
("LogsEmptyAction", "Choose an instance", "选择实例"),
|
||||
("CrashDialogTitleFailed", "Launch failed", "启动失败"),
|
||||
("CrashDialogTitleCrashed", "The game crashed", "游戏崩溃"),
|
||||
("CrashDialogExitCode", "Exit code {code}", "退出码 {code}"),
|
||||
("CrashDialogStartedThenDied", "The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"),
|
||||
("CrashDialogSilentFailure", "The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"),
|
||||
("CrashDialogStageReason", "It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"),
|
||||
("CrashDialogCrashReportFound", "The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"),
|
||||
("CrashDialogNoGameLog", "No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"),
|
||||
("CrashDialogNoLogAtAll", "No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"),
|
||||
("CrashDialogShowDetail", "Show crash details", "展开崩溃说明"),
|
||||
("CrashDialogHideDetail", "Hide crash details", "收起崩溃说明"),
|
||||
("CrashDialogLauncherLogs", "Launcher logs", "启动器日志"),
|
||||
("CrashDialogSessionLog", "This session", "本次会话"),
|
||||
("CrashDialogGameLog", "Game latest.log", "游戏 latest.log"),
|
||||
("CrashDialogCrashReports", "Crash reports", "崩溃报告目录"),
|
||||
("CrashDialogCapturedTail", "CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"),
|
||||
("CrashDialogExport", "Export report bundle", "导出诊断包"),
|
||||
("CrashDialogOpenFolder", "Open log folder", "打开日志文件夹"),
|
||||
("CrashDialogCopyPaths", "Copy file paths", "复制文件路径"),
|
||||
("CrashDialogClose", "Close", "关闭"),
|
||||
("CrashDialogCopied", "File paths copied to the clipboard", "文件路径已复制到剪贴板"),
|
||||
("CrashDialogExportDone", "Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"),
|
||||
("CrashDialogExportFailed", "Export failed: {error}", "导出失败:{error}"),
|
||||
("CrashDialogExportTruncated", "{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"),
|
||||
("CrashDialogOpenFailed", "Could not open the folder: {error}", "打不开文件夹:{error}"),
|
||||
("AgentTitle", "Assistant", "助手"),
|
||||
("AgentSubtitle", "Simulated assistant · no real model is connected", "模拟助手 · 还没接真实模型"),
|
||||
("AgentCollapse", "Collapse", "收起"),
|
||||
("AgentDetach", "Pop out", "拆出"),
|
||||
("AgentDock", "Dock back", "收回"),
|
||||
("AgentOpen", "Open assistant", "打开助手"),
|
||||
("AgentStripLabel", "AI", "助手"),
|
||||
("AgentSend", "Send", "发送"),
|
||||
("AgentStop", "Stop", "中断"),
|
||||
("AgentPlaceholder", "Message the simulated assistant…", "给模拟助手发消息…"),
|
||||
("AgentEmptyTitle", "Nothing in this session yet", "这个会话还没有内容"),
|
||||
("AgentEmptyBody", "Ask about instances, downloads or logs. This assistant is simulated: no real model is connected.", "可以问实例、下载或日志。当前是模拟助手,没有接真实模型。"),
|
||||
("AgentNotePending", "Placeholder shell: your message is kept here, nothing was sent to a model.", "占位外壳:消息留在这里,没有发给任何模型。"),
|
||||
("AgentNoteStopped", "Stopped by you.", "你中断了这次回复。"),
|
||||
("AgentUnread", "{count} unread", "{count} 条未读"),
|
||||
("AgentResize", "Drag to resize the assistant rail", "拖动调整助手栏宽度"),
|
||||
("AgentWidthLabel", "Width {width}", "宽度 {width}"),
|
||||
("AgentRoleYou", "You", "你"),
|
||||
("AgentRoleAssistant", "Assistant", "助手"),
|
||||
("AgentRoleNotice", "Notice", "提示"),
|
||||
("AgentErrorTitle", "Assistant error", "助手出错"),
|
||||
("AgentErrorUnavailable", "Couldn't reach the assistant backend. Check the endpoint, then try again.", "连不上助手后端:检查端点后重试。"),
|
||||
("AgentErrorNotImplemented", "This assistant request isn't wired up yet.", "这个助手请求还没接线。"),
|
||||
("AgentConfirmRequest", "The assistant wants to change something: {summary}", "助手想改一项设置:{summary}"),
|
||||
("AgentConfirmAllow", "Allow", "允许"),
|
||||
("AgentConfirmDeny", "Deny", "拒绝"),
|
||||
("AgentSimulated", "Simulated", "模拟"),
|
||||
("AgentSubtitleReal", "Conversation", "会话"),
|
||||
("AgentPlaceholderReal", "Message the assistant…", "给助手发消息…"),
|
||||
("AgentEmptyBodyReal", "Ask about instances, downloads or logs.", "可以问实例、下载或日志。"),
|
||||
("SettingsLanguageFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsLanguageZhCn", "Chinese (Simplified)", "简体中文"),
|
||||
("SettingsLanguageEn", "English", "英文"),
|
||||
("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"),
|
||||
("SettingsTheme", "Theme", "主题"),
|
||||
("SettingsThemeFollowSystem", "Follow system", "跟随系统"),
|
||||
("SettingsThemeInk", "Ink", "Ink(深色)"),
|
||||
("SettingsThemeSilver", "Silver", "Silver(浅色)"),
|
||||
(
|
||||
"SettingsThemeHint",
|
||||
"Ink is the dark scheme, Silver the light one. Follow system picks between them as Windows switches.",
|
||||
"Ink 是深色主方案,Silver 是浅色主方案;跟随系统会随 Windows 的深浅在这两块之间切换。",
|
||||
),
|
||||
("SettingsStartupPage", "Startup page", "启动页"),
|
||||
("SettingsStartupInstances", "Instances", "实例"),
|
||||
("SettingsCloseButton", "Close button", "关闭按钮"),
|
||||
("SettingsCloseToTray", "Exit to tray", "退出到托盘"),
|
||||
("SettingsSubtitle", "Launcher behaviour, Java runtimes and download sources", "启动器行为、Java 运行时与下载源"),
|
||||
("SettingsResetSection", "Reset section", "重置本组"),
|
||||
("SettingsSave", "Save", "保存"),
|
||||
("NavAi", "Assistant", "助手"),
|
||||
("SettingsGroupGeneral", "General", "通用"),
|
||||
("SettingsGroupAppearance", "Appearance", "外观"),
|
||||
("SettingsGroupJava", "Java", "Java 运行时"),
|
||||
("SettingsGroupDownloads", "Downloads", "下载"),
|
||||
("AgentFallbackBody", "The assistant page is not built yet. Chat lives in the docked panel once the AI mission lands; this screen is the M68 skeleton fallback.", "助手页还没做。AI 接线落地后对话会出现在右侧停靠面板;这一屏是 M68 的骨架回退。"),
|
||||
("VersionRailTitle", "Version type", "版本类型"),
|
||||
("VersionMajorTitle", "Major version", "主版本"),
|
||||
("VersionMajorAll", "All versions", "全部版本"),
|
||||
("VersionSearchPlaceholder", "Search version numbers", "搜版本号"),
|
||||
("VersionSearchClear", "Clear search", "清空搜索"),
|
||||
("VersionSearchHits", "{count} matches", "{count} 个匹配"),
|
||||
("VersionSearchEmptyTitle", "No matching versions", "没有匹配的版本"),
|
||||
("VersionSearchEmptyBody", "Try a shorter piece of the number — 1.21, 24w, a1.", "试试更短的片段 — 1.21、24w、a1。"),
|
||||
("VersionSearchElsewhere", "{count} versions match, but they are in another type.", "有 {count} 个版本匹配,但它们在别的类型里。"),
|
||||
("VersionSearchElsewhereAction", "Show all types", "看全部类型"),
|
||||
("VersionSearchHitsOne", "1 match", "1 个匹配"),
|
||||
("VersionSearchElsewhereTitle", "Found in another type", "在别的类型里找到了"),
|
||||
("VersionSearchElsewhereOne", "1 version matches, but it is in another type.", "有 1 个版本匹配,但它在别的类型里。"),
|
||||
("ContentTitle", "Content", "内容库"),
|
||||
("ContentSubtitle", "Modpacks, mods, resource packs and shaders", "整合包、Mod、资源包与光影"),
|
||||
("ContentSectionVersions", "Version management", "版本管理"),
|
||||
("ContentSectionContent", "Content library", "内容库"),
|
||||
("ContentKindModpack", "Modpacks", "整合包"),
|
||||
("ContentKindMod", "Mods", "Mod"),
|
||||
("ContentKindResourcepack", "Resource packs", "资源包"),
|
||||
("ContentKindShader", "Shaders", "光影"),
|
||||
("ContentSourceModrinth", "Modrinth", "Modrinth 源"),
|
||||
("ContentSourceCurseforge", "CurseForge", "CurseForge 源"),
|
||||
("ContentSortRelevance", "Relevance", "相关度"),
|
||||
("ContentSortDownloads", "Downloads", "下载量"),
|
||||
("ContentSortNewest", "Newest", "最新发布"),
|
||||
("ContentSortUpdated", "Recently updated", "最近更新"),
|
||||
("ContentSearchPlaceholder", "Search content…", "搜索内容…"),
|
||||
("ContentSearch", "Search", "搜索"),
|
||||
("ContentFilterVersion", "Game version", "游戏版本"),
|
||||
("ContentFilterLoader", "Loader", "加载器"),
|
||||
("ContentFilterAny", "Any", "不限"),
|
||||
("ContentCount", "{count} results", "{count} 个结果"),
|
||||
("ContentLoading", "Loading content…", "正在加载内容…"),
|
||||
("ContentLoadingHint", "Results come from the network; nothing is cached offline yet.", "结果来自网络,暂时没有离线缓存。"),
|
||||
("ContentEmptyTitle", "Nothing matched", "没有匹配的内容"),
|
||||
("ContentEmptyBody", "Try another keyword, or clear a filter.", "换个关键词,或清掉一个筛选条件。"),
|
||||
("ContentFailedTitle", "Could not load content", "内容加载失败"),
|
||||
("ContentFailedBody", "Check the network, then retry.", "检查网络后重试。"),
|
||||
("ContentOfflineTitle", "No connection", "未联网"),
|
||||
("ContentOfflineBody", "Content search needs the network. Check your connection, then retry.", "内容检索需要联网。检查网络后重试。"),
|
||||
("ContentRetry", "Retry", "重试"),
|
||||
("ContentLoadMore", "Load more", "加载更多"),
|
||||
("ContentNoMore", "No more results", "没有更多了"),
|
||||
("ContentDetailEmpty", "Pick an item to see what it supports.", "选一个项目看它支持什么。"),
|
||||
("ContentDetailDownloads", "{count} downloads", "下载量 {count}"),
|
||||
("ContentDetailUpdated", "Updated {date}", "更新于 {date}"),
|
||||
("ContentDetailLoaders", "Loaders: {list}", "加载器:{list}"),
|
||||
("ContentDetailVersions", "Game versions: {list}", "游戏版本:{list}"),
|
||||
("ContentDetailFetching", "Loading versions…", "正在取版本列表…"),
|
||||
("ContentDetailFetchFailed", "Could not load versions: {reason}", "版本列表取不到:{reason}"),
|
||||
("ContentDetailVersionCount", "{count} versions", "{count} 个版本"),
|
||||
("ContentDetailVersionLine", "{number} · {kind} · {date}", "{number}({kind})· {date}"),
|
||||
("ContentInstall", "Install into instance", "装入实例"),
|
||||
("ContentTargetInstance", "Target instance", "目标实例"),
|
||||
("ContentNoInstances", "No instance to install into", "没有可装入的实例"),
|
||||
("ContentIncompatibleVersion", "Needs {version}; this instance runs {actual}", "需要 {version},该实例运行的是 {actual}"),
|
||||
("ContentIncompatibleLoader", "Not built for {loader}", "不支持 {loader}"),
|
||||
("ContentModpackNotReady", "Installing modpacks from the library into an instance is not available yet", "从内容库一键安装整合包到实例还没做(本地的包可以直接导入)"),
|
||||
("ContentSourceUnavailable", "CurseForge needs an API key: add one in Settings", "CurseForge 需要在设置里配置密钥才能搜索"),
|
||||
("ContentSourceNotWired", "Installing from CurseForge is not available yet", "CurseForge 来源的安装尚未开放"),
|
||||
("ContentInstallFailedTitle", "Install did not finish — {count} items failed", "安装未完成 —— {count} 项没装上"),
|
||||
("ContentInstallRetryIncompatible", "Not available for this version/loader", "不兼容所选版本/加载器"),
|
||||
("ContentInstallRetryUnreachable", "Could not fetch (network or missing project)", "取不到(网络 / 项目不存在)"),
|
||||
("ContentInstallRetryOnly", "Retry just these {count}", "只重试这 {count} 个"),
|
||||
("ContentInstallRetryHint", "Retry asks again only for the items above; files that already landed stay put.", "重试只重新解析上面这几项,已落盘的文件不动。"),
|
||||
("PackTitle", "Modpack import & export", "整合包导入 / 导出"),
|
||||
("PackImportTitle", "Import a modpack", "导入整合包"),
|
||||
("PackDropHint", "Drop a .mrpack, .zip or a MultiMC/Prism instance folder here", "把 .mrpack / .zip 或 MultiMC·Prism 实例目录拖到这里"),
|
||||
("PackDropActive", "Release to probe the pack", "松手开始探测"),
|
||||
("PackNoDialog", "This build has no system file picker: drag the file in, or paste its path below.", "本版没有系统文件选择框:请把文件拖进来,或在下面粘贴路径。"),
|
||||
("PackPathLabel", "Pack path", "整合包路径"),
|
||||
("PackPathPlaceholder", "D:\\packs\\Better MC.mrpack", "D:\\整合包\\更好的MC.mrpack"),
|
||||
];
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 实例页文案:页头、列表列头、详情动作、元信息卡的标签与值、运行状态与启动阶段。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
//!
|
||||
//! ## 这一族在装配链的**第 2 位**(M120 钉住)
|
||||
//!
|
||||
//! `instances_entries!` 紧跟 `nav_entries!`,所以本族的 key 落在 `Key::ALL` 的前段、
|
||||
//! `LoaderVanilla` 之前。M120 的新 key 因此**追加在本域末尾**(= `InstancesEmptyBody` 之后),
|
||||
//! 既没插到别的族中间,也没为了「排在文件末尾」去重排别人的族:
|
||||
//! 金表([`crate::catalog::golden`])是 `Key::ALL` 的逐 index 镜像,位置跟着族走。
|
||||
//!
|
||||
//! ## M120 之前这七条全是**死 key**
|
||||
//!
|
||||
//! M117 加 `InstancesNew` / `InstancesImport` / `InstancesBack` / `InstancesPlay` /
|
||||
//! `InstancesLastPlayed` / `InstancesEmptyTitle` / `InstancesEmptyBody` 时,实例页并没有接它们
|
||||
//! ——页面上还是英文基线字面量(`"New instance"` / `"ALL INSTANCES"` / `"Present on disk"`…)。
|
||||
//! M120 一次接完,同时:
|
||||
//!
|
||||
//! - `InstancesEmptyBody` 的译文换成详情列真正画的那句(原来那句是「看详情」的泛说);
|
||||
//! - **`InstancesLastPlayed` 删掉**:它的形状是「上一次游玩 {when}」这种整句,而页面上是
|
||||
//! 元信息卡(标签 `LAST PLAYED` + 值 `2026-10-29 19:22 UTC`,或值 `Never`)。留着它只会
|
||||
//! 多出一条没人用的 key,所以拆成 [`Key::InstancesMetaLastPlayed`](标签)与
|
||||
//! [`Key::InstancesNeverPlayed`](值)。
|
||||
//!
|
||||
//! ## 两条口径(复审会盯)
|
||||
//!
|
||||
//! 1. **品牌与技术标识不翻译**:`Java`(元信息标签的值域)与 `pid`(进程号)原样保留两套语言;
|
||||
//! 2. **数字与分隔符不随语言变形**:[`Key::InstancesRunning`] 的中英都写成
|
||||
//! `… · pid {pid}`,只有前半句换了;`{pid}` 之外一个字都不许动(`pages/instances/tests/i18n.rs`
|
||||
//! 用「中英同位置取占位符 + 数字原样」的断言钉这条)。
|
||||
|
||||
macro_rules! instances_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 实例页(M117 的七条:其中 `InstancesLastPlayed` 见模块文档,M120 删除)
|
||||
InstancesNew => ("New instance", "新建实例"),
|
||||
InstancesImport => ("Import", "导入"),
|
||||
InstancesBack => ("Back to instances", "返回实例列表"),
|
||||
InstancesPlay => ("Play", "启动"),
|
||||
InstancesEmptyTitle => ("No instance selected", "未选择实例"),
|
||||
InstancesEmptyBody => ("Pick an instance on the left to see its version, loader and launch options.", "在左侧选择一个实例,查看它的版本、加载器与启动选项。"),
|
||||
// ── 页头与列表列头(M120)
|
||||
InstancesTitle => ("Instances", "实例"),
|
||||
InstancesSubtitle => ("Local versions and their mod loaders", "本地版本与它们的模组加载器"),
|
||||
InstancesAllTitle => ("ALL INSTANCES", "全部实例"),
|
||||
// ── 详情动作(M120)
|
||||
InstancesStop => ("Stop", "停止"),
|
||||
InstancesDuplicate => ("Duplicate", "复制"),
|
||||
InstancesOpenFolder => ("Open folder", "打开文件夹"),
|
||||
InstancesDelete => ("Delete", "删除"),
|
||||
InstancesNarrowNote => (
|
||||
"Below 840 px the list and the detail share one column, so opening an instance replaces this screen (full-page switch). Widen the window to see both panes again.",
|
||||
"窗口窄于 840 像素时,列表与详情共用一列:打开一个实例会整页替换这一屏(整页切换)。把窗口拉宽就能重新同时看见两栏。"
|
||||
),
|
||||
// ── 元信息卡的标签(M120;`Java` 是品牌名,两套语言都写 Java)
|
||||
InstancesMetaVersion => ("VERSION", "版本"),
|
||||
InstancesMetaLoader => ("LOADER", "加载器"),
|
||||
InstancesMetaMemory => ("MEMORY", "内存"),
|
||||
InstancesMetaJava => ("JAVA", "Java"),
|
||||
InstancesMetaMods => ("MODS", "模组"),
|
||||
InstancesMetaIsolation => ("ISOLATION", "隔离方式"),
|
||||
InstancesMetaVersionId => ("VERSION ID", "版本标识"),
|
||||
InstancesMetaFiles => ("FILES", "文件"),
|
||||
InstancesMetaLastPlayed => ("LAST PLAYED", "上次游玩"),
|
||||
// ── 元信息卡的值(M120)
|
||||
InstancesPresentOnDisk => ("Present on disk", "已在磁盘上"),
|
||||
InstancesMissing => ("Missing", "缺失"),
|
||||
InstancesNeverPlayed => ("Never", "从未游玩"),
|
||||
// 隔离档位的两个稳定 id(`core::game::IsolationMode::as_str`)→ 人话。
|
||||
// 白名单只覆盖真实存在的这两档;`inherit` / `custom` / 未知值原样上屏(不猜)。
|
||||
InstancesIsolationIsolated => ("Isolated", "独立目录"),
|
||||
InstancesIsolationShared => ("Shared", "共用目录"),
|
||||
// ── 运行状态与启动阶段(M120;数字与分隔符不随语言变形)
|
||||
InstancesRunning => ("Running · pid {pid}", "运行中 · pid {pid}"),
|
||||
// 失败状态行的**框架**:`{reason}` 是「哪一步」或「结局」的短句
|
||||
// (M161 复审 P1-1:「起来了又崩」与「没起来」必须能说出不同的话)。
|
||||
InstancesFailed => ("Launch failed · {reason}", "启动失败 · {reason}"),
|
||||
InstancesCrashed => ("the game crashed (exit {code})", "游戏崩溃(退出码 {code})"),
|
||||
// 单行列表行也要放得下(`Launch failed · ` + 这一句 ≤ 行宽),
|
||||
// 「exit 0 却没起来」这一条信息必须留(复审 P1-1 第 ③ 条)。
|
||||
InstancesSilentFailed => ("exit 0, but never started", "退出码 0,没起来"),
|
||||
InstancesStageDownload => ("Downloading", "下载中"),
|
||||
InstancesStageVerify => ("Verifying", "校验中"),
|
||||
InstancesStageLaunch => ("Starting", "启动中"),
|
||||
InstancesStageWindow => ("Waiting for window", "等待游戏窗口"),
|
||||
// ── 启动参数区(M157):实例配置页的五项 + 三态后端 + 门槛提示
|
||||
InstancesLaunchTitle => ("LAUNCH OPTIONS", "启动参数"),
|
||||
InstancesLaunchJvm => ("JVM arguments", "JVM 参数"),
|
||||
InstancesLaunchJvmHint => ("Space separated, quotes allowed. Empty = follow the global extra JVM arguments (both are sent; on duplicates the instance wins).", "空格分隔、支持引号。留空 = 跟随全局附加 JVM 参数(两者叠加下发,同名选项实例赢)。"),
|
||||
InstancesLaunchGame => ("Game arguments", "游戏参数"),
|
||||
InstancesLaunchGameHint => ("Appended last, so they override earlier game arguments. Empty = nothing appended.", "追加在命令行最后,可覆盖前面的游戏参数。留空 = 不追加。"),
|
||||
InstancesLaunchMemory => ("Max memory (MiB)", "最大内存(MiB)"),
|
||||
InstancesLaunchMemoryHint => ("Empty = follow the global default ({default} MiB).", "留空 = 跟随全局默认({default} MiB)。"),
|
||||
InstancesLaunchJava => ("Java executable", "Java 可执行文件"),
|
||||
InstancesLaunchJavaHint => ("Empty = follow the global setting, then auto-pick (managed > JAVA_HOME > PATH).", "留空 = 跟随全局设置,再退到自动挑选(托管 > 环境变量 > 系统路径)。"),
|
||||
InstancesLaunchBackend => ("Graphics backend", "图形后端"),
|
||||
InstancesLaunchBackendGated => ("Requires MC {min}+ (this instance: {version}). The flag is not sent on this version.", "需游戏版本 {min} 及以上(本实例 {version}),这个版本不会下发该参数。"),
|
||||
InstancesLaunchEffective => ("Effective: {value}", "生效:{value}"),
|
||||
InstancesBackendDefault => ("Default", "默认"),
|
||||
InstancesBackendOpengl => ("OpenGL", "OpenGL 后端"),
|
||||
InstancesBackendVulkan => ("Vulkan", "Vulkan 后端"),
|
||||
InstancesLaunchSave => ("Save", "保存"),
|
||||
InstancesLaunchReset => ("Reset to global", "恢复跟随全局"),
|
||||
|
||||
// ── 导入已有版本(M223):向导的全部文案
|
||||
AdoptTitle => ("Import an existing version", "导入已有版本"),
|
||||
AdoptSubtitle => ("Register versions another launcher already installed. Nothing is copied: versions, libraries and assets stay where they are.", "把别的启动器已经装好的版本登记成实例。不复制任何东西:versions / libraries / assets 原地不动。"),
|
||||
AdoptScanning => ("Scanning versions/…", "正在扫描 versions/…"),
|
||||
AdoptEmpty => ("No version found under {dir}. Install one here, or point the game folder at the .minecraft another launcher uses.", "在 {dir} 下一个版本都没找到。可以用本启动器装一个,或者把游戏目录指向别的启动器用的那个 .minecraft。"),
|
||||
AdoptSelectLabel => ("Pick a version", "选择版本"),
|
||||
AdoptReady => ("Ready", "可导入"),
|
||||
AdoptBlocked => ("{count} problem(s)", "{count} 个问题"),
|
||||
AdoptRegistered => ("Already used by {name}", "已被 {name} 使用"),
|
||||
AdoptTargetLabel => ("Where should the game run?", "游戏在哪个目录里跑"),
|
||||
AdoptTargetInstanceDir => ("New folder in ULL data (default)", "ULL 数据区新建目录(缺省)"),
|
||||
AdoptTargetInstanceDirHint => ("mods, saves and config stay out of the other launcher's .minecraft", "mods / saves / config 不会落进别人的 .minecraft"),
|
||||
AdoptTargetShared => ("Share the game folder", "共用游戏目录"),
|
||||
AdoptTargetSharedHint => ("mods, saves and config are written into the other launcher's .minecraft", "mods / saves / config 会写进别人的 .minecraft"),
|
||||
AdoptTargetVersionDir => ("Use the version folder", "用版本目录"),
|
||||
AdoptTargetVersionDirHint => ("player data grows inside versions/<id>", "玩家数据会长在 versions/<id> 里"),
|
||||
AdoptNameLabel => ("Instance name (empty = version id)", "实例名(留空 = 版本 id)"),
|
||||
AdoptPreview => ("Check", "预检"),
|
||||
AdoptConfirm => ("Import", "导入"),
|
||||
AdoptBack => ("Back", "返回"),
|
||||
AdoptClose => ("Close", "关闭"),
|
||||
AdoptPlanTitle => ("Ready to import", "可以导入"),
|
||||
AdoptPlanRunDir => ("Game folder", "运行目录"),
|
||||
AdoptPlanIsolation => ("Isolation", "隔离方式"),
|
||||
AdoptFailureTitle => ("Import failed", "导入失败"),
|
||||
AdoptProblemCount => ("Every reason is listed below:", "全部原因如下:"),
|
||||
AdoptInstanceName => ("Instance name", "实例名"),
|
||||
AdoptDoneTitle => ("Imported", "已导入"),
|
||||
AdoptDoneHint => ("It is in the instance list now. Player data will live in the game folder below.", "它已经出现在实例列表里;玩家数据会落在下面这个运行目录。"),
|
||||
// ── MODS 区(M224):本地 mod 列表 + 启停开关。
|
||||
// `mods` 是**目录名**(文件系统事实,不翻译),所以中文那栏里也出现它;
|
||||
// 页面守门的 ALLOWED 里因此有一条 `mods`(见 `pages/instances/tests/i18n.rs`)。
|
||||
InstancesModsTitle => ("MODS", "模组"),
|
||||
InstancesModsCount => ("{enabled} enabled · {disabled} disabled", "{enabled} 启用 · {disabled} 禁用"),
|
||||
InstancesModsLoading => ("Reading mods/…", "正在读取 mods/…"),
|
||||
InstancesModsEmpty => ("No mods here yet — put jars into mods/ and they show up.", "这里还没有模组——把模组文件放进 mods/ 就会出现在这里。"),
|
||||
InstancesModsMissingDir => ("This instance has no mods/ directory yet.", "这个实例还没有 mods/ 目录。"),
|
||||
InstancesModsRunning => ("The game is running — toggles are read-only until it exits.", "游戏正在运行——退出前开关只读。"),
|
||||
InstancesModsOldBadge => ("legacy file", "旧版本残留"),
|
||||
InstancesModsSkipped => ("{count} entries skipped", "跳过 {count} 项"),
|
||||
InstancesModsFailed => ("Could not read mods/: {reason}", "读取 mods/ 失败:{reason}"),
|
||||
InstancesModsToggleFailed => ("Could not toggle {file}: {reason}", "启停 {file} 失败:{reason}"),
|
||||
// 启停失败的**具名原因**(`ModFailureKind` → 一句话;`{detail}` 是 OS 原文,只进括号)
|
||||
InstancesModsReasonRunning => ("the game is running", "游戏正在运行"),
|
||||
InstancesModsReasonInstance => ("the instance is gone", "实例已不存在"),
|
||||
InstancesModsReasonName => ("the file name is not usable", "文件名不可用"),
|
||||
InstancesModsReasonDirectory => ("there is no usable mods/ directory", "没有可用的 mods/ 目录"),
|
||||
InstancesModsReasonMissing => ("the file is gone", "文件已不在"),
|
||||
InstancesModsReasonNotFile => ("that is not a regular file", "那不是普通文件"),
|
||||
InstancesModsReasonCollision => ("a file with that name already exists", "同名文件已存在"),
|
||||
InstancesModsReasonRename => ("the rename failed", "改名失败"),
|
||||
// ── 存档区(M235 · STOREMVP 读路径 / M247 · SAVESOPS 写操作)。
|
||||
// M235 那条「这一版只读」(`InstancesSavesReadOnly`)**已删**:写操作接上之后它是假的,
|
||||
// 边界改由 core 的 `game::saves::write::boundary` 守(越界 / 符号链接 / 撞名都在那里拒)。
|
||||
// `saves/` / `screenshots/` 是**目录名**,两套语言都原样保留(与 MODS 区的 `mods` 同一条口径)。
|
||||
InstancesSavesTitle => ("SAVES", "存档"),
|
||||
InstancesSavesCount => ("{worlds} worlds · {shots} screenshots", "{worlds} 个世界 · {shots} 张截图"),
|
||||
InstancesSavesLoading => ("Reading saves/…", "正在读取 saves/…"),
|
||||
InstancesSavesEmpty => ("No worlds or screenshots yet — play once and they show up here.", "还没有世界或截图——玩一次就会出现在这里。"),
|
||||
InstancesSavesMissingDir => ("This instance has no saves/ or screenshots/ directory yet.", "这个实例还没有 saves/ 或 screenshots/ 目录。"),
|
||||
InstancesSavesWorlds => ("WORLDS", "世界"),
|
||||
InstancesSavesScreenshots => ("SCREENSHOTS", "截图"),
|
||||
InstancesSavesNoWorlds => ("No worlds yet.", "还没有世界。"),
|
||||
InstancesSavesNoScreenshots => ("No screenshots yet.", "还没有截图。"),
|
||||
InstancesSavesWorldIcon => ("has icon", "有图标"),
|
||||
InstancesSavesTimeUnknown => ("time unknown", "时间未知"),
|
||||
InstancesSavesSkipped => ("{count} entries skipped", "跳过 {count} 项"),
|
||||
InstancesSavesOpenFolder => ("Open saves folder", "打开存档文件夹"),
|
||||
InstancesSavesRetry => ("Retry", "重试"),
|
||||
InstancesSavesFailed => ("Could not read saves/: {reason}", "读取 saves/ 失败:{reason}"),
|
||||
InstancesSavesOpenFailed => ("Could not open the folder: {reason}", "打开文件夹失败:{reason}"),
|
||||
InstancesSavesReasonDir => ("the directory is not usable", "目录不可用"),
|
||||
InstancesSavesReasonInstance => ("the instance is gone", "实例已不存在"),
|
||||
InstancesSavesReasonOpen => ("the file manager did not start", "文件管理器没起来"),
|
||||
// ── 存档**写操作**(M247 · SAVESOPS):重命名 / 删除(二次确认)/ 备份导出。
|
||||
// M235 那条「这一版只读」的 `InstancesSavesReadOnly` **已删**:写操作接上之后它就是假的
|
||||
// (金表同步少了那一行,不是重排)。删除确认框的三句是**不可逆动作**的兜底,不许省。
|
||||
InstancesSavesRename => ("Rename", "重命名"),
|
||||
InstancesSavesBackup => ("Back up", "备份"),
|
||||
InstancesSavesDelete => ("Delete", "删除"),
|
||||
InstancesSavesRenamePlaceholder => ("New name for this world", "这个世界的新名字"),
|
||||
InstancesSavesRenameConfirm => ("Rename", "改名"),
|
||||
InstancesSavesCancel => ("Cancel", "取消"),
|
||||
InstancesSavesBusy => ("Working…", "正在处理…"),
|
||||
InstancesSavesDeleteTitle => ("Delete this world?", "删除这个世界?"),
|
||||
InstancesSavesDeleteBody => ("{dir} and everything inside it will be deleted. This cannot be undone.", "{dir} 和它里面的全部内容都会被删除,且不能撤销。"),
|
||||
InstancesSavesDeleteConfirm => ("Delete world", "删除世界"),
|
||||
InstancesSavesRenamed => ("Renamed {from} → {to}.", "已把 {from} 改名为 {to}。"),
|
||||
InstancesSavesDeleted => ("Deleted {dir}.", "已删除 {dir}。"),
|
||||
InstancesSavesBackedUp => ("Backed up {dir} to {path} ({size}).", "已把 {dir} 备份到 {path}({size})。"),
|
||||
InstancesSavesBackupSkipped => ("{count} symlinks skipped", "跳过 {count} 个符号链接"),
|
||||
InstancesSavesRenameFailed => ("Could not rename {file}: {reason}", "重命名 {file} 失败:{reason}"),
|
||||
InstancesSavesDeleteFailed => ("Could not delete {file}: {reason}", "删除 {file} 失败:{reason}"),
|
||||
InstancesSavesBackupFailed => ("Could not back up {file}: {reason}", "备份 {file} 失败:{reason}"),
|
||||
InstancesSavesReasonUnsafeName => ("the name is not usable", "名字不可用"),
|
||||
InstancesSavesReasonSymlink => ("that path is a symbolic link (not followed)", "那个路径是符号链接(不跟随)"),
|
||||
InstancesSavesReasonMissing => ("the world is gone", "那个世界已不在"),
|
||||
InstancesSavesReasonNotDir => ("that is not a directory", "那不是目录"),
|
||||
InstancesSavesReasonConflict => ("a world with that name already exists", "同名世界已存在"),
|
||||
InstancesSavesReasonBoundary => ("that path is outside saves/", "那个路径落在 saves/ 之外"),
|
||||
InstancesSavesReasonTooLarge => ("the world is too large for one zip", "这个世界太大,一个 zip 装不下"),
|
||||
InstancesSavesReasonWrite => ("the write failed", "写盘失败"),
|
||||
// 实例在启动/运行中:三个写操作**主动不写盘**(不是"写盘失败"——Linux 上删一个正在跑的
|
||||
// 世界是会成功的,所以这条要直说"先退出",别让用户以为是权限问题)。
|
||||
InstancesSavesReasonRunning => ("the game is running (exit it first)", "游戏正在运行(先退出再操作)"),
|
||||
// ── 截图**缩略图**(M251 · SAVESHOTS,D54)。
|
||||
// 四句都是「这一格为什么没有图」:没轮到(超 60 张预算)/ 太大 / 读不出 / 解不出。
|
||||
// 第 1 句与后三句分开是刻意的——"没生成"与"生成了但失败"对用户的含义不同,
|
||||
// 前者点一下刷新可能就有了,后者得用户自己去动文件。
|
||||
InstancesSavesPreviewNone => ("no preview", "未生成预览"),
|
||||
InstancesSavesPreviewTooLarge => ("too large for a preview (over 8 MiB)", "文件过大,未生成预览(超过 8 MiB)"),
|
||||
InstancesSavesPreviewUnreadable => ("the file could not be read", "读不出这张图"),
|
||||
InstancesSavesPreviewDecode => ("not a decodable image", "解不出这张图(可能已损坏或格式不支持)"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use instances_entries;
|
||||
@@ -0,0 +1,70 @@
|
||||
//! 启动结局文案:一句话 = 一个 key(原来 UI 把 `"Launch failed"` 与 core 的「静默失败」拼成一句话)。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! launch_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 启动结局(原来 UI 把 "Launch failed" 与 core 的「静默失败」拼成一句话,现在一句话 = 一个 key)
|
||||
LaunchOutcomeNormal => ("Finished normally", "正常结束"),
|
||||
LaunchOutcomeCrash => ("The game crashed", "游戏崩溃"),
|
||||
LaunchOutcomeSilent => (
|
||||
"Suspected silent failure: exit code 0, but the game never really started",
|
||||
"疑似静默失败:退出码 0,但游戏没真正跑起来"
|
||||
),
|
||||
LaunchFailedWithCode => ("Launch failed (exit {code}): {reason}", "启动失败(退出码 {code}):{reason}"),
|
||||
// M161:启动失败的**阶段**词表。presenter 只给稳定 slug
|
||||
// (`LaunchFailureStage`),界面在这里选词——此前这些前缀是 presenter 里拼的中文
|
||||
// (「读版本 JSON 失败」「隔离目录派生失败」…),英文界面上原样露出来。
|
||||
LaunchStageVersionManifest => ("Reading the version file", "读版本文件"),
|
||||
LaunchStageInheritance => ("Resolving the inheritance chain", "解析继承链"),
|
||||
LaunchStageInstanceConfig => ("Reading the instance settings", "读实例设置"),
|
||||
LaunchStageIsolation => ("Preparing the isolated game folder", "准备隔离游戏目录"),
|
||||
LaunchStageIdentity => ("Preparing the account", "准备账户"),
|
||||
LaunchStageSpawn => ("Starting the game process", "启动游戏进程"),
|
||||
LaunchStageFailed => ("Launch failed · {stage}: {detail}", "启动失败 · {stage}:{detail}"),
|
||||
LaunchToastEnded => ("{outcome} · {seconds}s", "{outcome} · {seconds} 秒"),
|
||||
// M117:微软轨的 client id 没配是**配置缺失**,不是「本期未支持」。这句话是那条
|
||||
// 启动失败文案的**双语口径**(真源在 `core::auth::microsoft` 的
|
||||
// `MISSING_CLIENT_ID_MESSAGE`,中文逐字一致;英文这条给 En 界面用)。
|
||||
// 判据:中英都点名 `ULL_MSA_CLIENT_ID` 并给出构建期注入这条路,且**不许**出现
|
||||
// 「unsupported / 未支持」——用户实跑就是被那句误导读成「功能没做」。
|
||||
LaunchMsaNotConfigured => (
|
||||
"Microsoft sign-in is not configured: it needs an Azure public client id (the login flow itself is implemented). Build with ULL_MSA_CLIENT_ID=<id>, or set that environment variable before launching.",
|
||||
"微软登录未配置:缺少 Azure 公共客户端 id(登录功能已实现,只差这一项配置)。构建时注入:ULL_MSA_CLIENT_ID=<id> cargo build;或启动前设置同名环境变量 ULL_MSA_CLIENT_ID=<id>。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use launch_entries;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{Catalog, Key, Lang};
|
||||
|
||||
#[test]
|
||||
fn msa_not_configured_copy_is_accurate_in_both_languages() {
|
||||
// 抓得住:文案退回「本期未支持」这类说法(M117 用户实跑踩到的原 bug),
|
||||
// 以及两条译文只剩一句「需要 id」却不给怎么配。
|
||||
// 抓不住:读起来顺不顺——那要靠 review 与截图。
|
||||
let en = Catalog::of(Lang::En).text(Key::LaunchMsaNotConfigured);
|
||||
let zh = Catalog::of(Lang::ZhCn).text(Key::LaunchMsaNotConfigured);
|
||||
|
||||
for (tag, text) in [("en", en), ("zh-CN", zh)] {
|
||||
assert!(text.contains("ULL_MSA_CLIENT_ID"), "{tag}: {text}");
|
||||
}
|
||||
assert!(en.contains("client id"), "{en}");
|
||||
assert!(en.contains("not configured"), "{en}");
|
||||
assert!(!en.to_lowercase().contains("unsupported"), "{en}");
|
||||
assert!(!en.to_lowercase().contains("not implemented"), "{en}");
|
||||
|
||||
assert!(zh.contains("客户端 id"), "{zh}");
|
||||
assert!(zh.contains("未配置"), "{zh}");
|
||||
assert!(!zh.contains("未支持"), "{zh}");
|
||||
assert!(!zh.contains("未实现"), "{zh}");
|
||||
// 硬约束:词条里只许出现 `<id>` 占位,不出现真 id(repo 任何文件都不许带)。
|
||||
assert!(en.contains("<id>") && zh.contains("<id>"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! 加载器文案:品牌名不翻译(Fabric / Forge / Quilt 一律原样),只有「原版」是一个词。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! loader_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 加载器(品牌名不翻译:Fabric / Forge / Quilt 一律原样,只有「原版」是词)
|
||||
LoaderVanilla => ("Vanilla", "原版"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use loader_entries;
|
||||
@@ -0,0 +1,66 @@
|
||||
//! 日志页文案(M95)。
|
||||
//!
|
||||
//! 日志页的**界面文案**(标题 / 按钮 / 分组名 / 空态)。诊断结论的正文不在这里:
|
||||
//! 那是规则表 `rules.d/*.yaml` 的输出(数据),见 `pages/logs/diagnostic.rs` 的模块文档。
|
||||
//!
|
||||
//! `LogsStatus` 是**模板**:中英差别落在分隔符上(中文用全角竖线——`·` 是外来符号,
|
||||
//! 中文正文里偏挤;这也让「中文目录必须与英文不同」这条守卫成立,不是为过测试硬造差异)。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! logs_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// --- logs page (M95) ---
|
||||
LogsSubtitleIdle => ("Nothing is running right now", "当前没有运行中的实例"),
|
||||
LogsStatus => ("{instance} · {when} · {outcome}", "{instance}|{when}|{outcome}"),
|
||||
LogsOpenFolder => ("Open folder", "打开目录"),
|
||||
LogsClear => ("Clear", "清空"),
|
||||
LogsOutputTitle => ("OUTPUT", "输出"),
|
||||
LogsDiagnosisTitle => ("DIAGNOSIS", "诊断"),
|
||||
LogsConfidenceHigh => ("HIGH CONFIDENCE", "高置信度"),
|
||||
LogsConfidenceMedium => ("MEDIUM CONFIDENCE", "中置信度"),
|
||||
LogsConfidenceLow => ("LOW CONFIDENCE", "低置信度"),
|
||||
LogsSubtitleLive => ("Running now — logs stream as they arrive", "正在运行 — 日志实时滚动"),
|
||||
LogsConfidence => ("Confidence {percent}%", "置信度 {percent}%"),
|
||||
LogsSuggestedFix => ("Suggested fix", "建议动作"),
|
||||
LogsSummaryLabel => ("One-line summary", "一行摘要"),
|
||||
LogsCopySummary => ("Copy summary", "复制摘要"),
|
||||
LogsEmptyTitle => ("No output yet", "还没有输出"),
|
||||
LogsEmptyBody => (
|
||||
"Logs stream here while the launcher or the game is running. Pick an instance and start it to see the first lines.",
|
||||
"启动器或游戏运行时,日志会实时出现在这里。选择一个实例并启动,就能看到第一批日志。"
|
||||
),
|
||||
LogsEmptyAction => ("Choose an instance", "选择实例"),
|
||||
// --- 启动失败弹窗(M187)---
|
||||
CrashDialogTitleFailed => ("Launch failed", "启动失败"),
|
||||
CrashDialogTitleCrashed => ("The game crashed", "游戏崩溃"),
|
||||
CrashDialogExitCode => ("Exit code {code}", "退出码 {code}"),
|
||||
CrashDialogStartedThenDied => ("The game process started, then exited abnormally.", "游戏进程起来了,随后异常退出。"),
|
||||
CrashDialogSilentFailure => ("The game exited with code 0 but never really started.", "游戏以退出码 0 结束,但并没有真正起来。"),
|
||||
CrashDialogStageReason => ("It failed before the game could start: {stage}", "游戏还没起来就失败了:{stage}"),
|
||||
CrashDialogCrashReportFound => ("The game wrote a crash report — the details are below.", "游戏写出了崩溃报告,详见下方说明。"),
|
||||
CrashDialogNoGameLog => ("No crash report this time: the game stopped before it could write one. The last lines the launcher captured are below.", "这次没有生成崩溃报告:游戏还没来得及写就停了。下面是启动器捕获的最后若干行。"),
|
||||
CrashDialogNoLogAtAll => ("No crash report and no captured output. Most likely: missing Java, not enough memory, wrong launch arguments, or missing libraries.", "既没有崩溃报告,也没有捕获到输出。最可能的原因:缺少 Java、内存不足、启动参数错误、或库文件缺失。"),
|
||||
CrashDialogShowDetail => ("Show crash details", "展开崩溃说明"),
|
||||
CrashDialogHideDetail => ("Hide crash details", "收起崩溃说明"),
|
||||
CrashDialogLauncherLogs => ("Launcher logs", "启动器日志"),
|
||||
CrashDialogSessionLog => ("This session", "本次会话"),
|
||||
CrashDialogGameLog => ("Game latest.log", "游戏 latest.log"),
|
||||
CrashDialogCrashReports => ("Crash reports", "崩溃报告目录"),
|
||||
CrashDialogCapturedTail => ("CAPTURED OUTPUT (last {count} lines)", "捕获输出(最后 {count} 行)"),
|
||||
CrashDialogExport => ("Export report bundle", "导出诊断包"),
|
||||
CrashDialogOpenFolder => ("Open log folder", "打开日志文件夹"),
|
||||
CrashDialogCopyPaths => ("Copy file paths", "复制文件路径"),
|
||||
CrashDialogClose => ("Close", "关闭"),
|
||||
CrashDialogCopied => ("File paths copied to the clipboard", "文件路径已复制到剪贴板"),
|
||||
CrashDialogExportDone => ("Exported {bytes} bytes to {path}", "已导出 {bytes} 字节到 {path}"),
|
||||
CrashDialogExportFailed => ("Export failed: {error}", "导出失败:{error}"),
|
||||
CrashDialogExportTruncated => ("{count} file(s) were too large and are truncated (head + tail kept).", "有 {count} 个文件太大,已截断(保留头 + 尾)。"),
|
||||
CrashDialogOpenFailed => ("Could not open the folder: {error}", "打不开文件夹:{error}"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use logs_entries;
|
||||
@@ -0,0 +1,42 @@
|
||||
//! 导航文案:四个一级页。AI 独立页没有导航高亮项(`Route::Agent.nav()` 是 `None`),所以没有 key。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节:本宏把条目**追加**到 `$acc` 之后,
|
||||
//! 再把「还没装配的域」原样交给 `$next`。条目顺序 = `Key` 的声明顺序(金表 `catalog::golden` 守门)。
|
||||
//!
|
||||
//! **显示名与标识符不同名**(M108):`NavDownload` 这条说的是「版本管理」,而它指向的仍是
|
||||
//! `Route::Download` / `NavSection::Download`。用户令(2026-11-04)只改**显示层**:全仓重命名
|
||||
//! 标识符会与在途的多条线全面撞车,收益只在观感,所以标识符改名后置。
|
||||
//!
|
||||
//! ## 为什么「版本管理」有两条 key(M108)
|
||||
//!
|
||||
//! D33 定死了导航两态:**88 收起轨 ⇄ 240 展开抽屉**。88px 里塞得下中文「版本管理」(4 字 ≈48px),
|
||||
//! 但塞不下英文 `Version management`(18 字符 ≈101px > 88)——那会变成截断或两行,两个都是视觉缺陷
|
||||
//! (两行还要把项高从 52 抬到 68,等于为一个词改全局导航节奏)。
|
||||
//!
|
||||
//! 所以按**容器宽度**分两条:
|
||||
//!
|
||||
//! | key | 用在哪 | en | zh-CN |
|
||||
//! |---|---|---|---|
|
||||
//! | [`NavDownload`] | 88 收起轨(`shell/nav.rs` / `pages/nav.rs`) | `Versions` | 版本管理 |
|
||||
//! | [`NavDownloadExpanded`] | 240 展开抽屉(D33 的展开态) | `Version management` | 版本管理 |
|
||||
//!
|
||||
//! 中文两条**故意同值**:48px 放得下就没有理由为「对称」砍短(那是视觉缺陷,不是风格)。
|
||||
//! 英文短名选 `Versions` 而不是 `Versions list` / `Downloads`:单一词最省宽度,且不与「下载」
|
||||
//! (真在传文件的动作、设置页的下载分组)混淆。切换点只有 [`crate::catalog`] 的调用方一处,
|
||||
//! 见 `pages/nav.rs` 的 `label_key`。
|
||||
|
||||
macro_rules! nav_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 导航轨(`pages::Route` 的四项)
|
||||
NavInstances => ("Instances", "实例"),
|
||||
NavDownload => ("Versions", "版本管理"),
|
||||
NavDownloadExpanded => ("Version management", "版本管理"),
|
||||
NavLogs => ("Logs", "日志"),
|
||||
NavSettings => ("Settings", "设置"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use nav_entries;
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 设置页「Appearance」分组**强调色**一族的文案(M250 ACCENTWIRE)。
|
||||
//!
|
||||
//! ## 为什么单开一个域文件,而不是写进 `settings/appearance.rs`
|
||||
//!
|
||||
//! `settings_appearance_entries` 在装配链**中间**。往那个域后面追加,会把其后所有 key 在
|
||||
//! `Key::ALL` 里整体平移 ⇒ 金表(逐 index 逐字节)整片红。本域挂在**链尾**
|
||||
//! (`font_install_entries` 之后),于是既有顺序一个不动,金表只需在 `tail2::ROWS` 末尾追加
|
||||
//! (M104 / M106 / M107 / M109 / M114 / M159 / M168 都是这条做法)。
|
||||
//!
|
||||
//! key 前缀仍叫 `SettingsAppearanceAccent*`:`catalog/settings/appearance.rs` 的译文守卫按
|
||||
//! **名字前缀**筛 key,改名会让那两条守卫漏掉这一族。
|
||||
//!
|
||||
//! ## 三档失败原因为什么分开写
|
||||
//!
|
||||
//! `#12`(位数不足)、`#006A5C00`(位数过多)、`zzzzzz`(非十六进制)是三种不同的手误,
|
||||
//! 而「格式不对」对用户没有行动性。分开写之后错行能直接说「现在 2 位」「有非十六进制字符 z」。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! settings_accent_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 强调色(M250:设置页 Appearance 组的第一个控件)
|
||||
SettingsAppearanceAccent => ("Accent colour", "强调色"),
|
||||
SettingsAppearanceAccentPresets => ("Presets", "预设"),
|
||||
SettingsAppearanceAccentFactory => ("Factory", "出厂"),
|
||||
SettingsAppearanceAccentHint => (
|
||||
"Empty = the factory accent from the brand board. A custom accent replaces primary / secondary / tertiary only; neutral surfaces and error colours do not follow it.",
|
||||
"空 = 出厂品牌板的强调色。自定义强调色只替换 primary / secondary / tertiary 三族;中性表面与错误色不随强调色变。"
|
||||
),
|
||||
SettingsAppearanceAccentShort => (
|
||||
"Accent needs 6 hex digits (RRGGBB); got {digits}.",
|
||||
"强调色要 6 位十六进制(RRGGBB),现在 {digits} 位。"
|
||||
),
|
||||
SettingsAppearanceAccentLong => (
|
||||
"Accent has {digits} digits; a colour takes exactly 6.",
|
||||
"强调色有 {digits} 位,一个颜色只要 6 位。"
|
||||
),
|
||||
SettingsAppearanceAccentDigit => (
|
||||
"Accent contains a character that is not a hex digit: {ch}",
|
||||
"强调色里有非十六进制字符:{ch}"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use settings_accent_entries;
|
||||
@@ -0,0 +1,96 @@
|
||||
//! 设置页「助手(AI)」分组文案(M174):状态矩阵四档 + 五个输入项 + 测试连接六档。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在
|
||||
//! `settings_download_entries` 之后、`core_text_entries` 之前(设置页五域的位置),
|
||||
//! 所以金表那几行也要插在 `SettingsAppearanceCornerSuspended` 之后——**顺序即 `Key::ALL`**。
|
||||
//!
|
||||
//! 三条口径:
|
||||
//!
|
||||
//! 1. **失败四种各写各的句子**(鉴权 / 模型不存在 / 不可达 / 超时):合成一句「测试失败」等于
|
||||
//! 让用户自己猜是 key 错了还是地址错了;
|
||||
//! 2. 密钥的**值**永远不进文案:只有 `{chars}`(已存字符数)与「未配置」两种说法;
|
||||
//! 3. 技术名词不翻:`base_url` / `OpenAI` / `Anthropic` / `Ollama` 原样。
|
||||
|
||||
macro_rules! settings_ai_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 左列分组名(与 NavSettings 同源:左列是导航,标题在正文里)
|
||||
SettingsGroupAssistant => ("Assistant", "助手"),
|
||||
// ── 状态矩阵(未配置 / 已配置 / 已通过 / 测试失败)
|
||||
SettingsAiStatusLabel => ("Status: {status}", "状态:{status}"),
|
||||
SettingsAiStatusUnconfigured => ("not configured", "未配置"),
|
||||
SettingsAiStatusConfigured => ("configured", "已配置"),
|
||||
SettingsAiStatusVerified => ("verified", "连接已通过"),
|
||||
SettingsAiStatusTestFailed => ("test failed", "测试失败"),
|
||||
// ── 开关
|
||||
SettingsAiEnable => ("Enable the assistant", "启用助手"),
|
||||
SettingsAiEnableHint => (
|
||||
"With this off, the assistant stays quiet; crash diagnosis from the rule engine keeps working.",
|
||||
"关掉之后助手不再工作;规则引擎的崩溃诊断照常可用。"
|
||||
),
|
||||
// ── 提供商
|
||||
SettingsAiProviderLabel => ("Provider", "提供商"),
|
||||
SettingsAiProviderOpenAi => ("OpenAI compatible", "OpenAI 兼容"),
|
||||
SettingsAiProviderAnthropic => ("Anthropic", "Anthropic 原生"),
|
||||
SettingsAiProviderOllama => ("Ollama", "Ollama 原生"),
|
||||
SettingsAiProviderDisabled => ("Disabled", "已禁用"),
|
||||
// ── 端点 / 模型
|
||||
SettingsAiBaseUrl => ("Endpoint (base_url)", "端点(base_url)"),
|
||||
SettingsAiBaseUrlPlaceholder => ("http://127.0.0.1:8080/v1", "例:http://127.0.0.1:8080/v1"),
|
||||
SettingsAiBaseUrlHint => (
|
||||
"The /v1 root of an OpenAI-compatible service. Leave empty to use the provider default.",
|
||||
"OpenAI 兼容服务的 /v1 根地址;留空则用该提供商的默认端点。"
|
||||
),
|
||||
SettingsAiEffective => ("Effective: {value}", "生效:{value}"),
|
||||
SettingsAiModel => ("Model name", "模型名称"),
|
||||
SettingsAiModelPlaceholder => ("deepseek-chat", "例:deepseek-chat"),
|
||||
// ── 密钥(只有字符数与掩码,没有值)
|
||||
SettingsAiApiKey => ("API key", "API 密钥"),
|
||||
SettingsAiApiKeyPlaceholder => ("Paste the key", "粘贴密钥"),
|
||||
SettingsAiApiKeyConfigured => (
|
||||
"A key is stored ({chars} characters). Leave this empty to keep it.",
|
||||
"已保存密钥({chars} 个字符)。留空则保持不变。"
|
||||
),
|
||||
SettingsAiApiKeyMissing => ("No key stored yet", "还没有保存密钥"),
|
||||
SettingsAiShow => ("Show", "显示"),
|
||||
SettingsAiHide => ("Hide", "隐藏"),
|
||||
// ── 动作与回执
|
||||
SettingsAiSave => ("Save", "保存"),
|
||||
SettingsAiTest => ("Test connection", "测试连接"),
|
||||
SettingsAiTesting => ("Testing…", "正在测试…"),
|
||||
SettingsAiSaved => ("Saved. It takes effect immediately, no restart needed.", "已保存,立刻生效,不用重启。"),
|
||||
SettingsAiRefused => ("Not saved: {reason}", "未保存:{reason}"),
|
||||
SettingsAiUnsaved => ("There are unsaved changes.", "有未保存的改动。"),
|
||||
SettingsAiNoStorage => (
|
||||
"No settings file is available in this run, so nothing can be saved or tested.",
|
||||
"这一次运行没有可用的设置文件,保存与测试都做不了。"
|
||||
),
|
||||
SettingsAiTestDisabled => (
|
||||
"Fill in the missing field before testing: {missing}.",
|
||||
"先补上这一项再测试:{missing}。"
|
||||
),
|
||||
// ── 缺项(四种各说各的)
|
||||
SettingsAiMissingDisabled => ("the assistant is disabled", "助手处于关闭状态"),
|
||||
SettingsAiMissingBaseUrl => ("the endpoint address", "端点地址"),
|
||||
SettingsAiMissingModel => ("the model name", "模型名称"),
|
||||
SettingsAiMissingApiKey => ("the API key", "API 密钥"),
|
||||
// ── 拒绝保存的原因(本地校验)
|
||||
SettingsAiReasonProvider => ("the provider value is not recognised", "提供商取值不认识"),
|
||||
SettingsAiReasonBaseUrl => ("the endpoint must start with http:// or https://", "端点必须以 http:// 或 https:// 开头"),
|
||||
SettingsAiReasonModel => ("the model name is required", "必须填模型名称"),
|
||||
// ── 测试结论(六档)
|
||||
SettingsAiTestPassed => ("Connected — the endpoint answered.", "连接通过 —— 端点有正常回应。"),
|
||||
SettingsAiTestAuth => ("Authentication failed: the endpoint rejected the key.", "鉴权失败:端点拒绝了这个密钥。"),
|
||||
SettingsAiTestModel => ("The endpoint answered, but this model does not exist.", "端点通了,但没有这个模型。"),
|
||||
SettingsAiTestUnreachable => ("The endpoint is unreachable.", "端点不可达。"),
|
||||
SettingsAiTestTimeout => ("Timed out while waiting for the endpoint.", "等端点回应超时了。"),
|
||||
SettingsAiTestOther => ("The request failed for another reason (see the detail below).", "请求因为别的原因失败了(详情见下)。"),
|
||||
SettingsAiTestNotConfigured => ("Nothing was sent: the configuration is incomplete.", "什么都没有发出去:配置还不完整。"),
|
||||
SettingsAiTestTarget => ("Target: {base_url} · model {model} · {ms} ms", "目标:{base_url} · 模型 {model} · {ms} 毫秒"),
|
||||
SettingsAiTestDetail => ("Detail: {detail}", "详情:{detail}"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use settings_ai_entries;
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 设置页「Appearance」分组文案(M116 落了圆角一族;M180 `I18NAPPEAR` 补齐全组)。
|
||||
//!
|
||||
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
|
||||
//!
|
||||
//! ## 两批 key:圆角(M116)在前,其余(M180)在后
|
||||
//!
|
||||
//! M116 只落了圆角那 5 条;本分组其余控件(整窗/面板透明度、背景图、填充、暗化、配色来源)
|
||||
//! 是 M93 落的,文案一直是英文基线字面量。M172 审计量出这一页 27 处文案里 22 处硬编码、中文界面
|
||||
//! 本地化 0/27。M180 把**控件文案 + 运行时诊断 + 配色回落提示**全部搬进本文件:
|
||||
//!
|
||||
//! | 批次 | 条目 | 来源 |
|
||||
//! |---|---|---|
|
||||
//! | M116 | `Corner` … `CornerSuspended` | `pages/appearance/section/window.rs` 的圆角一段 |
|
||||
//! | M180 | `WindowOpacity` … `PaletteAdjusted` | 卡片各段标签/说明 + `state.rs` / `background.rs` / `palette.rs` 的诊断 prose |
|
||||
//!
|
||||
//! ## 为什么诊断也在这里(而不是留在 `state.rs`)
|
||||
//!
|
||||
//! `pages/appearance/state.rs` 已经 390 行、贴着 400 行硬线,放不下任何一张文案表。所以那一层只
|
||||
//! 给 **key + 已格式化好的参数**(`background::Note`),句子一律在本文件里。
|
||||
//!
|
||||
//! ## 数值参数:模板里只有 `{name}`,格式在 Rust 侧做
|
||||
//!
|
||||
//! [`crate::catalog::Catalog::render`] 只做 `{name}` → 值的字面替换,**不认** `{:.2}` 这类格式说明。
|
||||
//! 所以「位置/尺寸/上限」这些数字由调用方先 `format!("{:.2}")` 好再当参数传进来:
|
||||
//!
|
||||
//! | key | 原字面量 | 现在 |
|
||||
//! |---|---|---|
|
||||
//! | `SettingsAppearancePanelOpacityForcedValue` | `format!("100% (forced from {:.2})")` | `{requested}` = `format!("{:.2}")` |
|
||||
//! | `SettingsAppearanceWindowOpacityHint` | `Floor {:.2}` = `WINDOW_ALPHA_FLOOR` | `{floor}` |
|
||||
//! | `SettingsAppearancePanelOpacityHint` | `Floor 0.40` = `PANEL_ALPHA_FLOOR` | `{floor}` |
|
||||
//! | `SettingsAppearanceBgDimHint` | `Max 0.95` = `BG_DIM_MAX` | `{max}` |
|
||||
//! | `SettingsAppearanceBgTooLarge` | `(limit 8192 px per edge)` = `MAX_SOURCE_EDGE` | `{limit}` |
|
||||
//! | `SettingsAppearanceBgSmallTile` | `(max 64 tiles …)` = `MAX_TILES` | `{max}` |
|
||||
//!
|
||||
//! 常量走占位符而不是写进译文,是为了「改常量不用改两套译文」(M180 任务口径)。
|
||||
//!
|
||||
//! ## 文案口径(不许暗示所有平台一致)
|
||||
//!
|
||||
//! 用户定案:Win11 / Server 2025 开系统圆角,Win10 可以不开,其他系统按窗口合成器的默认规则。
|
||||
//! 所以说明句必须写清「谁负责画圆」而不是「我们支持圆角」——后半句在 Win10 上是假的。
|
||||
|
||||
macro_rules! settings_appearance_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
SettingsAppearanceCorner => ("Window corners", "窗口圆角"),
|
||||
SettingsAppearanceCornerSystem => ("Follow system", "跟随系统"),
|
||||
SettingsAppearanceCornerSquare => ("Square", "直角"),
|
||||
SettingsAppearanceCornerHint => (
|
||||
"On Windows 11 and Server 2025 we ask the system for rounded corners; Windows 10 keeps square corners. On macOS and Linux the desktop's own rules apply. We never draw corners ourselves.",
|
||||
"Windows 11 与 Windows Server 2025 上我们请求系统处理外框圆角;Windows 10 保持直角;macOS 与 Linux 交给桌面合成器自己的规则;我们不自己画圆角。"
|
||||
),
|
||||
SettingsAppearanceCornerSuspended => (
|
||||
"Not in effect right now: window transparency is below 100%. Setting it back to 100% restores the rounded corners.",
|
||||
"当前因整窗透明度不生效:把整窗透明度调回 100% 圆角即恢复。"
|
||||
),
|
||||
// ---- M180 `I18NAPPEAR`:卡片内文案(① 整窗 / ③ 面板 / ④~⑧ 背景图 / ⑨ 配色来源)
|
||||
SettingsAppearanceWindowOpacity => ("Window transparency", "整窗透明度"),
|
||||
SettingsAppearanceWindowOpacityHint => (
|
||||
"Applies to the whole window. Floor {floor}; 1.00 = fully opaque.",
|
||||
"作用于整个窗口。下限 {floor};1.00 = 完全不透明。"
|
||||
),
|
||||
SettingsAppearancePanelOpacity => ("Panel transparency", "面板透明度"),
|
||||
SettingsAppearancePanelOpacityHint => (
|
||||
"Only the panel/card layer. Floor {floor}; independent of window transparency.",
|
||||
"只作用于面板 / 卡片这一层。下限 {floor};与整窗透明度互不影响。"
|
||||
),
|
||||
SettingsAppearancePanelOpacityForcedValue => (
|
||||
"100% (forced from {requested})",
|
||||
"100%(强制,原请求值 {requested})"
|
||||
),
|
||||
SettingsAppearancePanelOpacityForcedHint => (
|
||||
"Software renderer: panels and glass stay fully opaque (window transparency still works).",
|
||||
"软件渲染:面板与玻璃效果恒为不透明(整窗透明度仍然有效)。"
|
||||
),
|
||||
SettingsAppearanceBackgroundImage => ("Background image", "背景图"),
|
||||
SettingsAppearanceOff => ("Off", "关"),
|
||||
SettingsAppearanceOn => ("On", "开"),
|
||||
SettingsAppearanceImagePath => ("Image path", "图片路径"),
|
||||
SettingsAppearancePaletteLevel => ("Colours: {level}", "配色:{level}"),
|
||||
SettingsAppearanceBgFill => ("Fill", "填充方式"),
|
||||
SettingsAppearanceBgFillCover => ("Cover", "铺满"),
|
||||
SettingsAppearanceBgFillContain => ("Contain", "完整显示"),
|
||||
SettingsAppearanceBgFillTile => ("Tile", "平铺"),
|
||||
SettingsAppearanceBgDim => ("Dim", "暗化"),
|
||||
SettingsAppearanceBgDimHint => (
|
||||
"Blends the image toward the panel colour so text stays readable. Max {max}.",
|
||||
"把背景图往面板底色上退,让文字读得清。上限 {max}。"
|
||||
),
|
||||
SettingsAppearancePaletteSource => ("Colour source", "配色来源"),
|
||||
SettingsAppearancePaletteBackground => ("Background", "取自背景图"),
|
||||
SettingsAppearancePaletteWallpaper => ("Wallpaper", "取自壁纸"),
|
||||
SettingsAppearancePalettePreset => ("Preset", "内置色卡"),
|
||||
// ---- M180:运行时诊断(`background.rs` 的三种读盘失败 + 小图平铺)。
|
||||
//
|
||||
// M180 r1 复审 P2-1:软件渲染那句**只有一条**,就挂在面板那一行(`PanelOpacityForcedHint`)
|
||||
// 上——它挨着被禁用的滑块,正好回答「为什么拖不动」;这里不再有第二句。
|
||||
SettingsAppearanceBgReadFailed => (
|
||||
"Background image could not be read: {reason}",
|
||||
"背景图读不出来:{reason}"
|
||||
),
|
||||
SettingsAppearanceBgDecodeFailed => (
|
||||
"Background image could not be decoded ({reason}; this build decodes PNG, JPEG and BMP)",
|
||||
"背景图解不开({reason};这个构建只支持 PNG、JPEG 与 BMP)"
|
||||
),
|
||||
SettingsAppearanceBgTooLarge => (
|
||||
"Background image is too large to decode: {width}×{height} (limit {limit} px per edge)",
|
||||
"背景图太大,解不开:{width}×{height}(每边上限 {limit} 像素)"
|
||||
),
|
||||
SettingsAppearanceBgSmallTile => (
|
||||
"Small image ({width}×{height}): tiles are scaled up so the window stays covered (max {max} tiles, no gaps).",
|
||||
"图片很小({width}×{height}):图块会被放大,保证窗口铺满(最多 {max} 块,不留缝)。"
|
||||
),
|
||||
// ---- M180:配色回落提示(`palette.rs` 把 design 的 `hint_key()` 翻成下面这些)
|
||||
SettingsAppearancePaletteBgUndecodable => (
|
||||
"Background image could not be read; colours fall back.",
|
||||
"背景图读不出来,配色回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePaletteBgTooSmall => (
|
||||
"Background image is too small to sample; colours fall back.",
|
||||
"背景图太小,取不了色,配色回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePaletteBgBadDimensions => (
|
||||
"Background image data looks inconsistent; colours fall back.",
|
||||
"背景图数据前后不一致,配色回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePaletteBgNoOpaquePixels => (
|
||||
"Background image is almost fully transparent; colours fall back.",
|
||||
"背景图几乎全透明,配色回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePaletteBgLowChroma => (
|
||||
"Background image is too monochrome to tint from; colours fall back.",
|
||||
"背景图太单调(彩度不足),取不出配色,回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePaletteBgContrast => (
|
||||
"Background image colours would be unreadable; colours fall back.",
|
||||
"按背景图取的颜色读不清,配色回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePaletteWallpaperUndecodable => (
|
||||
"Desktop wallpaper could not be read.",
|
||||
"桌面壁纸读不出来。"
|
||||
),
|
||||
SettingsAppearancePaletteWallpaperTooSmall => (
|
||||
"Desktop wallpaper is too small to sample.",
|
||||
"桌面壁纸太小,取不了色。"
|
||||
),
|
||||
SettingsAppearancePaletteWallpaperBadDimensions => (
|
||||
"Desktop wallpaper data looks inconsistent.",
|
||||
"桌面壁纸数据前后不一致。"
|
||||
),
|
||||
SettingsAppearancePaletteWallpaperNoOpaquePixels => (
|
||||
"Desktop wallpaper is almost fully transparent.",
|
||||
"桌面壁纸几乎全透明。"
|
||||
),
|
||||
SettingsAppearancePaletteWallpaperLowChroma => (
|
||||
"Desktop wallpaper is too monochrome to tint from.",
|
||||
"桌面壁纸太单调(彩度不足),取不出配色。"
|
||||
),
|
||||
SettingsAppearancePaletteWallpaperContrast => (
|
||||
"Desktop wallpaper colours would be unreadable; colours fall back.",
|
||||
"按桌面壁纸取的颜色读不清,配色回落到下一级。"
|
||||
),
|
||||
SettingsAppearancePalettePresetContrast => (
|
||||
"The built-in card needed a small contrast correction.",
|
||||
"内置色卡做了一点对比度修正。"
|
||||
),
|
||||
SettingsAppearancePaletteAdjusted => (
|
||||
"A few colours were nudged for readability.",
|
||||
"有几个颜色为了可读性做了微调。"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use settings_appearance_entries;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::catalog::{placeholders, Catalog, Key};
|
||||
use crate::Lang;
|
||||
|
||||
/// 本域的 key(`Key::ALL` 里按名字前缀筛;不手工列表,漏一条就少一条守卫)。
|
||||
fn appearance_keys() -> Vec<Key> {
|
||||
Key::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|key| key.name().starts_with("SettingsAppearance"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 有没有汉字(与 `pages/settings/tests/appearance_section.rs` 的 `is_han` 同一段码位)。
|
||||
fn is_han(ch: char) -> bool {
|
||||
('\u{4e00}'..='\u{9fff}').contains(&ch)
|
||||
}
|
||||
|
||||
/// **M180 守门 ②**:外观分组的每条 key 都必须真翻过——英文照抄进中文表就地判红。
|
||||
///
|
||||
/// 抓得住:① 新加 key 时中文栏直接抄英文(`chinese_catalog_is_actually_chinese` 也抓这条,
|
||||
/// 但那条在 `catalog/tests.rs`、不属于本 mission 的 scope,而且不查「中文字里有没有汉字」);
|
||||
/// ② 中文译文变成纯 ASCII(比如只写了 `PNG` / `Preset` 这种专名);③ 占位符集合两边不一致
|
||||
/// (`{floor}` 只在一边有 ⇒ `render()` 吐出半截句子)。
|
||||
/// 抓不住:译文翻得对不对(语义只能人审)。
|
||||
///
|
||||
/// 变异证明:把任一条的第二个字符串换成第一个(`("Cover", "Cover")`)⇒ 本条立刻红(`assert_ne!`
|
||||
/// 与 `is_han` 命中同一行)。
|
||||
#[test]
|
||||
fn every_appearance_key_is_translated_into_chinese() {
|
||||
let keys = appearance_keys();
|
||||
assert!(
|
||||
keys.len() >= 40,
|
||||
"外观分组至少 40 条 key(M180 落地时是 45 条),实际 {}:\
|
||||
key 被搬走或前缀改名了?",
|
||||
keys.len()
|
||||
);
|
||||
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in keys {
|
||||
let (english, chinese) = (en.text(key), zh.text(key));
|
||||
assert_ne!(english, chinese, "{} 的中文是英文照抄", key.name());
|
||||
assert!(
|
||||
chinese.chars().any(is_han),
|
||||
"{} 的中文里一个汉字都没有:{chinese}",
|
||||
key.name()
|
||||
);
|
||||
let mut left = placeholders(english);
|
||||
let mut right = placeholders(chinese);
|
||||
left.sort_unstable();
|
||||
right.sort_unstable();
|
||||
assert_eq!(left, right, "{} 的占位符两套译文不一致", key.name());
|
||||
}
|
||||
}
|
||||
|
||||
/// 数值参数必须走占位符,不许把常量写进译文(改常量时不该还要改两套译文)。
|
||||
#[test]
|
||||
fn numeric_limits_are_placeholders_not_text() {
|
||||
let en = Catalog::of(Lang::En);
|
||||
for (key, needle, placeholder) in [
|
||||
(Key::SettingsAppearanceWindowOpacityHint, "0.08", "floor"),
|
||||
(Key::SettingsAppearancePanelOpacityHint, "0.40", "floor"),
|
||||
(Key::SettingsAppearanceBgDimHint, "0.95", "max"),
|
||||
(Key::SettingsAppearanceBgTooLarge, "8192", "limit"),
|
||||
(Key::SettingsAppearanceBgSmallTile, "64", "max"),
|
||||
] {
|
||||
let text = en.text(key);
|
||||
assert!(
|
||||
!text.contains(needle),
|
||||
"{} 把常量 {needle} 写死在译文里了(应当走 {{{placeholder}}})",
|
||||
key.name()
|
||||
);
|
||||
assert!(
|
||||
placeholders(text).contains(&placeholder),
|
||||
"{} 缺 {{{placeholder}}} 占位符:{text}",
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! 设置页「Downloads」分组文案(M201 起有内容:这一组接了真控件)。
|
||||
//!
|
||||
//! 加 key 时把条目写进 `$($acc)*` 后面即可(形状见 [`crate::catalog`] 的「域装配」一节)。
|
||||
//! 本域在装配链里排在 `settings_java_entries` 之后、`settings_ai_entries` 之前,所以金表那几行
|
||||
//! 也落在 `SettingsJavaReasonInstallRootTooLong` 与 `SettingsGroupAssistant` 之间。
|
||||
//!
|
||||
//! ## 只有一条 key 的理由
|
||||
//!
|
||||
//! 下载组的**行文案**(`并发数` / `单源尝试次数` / 「原值 9999 已按出厂 8 生效」这些)不在这里:
|
||||
//! 它们由 presenter 按 schema 键现算(`dto::SettingRowView::label` / `description`),因为那些句子
|
||||
//! 里带着**生效值**——把数字搬进静态目录只会再长出一份会说谎的文案。这一域只放「页面自己的
|
||||
//! 按钮词」:提交文本框那一下的「应用」。
|
||||
|
||||
macro_rules! settings_download_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 文本行的提交按钮(`pages/settings/download.rs` 的 text 行)
|
||||
SettingsDownloadsApply => ("Apply", "应用"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use settings_download_entries;
|
||||
@@ -0,0 +1,30 @@
|
||||
//! 设置页「General」分组文案:语言(Theme / Startup page / Close button 的 key 还没建)。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。
|
||||
|
||||
macro_rules! settings_general_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 设置
|
||||
SettingsLanguage => ("Language", "界面语言"),
|
||||
SettingsLanguageHint => (
|
||||
"Switches the interface language. Chinese text needs a Chinese font.",
|
||||
"切换界面语言。显示中文需要中文字体。"
|
||||
),
|
||||
// ── 日志文件夹(M188:用户投诉「找不到崩溃日志」的直接落点)
|
||||
SettingsLogsFolder => ("Log folder", "日志文件夹"),
|
||||
SettingsLogsFolderHint => (
|
||||
"What explains a crash lives here: the launcher's own runtime log (launcher.log, rolled over at 1 MiB with one old copy kept) and one jsonl per game session under logs/game/. Sessions from older versions (data/agent/sessions) are still read, not moved.",
|
||||
"解释一次崩溃要看的东西都在这:启动器自己的运行日志(launcher.log,满 1 MiB 滚动一次、只留一份旧档)与每次游戏会话的 jsonl(logs/game/)。旧版本留下的会话(data/agent/sessions)仍然读得出来,不会被搬动。"
|
||||
),
|
||||
SettingsLogsCopy => ("Copy path", "复制路径"),
|
||||
SettingsLogsUnknown => (
|
||||
"Data directory not resolved — restart the launcher from its own folder",
|
||||
"数据根没解析出来——从启动器所在目录重新启动试试"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use settings_general_entries;
|
||||
@@ -0,0 +1,323 @@
|
||||
//! 设置页「Java」分组文案(M175):扫描 / 运行时列表 / 默认 JDK / 内存 / JVM 参数。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节。本域在装配链里排在
|
||||
//! `settings_appearance_entries` 之后、`settings_download_entries` 之前,所以金表那批行要插在
|
||||
//! `SettingsAppearanceCornerSuspended` 之后、`SettingsGroupAssistant`(M174 的助手域)之前
|
||||
//! ——**顺序即 `Key::ALL`**。
|
||||
//!
|
||||
//! 三条口径:
|
||||
//!
|
||||
//! 1. 技术名词不翻:`JDK` / `JVM` / `-version` / `bin/java` / `java.selected_path` 原样;
|
||||
//! 2. **不许出现写死的版本号或数字**:本组以前那三行(`Temurin 21.0.4` / `4 GiB`)正是被用户
|
||||
//! 当成假数据的那三条,现在所有数值都由页面从真读盘的结果里格式化进来;
|
||||
//! 3. 拒绝原因一句一条(空 / 不存在 / 不是 java / 跑不出 -version / 重复),合成一句
|
||||
//! 「路径无效」等于让用户自己猜。
|
||||
|
||||
macro_rules! settings_java_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 扫描(状态 / 进度 / 取消)
|
||||
SettingsJavaScan => ("Scan now", "立即扫描"),
|
||||
SettingsJavaScanningStart => ("Scanning…", "正在扫描…"),
|
||||
SettingsJavaScanning => ("Scanning… {done}/{total}", "正在扫描… {done}/{total}"),
|
||||
SettingsJavaCancelling => ("Cancelling…", "正在取消…"),
|
||||
SettingsJavaCancel => ("Cancel", "取消"),
|
||||
SettingsJavaCancelled => (
|
||||
"The scan was cancelled; the list below is from the previous run.",
|
||||
"扫描已取消;下面的列表还是上一次的结果。"
|
||||
),
|
||||
SettingsJavaScanDone => ("{count} JDK found", "找到 {count} 个 JDK"),
|
||||
SettingsJavaNeverScanned => ("Not scanned yet", "还没扫描过"),
|
||||
SettingsJavaNeverScannedBody => (
|
||||
"The list is still empty. The launcher scans once by itself when the settings page opens; press Scan now to look again right away.",
|
||||
"列表还是空的。打开设置页时启动器会自己扫一次;也可以点「立即扫描」立刻再扫一遍。"
|
||||
),
|
||||
SettingsJavaEmptyTitle => ("No Java runtime found", "未检测到 Java 运行时"),
|
||||
SettingsJavaEmptyBody => (
|
||||
"Nothing matched. Add the path of a JDK you already unpacked below, or install one with ull java install 21 on the command line.",
|
||||
"没找到任何 JDK。可以在下面手动加上你自己解压的那份,或者用命令行 ull java install 21 装一个。"
|
||||
),
|
||||
// ── 运行时列表
|
||||
SettingsJavaDefault => ("Default", "默认"),
|
||||
SettingsJavaSetDefault => ("Use as default", "设为默认"),
|
||||
SettingsJavaFits => ("Meets the current instance requirement", "满足当前实例的要求"),
|
||||
SettingsJavaTooOld => (
|
||||
"Below the requirement: this instance needs Java {minimum}+",
|
||||
"低于要求:当前实例需要 Java {minimum} 及以上"
|
||||
),
|
||||
SettingsJavaDefaultSet => ("Default JDK is now {path}", "默认 JDK 已设为 {path}"),
|
||||
SettingsJavaSourceManaged => ("installed by this launcher", "本启动器安装"),
|
||||
SettingsJavaSourceEnv => ("JAVA_HOME (environment variable)", "JAVA_HOME(环境变量)"),
|
||||
SettingsJavaSourcePath => ("on PATH", "PATH 上"),
|
||||
SettingsJavaSourceSystem => ("system install", "系统安装"),
|
||||
SettingsJavaSourceManual => ("added by you", "手动添加"),
|
||||
// ── 手动添加
|
||||
SettingsJavaAdd => ("Add a JDK path", "手动添加 JDK 路径"),
|
||||
SettingsJavaAddPlaceholder => (
|
||||
"C:\\Program Files\\Java\\jdk-21",
|
||||
"例:C:\\Program Files\\Java\\jdk-21"
|
||||
),
|
||||
SettingsJavaAddHint => (
|
||||
"A JDK folder or its java executable. We run java -version on it; anything that cannot answer is refused.",
|
||||
"JDK 目录或它的 java 可执行文件。会真跑一次 java -version;答不出话的一律拒绝。"
|
||||
),
|
||||
SettingsJavaAddAction => ("Add", "添加"),
|
||||
SettingsJavaAdded => ("Added {path}", "已添加 {path}"),
|
||||
// ── 内存
|
||||
SettingsJavaMemory => ("Default memory (MB)", "默认内存(MB)"),
|
||||
SettingsJavaMemoryHint => (
|
||||
"Whole megabytes, {min}–{max}. Instances without their own override follow this value.",
|
||||
"整数 MB,范围 {min}~{max}。没有实例级覆盖的实例都用它。"
|
||||
),
|
||||
SettingsJavaLayerGlobal => (
|
||||
"In effect: {mb} MB, from the global default.",
|
||||
"生效:{mb} MB,来自全局默认值。"
|
||||
),
|
||||
SettingsJavaLayerInstance => (
|
||||
"In effect: {mb} MB, set by instance {instance}. Changing the global value below will not affect that instance.",
|
||||
"生效:{mb} MB,由实例 {instance} 覆盖。改下面的全局值不会影响它。"
|
||||
),
|
||||
// ── JVM 参数
|
||||
SettingsJavaJvm => ("Extra JVM arguments", "额外 JVM 参数"),
|
||||
SettingsJavaJvmPlaceholder => ("-XX:+UseG1GC -Dfile.encoding=UTF-8", "例:-XX:+UseG1GC -Dfile.encoding=UTF-8"),
|
||||
SettingsJavaJvmHint => (
|
||||
"Separated by spaces, quotes allowed. Appended to the command line of every launch; an instance's own JVM arguments come after it.",
|
||||
"按空白分隔,支持引号。会拼到每次启动的命令行上;实例自己的 JVM 参数排在它后面。"
|
||||
),
|
||||
SettingsJavaJvmSaved => ("Saved value: {value}", "已保存的值:{value}"),
|
||||
SettingsJavaJvmSavedNone => (
|
||||
"No extra JVM arguments are saved yet.",
|
||||
"还没有保存任何额外参数。"
|
||||
),
|
||||
// ── 保存与回执
|
||||
SettingsJavaSave => ("Save", "保存"),
|
||||
SettingsJavaSaved => (
|
||||
"Saved and read back from disk. The next launch uses the new value.",
|
||||
"已保存,并从磁盘读回确认。下次启动就用新值。"
|
||||
),
|
||||
SettingsJavaUnsaved => ("There are unsaved changes.", "有未保存的改动。"),
|
||||
SettingsJavaRefused => ("Not saved: {reason}", "未保存:{reason}"),
|
||||
SettingsJavaSkipNotDirty => (
|
||||
"there was nothing to change, so no file was written",
|
||||
"没有任何改动,因此没有写文件"
|
||||
),
|
||||
SettingsJavaSkipReadOnly => (
|
||||
"the settings file is read-only: a newer launcher wrote it, or it is marked untrusted",
|
||||
"配置文件只读:它被更新版本的启动器写过,或被标记为不可信"
|
||||
),
|
||||
SettingsJavaSkipUnreadable => (
|
||||
"the settings file is untrusted, so it is treated as read-only",
|
||||
"配置文件不可信,因此按只读处理"
|
||||
),
|
||||
SettingsJavaNoStorage => (
|
||||
"No settings file is available in this run, so nothing can be scanned or saved.",
|
||||
"这一次运行没有可用的设置文件,扫描与保存都做不了。"
|
||||
),
|
||||
// ── 拒绝原因(一条一句)
|
||||
SettingsJavaReasonMemoryEmpty => ("the memory field is empty", "内存没填"),
|
||||
SettingsJavaReasonMemoryNotNumber => ("the memory must be a whole number of MB", "内存必须是整数 MB"),
|
||||
SettingsJavaReasonMemoryRange => (
|
||||
"the memory is outside the allowed range",
|
||||
"内存超出允许范围"
|
||||
),
|
||||
SettingsJavaReasonJvmQuotes => (
|
||||
"the JVM arguments have an unclosed quote",
|
||||
"JVM 参数里有没配对的引号"
|
||||
),
|
||||
SettingsJavaReasonJvmTooLong => (
|
||||
"the JVM arguments are longer than the settings file allows (1024 characters)",
|
||||
"JVM 参数超过配置文件允许的长度(1024 字符)"
|
||||
),
|
||||
SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"),
|
||||
SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"),
|
||||
SettingsJavaReasonAddNotJava => (
|
||||
"there is no java executable at that path",
|
||||
"那个路径里没有 java 可执行文件"
|
||||
),
|
||||
SettingsJavaReasonAddNoVersion => (
|
||||
"java -version did not answer there, so it is not a usable JDK",
|
||||
"在那里跑不出 java -version,不是一份能用的 JDK"
|
||||
),
|
||||
SettingsJavaReasonAddTooLong => (
|
||||
"the path is too long to be a JDK location",
|
||||
"路径太长,不像是一个 JDK 位置"
|
||||
),
|
||||
SettingsJavaReasonAddWriteFailed => (
|
||||
"the JDK is fine, but the list file could not be written (disk or permission)",
|
||||
"这份 JDK 没问题,但名单文件写不进去(磁盘或权限)"
|
||||
),
|
||||
SettingsJavaReasonAddDuplicate => ("that JDK is already in the list", "这份 JDK 已经在列表里了"),
|
||||
SettingsJavaReasonWriteFailed => (
|
||||
"the settings file could not be written (disk full, no permission, or the value was rejected)",
|
||||
"设置写不进去(磁盘满、没权限,或这个值被拒绝)"
|
||||
),
|
||||
// ── M197:版本与实例要求的匹配(两向)
|
||||
SettingsJavaTooNew => (
|
||||
"Above the requirement: this instance can only use Java {maximum} or older (the old loader cannot read newer class files)",
|
||||
"高于要求:当前实例只能用 Java {maximum} 及以下(老加载器吃不了新 class 文件)"
|
||||
),
|
||||
// ── M197:下载一个 JDK(版本目录 / 下载中 / 失败三态)
|
||||
SettingsJavaDownloadTitle => ("Download a JDK", "下载一个 JDK"),
|
||||
SettingsJavaDownloadHint => (
|
||||
"Downloads go through the built-in mirror table: in China the campus mirrors are tried first (Temurin over Tsinghua TUNA), and GitHub-hosted archives are fetched through gh-proxy. Every archive is verified against its SHA-256 before it is unpacked.",
|
||||
"下载走内置镜像表:国内先试院校镜像(Temurin 走清华 TUNA),GitHub 上的归档套 gh-proxy。落盘前一律按 SHA-256 校验。"
|
||||
),
|
||||
SettingsJavaDownloadLts => ("LTS", "长期支持版(LTS)"),
|
||||
SettingsJavaDownloadBaseline => ("Recommended baseline", "推荐基准版本"),
|
||||
SettingsJavaDownloadVendors => ("Vendors: {vendors}", "发行版:{vendors}"),
|
||||
SettingsJavaDownloadNoVendor => (
|
||||
"No vendor publishes this major version on this platform.",
|
||||
"这个平台上没有发行版发这个大版本。"
|
||||
),
|
||||
SettingsJavaDownloadAction => ("Install", "安装"),
|
||||
SettingsJavaDownloadReinstall => ("Install again", "重装"),
|
||||
SettingsJavaDownloadInstalled => ("Already installed: {path}", "已安装:{path}"),
|
||||
SettingsJavaDownloadNeeded => (
|
||||
"The current instance needs {requirement}.",
|
||||
"当前实例需要 {requirement}。"
|
||||
),
|
||||
SettingsJavaDownloadFits => (
|
||||
"Usable by the current instance",
|
||||
"当前实例可用"
|
||||
),
|
||||
SettingsJavaDownloadTooLow => (
|
||||
"The current instance needs Java {minimum} or newer, so installing this one will not start it.",
|
||||
"当前实例需要 Java {minimum} 及以上 —— 装这一档也起不来。"
|
||||
),
|
||||
SettingsJavaDownloadTooHigh => (
|
||||
"The current instance can only use Java {maximum} or older, so installing this one will not start it.",
|
||||
"当前实例只能用 Java {maximum} 及以下 —— 装这一档也起不来。"
|
||||
),
|
||||
SettingsJavaDownloadRecommended => (
|
||||
"Recommended for the current instance",
|
||||
"为当前实例推荐这一档"
|
||||
),
|
||||
SettingsJavaDownloadBusy => ("Installing Java {major}…", "正在安装 Java {major}…"),
|
||||
SettingsJavaDownloadPhasePreparing => ("Looking up the release…", "正在查版本…"),
|
||||
SettingsJavaDownloadPhaseDownloading => (
|
||||
"Downloading {percent}% ({done} of {total})",
|
||||
"下载中 {percent}%({done} / {total})"
|
||||
),
|
||||
SettingsJavaDownloadPhaseVerifying => ("Verifying the archive…", "正在校验归档…"),
|
||||
SettingsJavaDownloadPhaseExtracting => ("Unpacking…", "正在解压…"),
|
||||
SettingsJavaDownloadSource => ("Source: {source}", "来源:{source}"),
|
||||
SettingsJavaDownloadRetry => (
|
||||
"{source} failed ({reason}); retry {attempt} in {seconds}s",
|
||||
"{source} 失败({reason});{seconds} 秒后重试第 {attempt} 次"
|
||||
),
|
||||
SettingsJavaDownloadSwitch => ("Switching source: {from} → {to}", "换源:{from} → {to}"),
|
||||
SettingsJavaDownloadDone => (
|
||||
"Java {major} ({vendor}) is ready at {root} · source {source}",
|
||||
"Java {major}({vendor})已就绪:{root} · 来源 {source}"
|
||||
),
|
||||
SettingsJavaDownloadDoneReused => (
|
||||
"Java {major} ({vendor}) was already installed at {root}; nothing was downloaded.",
|
||||
"Java {major}({vendor})之前已经装好:{root},这一次没有下载。"
|
||||
),
|
||||
SettingsJavaDownloadFailed => (
|
||||
"Java {major} could not be installed; the last source tried was {source}. The full reason is in the log.",
|
||||
"Java {major} 没装成;最后试到的源是 {source}。完整原因在日志里。"
|
||||
),
|
||||
SettingsJavaDownloadFailedNoSource => (
|
||||
"Java {major} could not be installed: the version lookup itself failed. The full reason is in the log.",
|
||||
"Java {major} 没装成:查版本这一步就失败了。完整原因在日志里。"
|
||||
),
|
||||
SettingsJavaDownloadFailedHint => (
|
||||
"Check the network, or change the vendor order below.",
|
||||
"先查网络,或者改下面的发行版顺序。"
|
||||
),
|
||||
SettingsJavaDownloadTriedHeader => ("Sources tried:", "试过的源:"),
|
||||
SettingsJavaDownloadTried => ("{source}: {reason}", "{source}:{reason}"),
|
||||
SettingsJavaDownloadRefused => ("Not started: {reason}", "没开始:{reason}"),
|
||||
SettingsJavaReasonDownloadBusy => (
|
||||
"another JDK install is already running",
|
||||
"已经有一个 JDK 在装了"
|
||||
),
|
||||
SettingsJavaReasonDownloadUnknownMajor => (
|
||||
"that major version is not in the catalog",
|
||||
"目录里没有这个大版本"
|
||||
),
|
||||
SettingsJavaReasonDownloadNoVendor => (
|
||||
"no vendor publishes that major version here",
|
||||
"没有发行版在这里发这个大版本"
|
||||
),
|
||||
SettingsJavaReasonRootUnresolved => (
|
||||
"the JDK install folder cannot be resolved on this system",
|
||||
"这台机器上解析不出 JDK 安装目录"
|
||||
),
|
||||
// ── 源失败原因(结构化,`DownloadErrorKind` 的文案面;core 的原句只进日志)
|
||||
SettingsJavaSourceReasonNotFound => (
|
||||
"this mirror does not have that file (404)",
|
||||
"这个镜像没有这个文件(404)"
|
||||
),
|
||||
SettingsJavaSourceReasonServer => (
|
||||
"the server answered with a 5xx / 429 error",
|
||||
"服务器报错 5xx / 429"
|
||||
),
|
||||
SettingsJavaSourceReasonTimeout => ("the request timed out", "请求超时"),
|
||||
SettingsJavaSourceReasonNetwork => ("the connection failed", "连不上"),
|
||||
SettingsJavaSourceReasonIntegrity => ("the checksum did not match", "校验不符"),
|
||||
SettingsJavaSourceReasonIo => ("a local disk error", "本地磁盘错误"),
|
||||
SettingsJavaSourceReasonOther => ("the request was rejected", "请求被拒"),
|
||||
// ── M197:三个设置键(`java.installRoot` / `java.vendorPriority` / `java.auto_install`)
|
||||
SettingsJavaInstallRoot => ("JDK install folder", "JDK 安装目录"),
|
||||
SettingsJavaInstallRootPlaceholder => ("D:\\Java", "例:D:\\Java"),
|
||||
SettingsJavaInstallRootHint => (
|
||||
"Leave it empty to follow the data mode: portable keeps JDKs inside the launcher folder, standard uses the system Java folder. A path here overrides both, and JDKs land in <root>/<vendor>/<version>/.",
|
||||
"留空 = 跟数据目录模式走:portable 放在启动器目录里,standard 放在系统 Java 目录。填了它覆盖前两者,JDK 落在 <root>/<发行方>/<版本>/。"
|
||||
),
|
||||
SettingsJavaInstallRootEffective => ("In effect: {mode} · {path}", "生效:{mode} · {path}"),
|
||||
SettingsJavaInstallRootLayerSetting => (
|
||||
"this comes from java.installRoot",
|
||||
"这一项来自 java.installRoot"
|
||||
),
|
||||
SettingsJavaInstallRootLayerDataMode => (
|
||||
"this comes from core.data_mode ({mode})",
|
||||
"这一项来自 core.data_mode({mode})"
|
||||
),
|
||||
SettingsJavaInstallRootUnresolved => (
|
||||
"The system Java folder cannot be resolved here. Type a folder above, or set core.data_mode back to portable.",
|
||||
"这台机器上解析不出系统标准 Java 目录。在上面填一个目录,或把 core.data_mode 改回 portable。"
|
||||
),
|
||||
SettingsJavaVendorPriority => ("Vendor order", "发行版顺序"),
|
||||
SettingsJavaVendorOrderEffective => ("In effect: {order}", "生效顺序:{order}"),
|
||||
SettingsJavaVendorOrderLayerSetting => (
|
||||
"this comes from java.vendorPriority",
|
||||
"这一项来自 java.vendorPriority"
|
||||
),
|
||||
SettingsJavaVendorOrderLayerRegion => (
|
||||
"this comes from the built-in order for your region ({region})",
|
||||
"这一项来自内置的地区默认序({region})"
|
||||
),
|
||||
SettingsJavaRegionDomestic => ("China", "国内"),
|
||||
SettingsJavaRegionGlobal => ("rest of the world", "国外"),
|
||||
SettingsJavaVendorUp => ("Up", "上移"),
|
||||
SettingsJavaVendorDown => ("Down", "下移"),
|
||||
SettingsJavaVendorReset => ("Reset to the region default", "恢复地区默认序"),
|
||||
SettingsJavaVendorSupports => ("ships {majors}", "发 {majors}"),
|
||||
SettingsJavaAutoInstall => ("Recommend a JDK when none is found", "缺 JDK 时推荐一档"),
|
||||
SettingsJavaAutoInstallHint => (
|
||||
"On: the catalog below marks the version the current instance needs, and a freshly installed JDK becomes the default when no default is set yet. Nothing is downloaded on its own — you press Install. Off: nothing is marked and you pick everything yourself.",
|
||||
"开:下面的目录会按当前实例的要求标出该装哪一档,装完之后若还没有默认 JDK 就自动设为默认。不会自己开始下载 —— 点「安装」才会下。关:什么也不标,全部自己挑。"
|
||||
),
|
||||
SettingsJavaAutoInstallOn => ("on", "开"),
|
||||
SettingsJavaAutoInstallOff => ("off", "关"),
|
||||
SettingsJavaPrefsSave => ("Save", "保存"),
|
||||
SettingsJavaPrefsSaved => (
|
||||
"Saved and read back from disk: all three keys are in the settings file.",
|
||||
"已保存并从磁盘读回:三个键都进配置文件了。"
|
||||
),
|
||||
SettingsJavaPrefsUnsaved => (
|
||||
"There are unsaved changes in this section.",
|
||||
"这一段有未保存的改动。"
|
||||
),
|
||||
SettingsJavaReasonInstallRootTooLong => (
|
||||
"the folder path is too long",
|
||||
"目录路径太长"
|
||||
),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use settings_java_entries;
|
||||
@@ -0,0 +1,31 @@
|
||||
//! 设置页文案:**一个分组一个文件**,与页面侧的 `pages/settings/**` 同构。
|
||||
//!
|
||||
//! 为什么拆到这个粒度:设置页正文接下来由四条并行 mission 分别接(java / download /
|
||||
//! language+appearance / ai+about),它们都会往设置页加 key。一个分组一个文件之后,每条线只改
|
||||
//! 自己那个文件,`catalog.rs` 的装配链一行都不用动(链里已经把这五个域接好了)。
|
||||
//!
|
||||
//! | 文件 | 分组(`pages/settings` 的 `GROUPS`) | 现状 |
|
||||
//! |---|---|---|
|
||||
//! | [`general`] | General(Language / Theme / Startup page / Close button) | 2 条 key(语言) |
|
||||
//! | [`accent`] | Appearance 组的**强调色**一族(M250) | 7 条 key(标签 / 预设 / 三档失败原因 / 说明) |
|
||||
//! | [`appearance`] | Appearance(Density / Corner style / Motion / Text scale) | 预留,暂无 key |
|
||||
//! | [`java`] | Java(Runtime / Default memory / JVM arguments) | 预留,暂无 key |
|
||||
//! | [`download`] | Downloads(Preferred source / Concurrency / Verify checksums) | 预留,暂无 key |
|
||||
//! | [`ai`] | AI / About(页面上还没有这个分组) | 预留,暂无 key |
|
||||
//!
|
||||
//! 「预留」的文件里是**空转移器**(只把累积表原样交给下一个域):加 key 时把条目写进它
|
||||
//! `$($acc)*` 后面即可——签名不用改,`catalog.rs` 不用碰,跨线零冲突。
|
||||
|
||||
mod accent;
|
||||
mod ai;
|
||||
mod appearance;
|
||||
mod download;
|
||||
mod general;
|
||||
mod java;
|
||||
|
||||
pub(crate) use accent::settings_accent_entries;
|
||||
pub(crate) use ai::settings_ai_entries;
|
||||
pub(crate) use appearance::settings_appearance_entries;
|
||||
pub(crate) use download::settings_download_entries;
|
||||
pub(crate) use general::settings_general_entries;
|
||||
pub(crate) use java::settings_java_entries;
|
||||
@@ -0,0 +1,252 @@
|
||||
//! `Catalog` 的守门测试(从 `catalog.rs` 原样搬出来:那边已经贴着 400 行硬上限,
|
||||
//! 而页面每加一批 key 都要在这个模块里占一行)。
|
||||
//!
|
||||
//! **零改动搬运**:用例、注释、断言一字未动;`use super::*` 指向的仍是 `crate::catalog`。
|
||||
//! M94(下载页)与 M95(日志页)几乎同时撞到这堵墙、各自搬了一次:两条 branch 的**用例部分
|
||||
//! 逐字相同**(解冲突时按 M94 那份合并,测试数量 7 条不变)。
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 手写一套**故意缺条**的目录(不经过 `catalog!`,模拟后续 mission 的注入表)。
|
||||
const PARTIAL_ZH: &[(Key, &str)] = &[
|
||||
(Key::NavInstances, "实例"),
|
||||
(Key::NavLogs, "日志"),
|
||||
(Key::NavSettings, "设置"),
|
||||
(Key::NavDownload, "下载"),
|
||||
(Key::InstancesNew, "新建实例"),
|
||||
(Key::InstancesImport, "导入"),
|
||||
(Key::InstancesBack, "返回实例列表"),
|
||||
(Key::InstancesPlay, "启动"),
|
||||
(Key::InstancesEmptyTitle, "未选择实例"),
|
||||
(
|
||||
Key::InstancesEmptyBody,
|
||||
"在左侧选择一个实例,查看它的版本、加载器与启动选项。",
|
||||
),
|
||||
(Key::LoaderVanilla, "原版"),
|
||||
(Key::LaunchOutcomeNormal, "正常结束"),
|
||||
(Key::LaunchOutcomeCrash, "游戏崩溃"),
|
||||
// 缺:LaunchOutcomeSilent / LaunchFailedWithCode / FontCjk* / Settings* / CoreTextUntranslated
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn built_in_catalogs_have_every_key() {
|
||||
// 抓得住:`catalog!` 的成对生成被绕开(有人改回「两套各写一遍」并漏了一条 / 抄重了一条)。
|
||||
// 判据是**集合相等**:`Key` 枚举拦住了「表里有、枚举没有」,这里拦反方向。
|
||||
// 抓不住:译文对不对(这只能是人工 / 后续的语言审查)。
|
||||
for lang in Lang::ALL {
|
||||
let catalog = Catalog::of(*lang);
|
||||
assert_eq!(catalog.missing(), Vec::new(), "{}", catalog.lang().tag());
|
||||
assert_eq!(catalog.diagnose(), None);
|
||||
|
||||
let mut keys = catalog
|
||||
.entries()
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
let total = keys.len();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), total, "{} 目录里有重复条目", lang.tag());
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
Key::ALL.len(),
|
||||
"{} 条目数与 key 数不等",
|
||||
lang.tag()
|
||||
);
|
||||
|
||||
for key in Key::ALL {
|
||||
assert!(
|
||||
catalog.get(*key).is_some(),
|
||||
"{} 缺 {}",
|
||||
lang.tag(),
|
||||
key.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(!Key::ALL.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_names_are_unique_and_stable() {
|
||||
// 抓得住:同一个 key 被写成两个变体(名字重复会让日志/诊断指认失准)。
|
||||
// 抓不住:重命名变体对下游源码的影响(那是编译错误,本来就跑不过)。
|
||||
let mut names = Key::ALL.iter().map(|key| key.name()).collect::<Vec<_>>();
|
||||
let total = names.len();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), total);
|
||||
assert_eq!(Key::NavInstances.name(), "NavInstances");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_catalog_is_actually_chinese() {
|
||||
// 抓得住:整句漏译(英文被抄进中文表);一条都没有说明这张表是真翻过的。
|
||||
// 抓不住:语义翻错、语气不合(机器能查的只有「与英文不同」这一层)。
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in Key::ALL {
|
||||
let english = en.text(*key);
|
||||
assert_ne!(english, zh.text(*key), "{} 未翻译", key.name());
|
||||
assert!(!english.is_empty() && !zh.text(*key).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_sets_match_across_languages() {
|
||||
// 抓得住:一处文案的 `{code}` / `{when}` 在另一套译文里丢了或写错名——
|
||||
// 那会让 render() 输出半截句子,或让参数静默失效。
|
||||
// 抓不住:占位符本身是不是正确的变量(那是调用方的职责,由页面侧测试守)。
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
for key in Key::ALL {
|
||||
let mut left = placeholders(en.text(*key));
|
||||
let mut right = placeholders(zh.text(*key));
|
||||
left.sort_unstable();
|
||||
right.sort_unstable();
|
||||
assert_eq!(left, right, "{}", key.name());
|
||||
}
|
||||
assert_eq!(placeholders("exit {code} at {when}"), vec!["code", "when"]);
|
||||
assert_eq!(placeholders("没有占位符"), Vec::<&str>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_rows_are_diagnosed_in_plain_words() {
|
||||
// 抓得住:缺译诊断的**可读性**(必须点名语言、key 名与英文原文)——注入目录缺条时靠它定位。
|
||||
// 抓不住:内置目录真的缺译(内置表由 catalog! 结构保证,见上一条测试)。
|
||||
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
|
||||
assert_eq!(partial.missing().len(), Key::ALL.len() - PARTIAL_ZH.len());
|
||||
|
||||
let report = partial.diagnose().expect("应当报缺译");
|
||||
assert!(report.starts_with("zh-CN 缺 "), "{report}");
|
||||
assert!(report.contains("LaunchOutcomeSilent"), "{report}");
|
||||
assert!(
|
||||
report.contains("Suspected silent failure"),
|
||||
"诊断要带上英文原文当参考:{report}"
|
||||
);
|
||||
assert!(
|
||||
!report.contains("NavInstances"),
|
||||
"已译的 key 不该出现在报告里:{report}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_translation_never_falls_back_silently() {
|
||||
// 抓得住:缺译被静默顶替(回落英文 / 回落中文 / 返回空串)——那会产出「半句中文半句英文」的界面。
|
||||
// 抓不住:MISSING 标记真的被画到屏幕上(那是 UI 侧截图验收的事)。
|
||||
let partial = Catalog::from_entries(Lang::ZhCn, PARTIAL_ZH);
|
||||
assert_eq!(partial.get(Key::LaunchOutcomeSilent), None);
|
||||
assert_eq!(partial.text(Key::LaunchOutcomeSilent), MISSING);
|
||||
assert_eq!(
|
||||
partial.render(
|
||||
Key::LaunchFailedWithCode,
|
||||
&[("code", "0"), ("reason", "JVM 拒绝启动")]
|
||||
),
|
||||
"⟪missing:LaunchFailedWithCode⟫"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_substitutes_and_keeps_unfilled_placeholders_visible() {
|
||||
// 抓得住:占位符替换的回归(漏参时**保留** `{code}` 而不是清空、不 panic)。
|
||||
// 抓不住:调用方传没传对值(`("code", "0")` 是不是真的退出码)。
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
assert_eq!(
|
||||
zh.render(
|
||||
Key::LaunchFailedWithCode,
|
||||
&[("code", "0"), ("reason", "mod 冲突")]
|
||||
),
|
||||
"启动失败(退出码 0):mod 冲突"
|
||||
);
|
||||
assert_eq!(
|
||||
zh.render(Key::LaunchFailedWithCode, &[("code", "0")]),
|
||||
"启动失败(退出码 0):{reason}"
|
||||
);
|
||||
assert_eq!(zh.text(Key::InstancesNew), "新建实例");
|
||||
assert_eq!(zh.lang(), Lang::ZhCn);
|
||||
}
|
||||
|
||||
/// M99:把单张条目表按域拆到 `catalog/<域>.rs` 之后,`key -> (en, zh-CN)` 必须**逐条逐字节**不变。
|
||||
///
|
||||
/// **抓得住**:① 某个 key 整条被搬丢(金表有、现在没有 ⇒ 条数或 key 名对不上);② 两条 key 的译文
|
||||
/// 互抄 / 同 key 被覆盖成别人的译文(该行 en 或 zh 不等);③ 某条译文改了一个字符(逐字节比,
|
||||
/// **不是**数条数——「A 丢了一条、B 复制成两条」条数照旧);④ 域的顺序被调换(`Key` 的 `Ord` 与
|
||||
/// `entries()` 的声明顺序都靠它,金表按原顺序比)。
|
||||
/// **抓不住**:译文本身翻得对不对——金表是「拆分前那一份」的快照,只能证明没变,不能证明翻得好。
|
||||
///
|
||||
/// 金表怎么来的:拆分前在这里临时加过一条 `println!("GOLD\t{}\t{}\t{}", …)` 的用例,把
|
||||
/// `Key::ALL` ×(`Catalog::of(En)`, `Catalog::of(ZhCn)`)导出成 72 行 TSV,再原样抄成
|
||||
/// `catalog/golden.rs`;那条导出用例随后已删除(不在提交里)。
|
||||
#[test]
|
||||
fn split_keeps_every_translation() {
|
||||
let en = Catalog::of(Lang::En);
|
||||
let zh = Catalog::of(Lang::ZhCn);
|
||||
let actual = Key::ALL
|
||||
.iter()
|
||||
.map(|key| (key.name(), en.text(*key), zh.text(*key)))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
actual.len(),
|
||||
super::golden::len(),
|
||||
"key 条数与拆分前不一致(某条被搬丢,或多出来一条)"
|
||||
);
|
||||
|
||||
for (index, expected) in super::golden::rows().enumerate() {
|
||||
let got = actual[index];
|
||||
assert_eq!(
|
||||
got.0, expected.0,
|
||||
"第 {index} 条的 key 变了:拆分的域顺序被打乱,或某条被搬到了别处"
|
||||
);
|
||||
assert_eq!(got.1, expected.1, "{} 的英文译文变了", expected.0);
|
||||
assert_eq!(got.2, expected.2, "{} 的中文译文变了", expected.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户可见文案里**不许**出现内部决策编号(`(D13)` / `(M175)` 这类)。
|
||||
///
|
||||
/// 为什么要在目录这一层守:`(D13)` 对用户没有任何意义(他不知道 D13 是什么),而这类串一旦
|
||||
/// 混进来就再没人记得删。M197 出图时实测到下载目录的基准版本徽标写着 `基准版本(D13)`。
|
||||
///
|
||||
/// 变异证明:把 `SettingsJavaDownloadBaseline` 的译文改回 `Baseline (D13)` / `基准版本(D13)`
|
||||
/// ⇒ 本用例红(中英两侧都扫)。判据是 `(`/`(` + `D`/`M` + 数字,不误伤 `(404)` / `(5xx / 429)`
|
||||
/// 这类真对用户有意义的括号短语。
|
||||
#[test]
|
||||
fn no_user_visible_string_mentions_an_internal_decision_id() {
|
||||
for (key, en, zh) in super::golden::rows().copied() {
|
||||
for (side, text) in [("en", en), ("zh-CN", zh)] {
|
||||
assert!(
|
||||
!mentions_decision_id(text),
|
||||
"{key} 的{side}译文里出现了内部决策编号:{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户可见文案里**不许**出现 markdown 反引号。
|
||||
///
|
||||
/// 渲染层没有内联代码通道(M175 r3 复审已核实 `RichText|markdown|Span::|spans(` 全树只命中
|
||||
/// 进度条与注释),反引号会原样上屏。M175 修过三处、M197 又修了三处;M168 的
|
||||
/// `FontCjkStatusHint` 是最后一条(M197 复审 r1 指出的),这里一并钉住。
|
||||
///
|
||||
/// 变异证明:给任意一条译文加一对反引号 ⇒ 本用例红。
|
||||
#[test]
|
||||
fn no_user_visible_string_carries_markdown_backticks() {
|
||||
for (key, en, zh) in super::golden::rows().copied() {
|
||||
for (side, text) in [("en", en), ("zh-CN", zh)] {
|
||||
assert!(
|
||||
!text.contains('`'),
|
||||
"{key} 的{side}译文里还有 markdown 反引号:{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 串里有没有「(D13)」「(M175)」这类内部编号(全角半角括号都算)。
|
||||
fn mentions_decision_id(text: &str) -> bool {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
chars.windows(3).any(|window| {
|
||||
(window[0] == '(' || window[0] == '(')
|
||||
&& (window[1] == 'D' || window[1] == 'M')
|
||||
&& window[2].is_ascii_digit()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! 自更新文案(M159):设置页「更新」分组 + 顶部提示条。
|
||||
//!
|
||||
//! 域文件的形状见 [`crate::catalog`] 的「域装配」一节;本域**追加在链尾**(`account_entries`
|
||||
//! 之后),于是 `Key::ALL` 顺序 = 金表顺序 = 先 main 的全部域,最后 update(金表只追加不重排)。
|
||||
//!
|
||||
//! 三条口径:
|
||||
//!
|
||||
//! 1. **「检查失败」与「已是最新」是两句不同的文案**(mission 的硬要求):前者带原因,
|
||||
//! 后者带版本号。别为了少一个 key 把它们合并成一条;
|
||||
//! 2. 体积走 `{size}`(由页面按 `human_bytes` 格式化后传入),版本号走 `{version}`——
|
||||
//! 目录里不写死任何版本号;
|
||||
//! 3. 品牌/技术名词不翻:`OpenGL` / `SHA256` / `GitHub` 这类保持原样。
|
||||
|
||||
macro_rules! update_entries {
|
||||
($next:ident, $($rest:ident,)* { $($acc:tt)* }) => {
|
||||
$next! { $($rest,)* {
|
||||
$($acc)*
|
||||
// ── 设置页「更新」分组
|
||||
SettingsGroupUpdate => ("Updates", "更新"),
|
||||
SettingsUpdateCurrent => ("Current version", "当前版本"),
|
||||
SettingsUpdateChannel => ("Channel", "更新渠道"),
|
||||
SettingsUpdateChannelStable => ("Stable", "稳定版"),
|
||||
SettingsUpdateAutoCheck => ("Check for updates on startup", "启动时检查更新"),
|
||||
SettingsUpdateCheckNow => ("Check now", "立即检查"),
|
||||
SettingsUpdateChecking => ("Checking…", "正在检查…"),
|
||||
SettingsUpdateNever => ("Not checked yet", "还没检查过"),
|
||||
SettingsUpdateUpToDate => ("You are on the latest version ({version})", "已是最新({version})"),
|
||||
SettingsUpdateAvailable => ("Version {version} is available ({size})", "有新版本 {version}({size})"),
|
||||
SettingsUpdateMissingArtifact => (
|
||||
"Version {version} is out, but there is no build for this platform yet.",
|
||||
"{version} 已发布,但还没有本平台的安装包。"
|
||||
),
|
||||
SettingsUpdateFailed => ("Check failed: {reason}", "检查失败:{reason}"),
|
||||
SettingsUpdateApply => ("Download and install", "下载并安装"),
|
||||
SettingsUpdateApplying => ("Downloading and verifying…", "正在下载并校验…"),
|
||||
SettingsUpdateReady => ("Version {version} is ready — restart to use it.", "版本 {version} 已就位,重启后生效。"),
|
||||
SettingsUpdateNotes => ("Release notes: {notes}", "更新说明:{notes}"),
|
||||
SettingsUpdateForced => ("This update is required", "这是一次强制更新"),
|
||||
SettingsUpdateLastChecked => ("Last check", "上次检查"),
|
||||
SettingsUpdateHint => (
|
||||
"Downloads are verified with SHA256 before they touch the launcher. The running copy stays as it is until you restart.",
|
||||
"更新包在落到启动器之前会做 SHA256 校验;正在运行的这一份在你重启之前不会变。"
|
||||
),
|
||||
// ── 顶部提示条
|
||||
UpdateBannerTitleAvailable => ("Version {version} is available", "发现新版本 {version}"),
|
||||
UpdateBannerBodyAvailable => ("Download size {size}", "下载体积 {size}"),
|
||||
UpdateBannerTitleReady => ("Update ready", "更新已就位"),
|
||||
UpdateBannerBodyReady => ("Version {version} will run after a restart.", "重启后运行版本 {version}。"),
|
||||
UpdateBannerTitleFailed => ("Update failed", "更新失败"),
|
||||
UpdateBannerApplying => ("Downloading the update…", "正在下载更新…"),
|
||||
UpdateBannerInstall => ("Download and install", "下载并安装"),
|
||||
UpdateBannerDismiss => ("Skip this version", "忽略这个版本"),
|
||||
UpdateBannerRestartNow => ("Restart now", "立即重启"),
|
||||
UpdateBannerRetry => ("Try again", "重试"),
|
||||
} }
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use update_entries;
|
||||
@@ -0,0 +1,258 @@
|
||||
//! 设置页的**界面语言档位**(M106 `I18NFOLLOW`):跟随系统 / 手选某一门。
|
||||
//!
|
||||
//! ## 为什么单独一个文件
|
||||
//!
|
||||
//! `lang.rs` 讲的是「系统说哪种语言」(locale → [`Lang`] 的探测,M92),本文件讲的是「用户要哪种
|
||||
//! 语言」——两者的判据不一样:探测只能尽量理解别人给的字符串,档位是我们自己写进配置的,写错就该
|
||||
//! 暴露。放在一起会让 `lang.rs` 顶破 400 行(任务书那条硬线要求按功能拆文件)。
|
||||
//!
|
||||
//! ## D28 的判定顺序(整条挂在 [`LangChoice::resolve`] 上)
|
||||
//!
|
||||
//! **手选 > 系统语言 > 环境变量 > 英文**。最后一级「英文」不是偷懒:纯英文环境不扫、不下载中文
|
||||
//! 字体(零额外体积 / 零网络),这条由 [`crate::font::decide`] 兜住,真值表见下面
|
||||
//! `english_never_needs_a_chinese_font_and_chinese_always_resolves_one`。
|
||||
//!
|
||||
//! ## 「跟随系统」怎么表达
|
||||
//!
|
||||
//! **不写 `ui.language` 这个键**([`LangChoice::setting_value`] 返回 `None`)。不写哨兵值是因为
|
||||
//! 两件事:① schema 的允许值只有 `zh_cn` / `en_us`(`core::setting::keys::LANGUAGES`),写别的
|
||||
//! 会被枚举校验拒掉;② 读的那一头(`SettingBridge::load_ui_language`)只认显式值,所以「键不存在」
|
||||
//! 与「用户选了跟随系统」本来就是同一件事。
|
||||
|
||||
use crate::catalog::Key;
|
||||
use crate::lang::{resolve, Detection, Lang, LocaleSource, ORIGIN_SETTING};
|
||||
|
||||
/// 设置页「界面语言」的三档选择。
|
||||
///
|
||||
/// 为什么不是 `Option<Lang>`:`None` 与「用户明确勾了跟随系统」在类型上分不开,而设置页要把那一档
|
||||
/// **画成选中**。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum LangChoice {
|
||||
/// 跟随系统(= 配置里没有 `ui.language`)。
|
||||
System,
|
||||
/// 用户手选的固定语言。
|
||||
Fixed(Lang),
|
||||
}
|
||||
|
||||
impl LangChoice {
|
||||
/// 全部档位(顺序即设置页语言行的按钮顺序)。
|
||||
pub const ALL: &'static [LangChoice] = &[
|
||||
LangChoice::System,
|
||||
LangChoice::Fixed(Lang::ZhCn),
|
||||
LangChoice::Fixed(Lang::En),
|
||||
];
|
||||
|
||||
/// 配置里 `ui.language` 的原文 → 档位。
|
||||
///
|
||||
/// `None`(键不存在)与**不认识的值**都算「跟随系统」——与 [`resolve`] 跳过坏值的行为同源:
|
||||
/// 配置被 AI / CLI 写脏时退回系统语言,而不是猜成某一种。
|
||||
#[must_use]
|
||||
pub fn from_setting(value: Option<&str>) -> LangChoice {
|
||||
value
|
||||
.and_then(Lang::from_setting)
|
||||
.map_or(LangChoice::System, LangChoice::Fixed)
|
||||
}
|
||||
|
||||
/// 落盘用:跟随系统 = `None`(**删掉那个键**,不写哨兵值——哨兵会让「跟随系统」永远失效)。
|
||||
#[must_use]
|
||||
pub const fn setting_value(self) -> Option<&'static str> {
|
||||
match self {
|
||||
LangChoice::System => None,
|
||||
LangChoice::Fixed(lang) => Some(lang.setting_value()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一档在设置页上的名字(key 在 `catalog/general.rs` 域)。
|
||||
#[must_use]
|
||||
pub const fn label_key(self) -> Key {
|
||||
match self {
|
||||
LangChoice::System => Key::SettingsLanguageFollowSystem,
|
||||
LangChoice::Fixed(Lang::ZhCn) => Key::SettingsLanguageZhCn,
|
||||
LangChoice::Fixed(Lang::En) => Key::SettingsLanguageEn,
|
||||
}
|
||||
}
|
||||
|
||||
/// 这一档最终生效的语言 + 来源(日志与设置页回显都用它)。
|
||||
///
|
||||
/// 跟随系统 → 走 [`resolve`] 的三级链;手选 → 直接赢,`origin = setting`
|
||||
/// (`raw` 写配置值,日志里能看出「这是用户手选的」)。
|
||||
#[must_use]
|
||||
pub fn resolve(self, platform: &dyn LocaleSource, env: &dyn LocaleSource) -> Detection {
|
||||
match self {
|
||||
LangChoice::System => resolve(None, platform, env),
|
||||
LangChoice::Fixed(lang) => Detection {
|
||||
lang,
|
||||
raw: Some(lang.setting_value().to_owned()),
|
||||
origin: ORIGIN_SETTING,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::lang::ORIGIN_DEFAULT;
|
||||
|
||||
#[test]
|
||||
fn choice_round_trips_through_the_config_value() {
|
||||
// 抓得住:①「跟随系统」被写成哨兵值——那会让 `Lang::from_setting` 认不出、下次启动退回
|
||||
// 系统语言,「手选」这件事静默丢失;②档位顺序被调换(设置页按钮顺序)。
|
||||
// 抓不住:配置文件里到底有没有这个键(那是 `SettingsBridge` 的读写,见 presenter 侧测试)。
|
||||
assert_eq!(LangChoice::from_setting(None), LangChoice::System);
|
||||
assert_eq!(
|
||||
LangChoice::from_setting(Some("zh_cn")),
|
||||
LangChoice::Fixed(Lang::ZhCn)
|
||||
);
|
||||
assert_eq!(
|
||||
LangChoice::from_setting(Some("en_us")),
|
||||
LangChoice::Fixed(Lang::En)
|
||||
);
|
||||
for bogus in ["zh-CN", "system", "", "fr_fr"] {
|
||||
assert_eq!(
|
||||
LangChoice::from_setting(Some(bogus)),
|
||||
LangChoice::System,
|
||||
"{bogus}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(LangChoice::System.setting_value(), None, "跟随系统不写键");
|
||||
for choice in LangChoice::ALL {
|
||||
assert_eq!(
|
||||
LangChoice::from_setting(choice.setting_value()),
|
||||
*choice,
|
||||
"{} 落盘后读不回来",
|
||||
choice.setting_value().unwrap_or("<跟随系统>")
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
LangChoice::ALL,
|
||||
&[
|
||||
LangChoice::System,
|
||||
LangChoice::Fixed(Lang::ZhCn),
|
||||
LangChoice::Fixed(Lang::En)
|
||||
][..]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choice_resolution_truth_table() {
|
||||
// D28 的四级优先级,**逐档逐源**列出来:手选 > 系统 > 环境变量 > 英文。
|
||||
// 抓得住:优先级顺序被改(例如手选的英文被中文系统盖掉)、或「跟随系统」直接落英文。
|
||||
// 抓不住:这些源在真机上读到什么(`SystemLocale` 的真机行为在 `app-iced` 侧)。
|
||||
struct Named(&'static str, Option<&'static str>);
|
||||
|
||||
impl LocaleSource for Named {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.1.map(str::to_owned)
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
let registry_zh = Named("registry", Some("zh-CN"));
|
||||
let registry_en = Named("registry", Some("en-US"));
|
||||
let nothing = Named("registry", None);
|
||||
let env_zh = Named("env", Some("zh_CN.UTF-8"));
|
||||
let env_en = Named("env", Some("en_US.UTF-8"));
|
||||
let env_none = Named("env", None);
|
||||
|
||||
let follow = LangChoice::System;
|
||||
let en_choice = LangChoice::Fixed(Lang::En);
|
||||
let zh = LangChoice::Fixed(Lang::ZhCn);
|
||||
|
||||
// (档位, 平台源, 环境变量源) -> (生效语言, 来源)
|
||||
let table: &[(LangChoice, &dyn LocaleSource, &dyn LocaleSource, Lang, &str)] = &[
|
||||
// ① 手选压过一切(系统是中文、环境是中文,手选英文就画英文)
|
||||
(en_choice, ®istry_zh, &env_zh, Lang::En, ORIGIN_SETTING),
|
||||
(zh, ®istry_en, &env_en, Lang::ZhCn, ORIGIN_SETTING),
|
||||
// ② 跟随系统 → 系统源
|
||||
(follow, ®istry_zh, &env_en, Lang::ZhCn, "registry"),
|
||||
(follow, ®istry_en, &env_zh, Lang::En, "registry"),
|
||||
// ③ 系统源取不到 → 环境变量
|
||||
(follow, ¬hing, &env_zh, Lang::ZhCn, "env"),
|
||||
(follow, ¬hing, &env_en, Lang::En, "env"),
|
||||
// ④ 都没有 → 英文 + default(D28:纯英文环境不用管)
|
||||
(follow, ¬hing, &env_none, Lang::En, ORIGIN_DEFAULT),
|
||||
];
|
||||
|
||||
for (choice, platform, env, lang, origin) in table {
|
||||
let detection = choice.resolve(*platform, *env);
|
||||
assert_eq!(
|
||||
(detection.lang, detection.origin),
|
||||
(*lang, *origin),
|
||||
"{choice:?} platform={:?} env={:?}",
|
||||
platform.locale(),
|
||||
env.locale()
|
||||
);
|
||||
}
|
||||
|
||||
// 手选档的 `raw` 是配置值(日志一眼看出「用户手选的」),跟随系统档的 `raw` 是系统原文。
|
||||
assert_eq!(
|
||||
zh.resolve(®istry_en, &env_en).describe(),
|
||||
"lang=zh-CN locale=zh_cn source=setting"
|
||||
);
|
||||
assert_eq!(
|
||||
follow.resolve(®istry_zh, &env_en).describe(),
|
||||
"lang=zh-CN locale=zh-CN source=registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_never_needs_a_chinese_font_and_chinese_always_resolves_one() {
|
||||
// D28 字体那一级的真值表(`font::decide` 是纯函数,这里把语言 × 系统字体库全组合扫一遍):
|
||||
// 抓得住:①英文环境被接上中文字体/下载(「零额外体积/零网络」的直接违约);②中文环境扫不到
|
||||
// 字体却不给下载信号(会白块);③扫到了却不用系统字体(会白下 20 MB)。
|
||||
// 抓不住:本机到底装没装这些族(`app-iced` 的 `fonts/cjk.rs` 把真字体库喂进来)。
|
||||
use crate::font::{self, Strategy, CJK_FAMILIES, NOTO_SANS_SC};
|
||||
|
||||
let has_font =
|
||||
|families: &'static [&'static str]| move |family: &str| families.contains(&family);
|
||||
|
||||
for installed in [
|
||||
has_font(&[]),
|
||||
has_font(&["Segoe UI"]),
|
||||
has_font(CJK_FAMILIES),
|
||||
] {
|
||||
assert_eq!(
|
||||
font::decide(Lang::En, &installed),
|
||||
Strategy::NotNeeded,
|
||||
"英文环境不许要中文字体"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
font::decide(Lang::ZhCn, &has_font(&["Segoe UI"])),
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC)
|
||||
);
|
||||
let first = CJK_FAMILIES[0];
|
||||
let installed = has_font(&["Segoe UI", "Microsoft YaHei UI"]);
|
||||
assert_eq!(first, "Microsoft YaHei UI");
|
||||
assert_eq!(
|
||||
font::decide(Lang::ZhCn, &installed),
|
||||
Strategy::System { family: first }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_choice_has_a_label_in_both_catalogs() {
|
||||
// 抓得住:设置页语言行接了一个 catalog 里不存在的 key(画出来是 `⟪missing:…⟫`)。
|
||||
// 抓不住:译文翻得对不对(金表 + `chinese_catalog_is_actually_chinese` 管那一层)。
|
||||
let mut keys = Vec::new();
|
||||
for choice in LangChoice::ALL {
|
||||
let key = choice.label_key();
|
||||
for lang in Lang::ALL {
|
||||
assert!(
|
||||
lang.catalog().get(key).is_some(),
|
||||
"{key:?} 在 {} 目录里缺词",
|
||||
lang.tag()
|
||||
);
|
||||
}
|
||||
keys.push(key);
|
||||
}
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), LangChoice::ALL.len(), "两档用了同一个 key");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! 中文字体策略(D28 后半):**只做判据与「该下载什么」,不联网**。
|
||||
//!
|
||||
//! ## 三句话
|
||||
//!
|
||||
//! 1. **英文环境不要字体**:`Lang::En` → [`Strategy::NotNeeded`],与系统里装了什么无关。
|
||||
//! D28 原话是「纯英文环境不用管(零额外体积/零网络)」,这条是本模块最重要的断言。
|
||||
//! 2. **中文环境先扫系统**:候选族名单见 [`CJK_FAMILIES`],命中即用([`Strategy::System`])。
|
||||
//! 3. **扫不到才说要下载**:[`Strategy::NeedsDownload`] 是一个**信号**,不是动作——
|
||||
//! 真正下载由注入的 [`Fetcher`] 实现(M168 起的真实现是
|
||||
//! `crates/app-iced/src/fonts/cdn.rs`,底层走 `unlucky_core::download::font`)。本 crate 保持
|
||||
//! **零依赖**:它只携带「下什么」([`Source::Pinned`]:URL 候选 + sha256 + 字节数),不碰网络。
|
||||
//!
|
||||
//! ## 谁在什么时刻问这里
|
||||
//!
|
||||
//! | 调用点 | 时机 | 后果 |
|
||||
//! |---|---|---|
|
||||
//! | `crates/app-iced/src/fonts.rs::resolve` | 启动(按系统 locale) | 打一行 `cjk=…` 日志 |
|
||||
//! | `crates/app-iced/src/fonts.rs::cjk_for` | 设置页切语言 | 复算 + 需要时**触发下载** |
|
||||
//!
|
||||
//! 两条路都**只**在 [`Strategy::NeedsDownload`] 时才碰 [`Fetcher`]([`ensure`] 的判据),
|
||||
//! 英文环境与「系统已有字体」两条路一次网络调用都没有(守卫见本文件的单测与
|
||||
//! `crates/app-iced/src/fonts/install/tests.rs`)。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:语言 → 是否需要中文 → 系统里有没有候选族 → 要不要下载 这条链路的判定;
|
||||
//! - **抓不住**:①**族名存在 ≠ 字形覆盖**——`fontdb` 里挂着一个叫 `SimSun` 的族也可能是个残缺子集,
|
||||
//! 真判据要用 `ttf-parser` 查 cmap(M60 Q3 的 spike 做过,本 crate 不引依赖);
|
||||
//! ②下载下来的字节能不能在**这台机器**的渲染器上正常出字(注册与回退在 app-iced;macOS/Linux
|
||||
//! 没有真机验证,见 M168 报告)。
|
||||
|
||||
use crate::lang::Lang;
|
||||
|
||||
/// 已知的系统 CJK 族(Windows / macOS / Linux,按「观感优先」排序,命中第一个就用)。
|
||||
///
|
||||
/// 只收**简体**优先的常见族;`Microsoft JhengHei` 这类繁体族留在名单里垫底——
|
||||
/// 仓内只有一套中文目录(简体),繁体用户看简体的观感仍好过看英文。
|
||||
pub const CJK_FAMILIES: &[&str] = &[
|
||||
// Windows 10/11
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei",
|
||||
"DengXian",
|
||||
"SimHei",
|
||||
"SimSun",
|
||||
"Microsoft JhengHei",
|
||||
// macOS
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Heiti SC",
|
||||
"Songti SC",
|
||||
// Linux
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans SC",
|
||||
"Source Han Sans SC",
|
||||
"Source Han Sans CN",
|
||||
"Sarasa Gothic SC",
|
||||
"WenQuanYi Micro Hei",
|
||||
"Droid Sans Fallback",
|
||||
];
|
||||
|
||||
/// 中文字体必须覆盖的字重(M-决定日志 ③:Android 16 的可变思源黑体缺 100–400,
|
||||
/// 而 M3 Expressive 的 emphasized 档要 700 ⇒ 「400 / 500 / 700 至少三档」)。
|
||||
pub const REQUIRED_WEIGHTS: &[u16] = &[400, 500, 700];
|
||||
|
||||
/// 可下载字体的来源:**已钉死**的 URL 候选 + `sha256` + 字节数。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Source {
|
||||
/// 钉死的一套下载参数(M168)。
|
||||
///
|
||||
/// 为什么是「常量 + 多重校验」而不是「让下载器自己挑」:CDN 上的字节会变(上游改字体、
|
||||
/// 镜像换了实现),而**字体是注册进渲染器的可执行内容**。所以 URL 钉到 **git commit**
|
||||
/// (不是 `@main`)、字节数与 `sha256` 一起钉,任何一项对不上就拒绝落盘(见
|
||||
/// `unlucky_core::download::font`)。
|
||||
///
|
||||
/// `urls` 是**候选**:首选挂了才试下一个。两个候选**必须是不同的失败域**(Cloudflare 侧 +
|
||||
/// Fastly 侧,见 [`NOTO_SANS_SC`] 的实测表);同一家 CDN 的两个域名不算冗余。
|
||||
///
|
||||
/// 每次尝试与整条取回都有整体时限(`unlucky_core::download::font` 的 `ATTEMPT_DEADLINE` /
|
||||
/// `TOTAL_DEADLINE`):慢而不死的连接(IPv6 爬行实测 67 KB/s)会在时限处被打断、转下一个
|
||||
/// 候选,而不是让用户在设置页干等四分钟。超时归「网络」档,界面文案是「网络不通或太慢」。
|
||||
Pinned {
|
||||
/// URL 候选(按顺序尝试)。
|
||||
urls: &'static [&'static str],
|
||||
/// 期望 `sha256`(小写十六进制,64 位)。
|
||||
sha256: &'static str,
|
||||
/// 期望字节数(进度预算 + 大小上限判据)。
|
||||
bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// 一套可下载的 CJK 字体描述(不含 URL / 哈希,原因见 [`Source`])。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CjkFont {
|
||||
/// 族名(下载并注册后,`Font` 用它取字)。
|
||||
pub family: &'static str,
|
||||
/// 许可证(必须是 OFL / Apache 这类可再分发的;D28 明令不得内置 Segoe)。
|
||||
pub license: &'static str,
|
||||
/// 这套字体可用的字重。
|
||||
pub weights: &'static [u16],
|
||||
/// 下载源(URL 候选 + sha256 + 字节数,见 [`Source::Pinned`])。
|
||||
pub source: Source,
|
||||
}
|
||||
|
||||
/// 缺字体时的默认方案:Noto Sans SC(OFL-1.1)**可变字体**(wght 100–900,整个简体字符集)。
|
||||
///
|
||||
/// 钉死的东西(M168 实测抓的,不是猜的):
|
||||
///
|
||||
/// | 项 | 值 |
|
||||
/// |---|---|
|
||||
/// | 上游 | `google/fonts` 仓库的 `ofl/notosanssc/NotoSansSC[wght].ttf` |
|
||||
/// | 版本 | commit `2894aab31764f10f29c421bdfd2340d3b382d384`(该文件的最后一次改动,2022-12-09) |
|
||||
/// | 字节数 | 17 772 300(16.95 MiB) |
|
||||
/// | sha256 | `a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da` |
|
||||
///
|
||||
/// 为什么钉 commit 而不是 `@main`:`@main` 会随上游改动漂移,哈希一漂就成了「每次中文环境都下
|
||||
/// 一份、然后校验失败、然后降级」——比不下更糟。commit 是不变内容,哈希因此是可复现的判据。
|
||||
///
|
||||
/// 为什么是**一个**可变字体而不是三个静态子集(400/500/700 各一份):三份静态子集合计 ≈25 MiB,
|
||||
/// 而可变字体一份 16.9 MiB 就覆盖 100–900(cosmic-text 0.15 会把请求的字重写进 `wght` 轴,见
|
||||
/// `crates/core/src/download/font.rs` 模块文档与 M168 报告里的「抓不住」一段)。
|
||||
pub const NOTO_SANS_SC: CjkFont = CjkFont {
|
||||
family: "Noto Sans SC",
|
||||
license: "OFL-1.1",
|
||||
weights: &[100, 200, 300, 400, 500, 600, 700, 800, 900],
|
||||
source: Source::Pinned {
|
||||
// 两个候选**必须是不同的失败域**(M168 复审 P2-1):`cdn.jsdelivr.net` 与
|
||||
// `fastly.jsdelivr.net` 都 CNAME 到 `jsdelivr.map.fastly.net`(同一批 Fastly 边缘),
|
||||
// 「一个坏了换另一个」等于没有冗余——上一版就是这样,被复审当场打掉。
|
||||
//
|
||||
// 实测(两份独立的国内网络取证,同一份 17 772 300 B 内容,sha256 都是 a3041811…):
|
||||
//
|
||||
// | 域名 | 解析 | 本机(本 worktree 开发机) | 复审机(无代理) |
|
||||
// |---|---|---|---|
|
||||
// | gcore.jsdelivr.net | Cloudflare 104.16.x | 13.3 s 完整 | 16.6 s 完整 |
|
||||
// | testingcf.jsdelivr.net | Cloudflare 104.16.x | 11.8 s 完整 | 14.8 s 完整 |
|
||||
// | cdn.jsdelivr.net | Fastly 151.101.x | 11.9 s 完整(另两次 5.7 s / 14.9 s)| 1.1 s,但**走 IPv6 时 3 次都掉到 ≈67 KB/s**(60 s 只到 4.0 MB)|
|
||||
// | fastly.jsdelivr.net | 同上(Fastly)| — | 20 s 只到 2.5 MB(比它「要备的那条」还慢)|
|
||||
//
|
||||
// 排序依据(不是「听说更稳」):**首选放 Cloudflare 那一侧**——Fastly 侧的 IPv6 边缘
|
||||
// 在复审环境里会掉进 60~77 KB/s 的慢路径(那条路上 17.7 MB 要 ≈4 分钟),而 Cloudflare
|
||||
// 侧两份取证都完整下完、且是独立的失败域。二选留 Fastly 侧:本机它反而最快(5.7~11.9 s),
|
||||
// 保留它是为了「Cloudflare 一侧整体不可用时还有另一家 CDN」。
|
||||
// 先后只影响速度,不影响正确性:sha256 只认一份,镜像给了别的内容会被当场拒。
|
||||
urls: &[
|
||||
"https://gcore.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||||
"https://cdn.jsdelivr.net/gh/google/fonts@2894aab31764f10f29c421bdfd2340d3b382d384/ofl/notosanssc/NotoSansSC%5Bwght%5D.ttf",
|
||||
],
|
||||
sha256: "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||
bytes: 17_772_300,
|
||||
},
|
||||
};
|
||||
|
||||
/// 字体策略结论。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Strategy {
|
||||
/// 不需要中文字体(英文环境)。
|
||||
NotNeeded,
|
||||
/// 系统里已有可用族,直接用。
|
||||
System {
|
||||
/// 命中的族名。
|
||||
family: &'static str,
|
||||
},
|
||||
/// 需要下载(本模块只发信号,不联网)。
|
||||
NeedsDownload(&'static CjkFont),
|
||||
/// 探测不出(拿不到系统字体库)——**不等于**「需下载」:不许凭猜测去下载。
|
||||
Unprobeable,
|
||||
}
|
||||
|
||||
impl Strategy {
|
||||
/// 一行日志片段(`cjk=system:"Microsoft YaHei"` / `cjk=needs-download(Noto Sans SC)` …)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
match self {
|
||||
Strategy::NotNeeded => "cjk=not-needed".to_owned(),
|
||||
Strategy::System { family } => format!("cjk=system:\"{family}\""),
|
||||
Strategy::NeedsDownload(font) => format!("cjk=needs-download({})", font.family),
|
||||
Strategy::Unprobeable => "cjk=unprobeable".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判据(纯函数):语言 + 「某族装没装」→ 策略。
|
||||
///
|
||||
/// `installed` 由调用方提供(iced 版查系统字体库,单测给假闭包)。
|
||||
#[must_use]
|
||||
pub fn decide(lang: Lang, installed: &dyn Fn(&str) -> bool) -> Strategy {
|
||||
if lang != Lang::ZhCn {
|
||||
// D28:纯英文环境不加载任何中文字体,也不下载。
|
||||
return Strategy::NotNeeded;
|
||||
}
|
||||
|
||||
CJK_FAMILIES
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|family| installed(family))
|
||||
.map_or(Strategy::NeedsDownload(&NOTO_SANS_SC), |family| {
|
||||
Strategy::System { family }
|
||||
})
|
||||
}
|
||||
|
||||
/// 字体下载器接口(**注入式**:i18n 不碰网络,真实现由 app 层提供)。
|
||||
pub trait Fetcher {
|
||||
/// 取一套字体(返回原始字节,装进字体系统是调用方的事)。
|
||||
///
|
||||
/// 契约:**同一个进程里第二次调用应当零网络**(缓存命中),且返回的字节已通过校验
|
||||
/// (实现方的责任,见 `unlucky_core::download::font`)。
|
||||
///
|
||||
/// # Errors
|
||||
/// 网络失败 / 校验失败 / 磁盘失败三类人话错误。
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError>;
|
||||
}
|
||||
|
||||
/// 下载失败的分类(给 UI 决定要不要重试;分类少是故意的)。
|
||||
///
|
||||
/// 三类对应三句不同的话:网络类「过会儿再试」、校验类「这个源给的东西不对」、磁盘类
|
||||
/// 「腾点空间」。UI 文案按 [`FetchError::class`] 选词,**不要**按错误串猜。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum FetchError {
|
||||
/// 网络层失败(超时 / 连不上 / 非 2xx)。
|
||||
Network(String),
|
||||
/// 校验失败(哈希 / 长度不符,或不是字体文件)。
|
||||
Integrity(String),
|
||||
/// 本地磁盘失败(建目录 / 写文件 / 换名)。
|
||||
Disk(String),
|
||||
}
|
||||
|
||||
impl FetchError {
|
||||
/// 稳定类别名(`network` / `integrity` / `disk`;日志与 i18n 金表按它选词)。
|
||||
#[must_use]
|
||||
pub const fn class(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Network(_) => "network",
|
||||
Self::Integrity(_) => "integrity",
|
||||
Self::Disk(_) => "disk",
|
||||
}
|
||||
}
|
||||
|
||||
/// 人类可读描述。
|
||||
#[must_use]
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::Network(message) | Self::Integrity(message) | Self::Disk(message) => message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`ensure`] 的结局。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Fetched {
|
||||
/// 本来就不需要(英文环境)。
|
||||
NothingToDo,
|
||||
/// 系统已有,直接用。
|
||||
AlreadySystem {
|
||||
/// 命中的族名。
|
||||
family: &'static str,
|
||||
},
|
||||
/// 下载成功,拿到字节(交给调用方注册)。
|
||||
Downloaded(Vec<u8>),
|
||||
/// 探测不出,什么都没做。
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// 按策略执行:需要下载时才碰 [`Fetcher`]。
|
||||
///
|
||||
/// # Errors
|
||||
/// 下载器返回的错误原样透出(不吞、不改写分类)。
|
||||
pub fn ensure(strategy: Strategy, fetcher: &dyn Fetcher) -> Result<Fetched, FetchError> {
|
||||
match strategy {
|
||||
Strategy::NotNeeded => Ok(Fetched::NothingToDo),
|
||||
Strategy::System { family } => Ok(Fetched::AlreadySystem { family }),
|
||||
Strategy::Unprobeable => Ok(Fetched::Unknown),
|
||||
Strategy::NeedsDownload(font) => fetcher.fetch(font).map(Fetched::Downloaded),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "font/tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `i18n::font` 的单测(拆出来只为守 400 行硬线)。
|
||||
|
||||
use std::cell::Cell;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn only(installed: &'static [&'static str]) -> impl Fn(&str) -> bool {
|
||||
move |family| installed.contains(&family)
|
||||
}
|
||||
|
||||
/// 假下载器:记录调用次数,避免「悄悄联网」这种事在单测里被放过。
|
||||
struct FakeFetcher {
|
||||
calls: Cell<usize>,
|
||||
result: Result<Vec<u8>, FetchError>,
|
||||
seen: Cell<Option<&'static str>>,
|
||||
}
|
||||
|
||||
impl FakeFetcher {
|
||||
fn new(result: Result<Vec<u8>, FetchError>) -> Self {
|
||||
FakeFetcher {
|
||||
calls: Cell::new(0),
|
||||
result,
|
||||
seen: Cell::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Fetcher for FakeFetcher {
|
||||
fn fetch(&self, font: &'static CjkFont) -> Result<Vec<u8>, FetchError> {
|
||||
self.calls.set(self.calls.get() + 1);
|
||||
self.seen.set(Some(font.family));
|
||||
self.result.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn english_never_wants_a_cjk_font() {
|
||||
// 抓得住:D28 的核心承诺——英文环境不加载/不下载中文字体(D28 说「零额外体积/零网络」)。
|
||||
// 抓不住:程序真的没去读那个字体文件(那是加载器的行为,本 crate 只管判据)。
|
||||
assert_eq!(decide(Lang::En, &only(&[])), Strategy::NotNeeded);
|
||||
assert_eq!(
|
||||
decide(Lang::En, &only(&["Microsoft YaHei"])),
|
||||
Strategy::NotNeeded,
|
||||
"装了中文字体也不该进入中文分支"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_prefers_the_first_installed_candidate() {
|
||||
// 抓得住:候选顺序(YaHei UI > YaHei > SimSun…)被调换,或命中判断写成了「装了任意字面就 ok」。
|
||||
// 抓不住:命中的族是否真有简体字形覆盖(族名存在 ≠ cmap 覆盖,见模块文档)。
|
||||
assert_eq!(
|
||||
decide(
|
||||
Lang::ZhCn,
|
||||
&only(&["Segoe UI", "Microsoft YaHei", "SimSun"])
|
||||
),
|
||||
Strategy::System {
|
||||
family: "Microsoft YaHei"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["PingFang SC"])),
|
||||
Strategy::System {
|
||||
family: "PingFang SC"
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
decide(Lang::ZhCn, &only(&["Noto Sans CJK SC", "Noto Sans SC"])),
|
||||
Strategy::System {
|
||||
family: "Noto Sans CJK SC"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chinese_without_cjk_fonts_emits_the_download_signal() {
|
||||
// 抓得住:中文环境 + 系统无 CJK 时**给信号**(而不是静默回英文、也不是自己联网)。
|
||||
// 抓不住:信号之后 UI 有没有把提示画出来(页面侧 / 截图验收)。
|
||||
let strategy = decide(Lang::ZhCn, &only(&["Segoe UI", "Roboto", "Cascadia Mono"]));
|
||||
let Strategy::NeedsDownload(font) = strategy else {
|
||||
panic!("应当要求下载:{strategy:?}");
|
||||
};
|
||||
assert_eq!(font.family, "Noto Sans SC");
|
||||
assert_eq!(font.license, "OFL-1.1");
|
||||
assert_eq!(strategy.describe(), "cjk=needs-download(Noto Sans SC)");
|
||||
}
|
||||
|
||||
/// pin 的形态守卫(M168):URL 走 https、哈希是 64 位小写十六进制、字节数在大小上限内。
|
||||
///
|
||||
/// 抓得住:①有人把 URL 改成 `@main` 这类会漂移的引用(不是不行,但那样哈希必须跟着变,
|
||||
/// 这条断言至少逼他看见自己在动什么);②手抄哈希抄错一位(下一个人只会在真机上看到
|
||||
/// 「校验失败」然后降级);③把三个静态子集塞进来却没改字节数。
|
||||
/// 抓不住:CDN 上今天还发不发得出这份字节(要真联网,报告里写明未验)。
|
||||
#[test]
|
||||
fn the_pinned_source_is_well_formed() {
|
||||
let Source::Pinned {
|
||||
urls,
|
||||
sha256,
|
||||
bytes,
|
||||
} = NOTO_SANS_SC.source;
|
||||
assert!(!urls.is_empty(), "至少要有一个 URL");
|
||||
for url in urls {
|
||||
assert!(url.starts_with("https://"), "字体必须走 https:{url}");
|
||||
assert!(
|
||||
!url.contains("@main"),
|
||||
"钉 commit 而不是会漂移的分支:{url}"
|
||||
);
|
||||
}
|
||||
assert_eq!(sha256.len(), 64, "{sha256}");
|
||||
assert!(
|
||||
sha256.bytes().all(|byte| byte.is_ascii_hexdigit()),
|
||||
"{sha256}"
|
||||
);
|
||||
assert_eq!(sha256, sha256.to_ascii_lowercase(), "哈希用小写:{sha256}");
|
||||
assert!(bytes > 0 && bytes < 32 * 1024 * 1024, "{bytes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_fonts_cover_the_required_weights() {
|
||||
// 抓得住:将来换了字体却没覆盖 400/500/700(M3 Expressive 的 emphasized 档要 700)。
|
||||
// 抓不住:字重是可变轴还是静态子集(那要看下载 mission 选的文件,本 crate 只有元数据)。
|
||||
for weight in REQUIRED_WEIGHTS {
|
||||
assert!(
|
||||
NOTO_SANS_SC.weights.contains(weight),
|
||||
"缺 {weight} 档:{:?}",
|
||||
NOTO_SANS_SC.weights
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_only_touches_the_fetcher_when_download_is_needed() {
|
||||
// 抓得住:英文环境/系统已有字体时**仍然调了下载器**(= 偷跑网络请求);
|
||||
// 也抓得住下载失败被吞掉(错误必须原样透出)。
|
||||
// 抓不住:真实现的网络行为(本 crate 没有网络实现,这正是不联网的证据)。
|
||||
let idle = FakeFetcher::new(Ok(vec![1, 2, 3]));
|
||||
assert_eq!(ensure(Strategy::NotNeeded, &idle), Ok(Fetched::NothingToDo));
|
||||
assert_eq!(
|
||||
ensure(Strategy::System { family: "SimSun" }, &idle),
|
||||
Ok(Fetched::AlreadySystem { family: "SimSun" })
|
||||
);
|
||||
assert_eq!(ensure(Strategy::Unprobeable, &idle), Ok(Fetched::Unknown));
|
||||
assert_eq!(idle.calls.get(), 0, "这三种情况都不许联网");
|
||||
|
||||
let ok = FakeFetcher::new(Ok(vec![9; 4]));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &ok),
|
||||
Ok(Fetched::Downloaded(vec![9; 4]))
|
||||
);
|
||||
assert_eq!(ok.calls.get(), 1);
|
||||
assert_eq!(ok.seen.get(), Some("Noto Sans SC"));
|
||||
|
||||
let broken = FakeFetcher::new(Err(FetchError::Network("超时".to_owned())));
|
||||
assert_eq!(
|
||||
ensure(Strategy::NeedsDownload(&NOTO_SANS_SC), &broken),
|
||||
Err(FetchError::Network("超时".to_owned()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strategy_describe_is_one_line_for_every_case() {
|
||||
// 抓得住:日志片段里混进换行(启动日志是按行解析的)。
|
||||
// 抓不住:这些片段是否真的被打印(app-iced 的启动路径在那边测)。
|
||||
let cases = [
|
||||
Strategy::NotNeeded,
|
||||
Strategy::System { family: "SimSun" },
|
||||
Strategy::NeedsDownload(&NOTO_SANS_SC),
|
||||
Strategy::Unprobeable,
|
||||
];
|
||||
for case in cases {
|
||||
let line = case.describe();
|
||||
assert_eq!(line.lines().count(), 1, "{line}");
|
||||
assert!(line.starts_with("cjk="), "{line}");
|
||||
}
|
||||
assert_eq!(Strategy::NotNeeded.describe(), "cjk=not-needed");
|
||||
assert_eq!(Strategy::Unprobeable.describe(), "cjk=unprobeable");
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 语言与系统 locale 探测(D28 前半)。
|
||||
//!
|
||||
//! ## 判据只有两条
|
||||
//!
|
||||
//! 1. **中文环境**(`zh*` 任意变体)→ [`Lang::ZhCn`];
|
||||
//! 2. **其它一切**(`en*`、`ja*`、`fr*`、`C`、`POSIX`、变量缺失、解析不了)→ [`Lang::En`]。
|
||||
//!
|
||||
//! 「其它一切 → 英文」不是偷懒:D28 的原话是「纯英文环境不用管」,而把 ja/fr/de 猜成中文才是真错
|
||||
//! (会连带触发中文字体下载)。宁可给一个英文界面,也不给一个语种错误的中文界面。
|
||||
//!
|
||||
//! ## 为什么 locale 源要能注入
|
||||
//!
|
||||
//! [`detect`] 的形状是 `detect(&dyn LocaleSource)`,[`EnvLocale`] 只是其中一个实现。这样
|
||||
//! **单测不依赖跑测试那台机器的 locale**(真值表测试在 [`lang`] 自己的 `tests` 里跑固定输入),
|
||||
//! 后续 mission 加「Win32 `GetUserDefaultLocaleName`」或「读配置里的 `ui.language`」也只是再实现一遍
|
||||
//! 这个 trait。
|
||||
//!
|
||||
//! ## 优先级([`resolve`])
|
||||
//!
|
||||
//! **`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。本 crate 只有 [`EnvLocale`]
|
||||
//! 一个具体实现——平台真值源(Windows 注册表那一路)在 UI 壳层,因为读注册表要开子进程,
|
||||
//! 而本 crate 的纪律是不碰外部世界。
|
||||
//!
|
||||
//! ## 诚实边界(抓得住什么 / 抓不住什么)
|
||||
//!
|
||||
//! - **抓得住**:环境变量字符串 → 语言的映射(含 `zh_CN.UTF-8` / `zh-Hans-CN` / 大小写 / 修饰符);
|
||||
//! 变量优先级(`LC_ALL` > `LC_MESSAGES` > `LANG`);[`resolve`] 的四级优先级;
|
||||
//! - **抓不住**:**Windows 上这三个变量通常都不存在**——那条路的真值源是
|
||||
//! `crates/app-iced/src/platform/locale.rs`(`reg.exe` 读注册表,零 FFI),
|
||||
//! 它不在本 crate 里(本 crate 零依赖、不 spawn 进程)。所以「Windows 上拿不到语言」
|
||||
//! 这件事只在**没有接线平台源**时成立,接线后由 `resolve` 的二级兜住。
|
||||
//! 配置里的 `ui.language` 读取属于设置层接线(本 crate 只提供 [`Lang::from_setting`] 与
|
||||
//! [`resolve`] 的形状)。
|
||||
|
||||
use std::env;
|
||||
|
||||
use crate::catalog::Catalog;
|
||||
|
||||
/// 界面语言。目前只有中英两套(D28 的「至少 zh-CN / en-US」)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum Lang {
|
||||
/// 英文(默认;未知 locale 也落在这里)。
|
||||
En,
|
||||
/// 简体中文。
|
||||
ZhCn,
|
||||
}
|
||||
|
||||
impl Lang {
|
||||
/// 全部语言(顺序即设置页下拉框的顺序)。
|
||||
pub const ALL: &'static [Lang] = &[Lang::En, Lang::ZhCn];
|
||||
|
||||
/// BCP-47 标签(日志 / 字体请求里用;`zh-CN` 是本 crate 唯一的中文标识)。
|
||||
#[must_use]
|
||||
pub const fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en",
|
||||
Lang::ZhCn => "zh-CN",
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置值(`core::setting::keys::LANGUAGES` 的同一份词表)。
|
||||
#[must_use]
|
||||
pub const fn setting_value(self) -> &'static str {
|
||||
match self {
|
||||
Lang::En => "en_us",
|
||||
Lang::ZhCn => "zh_cn",
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析**配置值**(严格):只认 [`Lang::setting_value`] 那两个字符串。
|
||||
///
|
||||
/// 与宽松的 [`Lang::from_locale`] 分工明确:配置是我们自己写进去的,写错就该暴露(`None` 让设置层
|
||||
/// 报错),而不是悄悄换成别的语言;系统 locale 是别人给的,只能尽量理解。
|
||||
#[must_use]
|
||||
pub fn from_setting(value: &str) -> Option<Lang> {
|
||||
Lang::ALL
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|lang| lang.setting_value() == value)
|
||||
}
|
||||
|
||||
/// 解析 **locale / 语言标签**(宽松):`zh`、`zh_CN.UTF-8`、`zh-Hans-CN`、`ZH_cn` 都是中文。
|
||||
///
|
||||
/// 只看**主语言子标签**:`zh-TW` / `zh-Hant` 也归 [`Lang::ZhCn`]——仓内只有一套中文目录,
|
||||
/// 繁体用户看简体比看英文更有用;真要繁体得加第三套目录(见 `bridge` 的上游约定)。
|
||||
#[must_use]
|
||||
pub fn from_locale(tag: &str) -> Lang {
|
||||
let trimmed = tag.trim();
|
||||
// `zh_CN.UTF-8@euro`:codeset 与修饰符与语种无关,先切掉。
|
||||
let without_codeset = trimmed.split('.').next().unwrap_or("");
|
||||
let without_modifier = without_codeset.split('@').next().unwrap_or("");
|
||||
let primary = without_modifier
|
||||
.split(['-', '_'])
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
|
||||
if primary == "zh" {
|
||||
Lang::ZhCn
|
||||
} else {
|
||||
Lang::En
|
||||
}
|
||||
}
|
||||
|
||||
/// 本语言的消息目录。
|
||||
#[must_use]
|
||||
pub const fn catalog(self) -> Catalog {
|
||||
Catalog::of(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// 没有任何 locale 信息时的落点(D28:纯英文环境不用管)。
|
||||
impl Default for Lang {
|
||||
fn default() -> Self {
|
||||
Lang::En
|
||||
}
|
||||
}
|
||||
|
||||
/// locale 的来源。实现方负责回答「这台机器说哪种语言」。
|
||||
pub trait LocaleSource {
|
||||
/// 原始 locale 标签(`None` = 取不到,不是「英文」的断言)。
|
||||
fn locale(&self) -> Option<String>;
|
||||
|
||||
/// 这个源在启动日志里的名字(`registry` / `env` / …)。默认 `injected`(测试用)。
|
||||
fn label(&self) -> &'static str {
|
||||
"injected"
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Detection::origin`]:结论来自 `ui.language` 的显式值。
|
||||
pub const ORIGIN_SETTING: &str = "setting";
|
||||
/// [`Detection::origin`]:结论来自环境变量([`EnvLocale`])。
|
||||
pub const ORIGIN_ENV: &str = "env";
|
||||
/// [`Detection::origin`]:一个源都没取到,落到默认英文。
|
||||
pub const ORIGIN_DEFAULT: &str = "default";
|
||||
|
||||
/// 进程环境变量式 locale 源(POSIX 语义;Windows 上基本取不到,见模块文档)。
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct EnvLocale;
|
||||
|
||||
impl LocaleSource for EnvLocale {
|
||||
fn locale(&self) -> Option<String> {
|
||||
// POSIX 优先级:LC_ALL > LC_MESSAGES > LANG。空串按「没设置」处理。
|
||||
["LC_ALL", "LC_MESSAGES", "LANG"]
|
||||
.iter()
|
||||
.find_map(|name| env::var(name).ok().filter(|value| !value.trim().is_empty()))
|
||||
}
|
||||
|
||||
fn label(&self) -> &'static str {
|
||||
ORIGIN_ENV
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测结论(`raw` 与 `origin` 一起带出来,是为了日志能解释「为什么是英文」)。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Detection {
|
||||
/// 结论。
|
||||
pub lang: Lang,
|
||||
/// 原始 locale 标签(`None` = 一个源都没取到)。
|
||||
pub raw: Option<String>,
|
||||
/// 结论的来源(`setting` / 平台源自己的 [`LocaleSource::label`] / `env` / `default`)。
|
||||
pub origin: &'static str,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// 一行日志(`lang=zh-CN locale=zh-CN source=registry` / `lang=en locale=<未设置> source=default`)。
|
||||
#[must_use]
|
||||
pub fn describe(&self) -> String {
|
||||
format!(
|
||||
"lang={} locale={} source={}",
|
||||
self.lang.tag(),
|
||||
self.raw.as_deref().unwrap_or("<未设置>"),
|
||||
self.origin
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 用给定来源探测语言。
|
||||
#[must_use]
|
||||
pub fn detect(source: &dyn LocaleSource) -> Detection {
|
||||
match source.locale() {
|
||||
Some(raw) => Detection {
|
||||
lang: Lang::from_locale(&raw),
|
||||
raw: Some(raw),
|
||||
origin: source.label(),
|
||||
},
|
||||
None => Detection {
|
||||
lang: Lang::En,
|
||||
raw: None,
|
||||
origin: ORIGIN_DEFAULT,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// 用进程环境变量探测语言。
|
||||
#[must_use]
|
||||
pub fn detect_env() -> Detection {
|
||||
detect(&EnvLocale)
|
||||
}
|
||||
|
||||
/// 全量优先级:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。
|
||||
///
|
||||
/// - `setting`:配置里 `ui.language` 的原文(`zh_cn` / `en_us`)。给了但不认识 → **跳过**
|
||||
/// (当作「没配」继续往下走,而不是把它猜成某种语言);
|
||||
/// - `platform`:平台真值源(Windows 上是 `app-iced::platform::locale` 读注册表;其它平台没有);
|
||||
/// - `env`:`LC_ALL > LC_MESSAGES > LANG`([`EnvLocale`]);
|
||||
/// - 都不给 → [`Lang::En`],`origin = default`。
|
||||
///
|
||||
/// 这正是 D28 要的顺序:**用户手选的**压过系统,系统压过环境变量,都没有就英文。
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
setting: Option<&str>,
|
||||
platform: &dyn LocaleSource,
|
||||
env: &dyn LocaleSource,
|
||||
) -> Detection {
|
||||
if let Some(value) = setting {
|
||||
if let Some(lang) = Lang::from_setting(value) {
|
||||
return Detection {
|
||||
lang,
|
||||
raw: Some(value.to_owned()),
|
||||
origin: ORIGIN_SETTING,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let from_platform = detect(platform);
|
||||
if from_platform.raw.is_some() {
|
||||
return from_platform;
|
||||
}
|
||||
|
||||
detect(env)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Fixed(Option<&'static str>);
|
||||
|
||||
impl LocaleSource for Fixed {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.0.map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locale_truth_table() {
|
||||
// 抓得住:主语言子标签的解析(分隔符 / 大小写 / codeset / 修饰符 / 变体)。
|
||||
// 抓不住:这些字符串是否真的是这台机器的 locale——真值来自 LocaleSource 实现。
|
||||
let zh = [
|
||||
"zh",
|
||||
"zh_CN",
|
||||
"zh_CN.UTF-8",
|
||||
"zh-CN",
|
||||
"zh-Hans-CN",
|
||||
"zh_hans_cn",
|
||||
"ZH_cn",
|
||||
" zh_TW ",
|
||||
"zh_CN.GB18030@euro",
|
||||
];
|
||||
for tag in zh {
|
||||
assert_eq!(Lang::from_locale(tag), Lang::ZhCn, "{tag}");
|
||||
}
|
||||
|
||||
let en = [
|
||||
"",
|
||||
"C",
|
||||
"POSIX",
|
||||
"en",
|
||||
"en_US.UTF-8",
|
||||
"en-GB",
|
||||
"ja_JP.UTF-8",
|
||||
"fr_FR@euro",
|
||||
"de",
|
||||
// 不是中文但含 zh 之外的字:必须落英文,不许猜成中文
|
||||
"zhuang",
|
||||
];
|
||||
for tag in en {
|
||||
assert_eq!(Lang::from_locale(tag), Lang::En, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_falls_back_to_english_without_a_locale() {
|
||||
// 抓得住:取不到 locale 时**不 panic、不猜中文**(D28 的「纯英文环境不用管」)。
|
||||
// 抓不住:Windows 上「环境变量取不到」是否等于用户真的说英文——那是平台源的活
|
||||
// (`app-iced::platform::locale`),见下面 resolve 的优先级测试。
|
||||
let detection = detect(&Fixed(None));
|
||||
assert_eq!(detection.lang, Lang::En);
|
||||
assert_eq!(detection.raw, None);
|
||||
assert_eq!(detection.origin, ORIGIN_DEFAULT);
|
||||
assert_eq!(
|
||||
detection.describe(),
|
||||
"lang=en locale=<未设置> source=default"
|
||||
);
|
||||
assert_eq!(Lang::default(), Lang::En);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_uses_the_injected_source_verbatim() {
|
||||
// 抓得住:探测结论确实来自注入源(不会被本机环境变量污染),且 origin 取自源自报的名字。
|
||||
// 抓不住:EnvLocale 到底读了哪几个变量(那条路径只能靠人工/集成测试看日志)。
|
||||
let detection = detect(&Fixed(Some("zh_CN.UTF-8")));
|
||||
assert_eq!(detection.lang, Lang::ZhCn);
|
||||
assert_eq!(detection.raw.as_deref(), Some("zh_CN.UTF-8"));
|
||||
assert_eq!(detection.origin, "injected");
|
||||
assert_eq!(
|
||||
detection.describe(),
|
||||
"lang=zh-CN locale=zh_CN.UTF-8 source=injected"
|
||||
);
|
||||
assert_eq!(EnvLocale.label(), "env");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_priority_is_setting_then_platform_then_env_then_english() {
|
||||
// 抓得住:四级优先级的顺序本身(用户手选压过系统,系统压过环境变量,都没有才英文);
|
||||
// 以及「配置值不认识时不当成任何一种语言,继续往下走」。
|
||||
// 抓不住:这些源在真机上到底读到什么(平台源的真机行为在 app-iced 侧测)。
|
||||
struct Named(&'static str, Option<&'static str>);
|
||||
|
||||
impl LocaleSource for Named {
|
||||
fn locale(&self) -> Option<String> {
|
||||
self.1.map(str::to_owned)
|
||||
}
|
||||
fn label(&self) -> &'static str {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
let registry_zh = Named("registry", Some("zh-CN"));
|
||||
let env_en = Named("env", Some("en_US.UTF-8"));
|
||||
let nothing = Named("registry", None);
|
||||
|
||||
// ① 配置显式值压过一切
|
||||
let detection = resolve(Some("en_us"), ®istry_zh, &env_en);
|
||||
assert_eq!(
|
||||
(detection.lang, detection.origin),
|
||||
(Lang::En, ORIGIN_SETTING)
|
||||
);
|
||||
// ② 没配置(或配置不认识)→ 平台源
|
||||
for setting in [None, Some("zh-CN"), Some("")] {
|
||||
let detection = resolve(setting, ®istry_zh, &env_en);
|
||||
assert_eq!(
|
||||
(detection.lang, detection.raw.as_deref(), detection.origin),
|
||||
(Lang::ZhCn, Some("zh-CN"), "registry"),
|
||||
"setting={setting:?}"
|
||||
);
|
||||
}
|
||||
// ③ 平台源没有 → 环境变量
|
||||
let detection = resolve(None, ¬hing, &env_en);
|
||||
assert_eq!(
|
||||
(detection.lang, detection.origin),
|
||||
(Lang::En, "env"),
|
||||
"环境变量说是英文,但来源必须是 env"
|
||||
);
|
||||
// ④ 全没有 → 英文 + default
|
||||
let detection = resolve(None, ¬hing, &Named("env", None));
|
||||
assert_eq!(
|
||||
(detection.lang, detection.origin),
|
||||
(Lang::En, ORIGIN_DEFAULT)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_values_match_the_config_vocabulary() {
|
||||
// 抓得住:本 crate 的配置词表被改动(改名会让已落盘的 ui.language 读不出来)。
|
||||
// 抓不住:`core::setting::keys::LANGUAGES` 那边先改了——本 crate 不依赖 core(零依赖纪律),
|
||||
// 所以这条只能靠接线 mission 写一条跨 crate 的 pin 测试,或让 core 反向引用这份词表。
|
||||
let values = Lang::ALL
|
||||
.iter()
|
||||
.map(|lang| lang.setting_value())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(values, vec!["en_us", "zh_cn"]);
|
||||
assert_eq!(Lang::from_setting("zh_cn"), Some(Lang::ZhCn));
|
||||
assert_eq!(Lang::from_setting("en_us"), Some(Lang::En));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_parsing_is_strict_where_locale_parsing_is_lenient() {
|
||||
// 抓得住:宽松解析被误用到配置值上(`zh-CN` 是合法 locale 但不是合法配置值)。
|
||||
// 抓不住:有人把非法值直接塞进配置(那是设置层的 schema 校验与该键的 enumeration 约束)。
|
||||
for bogus in ["zh-CN", "ZH_CN", "zh", "", "en"] {
|
||||
assert_eq!(Lang::from_setting(bogus), None, "{bogus}");
|
||||
}
|
||||
assert_eq!(Lang::from_locale("zh-CN"), Lang::ZhCn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! # unlucky-i18n · 国际化地基(D28)
|
||||
//!
|
||||
//! 本 crate 只做**地基**,不接任何页面:语言探测、两套消息目录、缺译诊断、中文字体策略、
|
||||
//! 以及「core 的中文状态串怎么进 UI」的契约。页面侧接线的 mission 用它,而不是各自硬编码英文
|
||||
//! (先硬编码再全量重写就是本 crate 要避免的那条路)。
|
||||
//!
|
||||
//! | 模块 | 职责 | 不负责 |
|
||||
//! |---|---|---|
|
||||
//! | [`lang`] | `Lang`(en / zh-CN)+ locale 探测(源可注入)+ **四级优先级** [`lang::resolve`] | 读注册表 / 读配置 / spawn 进程 |
|
||||
//! | [`choice`] | `LangChoice`(跟随系统 / 手选):设置页那一档 + 它到生效语言的映射 | 落盘(那是 `presenter::settings_bridge`) |
|
||||
//! | [`catalog`] | `Key` 枚举 + 中英两套常量表 + 取词 / 占位符渲染 + 缺译诊断 | 排版、字号、断行 |
|
||||
//! | [`font`] | 中文环境扫系统 CJK 字体的**判据** + 缺字体时的「需下载」信号 + 注入式下载器接口 | 联网、写文件、装字体 |
|
||||
//! | [`bridge`] | core / presenter 的状态串 → `Key` 的**示例契约**(文案边界规则全文在这里) | 改 core 的产出 |
|
||||
//!
|
||||
//! 语言从哪来的优先级写在 [`lang::resolve`]:**`ui.language` 显式值 > 平台源 > POSIX 环境变量 > 英文**。
|
||||
//! 平台源(Windows 上 `reg.exe` 读注册表,零 FFI)在 UI 壳层实现并注入——本 crate 不认平台。
|
||||
//!
|
||||
//! ## 三条硬规矩(细则见 [`bridge`])
|
||||
//!
|
||||
//! 1. **一句话 = 一个 key**。UI 侧不得把片段拼成一句话,尤其不得拼中文:
|
||||
//! `"Launch failed · exit0 静默失败"` 这种混排就是拼接的后果。带变量的句子用 `{name}` 占位符,
|
||||
//! 由 [`catalog::Catalog::render`] 统一替换。
|
||||
//! 2. **core 的中文串不是文案**。core / presenter 里的中文状态串(`原版` / `静默失败(…)`)只用于
|
||||
//! 日志与 CLI;UI 要么走结构化出口(枚举)映射到 key,要么走 [`bridge::key_of_legacy_status`] 的
|
||||
//! 过渡表。**任何情况下都不许把未知中文串直接画到界面上。**
|
||||
//! 3. **英文环境零中文负担**(D28 原话):`Lang::En` 下不要求任何中文字体、不触发任何下载;
|
||||
//! 判据在 [`font::decide`],是纯函数,可脱机单测。
|
||||
//!
|
||||
//! ## 与别处的关系
|
||||
//!
|
||||
//! - **不依赖 iced / slint / presenter / core**:`cargo tree -p unlucky-i18n --edges normal` 只应打印它自己。
|
||||
//! 系统字体扫描由 UI 壳层做(iced 版在 `crates/app-iced/src/fonts/cjk.rs`),扫出来的族名喂给
|
||||
//! [`font::decide`];
|
||||
//! - 配置里的 `ui.language` 允许值 `zh_cn` / `en_us`(见 `core::setting::keys::LANGUAGES`)与
|
||||
//! [`lang::Lang::setting_value`] 必须是同一份词表,由 [`lang`] 的 pin 测试守门。
|
||||
|
||||
pub mod bridge;
|
||||
pub mod catalog;
|
||||
pub mod choice;
|
||||
pub mod font;
|
||||
pub mod lang;
|
||||
|
||||
pub use catalog::{Catalog, Key};
|
||||
pub use choice::LangChoice;
|
||||
pub use lang::Lang;
|
||||
Reference in new issue
Block a user