11.1 常见 UI 模式

> **本节目标**:把绑定、组件、样式组合成可复用的 UI 解决方案,覆盖表单、列表、对话框、抽屉、Tab、向导六大模式。

11.1.1 表单模式

受控表单

每个字段双向绑定到 ViewModel,提交时统一校验。

<div class="form">
  <label>
    邮箱
    <input type="email" value={email} />
  </label>
  <label>
    密码
    <input type="password" value={password} />
  </label>
  <p if={errors.email} class="error">{errors.email}</p>
  <p if={errors.password} class="error">{errors.password}</p>
  <Button label="提交" on-click={on_submit} disabled={!can_submit} />
</div>
#[derive(IModel)]
pub struct FormViewModel {
    pub email: SharedString,
    pub password: SharedString,
    pub errors: FormErrors,
    pub is_submitting: bool,
}

#[derive(Model, Default)]
pub struct FormErrors {
    pub email: Option<SharedString>,
    pub password: Option<SharedString>,
}

impl FormViewModel {
    #[computed]
    pub fn can_submit(&self) -> bool {
        !self.is_submitting
            && !self.email.is_empty()
            && !self.password.is_empty()
            && self.errors.email.is_none()
            && self.errors.password.is_none()
    }

    #[command]
    pub fn on_submit(&mut self, _ev: &SubmitEvent, cx: &mut ViewContext<Self>) {
        self.validate();
        if self.errors.email.is_some() || self.errors.password.is_some() {
            cx.notify();
            return;
        }
        self.is_submitting = true;
        cx.notify();
        // 提交逻辑...
    }

    fn validate(&mut self) {
        self.errors.email = if self.email.is_empty() { Some("必填".into()) }
            else if !self.email.contains('@') { Some("格式错误".into()) }
            else { None };
        self.errors.password = if self.password.len() < 8 { Some("至少 8 位".into()) }
            else { None };
    }
}

**要点**:

- 校验在 ViewModel,不在模板 - `can_submit` 计算属性控制按钮 disabled - 错误信息作为状态字段,模板只读

动态表单

字段数量动态变化(如“添加更多”按钮):

<div class="form">
  <div each={field in fields} key={field.id} class="field-row">
    <input value={field.value} on-change={on_field_change} />
    <Button label="删除" on-click={remove_field} />
  </div>
  <Button label="添加字段" on-click={add_field} />
</div>
#[command]
pub fn add_field(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
    self.fields.push(Field { id: uuid(), value: "".into() });
    cx.notify();
}

#[command]
pub fn remove_field(&mut self, ev: &ClickEvent, cx: &mut ViewContext<Self>) {
    let id = ev.data::<u64>();
    self.fields.retain(|f| f.id != id);
    cx.notify();
}

11.1.2 列表模式

可筛选列表

<div class="list-view">
  <input value={filter} placeholder="筛选…" on-change={on_filter} />
  <p if={filtered_items.is_empty()}>无匹配项</p>
  <ul>
    <li each={item in filtered_items} key={item.id} on-click={select}>
      <span>{item.title}</span>
      <span if={item.id == selected_id} class="check">✓</span>
    </li>
  </ul>
</div>
#[derive(IModel)]
pub struct ListViewModel {
    pub items: Vec<Item>,
    pub filter: SharedString,
    pub selected_id: Option<u64>,
}

impl ListViewModel {
    #[computed]
    pub fn filtered_items(&self) -> Vec<Item> {
        let f = self.filter.to_lowercase();
        self.items.iter()
            .filter(|i| i.title.to_lowercase().contains(&f))
            .cloned()
            .collect()
    }

    #[command]
    pub fn select(&mut self, ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        self.selected_id = ev.data::<u64>();
        cx.notify();
    }
}

分页列表

#[derive(IModel)]
pub struct PagedViewModel {
    pub items: Vec<Item>,
    pub page: usize,
    pub page_size: usize,
}

impl PagedViewModel {
    #[computed]
    pub fn current_page_items(&self) -> &[Item] {
        let start = self.page * self.page_size;
        let end = (start + self.page_size).min(self.items.len());
        &self.items[start..end]
    }

    #[computed]
    pub fn total_pages(&self) -> usize {
        (self.items.len() + self.page_size - 1) / self.page_size
    }

    #[command]
    pub fn next_page(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        if self.page + 1 < self.total_pages() {
            self.page += 1;
            cx.notify();
        }
    }
}

虚拟滚动大列表

<VirtualList items={items} item-height="40" height="600">
  <template slot="item">
    <div class="row">{title}</div>
  </template>
</VirtualList>

11.1.3 对话框模式

受控对话框

对话框的显隐由父视图状态控制,自身只管内容。

