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

+90 -89
View File
@@ -1,4 +1,5 @@
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP 读路径 + M247 · SAVESOPS 写操作):
//! 存档 / 截图列表 + 打开文件夹 + **每行的重命名 / 备份 / 删除**。
//!
//! # 位置与形状
//!
@@ -14,17 +15,42 @@
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
//!
//! # 只读(U3 的边界,页面固定画出来)
//! # 行操作区(M247)
//!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
//! 每个**世界**行右侧三个文字按钮:**重命名 / 备份 / 删除**(截图行没有操作区:截图不在这三个
//! 写操作的范围里)。三档状态:
//!
//! | 状态 | 画什么 | 为什么 |
//! |---|---|---|
//! | 空闲 | 三个按钮 | — |
//! | 这一行的请求在飞 | 一句「正在处理…」顶替按钮 | 连点两次会发两条 rename/delete,第二条必然「世界已不在」 |
//! | 正在行内改名 | 输入框 + 「改名」/「取消」 | 见下 |
//!
//! **不做乐观回显**:行上的名字只来自快照,点完等 host 回一份新快照才变(与 MODS 区同一纪律)。
//!
//! # 重命名为什么是行内输入,不是模态输入框
//!
//! 现成的模态队列有 `DialogKind::Input`,但 app 侧的 `dialog::Message` 只有主/次按钮与关闭,
//! **没有「输入变了」这一条**——用输入模态等于重写一套可编辑模态。行内输入复用
//! `components::inputs` 的单行框(与启动参数区同一个控件),一处新控件都不加。
//! 输入缓冲住在 presenter 的 [`SavesState::draft`](页面模型每帧从快照重建,缓冲留在页面里会被
//! 每一次事件冲掉)。
//!
//! # 删除为什么必须二次确认
//!
//! 这是本页唯一**不可逆**的动作。点「删除」只发一条
//! [`PageMessage::RequestDeleteWorld`],真正的删除要等模态确认框的主按钮
//! (`app/update/dialog.rs` 里那一支)。确认框复用仓内现成的模态队列(`state.dialogs`),
//! 页面不新写弹窗。
//!
//! # 失败与重试
//!
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败与写操作
//! 失败不画重试**:那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的
//! affordance(三种失败共用 `detail`,靠大类与 `op` 分别,见 [`failure_line`])。
//!
//! 写操作**成功**也有一句话([`Saves::notice`]):重命名 / 删除的结果列表上看得见,但备份的落点
//! 看不见——「点了备份然后什么也没发生」是静默失败,所以回执里带上完整路径与字节数。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
@@ -33,7 +59,7 @@ use unlucky_design::color::Role;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::Catalog;
use unlucky_presenter::dto::{human_bytes, SavesFailureKind, SavesFailureView};
use unlucky_presenter::dto::{SavesFailureKind, SavesFailureOp, SavesFailureView};
use unlucky_presenter::state::SavesState;
use crate::app::components::buttons::{self, Button};
@@ -46,15 +72,28 @@ use super::forced_state;
use super::state::Message as PageMessage;
use super::Texts;
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
mod rows;
/// 写操作成功那句话的产地(实现在 [`rows`] 里,页面与测试都从这一条路取)。
pub(super) use rows::op_line;
/// 列表里的一行(一个世界 / 一张截图)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 世界目录名或截图文件名(磁盘原样)。
/// 世界目录名或截图文件名(磁盘原样;写操作把它原样带回去当「源」)。
pub name: String,
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
pub detail: String,
}
/// 正在行内重命名的那一行(来源是 presenter 的 `SavesState::draft`)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Editing {
/// 哪个世界。
pub dir: String,
/// 输入框里的内容(原样)。
pub text: String,
}
/// SAVES 区的展示模型(`Model::saves`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Saves {
@@ -62,9 +101,9 @@ pub struct Saves {
pub bound: Option<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
/// 读盘/打开/写操作失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
/// 这一条失败能不能靠「再列一次」解决(只有读盘失败算)。
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
@@ -84,6 +123,12 @@ pub struct Saves {
pub skipped: usize,
/// 跳过提示(`跳过 3 项`)。
pub skipped_note: String,
/// 正在行内重命名的那一行(`None` = 没有在改名)。
pub editing: Option<Editing>,
/// 有写操作在飞的那个世界(`None` = 空闲)。
pub busy: Option<String>,
/// 上一次写操作成功的那句话(`None` = 这一拍没有回执)。
pub notice: Option<String>,
}
impl Saves {
@@ -98,21 +143,33 @@ impl Saves {
pub fn nothing_yet(&self) -> bool {
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
}
/// 这个世界的写操作在飞吗(那一行画「正在处理…」并把按钮拿掉)。
#[must_use]
pub fn busy_with(&self, dir: &str) -> bool {
self.busy.as_deref() == Some(dir)
}
}
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
///
/// 两条口径:
/// 三条口径:
///
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
/// 2. **不做乐观更新**:行上的名字只来自快照,写操作的"在飞"只用来置灰那一行;
/// 3. **回执只画一次**:`view.last_op` 是 host 写在快照里的,下一次普通 List 就没了。
#[must_use]
pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
let texts = Texts::of(catalog);
let mut out = Saves {
bound: state.bound.clone(),
loading: state.bound.is_some() && state.view.is_none() && state.failed.is_none(),
editing: state.draft.as_ref().map(|draft| Editing {
dir: draft.dir.clone(),
text: draft.text.clone(),
}),
busy: state.pending.as_ref().map(|op| op.dir().to_string()),
..Saves::default()
};
if let Some(view) = &state.view {
@@ -125,7 +182,7 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
.iter()
.map(|world| Row {
name: world.dir.clone(),
detail: world_detail(world.icon, world.modified_ms, texts),
detail: rows::world_detail(world.icon, world.modified_ms, texts),
})
.collect();
out.screenshots = view
@@ -133,30 +190,33 @@ pub fn from_state(state: &SavesState, catalog: Catalog) -> Saves {
.iter()
.map(|shot| Row {
name: shot.file.clone(),
detail: shot_detail(shot.bytes, shot.modified_ms, texts),
detail: rows::shot_detail(shot.bytes, shot.modified_ms, texts),
})
.collect();
out.count = texts.saves_count(view.world_count(), view.screenshot_count());
out.skipped = view.skipped_count();
out.skipped_note = texts.saves_skipped(out.skipped);
out.notice = view.last_op.as_ref().map(|op| op_line(op, texts));
}
if let Some(failure) = &state.failed {
out.loading = false;
out.error = Some(failure_line(failure, texts));
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
// 只有「读盘 / 列目录」失败才值得重试;写操作的重试就是再点一次那个按钮。
out.retryable = failure.op.is_none() && failure.kind != SavesFailureKind::OpenFailed;
}
out
}
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 Toast——只读列表的失败就地说明更近)。
/// 失败的一句话(**唯一产地**:页面内红字用它,本版没有 Toast)。
#[must_use]
pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
let reason = texts.saves_reason(failure.kind);
let sentence = match failure.kind {
SavesFailureKind::OpenFailed => texts.saves_open_failed(reason),
SavesFailureKind::DirUnusable | SavesFailureKind::InstanceUnavailable => {
texts.saves_failed(reason)
}
let sentence = match (failure.op, failure.kind) {
(Some(SavesFailureOp::Rename), _) => texts.saves_rename_failed(&failure.file, reason),
(Some(SavesFailureOp::Delete), _) => texts.saves_delete_failed(&failure.file, reason),
(Some(SavesFailureOp::Backup), _) => texts.saves_backup_failed(&failure.file, reason),
(None, SavesFailureKind::OpenFailed) => texts.saves_open_failed(reason),
(None, _) => texts.saves_failed(reason),
};
if failure.detail.is_empty() {
return sentence;
@@ -164,44 +224,6 @@ pub fn failure_line(failure: &SavesFailureView, texts: Texts) -> String {
format!("{sentence}({})", failure.detail)
}
/// 世界那一行的副文案:`2024-05-01 10:00 UTC`,有图标时再挂一个「有图标」。
fn world_detail(icon: bool, modified_ms: Option<u64>, texts: Texts) -> String {
let stamp = stamp(modified_ms, texts);
if icon {
format!("{stamp} · {}", texts.saves_world_icon())
} else {
stamp
}
}
/// 截图那一行的副文案:`1.2 MiB · 2024-05-01 10:00 UTC`。
fn shot_detail(bytes: u64, modified_ms: Option<u64>, texts: Texts) -> String {
format!("{} · {}", human_bytes(bytes), stamp(modified_ms, texts))
}
/// mtime(UTC 毫秒)→ `2024-05-01 10:00 UTC`;取不到就说"时间未知"(不拿 0 或当前时间糊)。
///
/// 换算走 `core::launch::time` 的 RFC3339 格式化,再按 `app/view/instances.rs::last_played`
/// 同一刀切开——**UTC 是 core 的口径,不做本地时区换算**(那属时间格式 mission)。
fn stamp(modified_ms: Option<u64>, texts: Texts) -> String {
let Some(text) = modified_ms.and_then(stamp_utc) else {
return texts.saves_time_unknown().to_string();
};
text
}
/// 毫秒 → `YYYY-MM-DD HH:MM UTC`(`None` = 早于 1970 / 溢出,不猜)。
fn stamp_utc(ms: u64) -> Option<String> {
let at = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_millis(ms))?;
let text = unlucky_core::launch::time::format_rfc3339_millis(at);
// `2024-05-01T10:00:00.000Z` → `2024-05-01 10:00 UTC`(切两段,不解析、不重排)。
// 模板与 `app/view/instances.rs::last_played` **一字不差**:同一句话只能有一份写法,
// 页面源码的字面量白名单里也只有这一条(`instances/tests/i18n.rs` 的 `ALLOWED_LITERALS`)。
let date = text.get(0..10)?;
let time = text.get(11..16)?;
Some(format!("{date} {time} UTC"))
}
/// 画 SAVES 区(实例页唯一入口,`detail.rs` 调它)。
pub(super) fn section<'a>(
ui: &'a Ui,
@@ -212,8 +234,6 @@ pub(super) fn section<'a>(
let mut body = column![head(ui, saves, forced, texts)]
.spacing(ui.space(Space::X3))
.width(Length::Fill);
// 只读边界固定画在第一行(见模块文档)。
body = body.push(note(ui, texts.saves_read_only()));
if saves.loading {
body = body.push(note(ui, texts.saves_loading()));
@@ -255,6 +275,10 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_empty()));
return frame::card(ui, body);
}
// 写操作成功的回执(重命名 / 删除 / 备份)——放在列表**上面**:用户刚点的就是这里。
if let Some(notice) = saves.notice.as_deref() {
body = body.push(note(ui, notice));
}
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
body = body.push(sub_head(ui, texts.saves_worlds()));
@@ -262,7 +286,7 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_no_worlds()));
} else {
for item in &saves.worlds {
body = body.push(entry_row(ui, item));
body = body.push(rows::world(ui, item, saves, forced, texts));
}
}
body = body.push(sub_head(ui, texts.saves_screenshots()));
@@ -270,7 +294,7 @@ pub(super) fn section<'a>(
body = body.push(note(ui, texts.saves_no_screenshots()));
} else {
for item in &saves.screenshots {
body = body.push(entry_row(ui, item));
body = body.push(rows::world_plain(ui, item));
}
}
if saves.skipped > 0 {
@@ -350,29 +374,6 @@ fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
)
}
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
fn entry_row<'a>(ui: &'a Ui, item: &'a Row) -> Element<'a, Message> {
column![
frame::text_role(
ui,
TypeRole::BodyMedium,
false,
item.name.as_str(),
ui.color(Role::OnSurface),
),
frame::text_role(
ui,
TypeRole::BodySmall,
false,
item.detail.as_str(),
ui.color(Role::OnSurfaceVariant),
),
]
.spacing(ui.space(Space::X1))
.width(Length::Fill)
.into()
}
fn note<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,