两张验收图(`DevDocs/artifacts/m119/`)暴露的问题,都是测试抓不住、只有图能看出来的那类: 1. **同屏两语**:`pages/agent/mod.rs` 里还留着 M104 的 `const LANG: Lang = Lang::En`,所以右栏的 标题/按钮恒英文,而 M119 新加的失败横幅走 `App::lang()`(这台机器是 zh-CN)⇒ 一屏里 「Assistant / Collapse」和「连不上助手后端」并排。现在删掉那个常量,`docked`/`page` 收一个 `Catalog` 形参,由 `App::lang().catalog()` 给——与设置页/账户页同一条口径(M106 的真值源)。 2. **确认条没有字**:`chip + 文字 + 两个按钮` 挤在同一条 `Row` 里,而右栗只有 280~360:320 宽下 文字列被压成一条竖缝,标题整段看不见(图 05 第一版就是这个样子)。改成文字行与动作**分行**, 动作右对齐——窄栏里才是可读的 M3 告警形态。 顺带:`Script::WriteOp` 补一句增量,免得那条助手气泡是空壳(出图更接近真后端)。 `cargo test -p unlucky-app-iced --lib`:599 passed / 0 failed;14 张图重出后逐张看过。
164 lines
5.7 KiB
Rust
164 lines
5.7 KiB
Rust
//! 会话里的两条**横幅**(M119 从 `conversation.rs` 拆出来守 400 行硬线):
|
||
//! 失败态(具名错误)与写操作确认。
|
||
//!
|
||
//! ## 为什么必须存在
|
||
//!
|
||
//! 任务书第 4 条:「presenter 返回错误时 UI 必须显示**具名**错误而不是静默无语」。M104 的壳没有
|
||
//! 任何错误位——`Submit` 只在本地数组追加一句「没发给模型」的提示。现在两条来源都有了落点:
|
||
//!
|
||
//! | 来源 | 触发 | 画什么 |
|
||
//! |---|---|---|
|
||
//! | `AgentHost::last_failure` | `agent_submit` 返回 `Err`(端点不可达 / 路由未实现) | 标题 = [`Key::AgentErrorTitle`],正文 = 原因码对应的 catalog 译文 |
|
||
//! | `AgentRail::error` | 后端发 `AgentEvent::Failed`(这一轮回复中止) | 标题同上,正文 = presenter 已脱敏的那句原因 |
|
||
//!
|
||
//! 两者都不把后端诊断原文直接上屏:诊断串只进日志(M114 复审 r1 立的口径)。
|
||
//!
|
||
//! ## 确认条
|
||
//!
|
||
//! `rail.confirm` 非空 ⇒ 一轮写操作在等用户裁决,而 `busy` 会一直锁着输入(presenter 的
|
||
//! `sync_busy` 语义:`busy = streaming || confirm.is_some()`)。所以这段 UI 不是装饰:
|
||
//! **不按就不动**。两个按钮都带文字标签(M104 的「不得只有图标」同一条规矩)。
|
||
|
||
use iced::widget::{column, container, row, Row};
|
||
use iced::{Alignment, Background, Element, Length};
|
||
|
||
use unlucky_design::color::Role;
|
||
use unlucky_design::elevation::MaterialLayer;
|
||
use unlucky_design::icons::IconKind;
|
||
use unlucky_design::shape::ShapeScale;
|
||
use unlucky_design::spacing::Space;
|
||
use unlucky_design::typography::TypeRole;
|
||
use unlucky_i18n::{Catalog, Key};
|
||
|
||
use unlucky_presenter::dto::ConfirmRequest;
|
||
|
||
use crate::app::components::buttons::{self, Button};
|
||
use crate::theme::Ui;
|
||
|
||
use super::super::{Failure, Message};
|
||
use super::text_role_owned;
|
||
|
||
/// 失败横幅:`ErrorContainer` 图标块 + 标题/正文(M3 内联 alert 的形状,与账户页横幅同源)。
|
||
pub(super) fn failure<'a>(
|
||
ui: &'a Ui,
|
||
failure: Failure<'a>,
|
||
catalog: Catalog,
|
||
) -> Element<'a, Message> {
|
||
banner(
|
||
ui,
|
||
catalog.text(failure.title).to_string(),
|
||
failure.body.to_string(),
|
||
Vec::new(),
|
||
)
|
||
}
|
||
|
||
/// 写操作确认条:一行请求摘要 + Allow / Deny。
|
||
pub(super) fn confirm<'a>(
|
||
ui: &'a Ui,
|
||
request: &'a ConfirmRequest,
|
||
catalog: Catalog,
|
||
) -> Element<'a, Message> {
|
||
// 摘要来自 presenter(已过 `scrub_text`):它是「要改什么」的唯一描述,必须显示,
|
||
// 否则用户是在盲签。句框走 catalog,摘要本身是正文。
|
||
let title = catalog.render(
|
||
Key::AgentConfirmRequest,
|
||
&[("summary", request.summary.as_str())],
|
||
);
|
||
let actions: Vec<Element<'a, Message>> = vec![
|
||
buttons::outlined(
|
||
ui,
|
||
Button::new(catalog.text(Key::AgentConfirmDeny), Message::Confirm(false))
|
||
.leading(IconKind::Close),
|
||
),
|
||
buttons::filled(
|
||
ui,
|
||
Button::new(catalog.text(Key::AgentConfirmAllow), Message::Confirm(true))
|
||
.leading(IconKind::Check),
|
||
),
|
||
];
|
||
banner(ui, title, String::new(), actions)
|
||
}
|
||
|
||
/// 横幅本体:图标块 + 标题/正文 + 右侧动作。
|
||
///
|
||
/// 形状与 `pages::account::widgets::alert` 同源(面板底走卡片层 + 1dp 描边,
|
||
/// `ErrorContainer` / `OnErrorContainer` 只作左侧图标块的强调,不是通栏红条)。
|
||
fn banner<'a>(
|
||
ui: &'a Ui,
|
||
title: String,
|
||
body: String,
|
||
actions: Vec<Element<'a, Message>>,
|
||
) -> Element<'a, Message> {
|
||
let side = ui.space(Space::X8);
|
||
let glyph_px = ui.space(Space::X6);
|
||
let chip = container(crate::icons::view_at(
|
||
IconKind::Error,
|
||
ui.color(Role::Error),
|
||
glyph_px,
|
||
))
|
||
.width(Length::Fixed(side))
|
||
.height(Length::Fixed(side))
|
||
.align_x(Alignment::Center)
|
||
.align_y(Alignment::Center)
|
||
.style(move |_theme| container::Style {
|
||
background: Some(Background::Color(ui.color(Role::ErrorContainer))),
|
||
border: iced::Border {
|
||
radius: ui.radius(ShapeScale::Full, side),
|
||
..iced::Border::default()
|
||
},
|
||
..container::Style::default()
|
||
});
|
||
|
||
let mut lines = column![text_role_owned(
|
||
ui,
|
||
TypeRole::TitleSmall,
|
||
true,
|
||
title,
|
||
ui.color(Role::OnSurface),
|
||
Length::Fill,
|
||
)]
|
||
.spacing(ui.space(Space::X1));
|
||
if !body.is_empty() {
|
||
lines = lines.push(text_role_owned(
|
||
ui,
|
||
TypeRole::BodySmall,
|
||
false,
|
||
body,
|
||
ui.color(Role::OnSurfaceVariant),
|
||
Length::Fill,
|
||
));
|
||
}
|
||
|
||
// 文字行与动作**分行**:右栗只有 280~360,chip + 两个按钮已占掉大半宽,
|
||
// 把文字挤在同一条 Row 里会被压成一条竖缝(M119 实测:320 宽下标题整段看不见)。
|
||
let head = Row::new()
|
||
.spacing(ui.space(Space::X3))
|
||
.align_y(Alignment::Center)
|
||
.width(Length::Fill)
|
||
.push(chip)
|
||
.push(container(lines).width(Length::Fill));
|
||
|
||
let mut content = column![Element::from(head)].spacing(ui.space(Space::X2));
|
||
if !actions.is_empty() {
|
||
content = content.push(
|
||
container(
|
||
row(actions)
|
||
.spacing(ui.space(Space::X2))
|
||
.align_y(Alignment::Center),
|
||
)
|
||
.width(Length::Fill)
|
||
.align_x(Alignment::End),
|
||
);
|
||
}
|
||
|
||
container(content)
|
||
.width(Length::Fill)
|
||
.padding(ui.metrics().card_padding)
|
||
.style(move |_theme| container::Style {
|
||
background: Some(Background::Color(ui.layer(MaterialLayer::Card))),
|
||
border: ui.outlined(ShapeScale::Medium),
|
||
..container::Style::default()
|
||
})
|
||
.into()
|
||
}
|