<!-- 父视图 -->
<div>
  <Button label="打开" on-click={open_dialog} />
  <Dialog open={is_dialog_open} title="确认删除?" on-confirm={confirm_delete} on-cancel={close_dialog} />
</div>
#[command]
pub fn open_dialog(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
    self.is_dialog_open = true;
    cx.notify();
}

#[command]
pub fn confirm_delete(&mut self, _ev: &ConfirmEvent, cx: &mut ViewContext<Self>) {
    self.is_dialog_open = false;
    self.delete_item(cx);
}

#[command]
pub fn close_dialog(&mut self, _ev: &CancelEvent, cx: &mut ViewContext<Self>) {
    self.is_dialog_open = false;
    cx.notify();
}

全局对话框服务

跨视图复用的对话框通过 Context 服务:

pub struct DialogService { current: Option<DialogConfig> }

impl DialogService {
    pub fn show(&mut self, config: DialogConfig, cx: &mut AppContext) {
        self.current = Some(config);
        cx.notify_global();
    }
}

任何视图调用 `cx.global::<DialogService>().show(...)` 即可弹窗。

11.1.4 抽屉模式

<div class="drawer-container">
  <div class="content">{children}</div>
  <aside class="drawer" class:open={is_drawer_open}>
    <header>
      <h2>{drawer_title}</h2>
      <Button label="✕" on-click={close_drawer} />
    </header>
    <div class="drawer-body">
      <template slot="drawer-content" />
    </div>
  </aside>
</div>

抽屉组件接受 `is_open`、`title` props 和 `drawer-content` 插槽,父视图控制状态。

11.1.5 Tab 模式

<div class="tabs">
  <div class="tab-headers">
    <Button each={tab in tabs} key={tab.id}
            class:active={tab.id == active_tab}
            on-click={switch_tab}
            label={tab.label} />
  </div>
  <div class="tab-content">
    <div if={active_tab == 'profile'}><ProfileTab /></div>
    <div if={active_tab == 'settings'}><SettingsTab /></div>
  </div>
</div>
#[command]
pub fn switch_tab(&mut self, ev: &ClickEvent, cx: &mut ViewContext<Self>) {
    self.active_tab = ev.data::<SharedString>();
    cx.notify();
}

**优化**:用枚举而非字符串表示 tab,避免拼写错误:

#[derive(Model, Clone, PartialEq)]
pub enum Tab { Profile, Settings }

#[derive(IModel)]
pub struct TabsVM { pub active: Tab }

11.1.6 向导模式(多步表单)

#[derive(IModel)]
pub struct WizardViewModel {
    pub step: usize,
    pub total_steps: usize,
    pub data: WizardData,
}

impl WizardViewModel {
    #[computed]
    pub fn can_next(&self) -> bool {
        match self.step {
            0 => !self.data.name.is_empty(),
            1 => self.data.email.contains('@'),
            2 => !self.data.password.is_empty(),
            _ => false,
        }
    }

    #[command]
    pub fn next(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        if self.can_next() && self.step < self.total_steps - 1 {
            self.step += 1;
            cx.notify();
        }
    }

    #[command]
    pub fn prev(&mut self, _ev: &ClickEvent, cx: &mut ViewContext<Self>) {
        if self.step > 0 {
            self.step -= 1;
            cx.notify();
        }
    }
}
<div class="wizard">
  <div class="steps">
    <span each={i in step_indices} key={i} class:active={i == step} />
  </div>
  <div class="step-content">
    <div if={step == 0}><input value={data.name} placeholder="姓名" /></div>
    <div if={step == 1}><input value={data.email} placeholder="邮箱" /></div>
    <div if={step == 2}><input value={data.password} type="password" /></div>
  </div>
  <div class="actions">
    <Button if={step > 0} label="上一步" on-click={prev} />
    <Button if={step < total_steps - 1} label="下一步" on-click={next} disabled={!can_next} />
    <Button if={step == total_steps - 1} label="完成" on-click={finish} />
  </div>
</div>

11.1.7 模式速查

| 需求 | 推荐模式 | | ----------- | --------------------- | | 用户输入并提交 | 受控表单 + 计算属性校验 | | 大量数据展示 | 虚拟列表 + 分页 | | 临时确认 | 受控对话框 / 全局对话框服务 | | 侧边详情 | 抽屉组件 + 插槽 | | 多视图切换 | Tab + 枚举状态 | | 多步流程 | 向导 + 步骤枚举 | | 实时搜索 | 防抖 input + 计算属性筛选 | | 拖拽排序 | `each` + 拖拽事件 + 重排命令 |

下一节 → [11.2 案例研究:Todo 应用](./case-study.md)