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

+1 -1
View File
@@ -178,7 +178,7 @@ impl App {
// 出图取证(`ULL_SAVES_FIXTURE=1`):后台往返赶不上第 3 帧出图,注入一份固定快照。
// 产品路径读不到这个变量 ⇒ 下面这行什么都不做(见 `saves_shot` 的模块文档)。
if saves_shot::enabled() {
self.state.saves.changed(saves_shot::view(&id));
saves_shot::seed(self, &id);
return;
}
// **不走 `App::dispatch`**:那条路会为「等第一条事件」把帧订阅打开 3 秒,
@@ -77,5 +77,76 @@ pub fn view(instance: &str) -> unlucky_presenter::dto::SavesListView {
shot("2024-04-20_08.30.00.png", 964_608, None),
],
skipped: Vec::new(),
// 出图夹具不造回执:默认那一张就是「刚列完目录」的样子。
// 要看「备份成功那句话」长什么样,用 `ULL_SAVES_FIXTURE=backed-up`(见 `seed`)。
last_op: None,
}
}
/// 夹具形态(由 `ULL_SAVES_FIXTURE` 的取值决定;未知值 = 默认那一种)。
///
/// | 取值 | 出图抓什么 |
/// |---|---|
/// | `1`(或任何未知值) | 有内容的列表:世界行右侧的重命名 / 备份 / 删除 |
/// | `rename` | 行内重命名进行中(输入框 + 改名/取消) |
/// | `confirm` | 删除的二次确认模态框 |
/// | `backed-up` | 备份成功的回执那句话(带落点路径与大小) |
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// 默认:列表 + 行操作区。
Plain,
/// 行内重命名进行中。
Rename,
/// 删除确认模态。
Confirm,
/// 备份成功的回执。
BackedUp,
}
fn mode() -> Mode {
match std::env::var("ULL_SAVES_FIXTURE")
.unwrap_or_default()
.trim()
{
"rename" => Mode::Rename,
"confirm" => Mode::Confirm,
"backed-up" => Mode::BackedUp,
_ => Mode::Plain,
}
}
/// 注入这一屏需要的状态(**只走产品路径的入口**:`changed` / `start_rename` / `ask_delete` /
/// `dialogs.push`,一处新渲染分支都没有)。
pub fn seed(app: &mut crate::app::state::App, instance: &str) {
let mode = mode();
let mut view = view(instance);
if mode == Mode::BackedUp {
view.last_op = Some(unlucky_presenter::dto::SavesOped::BackedUp {
dir: "Better World".to_string(),
file: "Better World-20240501-100000.zip".to_string(),
path: "D:/ULL/.ULL/data/exports/saves/Better World-20240501-100000.zip".to_string(),
bytes: 84_213_760,
skipped: 0,
});
}
let first = view.worlds.first().map(|world| world.dir.clone());
app.state.saves.changed(view);
let Some(dir) = first else {
return;
};
match mode {
Mode::Rename => app.state.saves.start_rename(&dir),
Mode::Confirm => {
app.state.saves.ask_delete(&dir);
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(app.lang().catalog(), &dir);
app.state.dialogs.push(
unlucky_presenter::dialogs::DialogSource::UserAction,
title,
body,
unlucky_presenter::dialogs::DialogKind::Confirm { primary, secondary },
);
}
Mode::Plain | Mode::BackedUp => {}
}
}
+20
View File
@@ -13,6 +13,24 @@ use crate::app::state::App;
pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::app::Message> {
match message {
DialogMessage::Primary => {
// M247:删除存档的二次确认——主按钮就是**真删**(这是本页唯一不可逆的动作)。
// 目标记在 presenter 的状态里(`SavesState::confirm`):模态队列只画框,不记"要删谁"。
// 取走即清,所以连点两下不会删两个(第二下拿不到目标 ⇒ 只关框)。
if let Some(dir) = app.state.saves.take_confirm() {
app.close_dialog();
if let Some(instance) = app.selected_instance_id() {
app.state
.saves
.mark_pending(unlucky_presenter::state::SavesOp::Delete {
dir: dir.clone(),
});
app.dispatch(unlucky_presenter::dto::AppCommand::Saves(Box::new(
unlucky_presenter::dto::SavesCommand::DeleteWorld { id: instance, dir },
)));
}
app.refresh_view();
return Task::none();
}
// 崩溃卡片的主按钮是「导出诊断包」;普通确认框的主按钮就是「关掉」。
let Some(card) = app.dialog_card.clone() else {
app.close_dialog();
@@ -38,6 +56,8 @@ pub(super) fn on_dialog(app: &mut App, message: DialogMessage) -> Task<crate::ap
Task::none()
}
DialogMessage::Secondary | DialogMessage::Dismiss => {
// M247:取消/关掉删除确认框 ⇒ 忘掉那个目标(不然下一次点确定会删上一个)。
app.state.saves.clear_confirm();
app.close_dialog();
// 立即重画:空闲态只有 1 Hz 心跳,只置 `view_dirty` 会让弹窗再留最多 1 秒
// (r1 复审 P3-1)。
+72 -1
View File
@@ -11,7 +11,9 @@
//! 要等回程事件,写盘失败时旧快照回来必须留住用户敲的值(M157 r3 复审 P2-2)。
use iced::Task;
use unlucky_presenter::dialogs::{DialogKind, DialogSource};
use unlucky_presenter::dto::{AppCommand, LaunchArgsPatch, ModsCommand, SavesCommand};
use unlucky_presenter::state::SavesOp;
use crate::pages::InstanceMessage;
@@ -56,6 +58,73 @@ impl App {
})));
return Task::none();
}
// M247:行内重命名的三个动作。**输入过程不 dispatch**(只改 presenter 的草稿),
// 提交才发命令——与启动参数表单「编辑只改状态,保存才派命令」同一条纪律。
InstanceMessage::StartRenameWorld { dir } => {
self.state.saves.start_rename(&dir);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::RenameWorldChanged(text) => {
self.state.saves.edit_rename(text);
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
InstanceMessage::CommitRenameWorld => {
let Some(draft) = self.state.saves.take_rename() else {
return Task::none();
};
// 空名字 / 没改 → 直接收起,不打后台:这两个不是"操作失败",是"用户改主意了"。
if draft.text.is_empty() || draft.text == draft.dir {
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
self.state.saves.mark_pending(SavesOp::Rename {
dir: draft.dir.clone(),
});
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::RenameWorld {
id: instance,
dir: draft.dir,
to: draft.text,
})));
return Task::none();
}
InstanceMessage::CancelRenameWorld => {
self.state.saves.cancel_rename();
self.view_dirty = true;
self.refresh_view();
return Task::none();
}
// M247:删除**先弹确认框**。确认目标是"哪个世界"必须记在状态里——
// 主按钮落点在 `app/update/dialog.rs`,那里只有 `App`,没有这个页面。
InstanceMessage::RequestDeleteWorld { dir } => {
self.state.saves.ask_delete(&dir);
let (title, body, primary, secondary) =
crate::pages::saves_delete_prompt(self.lang().catalog(), &dir);
self.state.dialogs.push(
DialogSource::UserAction,
title,
body,
DialogKind::Confirm { primary, secondary },
);
self.view_dirty = true;
return Task::none();
}
// M247:备份——落点由 host 定(`<data>/exports/saves/`),回执里带完整路径。
InstanceMessage::BackupWorld { dir } => {
self.state
.saves
.mark_pending(SavesOp::Backup { dir: dir.clone() });
self.dispatch(AppCommand::Saves(Box::new(SavesCommand::BackupWorld {
id: instance,
dir,
dest: None,
})));
return Task::none();
}
// 表单还没对着这个实例(首帧还没播种):不接编辑,免得改到别的实例上。
_ if !self.launch_form.bound_to(&instance) => return Task::none(),
InstanceMessage::Jvm(text) => {
@@ -96,7 +165,9 @@ impl App {
}
/// 详情页当前选中的实例 id(不在详情页时为 `None`)。
fn selected_instance_id(&self) -> Option<String> {
///
/// `pub(in crate::app)`(M247):删除确认框的主按钮在 `app/update/dialog.rs`,也要问同一个问题。
pub(in crate::app) fn selected_instance_id(&self) -> Option<String> {
match self.state.route() {
unlucky_presenter::router::Route::Detail(id) => Some(id.clone()),
_ => None,