feat(core,presenter,app-iced): 存档三个写操作(重命名 / 删除 / 备份)+ 行操作区(M247 SAVESOPS)

core::game::saves 新增 write 模块,三个入口的第一句都是 boundary::guard:

- rename_world:saves/<old> → saves/<new>,撞名/同名/非法名字各自具名拒绝
- delete_world:递归删(不跟随符号链接、层级上限 64),先过边界校验
- backup_world:流式 zip(限额预检 → 临时文件 → 原子改名),
  <世界名>-YYYYMMDD-HHMMSS.zip,撞名顺延,绝不覆盖上一份

guard 三道判据(每次写前重做,不信任调用方):名字必须是单段裸名字;
saves/ 必须是运行目录的真实直属子目录;世界目录必须是 saves/ 下的真目录
(解析后比父级,不比字符串前缀)。落点目录要求绝对路径(相对路径会落到进程 CWD,
实测过一次)。

presenter:SavesCommand 增 RenameWorld/DeleteWorld/BackupWorld,host 三个后台入口
写成功后重新列一遍目录并把「回执」挂在快照上(SavesOped:改名两端 / 被删的名字 /
备份落点+字节数+跳过的链接数)。SavesFailureView 增 file 与 op,写失败八类一一对应。
SavesState 增 draft / pending / confirm 三样交互态(草稿是行内输入缓冲,
pending 与 ModsState::pending 同一条先例,confirm 是删除确认框在等谁)。

app-iced:世界行右侧重命名/备份/删除;行内输入框改名(现成模态队列没有「输入变了」
这一条消息,用输入模态等于重写一套);删除走现成 modal 的 Confirm,主按钮落点接在
app/update/dialog.rs。回执行画在卡片里(备份落点看不见就是静默失败)。

i18n:新增 25 条中英成对 key + 金表同步;删掉 M235 的「这一版只读」那条(写操作接上
之后它是假的)。出图夹具 ULL_SAVES_FIXTURE=rename|confirm|backed-up 三种形态。
This commit is contained in:
AskaEth committed 2026-11-11 13:29:56 +08:00
1 parent 3e852331f4
commit c921b9c185
28 files changed
+3016 -216

No files matched your search

+89 -5
View File
@@ -216,10 +216,6 @@ impl Texts {
pub(super) fn saves_title(self) -> &'static str {
self.text(Key::InstancesSavesTitle)
}
/// 「这一版只读」——U3 的边界固定画出来(见 `catalog/instances.rs` 的那段注释)。
pub(super) fn saves_read_only(self) -> &'static str {
self.text(Key::InstancesSavesReadOnly)
}
/// 「3 个世界 · 12 张截图」。
pub(super) fn saves_count(self, worlds: usize, shots: usize) -> String {
self.0.render(
@@ -279,15 +275,103 @@ impl Texts {
self.0
.render(Key::InstancesSavesOpenFailed, &[("reason", reason)])
}
/// 失败大类的短句(`SavesFailureKind` → 一句话)。
/// 失败大类的短句(`SavesFailureKind` → 一句话;写操作那八类各有自己的话)。
pub(super) fn saves_reason(self, kind: SavesFailureKind) -> &'static str {
self.text(match kind {
SavesFailureKind::DirUnusable => Key::InstancesSavesReasonDir,
SavesFailureKind::InstanceUnavailable => Key::InstancesSavesReasonInstance,
SavesFailureKind::OpenFailed => Key::InstancesSavesReasonOpen,
SavesFailureKind::WriteUnsafeName => Key::InstancesSavesReasonUnsafeName,
SavesFailureKind::WriteSymlink => Key::InstancesSavesReasonSymlink,
SavesFailureKind::WriteMissing => Key::InstancesSavesReasonMissing,
SavesFailureKind::WriteNotADirectory => Key::InstancesSavesReasonNotDir,
SavesFailureKind::WriteConflict => Key::InstancesSavesReasonConflict,
SavesFailureKind::WriteBoundary => Key::InstancesSavesReasonBoundary,
SavesFailureKind::WriteTooLarge => Key::InstancesSavesReasonTooLarge,
SavesFailureKind::WriteFailed => Key::InstancesSavesReasonWrite,
})
}
// ------------------------------------------------- 存档写操作(M247 · SAVESOPS)
pub(super) fn saves_rename(self) -> &'static str {
self.text(Key::InstancesSavesRename)
}
pub(super) fn saves_backup(self) -> &'static str {
self.text(Key::InstancesSavesBackup)
}
pub(super) fn saves_delete(self) -> &'static str {
self.text(Key::InstancesSavesDelete)
}
/// 行内输入框的占位符(不是按钮文案:它说「该填什么」)。
pub(super) fn saves_rename_placeholder(self) -> &'static str {
self.text(Key::InstancesSavesRenamePlaceholder)
}
pub(super) fn saves_rename_confirm(self) -> &'static str {
self.text(Key::InstancesSavesRenameConfirm)
}
pub(super) fn saves_cancel(self) -> &'static str {
self.text(Key::InstancesSavesCancel)
}
/// 「正在处理…」:写请求在飞时顶替那一行的操作区。
pub(super) fn saves_busy(self) -> &'static str {
self.text(Key::InstancesSavesBusy)
}
pub(super) fn saves_delete_title(self) -> &'static str {
self.text(Key::InstancesSavesDeleteTitle)
}
/// 删除确认框的正文:`{dir} 和它里面的全部内容都会被删除,且不能撤销。`
pub(super) fn saves_delete_body(self, dir: &str) -> String {
self.0
.render(Key::InstancesSavesDeleteBody, &[("dir", dir)])
}
pub(super) fn saves_delete_confirm(self) -> &'static str {
self.text(Key::InstancesSavesDeleteConfirm)
}
/// 改名成功的回执:`已把 A 改名为 B。`
pub(super) fn saves_renamed(self, from: &str, to: &str) -> String {
self.0
.render(Key::InstancesSavesRenamed, &[("from", from), ("to", to)])
}
/// 删除成功的回执:`已删除 A。`
pub(super) fn saves_deleted(self, dir: &str) -> String {
self.0.render(Key::InstancesSavesDeleted, &[("dir", dir)])
}
/// 备份成功的回执:`已把 A 备份到 D:/…/A-…zip(1.2 MiB)。`
pub(super) fn saves_backed_up(self, dir: &str, path: &str, size: &str) -> String {
self.0.render(
Key::InstancesSavesBackedUp,
&[("dir", dir), ("path", path), ("size", size)],
)
}
/// 「跳过 N 个符号链接」(备份时没跟随的那些)。
pub(super) fn saves_backup_skipped(self, count: usize) -> String {
self.0.render(
Key::InstancesSavesBackupSkipped,
&[("count", &count.to_string())],
)
}
/// 重命名失败的主句:`重命名 A 失败:同名世界已存在`。
pub(super) fn saves_rename_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesRenameFailed,
&[("file", file), ("reason", reason)],
)
}
/// 删除失败的主句。
pub(super) fn saves_delete_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesDeleteFailed,
&[("file", file), ("reason", reason)],
)
}
/// 备份失败的主句。
pub(super) fn saves_backup_failed(self, file: &str, reason: &str) -> String {
self.0.render(
Key::InstancesSavesBackupFailed,
&[("file", file), ("reason", reason)],
)
}
fn text(self, key: Key) -> &'static str {
self.0.text(key)
}