feat(saves): 实例存档 / 截图的只读列表与打开文件夹(M235 STOREMVP)

用户诉求是 PCL 差距四项里的「存档截图 / 导入」,M230 审计确认三层全缺。塔裁决 U3
把这一版钉成「只读列表 + 打开文件夹」,不做缩略图墙(缩略图要引图片解码依赖,而
AGENTS §2 只允许 palette-extract 引 image 类依赖;截图/导入产物形态也没定型)。

core
- `game::saves`(新):只读列 `<run_dir>/saves`(世界目录)与 `<run_dir>/screenshots`
  (图片文件)。只调 symlink_metadata / read_dir / metadata,不建目录、不写文件、不读
  文件内容(`icon.png` 只判存在)。目录不存在 ⇒ 空列表不是错误;存在但不可用(符号
  链接 / 不是目录 / 读不了)⇒ 具名错误。条目级异常逐条登记原因,不静默丢。
- `platform::opener`(新):`open_dir` 是**目录**打开器的唯一收敛点(explorer / open /
  xdg-open),内部复用 `child::quiet`,cfg 分支只此一处。塔明确要求按 AGENTS §6 放
  platform 而不是业务模块,并要一条可执行判据防退化
  (`the_launcher_literals_live_here_only` 扫 `crates/core/src`:除本文件外不许出现
  `"explorer"` / `"xdg-open"` 字面量)。

presenter
- `dto::saves`:SavesCommand(List / OpenFolder)、SavesListView、SavesFailureView
  (结构 + OS 原文,不拼中文)、`SavesListView` 的计数助手。
- `host::saves`:走 `job::spawn`,UI 只收快照;运行目录仍是
  `isolation::derive_game_dir` 的同一个答案(与启动 / mod 列表同源,**不写盘**)。
  「打开文件夹」打开哪个目录由 host 定:`saves/` → `screenshots/` → 运行目录。
  失败分三类(InstanceUnavailable / DirUnusable / OpenFailed),可重试。
- `state::saves`:快照整体替换、迟到结果丢弃、失败保留绑定(重试才有意义)。
- 接线:CoreEvent::Saves / AppCommand::Saves / HostTask::Saves。

app-iced
- `pages::instances::saves`:详情页 SAVES 卡片(MODS 之后、启动参数之前;**不套第二层
  滚动条**,整列那一条是唯一入口)。世界行带 UTC 时间与「有图标」,截图行带大小与时间;
  第一行固定画「这一版只读」(U3 边界让用户看得见)。读盘失败画一条红字 + 「重试」;
  打开文件夹失败**不画**重试(那个动作的重试就是再点一次按钮)。
- 接线:Message::{ReloadSaves, OpenSavesFolder}、`sync_saves`(与 `sync_mods` 同一判据)、
  Model::saves、`saves_shot` 出图夹具(ULL_SAVES_FIXTURE=1)。

i18n
- `InstancesSaves*` 20 条中英成对,catalog 与金表在**同一条锚点**后追加、逐字节一致。

证据
- core 只读判据:`game::saves::tests` 在真盘建世界/截图,调用前后各拍一次
  (相对路径, 是否目录, 字节数, mtime)指纹并逐条比对;另有用例钉「目录缺失不建目录」。
- presenter:`host::saves::tests` 真盘端到端(custom 档位派生运行目录 → 快照)。
- GUI:`--software-renderer` 1180×760 浅/深各一张 + 1180×1280 两张「整块卡片可见」
  (1180×760 下 SAVES 卡片位于折叠线附近,只能看到卡头;第二对用 ULL_WINDOW_SIZE
  把窗口拉高,口径与 M98 的矩阵一致),落 `DevDocs/artifacts/M235/`。

没做的(U3 边界内,见 handoff):缩略图墙、导入、删除/重命名、`level.dat` 的
LevelName 解析(世界名暂时就是目录名)。
This commit is contained in:
AskaEth committed 2026-11-11 06:00:27 +08:00
1 parent 3789cb7e61
commit f217d83680
36 files changed
+2702 -17

No files matched your search

