feat(app-iced): 实例页「导入已有版本」向导(M223)

页头「导入」按钮(以前是占位动作,点了只打日志)现在打开四段向导:

| 段 | 画什么 |
|---|---|
| Pick | 候选版本(可导入的才可点)+ 目标三档 chip + 实例名(缺省 InstanceDir) |
| Preview | 生效运行目录(回程给的)+ 隔离档 + target_notes() 原文逐条 |
| Failed | 稳定码 + problems 逐条(不折叠成一句「导入失败」) |
| Done | 实例名 + 运行目录 + 提示原文 |

- 向导是叠在页面之上的一层(遮罩 + 居中卡),不搅乱 list-detail 的版式判据;
- 表单态在 App(app/adopt.rs 的 Form),事件真相在 presenter 的 AdoptState;
- 在等回程时主按钮禁用(连点「确认」不会写两条实例);
- i18n 中英目录 + 金表补 27 条向导文案(实例域末尾追加)。
This commit is contained in:
AskaEth committed 2026-11-10 19:37:42 +08:00
1 parent 5a59cb33b2
commit 41caae3571
16 files changed
+1265 -2

No files matched your search

+349
View File
@@ -0,0 +1,349 @@
//! 「导入已有版本」向导(M223 GUIADOPT):实例页页头的入口 + 一张盖在页面上的向导卡。
//!
//! ## 四段(与 presenter 的 `AdoptState` 一一对应)
//!
//! | 段 | 画什么 | 从哪来 | 文件 |
//! |---|---|---|---|
//! | Pick | 候选版本 + 目标档 + 实例名 | `AdoptEvent::Scan` | [`pick`] |
//! | Preview | 落点 / 隔离档 / **`target_notes()` 原文** | `AdoptEvent::Planned` | [`panes`] |
//! | Failed | 稳定码 + 人读原因 + **`problems[]` 逐条** | `AdoptEvent::Failed` | [`panes`] |
//! | Done | 实例名 / 运行目录 / 提示原文 | `AdoptEvent::Done` | [`panes`] |
//!
//! 「现在画哪一段」只有一处判据(presenter 的 `AdoptState::stage`),本文件只做投影。
//!
//! ## 三条纪律(契约 §5 的 1/2/3)
//!
//! 1. **运行目录不派生**:Preview / Done 上那个路径**原样**来自回程事件(缺省档可能带 `-2` 后缀,
//! 只有 core 知道最后用了哪个)。本模块没有任何「拼一个目录名」的代码。
//! 2. **后果话术不重写**:共享档 / 版本目录档的后果句在 Preview 段**逐条**画 `plan.notes`
//! (core `target_notes()` 的原文)。Pick 段只放一句短提示(选之前就得知道会碰别人的目录),
//! 真正的后果原文在确认按钮之前一定会出现。
//! 3. **不折叠问题**:Failed 段把 `problems` 一条一行画出来,标题只说「导入失败」,原因全在下面。
//!
//! ## 为什么是「盖一层」而不是实例页里的一段
//!
//! 向导有四段状态、每段内容都不一样,塞进 list-detail 的正文列会把版式判据(424 散文宽、
//! 列表独立滚动)搅乱。盖一层与 M187 的模态同一手法(遮罩 + 居中卡),但**不是**
//! `DialogQueue` 那种「标题 + 正文 + 两个按钮」的窄形态:向导要画候选列表、单选档位与输入框。
//!
//! ## 表单字段归 `App`,事件真相归 presenter
//!
//! [`Form`] 只装「用户正在填的东西」(选中哪个版本、展示名、目标档、有没有在等回程);
//! 候选、预检、失败、完成全部来自 `AppState.adopt`(presenter 的 `AdoptState`)。两边各管一半,
//! 于是「画出来的落点」与「真正写盘的落点」永远是同一份事实。
mod panes;
mod pick;
mod text;
#[cfg(test)]
mod tests;
use iced::widget::{column, container, mouse_area, opaque, row, Space};
use iced::{Alignment, Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space as Sp;
use unlucky_design::typography::TypeRole;
use unlucky_i18n::{Catalog, Key, Lang};
use unlucky_presenter::dto::{AdoptCandidateView, AdoptStage, AdoptState, AdoptTargetView};
use crate::app::components::buttons::{self, Button};
use crate::app::components::feedback::dialog::scrim;
use crate::pages::frame;
use crate::theme::Ui;
use self::text::{isolation_label, target_label, wrapped};
use super::message::Message;
/// 向导卡宽度:比模态卡(560)宽一档——候选行要放「版本 id + 状态」,目标档是三枚 chip。
const CARD_WIDTH: f32 = 640.0;
/// 候选列表 / 问题清单的滚动高(再多就滚,页脚按钮永远留在卡里)。
const CARD_LIST_HEIGHT: f32 = 260.0;
/// 卡片的最大高(窗口更矮时整卡按窗口走:内容在中间区滚)。
const CARD_MAX_HEIGHT: f32 = 560.0;
/// 正在等哪一条命令的回程。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Busy {
/// 空闲(可以点)
#[default]
Idle,
/// 正在扫描 `versions/`
Scanning,
/// 正在预检(零写盘)
Checking,
/// 正在真正登记
Importing,
}
impl Busy {
/// 有没有在等回程。
#[must_use]
pub const fn waiting(self) -> bool {
!matches!(self, Self::Idle)
}
}
/// 向导的表单态(**归 `App`**,页面借一帧)。
#[derive(Debug, Default)]
pub struct Form {
/// 面板开着吗
pub open: bool,
/// 选中的版本 id(`None` = 还没选)
pub selected: Option<String>,
/// 展示名(空 = 用版本 id)
pub name: String,
/// 运行目录档(缺省 [`AdoptTargetView::InstanceDir`],**不是** isolated)
pub target: AdoptTargetView,
/// 在等在飞的那一条命令
pub busy: Busy,
}
impl Form {
/// 打开面板(回到 Pick 段、清掉上一轮的选中与名字)。
pub fn open(&mut self) {
*self = Self {
open: true,
busy: Busy::Scanning,
..Self::default()
};
}
/// 关掉面板(全清)。
pub fn close(&mut self) {
*self = Self::default();
}
/// 回程到了:不在等任何东西了。
pub fn settled(&mut self) {
self.busy = Busy::Idle;
}
/// 当前选中的候选(选中项已不在列表里时返回 `None`,界面自动退回「请选择」)。
#[must_use]
pub fn candidate<'a>(&self, adopt_state: &'a AdoptState) -> Option<&'a AdoptCandidateView> {
let id = self.selected.as_deref()?;
adopt_state
.scan
.as_ref()?
.candidates
.iter()
.find(|candidate| candidate.version_id == id)
}
}
/// 向导消息(`crate::app::Message::Adopt` 的载荷)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdoptMessage {
/// 打开向导(实例页页头「导入」)
Open,
/// 关掉(点遮罩 / 关闭按钮)
Close,
/// 选中一个候选版本
Select(String),
/// 改展示名
Name(String),
/// 改运行目录档
Target(AdoptTargetView),
/// 预检(零写盘)
Check,
/// 确认登记(唯一写盘入口)
Confirm,
/// 从 Preview / Failed 退回候选列表(等价于重新扫一次)
Back,
}
/// 向导消息 → app 消息。
fn adopt(message: AdoptMessage) -> Message {
Message::Adopt(message)
}
// --------------------------------------------------------------- 视图
/// 把向导叠在页面之上(没打开时原样返回页面树)。
pub(super) fn layer<'a>(
ui: &'a Ui,
base: Element<'a, Message>,
form: &'a Form,
adopt_state: &'a AdoptState,
lang: Lang,
) -> Element<'a, Message> {
if !form.open {
return base;
}
let catalog = lang.catalog();
let backdrop = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(move |_theme| container::Style {
background: Some(Background::Color(scrim(ui))),
..container::Style::default()
}),
)
.on_press(adopt(AdoptMessage::Close));
// 透明的一层只负责居中;吃点击的 `opaque` 只包卡片(点卡内不关,点遮罩才关)。
let centered = container(opaque(card(ui, form, adopt_state, catalog)))
.width(Length::Fill)
.height(Length::Fill)
.align_x(Alignment::Center)
.align_y(Alignment::Center)
.padding(ui.space(Sp::X6));
iced::widget::stack![base, backdrop, centered].into()
}
/// 卡片本体:标题 + 一句说明 + 中间(按段) + 页脚按钮。
fn card<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
catalog: Catalog,
) -> Element<'a, Message> {
let body = column![
frame::text_role(
ui,
TypeRole::HeadlineSmall,
true,
title_text(adopt_state, catalog),
ui.color(Role::OnSurface),
)
.map(Message::Page),
wrapped(
ui,
subtitle(adopt_state, catalog),
ui.color(Role::OnSurfaceVariant)
),
frame::divider(ui).map(Message::Page),
middle(ui, form, adopt_state, catalog),
frame::divider(ui).map(Message::Page),
footer(ui, form, adopt_state, catalog),
]
.spacing(ui.space(Sp::X3))
.width(Length::Fill);
container(body)
.width(Length::Fixed(CARD_WIDTH))
.max_height(CARD_MAX_HEIGHT)
.padding(ui.space(Sp::X6))
.style(move |_theme| container::Style {
background: Some(Background::Color(ui.layer(MaterialLayer::Overlay))),
border: ui.outlined(ShapeScale::ExtraLarge),
..container::Style::default()
})
.into()
}
/// 段标题(四段各一句)。
fn title_text(adopt_state: &AdoptState, catalog: Catalog) -> &'static str {
match adopt_state.stage() {
AdoptStage::Pick => catalog.text(Key::AdoptTitle),
AdoptStage::Preview => catalog.text(Key::AdoptPlanTitle),
AdoptStage::Failed => catalog.text(Key::AdoptFailureTitle),
AdoptStage::Done => catalog.text(Key::AdoptDoneTitle),
}
}
/// 标题下那句说明(Pick 段说清「什么都不复制」;Done 段说清「去实例列表启动」)。
fn subtitle(adopt_state: &AdoptState, catalog: Catalog) -> String {
match adopt_state.stage() {
AdoptStage::Pick => catalog.text(Key::AdoptSubtitle).to_string(),
AdoptStage::Preview => adopt_state
.plan
.as_ref()
.map(|plan| {
format!(
"{} · {}",
isolation_label(plan.isolation, catalog),
target_label(plan.target, catalog)
)
})
.unwrap_or_default(),
AdoptStage::Failed => adopt_state
.failure
.as_ref()
.map(|failure| failure.message.clone())
.unwrap_or_default(),
AdoptStage::Done => catalog.text(Key::AdoptDoneHint).to_string(),
}
}
/// 中间区(按段交给 `pick` / `panes`)。
fn middle<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
catalog: Catalog,
) -> Element<'a, Message> {
match adopt_state.stage() {
AdoptStage::Pick => pick::pick(ui, form, adopt_state.scan.as_ref(), catalog),
AdoptStage::Preview => panes::preview(ui, adopt_state.plan.as_ref(), catalog),
AdoptStage::Failed => panes::failed(ui, adopt_state.failure.as_ref(), catalog),
AdoptStage::Done => panes::done(ui, adopt_state.done.as_ref(), catalog),
}
}
/// 页脚按钮(按段给不同组合;在等回程时主按钮禁用)。
fn footer<'a>(
ui: &'a Ui,
form: &'a Form,
adopt_state: &'a AdoptState,
catalog: Catalog,
) -> Element<'a, Message> {
let stage = adopt_state.stage();
let busy = form.busy.waiting();
let primary: Element<'a, Message> = match stage {
AdoptStage::Pick => {
let ready = form.candidate(adopt_state).is_some_and(|c| c.adoptable()) && !busy;
if ready {
buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptPreview), adopt(AdoptMessage::Check)),
)
} else {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptPreview)))
}
}
AdoptStage::Preview if !busy => buttons::filled(
ui,
Button::new(
catalog.text(Key::AdoptConfirm),
adopt(AdoptMessage::Confirm),
),
),
AdoptStage::Preview => {
buttons::filled(ui, Button::disabled(catalog.text(Key::AdoptConfirm)))
}
// 失败 / 完成:主按钮就是关掉(Done 不用「去列表看」——实例已经在列表里了)
AdoptStage::Failed | AdoptStage::Done => buttons::filled(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
),
};
let mut actions = row![].spacing(ui.space(Sp::X2)).width(Length::Fill);
// Preview / Failed 给一条「退回候选列表」(= 重新扫一次,代价只有一次读目录)
if matches!(stage, AdoptStage::Preview | AdoptStage::Failed) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptBack), adopt(AdoptMessage::Back)),
));
}
actions = actions.push(container(Space::new()).width(Length::Fill));
if !matches!(stage, AdoptStage::Done) {
actions = actions.push(buttons::text(
ui,
Button::new(catalog.text(Key::AdoptClose), adopt(AdoptMessage::Close)),
));
}
actions = actions.push(primary);
column![actions].width(Length::Fill).into()
}