@@ -0,0 +1,380 @@
//! 实例详情页的 **SAVES 区**(M235 · STOREMVP):存档 / 截图的**只读**列表 + 打开文件夹。
//!
//! # 位置与形状
//!
//! 挂在详情列里、MODS 区之后、启动参数卡**之前**(`pages/instances/detail.rs` 的 `detail_column`)。
//! 与 MODS 区同一套卡片口径:[`frame::card`] + 一个 `LabelMedium` 小标 + 内容。
//!
//! **为什么在启动参数之前**:与 MODS 区同一个理由(M224 出图实测)——详情列整列是一个滚动区,
//! 而启动参数卡本身就有一屏高;排在它后面等于这整块落在折叠线以下、根本看不见。顺序因此是:
//! 身份(页头 + 元信息)→ 内容(MODS → SAVES)→ 选项(启动参数)→ 动作。
//!
//! # 为什么不自己再套一层滚动区
//!
//! 详情列**整列**已经是滚动区(M157 r2)。M224 踩过一次「滚动条里的滚动条」:1180×1000 下两个条
//! 都认得出来,用户不知道该拖哪一个。这里同样只排内容,长度交给外层那一条。
//!
//! # 只读(U3 的边界,页面固定画出来)
//!
//! 塔裁决 U3 把这一版钉成「只读列表 + 打开文件夹」:没有删除、没有重命名、没有导入、**没有缩略图**
//! (缩略图要引图片解码依赖,`AGENTS.md` §2 不允许)。所以卡片里第一行永远画
//! [`Texts::saves_read_only`]——用户点开就看见"这一版只读",不会以为漏了删除按钮。
//!
//! # 失败与重试
//!
//! 读盘失败画一条红字 + 一个「重试」(再发一条 `SavesCommand::List`)。**打开文件夹失败不画重试**:
//! 那个动作的重试就是再点一次按钮,给一个会去重新列目录的「重试」是错的affordance
//! (两种失败共用 `detail`,只有大类不同,见 [`Saves::retryable`])。
use iced::widget::{column, container, row};
use iced::{Alignment, Element, Length};
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::state::SavesState;
use crate::app::components::buttons::{self, Button};
use crate::app::components::interaction::Interaction;
use crate::theme::Ui;
use super::super::frame;
use super::super::Message;
use super::forced_state;
use super::state::Message as PageMessage;
use super::Texts;
/// 列表里的一行(世界 / 截图共用:一个名字 + 一条副文案)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
/// 世界目录名或截图文件名(磁盘原样)。
pub name: String,
/// 右侧/下方副文案(时间、大小、图标标记,已由这一层拼好)。
pub detail: String,
}
/// SAVES 区的展示模型(`Model::saves`)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Saves {
/// 这份数据属于哪个实例(`None` = 不在详情页 / 还没请求过)。
pub bound: Option<String>,
/// 正在读盘(进详情页首帧:请求已发出、快照还没回来)。
pub loading: bool,
/// 读盘/打开失败的人读原因(app 层按 i18n 拼好)。
pub error: Option<String>,
/// 这一条失败能不能靠「再列一次」解决(打开文件夹失败不算)。
pub retryable: bool,
/// 手里有没有一份**快照**(`false` = 只拿到了失败,"还没有世界"那几句不许画)。
pub has_view: bool,
/// 两个目录都不存在(新实例的常态:还没进过游戏)。
pub missing: bool,
/// 世界行。
pub worlds: Vec<Row>,
/// 截图行(新的在前,顺序由 core 给)。
pub screenshots: Vec<Row>,
/// 计数行(`3 个世界 · 12 张截图`):**在这一层算好**,页面只借 `&str`。
///
/// 为什么不留到页面里 `format!`:iced 的 `Element` 只借这一帧的数据,页面函数返回的是
/// **借用局部变量的树**,局部 `String` 一出栈就悬空(`E0515`)。与 [`super::mods::Mods`]
/// 的 `count` 同一条做法。
pub count: String,
/// 跳过条目数(符号链接 / 非图片 / 读不出)。
pub skipped: usize,
/// 跳过提示(`跳过 3 项`)。
pub skipped_note: String,
}
impl Saves {
/// 有没有拿到一份快照(成功或失败都算;`loading` 中不算)。
#[must_use]
pub fn loaded(&self) -> bool {
self.bound.is_some() && !self.loading
}
/// 两个目录都在、但里面什么都没有(**必须先有一份快照**:纯失败态不是"这里空的")。
#[must_use]
pub fn nothing_yet(&self) -> bool {
self.has_view && !self.missing && self.worlds.is_empty() && self.screenshots.is_empty()
}
}
/// presenter 的视图态 → 页面要画的东西(**唯一**的转换点,`app/view/instances.rs` 调)。
///
/// 两条口径:
///
/// 1. **失败不清列表**:list 失败时保留上一份 `worlds`/`screenshots`(用户还能看见旧列表,
/// 只是多一条红字),只有"从来没有过快照"时才是一条纯错误态;
/// 2. **只读**:这里没有任何"乐观更新"可言——快照就是磁盘,下一次进详情页或点重试才刷新。
#[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(),
..Saves::default()
};
if let Some(view) = &state.view {
out.has_view = true;
out.missing = !view.exists && !view.screenshots_exists;
// 计数直接问快照(`view.world_count()`),不从刚装好的 `out.worlds` 再数一遍——
// M224 出图实测过"先算计数后装列表"画出 `0 启用 · 0 禁用` 的那种顺序错,这里从根上绕开。
out.worlds = view
.worlds
.iter()
.map(|world| Row {
name: world.dir.clone(),
detail: world_detail(world.icon, world.modified_ms, texts),
})
.collect();
out.screenshots = view
.screenshots
.iter()
.map(|shot| Row {
name: shot.file.clone(),
detail: 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);
}
if let Some(failure) = &state.failed {
out.loading = false;
out.error = Some(failure_line(failure, texts));
out.retryable = failure.kind != SavesFailureKind::OpenFailed;
}
out
}
/// 失败的一句话(**唯一产地**:页面内红字用它;本版没有 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)
}
};
if failure.detail.is_empty() {
return sentence;
}
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,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
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()));
return frame::card(ui, body);
}
if let Some(error) = saves.error.as_deref() {
body = body.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
error,
ui.color(Role::Error),
));
if saves.retryable {
body = body.push(buttons::text(
ui,
forced_state(
Button::new(
texts.saves_retry(),
Message::Instances(PageMessage::ReloadSaves),
),
forced,
),
));
}
// 旧列表还在时照旧画出来(失败不清列表),用户能看见上一次读到的东西;
// 一份快照都没有时到此为止——**不许**在"读不了"下面再补一句"这里还没有世界"。
if !saves.has_view {
return frame::card(ui, body);
}
}
if saves.missing {
body = body.push(note(ui, texts.saves_missing_dir()));
return frame::card(ui, body);
}
if saves.nothing_yet() {
body = body.push(note(ui, texts.saves_empty()));
return frame::card(ui, body);
}
// 世界 → 截图,两组各一个小标;某一组为空时只给一句话,不留空小标。
body = body.push(sub_head(ui, texts.saves_worlds()));
if saves.worlds.is_empty() {
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(sub_head(ui, texts.saves_screenshots()));
if saves.screenshots.is_empty() {
body = body.push(note(ui, texts.saves_no_screenshots()));
} else {
for item in &saves.screenshots {
body = body.push(entry_row(ui, item));
}
}
if saves.skipped > 0 {
body = body.push(note(ui, &saves.skipped_note));
}
frame::card(ui, body)
}
/// 小标行:`SAVES` + 计数 + 右侧「打开存档文件夹」。
fn head<'a>(
ui: &'a Ui,
saves: &'a Saves,
forced: Option<Interaction>,
texts: Texts,
) -> Element<'a, Message> {
// 左半是「存档 + 计数」,右半是「打开存档文件夹」:左半用一个吃满宽度的容器顶开,
// 按钮才会落在卡片右边缘(与 `head` 之外那些卡片的动作行同一套摆法)。
let mut left = row![frame::text_role(
ui,
TypeRole::LabelMedium,
false,
texts.saves_title(),
ui.color(Role::OnSurfaceVariant),
)]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center);
// 计数只在"真有一份可用快照"时画:读盘中 / 读失败 / 空目录画 0·0 会像"这里什么都没有"。
let show_count = saves.has_view
&& saves.error.is_none()
&& !saves.missing
&& !(saves.worlds.is_empty() && saves.screenshots.is_empty());
if show_count {
left = left.push(frame::text_role(
ui,
TypeRole::BodySmall,
false,
saves.count.as_str(),
ui.color(Role::OnSurfaceVariant),
));
}
// 「打开文件夹」始终可点:目录还没有时 host 会退到运行目录,失败也有就地说明
// (见 `host::saves::open_target`)。
let open = buttons::text(
ui,
forced_state(
Button::new(
texts.saves_open_folder(),
Message::Instances(PageMessage::OpenSavesFolder),
),
forced,
),
);
row![
container(left)
.width(Length::Fill)
.align_y(Alignment::Center),
open
]
.spacing(ui.space(Space::X2))
.align_y(Alignment::Center)
.width(Length::Fill)
.into()
}
fn sub_head<'a>(ui: &'a Ui, text: &'a str) -> Element<'a, Message> {
frame::text_role(
ui,
TypeRole::LabelSmall,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}
/// 一行:名字 + 副文案(只读,所以没有开关、没有按钮)。
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,
TypeRole::BodySmall,
false,
text,
ui.color(Role::OnSurfaceVariant),
)
}