Skip to Content
Week 10Day 5 - 详情与编辑

Day 5 - 详情与编辑

建议用时:180-210 分钟

你将学会什么

  • 详情和编辑有什么区别
  • 为什么编辑表单不要直接改原对象
  • 什么是编辑草稿
  • 保存时如何校验名称、价格、库存
  • 取消时如何恢复选中商品数据
  • 保存后如何刷新列表和选中项

今天重点是“选中一条商品后编辑它”。不要让输入框直接改列表里的原对象。更稳的做法是:选中商品时把数据复制到编辑字段,点击保存后再写回列表。

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“主列表页面”,今天继续学习“详情与编辑”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“详情与编辑”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。编辑页面最重要的问题是:输入框里的内容是否应该立刻改列表里的原数据。

详情和编辑的区别

详情是“看”。

编辑是“改”。

详情可以直接显示选中商品:

SelectedProduct.Name SelectedProduct.Price SelectedProduct.Stock

编辑最好使用单独字段:

EditName EditPriceText EditStockText

原因是:用户输入过程中可能还没保存,也可能点取消。

什么是编辑草稿

编辑草稿就是临时放在输入框里的数据。

本页的草稿字段是:

EditName EditPriceText EditStockText

当用户选中商品时,把商品复制到草稿:

EditName = SelectedProduct.Name; EditPriceText = SelectedProduct.Price.ToString(); EditStockText = SelectedProduct.Stock.ToString();

当用户点击保存时,再把草稿写回商品列表。

为什么不直接改原对象

如果输入框直接绑定 SelectedProduct.Name,用户每输入一个字,原对象就变了。

这样会导致:

  • 用户还没保存,列表已经变化。
  • 用户点取消时,需要额外记录原始值。
  • 输入了错误价格时,原数据可能已经被污染。

用编辑草稿可以避免这些问题。

保存时为什么替换整条商品

本页保存时不是直接修改原对象,而是创建一个 updated

var updated = new ProductItem { Id = SelectedProduct.Id, Name = name, Price = price, Stock = stock, CreatedAt = SelectedProduct.CreatedAt, UpdatedAt = DateTime.Now };

然后替换列表里的那一条:

Products[index] = updated; SelectedProduct = updated;

这样列表能明确刷新,选中项也会指向新对象。

保存时要校验什么

根据 Day 1 的需求,保存至少校验:

字段规则
名称不能为空,不能超过 50 个字符
价格必须是数字,并且大于 0
库存必须是整数,并且大于或等于 0

这些校验失败时,应该显示 Message,并且不保存。

取消到底做什么

取消不是删除商品。

取消只是放弃当前输入框里的草稿,把草稿恢复成选中商品的原数据。

对应代码:

LoadSelectedProductIntoEditor(); Message = $"已取消编辑:{SelectedProduct.Name}";

详情与编辑常用写法速查

需求常用写法
选中后显示详情SelectedProduct
编辑草稿字段EditNameEditPriceTextEditStockText
进入编辑把选中项复制到草稿
保存编辑校验草稿后写回选中项
取消编辑清空草稿或重新复制选中项
禁用保存CanSave
错误提示ErrorMessage

为什么要用草稿字段:

用户正在输入时,原商品不应该马上被改坏 点击保存后,校验通过才写回 SelectedProduct 点击取消时,原商品保持不变

第二部分:把知识组合成完整例子

今天做一个“列表 + 编辑表单”页面:

  • 左侧选择商品。
  • 右侧显示编辑表单。
  • 修改名称、价格、库存。
  • 点击保存后,列表里的商品更新。
  • 点击取消后,编辑表单恢复为选中商品原数据。
  • 输入错误时显示提示,不保存。

ViewModel

文件位置:

ViewModels/MainWindowViewModel.cs
using System; using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private ProductItem? selectedProduct; [ObservableProperty] private string editName = ""; [ObservableProperty] private string editPriceText = ""; [ObservableProperty] private string editStockText = ""; [ObservableProperty] private string message = "请选择商品后编辑。"; public MainWindowViewModel() { Products.Add(CreateProduct("Keyboard", 199m, 10)); Products.Add(CreateProduct("Mouse", 59m, 30)); Products.Add(CreateProduct("Monitor", 899m, 5)); SelectedProduct = Products.FirstOrDefault(); } partial void OnSelectedProductChanged(ProductItem? value) { LoadSelectedProductIntoEditor(); if (value is not null) { Message = $"正在编辑:{value.Name}"; } } [RelayCommand] private void SaveEdit() { if (SelectedProduct is null) { Message = "请先选择商品。"; return; } string name = EditName.Trim(); if (string.IsNullOrWhiteSpace(name)) { Message = "商品名称不能为空。"; return; } if (name.Length > 50) { Message = "商品名称不能超过 50 个字符。"; return; } if (!decimal.TryParse(EditPriceText, out decimal price) || price <= 0) { Message = "价格必须是大于 0 的数字。"; return; } if (!int.TryParse(EditStockText, out int stock) || stock < 0) { Message = "库存必须是大于或等于 0 的整数。"; return; } var updated = new ProductItem { Id = SelectedProduct.Id, Name = name, Price = price, Stock = stock, CreatedAt = SelectedProduct.CreatedAt, UpdatedAt = DateTime.Now }; int index = Products.IndexOf(SelectedProduct); if (index < 0) { Message = "要保存的商品不存在。"; return; } Products[index] = updated; SelectedProduct = updated; Message = $"已保存:{updated.Name}"; } [RelayCommand] private void CancelEdit() { if (SelectedProduct is null) { Message = "没有可取消的编辑。"; return; } LoadSelectedProductIntoEditor(); Message = $"已取消编辑:{SelectedProduct.Name}"; } private void LoadSelectedProductIntoEditor() { if (SelectedProduct is null) { EditName = ""; EditPriceText = ""; EditStockText = ""; return; } EditName = SelectedProduct.Name; EditPriceText = SelectedProduct.Price.ToString(); EditStockText = SelectedProduct.Stock.ToString(); } private static ProductItem CreateProduct(string name, decimal price, int stock) { DateTime now = DateTime.Now; return new ProductItem { Id = Guid.NewGuid(), Name = name, Price = price, Stock = stock, CreatedAt = now, UpdatedAt = now }; } } public sealed class ProductItem { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } }

窗口

文件位置:

Views/MainWindow.axaml
<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="860" Height="560" Title="Product Edit"> <Grid RowDefinitions="Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="商品详情与编辑" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="{Binding Message}" /> <Grid Grid.Row="2" ColumnDefinitions="320,*" ColumnSpacing="16"> <ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,80,60" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> <TextBlock Grid.Column="2" Text="{Binding Stock}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> <StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="编辑商品" FontSize="20" FontWeight="Bold" /> <TextBlock Text="名称" /> <TextBox Text="{Binding EditName, Mode=TwoWay}" /> <TextBlock Text="价格" /> <TextBox Text="{Binding EditPriceText, Mode=TwoWay}" /> <TextBlock Text="库存" /> <TextBox Text="{Binding EditStockText, Mode=TwoWay}" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button Content="保存" Command="{Binding SaveEditCommand}" /> <Button Content="取消" Command="{Binding CancelEditCommand}" /> </StackPanel> </StackPanel> </Grid> </Grid> </Window>

第三部分:跟着敲代码

从这里开始动手。打开 Avalonia 项目。

第 1 步:准备商品模型

public sealed class ProductItem { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } }

解释:

  • 这里加入了创建时间和更新时间。
  • 编辑保存时只改变 UpdatedAt

第 2 步:写列表和选中项

public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private ProductItem? selectedProduct;

解释:

  • Products 给列表显示。
  • SelectedProduct 代表当前正在编辑哪一条。

第 3 步:写编辑草稿字段

[ObservableProperty] private string editName = ""; [ObservableProperty] private string editPriceText = ""; [ObservableProperty] private string editStockText = ""; [ObservableProperty] private string message = "请选择商品后编辑。";

解释:

  • 输入框绑定这些草稿字段。
  • 不是直接绑定 SelectedProduct

第 4 步:选中商品时加载到草稿

partial void OnSelectedProductChanged(ProductItem? value) { LoadSelectedProductIntoEditor(); if (value is not null) { Message = $"正在编辑:{value.Name}"; } }

解释:

  • 每次选中项变化,就刷新编辑表单。

第 5 步:写加载草稿方法

private void LoadSelectedProductIntoEditor() { if (SelectedProduct is null) { EditName = ""; EditPriceText = ""; EditStockText = ""; return; } EditName = SelectedProduct.Name; EditPriceText = SelectedProduct.Price.ToString(); EditStockText = SelectedProduct.Stock.ToString(); }

解释:

  • 有选中商品时,把商品数据复制到输入框。
  • 没有选中商品时,清空输入框。

第 6 步:写保存校验

if (SelectedProduct is null) { Message = "请先选择商品。"; return; } string name = EditName.Trim(); if (string.IsNullOrWhiteSpace(name)) { Message = "商品名称不能为空。"; return; } if (name.Length > 50) { Message = "商品名称不能超过 50 个字符。"; return; }

解释:

  • 没选中商品不能保存。
  • 名称先去掉前后空格。
  • 名称为空或太长都不能保存。

第 7 步:继续校验价格和库存

if (!decimal.TryParse(EditPriceText, out decimal price) || price <= 0) { Message = "价格必须是大于 0 的数字。"; return; } if (!int.TryParse(EditStockText, out int stock) || stock < 0) { Message = "库存必须是大于或等于 0 的整数。"; return; }

解释:

  • TryParse 失败说明不是合法数字。
  • 价格要大于 0。
  • 库存不能小于 0。

第 8 步:保存时替换列表项

var updated = new ProductItem { Id = SelectedProduct.Id, Name = name, Price = price, Stock = stock, CreatedAt = SelectedProduct.CreatedAt, UpdatedAt = DateTime.Now }; int index = Products.IndexOf(SelectedProduct); if (index < 0) { Message = "要保存的商品不存在。"; return; } Products[index] = updated; SelectedProduct = updated; Message = $"已保存:{updated.Name}";

解释:

  • 保留原来的 Id 和 CreatedAt。
  • 更新时间改成当前时间。
  • 替换列表里的旧商品。

第 9 步:写取消命令

[RelayCommand] private void CancelEdit() { if (SelectedProduct is null) { Message = "没有可取消的编辑。"; return; } LoadSelectedProductIntoEditor(); Message = $"已取消编辑:{SelectedProduct.Name}"; }

解释:

  • 取消只是恢复输入框。
  • 不改变列表里的商品。

第 10 步:写编辑表单 XAML

<StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="编辑商品" FontSize="20" FontWeight="Bold" /> <TextBlock Text="名称" /> <TextBox Text="{Binding EditName, Mode=TwoWay}" /> <TextBlock Text="价格" /> <TextBox Text="{Binding EditPriceText, Mode=TwoWay}" /> <TextBlock Text="库存" /> <TextBox Text="{Binding EditStockText, Mode=TwoWay}" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button Content="保存" Command="{Binding SaveEditCommand}" /> <Button Content="取消" Command="{Binding CancelEditCommand}" /> </StackPanel> </StackPanel>

解释:

  • 输入框绑定草稿字段。
  • 保存按钮调用 SaveEditCommand
  • 取消按钮调用 CancelEditCommand

第 11 步:运行后检查

运行后按顺序检查:

  1. 选中 Keyboard
  2. 把名称改成 Keyboard Pro,点击保存。
  3. 左侧列表变成 Keyboard Pro
  4. 把价格改成 abc,点击保存。
  5. 状态提示显示价格错误,列表不变化。
  6. 修改名称后点击取消,输入框恢复选中商品原数据。
  7. 不选商品时保存,提示先选择商品。

常见错误和修法

错误为什么错修法
编辑时直接改列表对象取消编辑也会污染原数据用编辑副本,保存时再覆盖
价格输入直接用 decimal输入过程可能临时非法输入框用 string,保存时 TryParse
保存后不刷新列表页面显示旧数据保存成功后重新加载或更新集合
删除前不确认容易误删至少在逻辑上保留确认入口
没有处理未选中状态点击编辑或删除会空引用未选中时禁用按钮或提示

小白重复敲写训练

编辑功能先练草稿,不直接修改原对象。

训练 1:把选中商品复制到草稿

private void LoadDraft(Product product) { DraftName = product.Name; DraftPrice = product.Price.ToString(); DraftStock = product.Stock.ToString(); }

选中两条不同商品,确认草稿跟着切换。

训练 2:取消时恢复草稿

[RelayCommand] private void CancelEdit() { if (SelectedProduct is not null) { LoadDraft(SelectedProduct); } }

改动草稿后点击取消,确认原对象没有变化。

训练 3:校验后保存

if (string.IsNullOrWhiteSpace(DraftName)) { ErrorMessage = "名称不能为空"; return; } if (!decimal.TryParse(DraftPrice, out decimal price) || price < 0) { ErrorMessage = "价格格式不正确"; return; }

第三遍补库存校验和成功保存逻辑。

每日小测

做完本页后,用这 5 题检查是否真的掌握。

1. 判断题

本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。

答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。

2. 填空题

本页主题是:详情与编辑。今天至少要掌握的 3 个点是:

1. 详情和编辑有什么区别 2. 为什么编辑表单不要直接改原对象 3. 什么是编辑草稿

答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。

3. 流程题

遇到本页相关功能时,先按什么顺序处理?

答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。

4. 找错误题

如果本页代码运行失败,第一步应该做什么?

答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。

5. 改需求题

在本页完整例子跑通后,至少改一个小需求。

可选改法:

  • 改一个字段名称。
  • 多加一个校验条件。
  • 多输出一行结果。
  • 把固定数据改成用户输入。
  • 把一次处理改成多条数据处理。

答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:详情和编辑有什么区别。

上位机专项练习

详情页要回答设备是谁、是否在线、有哪些点位、最近值是多少、能执行哪些操作。

下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。

专项例子 1:设备详情头部

<StackPanel> <TextBlock Text="{Binding SelectedDevice.Name}" FontSize="30" /> <TextBlock Text="{Binding SelectedDevice.ConnectionText}" /> <TextBlock Text="{Binding SelectedDevice.LastSeenText}" /> </StackPanel>

运行结果或界面效果:

设备名、连接状态和最后通信时间清楚

改动任务: 增加 IP 地址。

专项例子 2:点位实时表

<DataGrid ItemsSource="{Binding SelectedDevice.Tags}" AutoGenerateColumns="False"> <DataGrid.Columns> <DataGridTextColumn Header="点位" Binding="{Binding Name}" /> <DataGridTextColumn Header="实时值" Binding="{Binding ValueText}" /> <DataGridTextColumn Header="质量" Binding="{Binding Quality}" /> </DataGrid.Columns> </DataGrid>

运行结果或界面效果:

每个点位显示值和质量

改动任务: 增加更新时间列。

专项例子 3:编辑报警阈值

[RelayCommand] private void SaveLimit() { if (SelectedTag is null) return; tagRepository.UpdateLimit(SelectedTag.Id, SelectedTag.AlarmLimit); Message = "阈值已保存"; }

运行结果或界面效果:

选中点位后可保存阈值

改动任务: 保存前检查上下限关系。

第四部分:作业完整答案

作业要求:完成商品详情与编辑页面,必须包含编辑草稿、保存校验、取消恢复、列表刷新。

答案文件 1:ViewModels/MainWindowViewModel.cs

using System; using System.Collections.ObjectModel; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; namespace ProductApp.ViewModels; public partial class MainWindowViewModel : ViewModelBase { public ObservableCollection<ProductItem> Products { get; } = new(); [ObservableProperty] private ProductItem? selectedProduct; [ObservableProperty] private string editName = ""; [ObservableProperty] private string editPriceText = ""; [ObservableProperty] private string editStockText = ""; [ObservableProperty] private string message = "请选择商品后编辑。"; public MainWindowViewModel() { Products.Add(CreateProduct("Keyboard", 199m, 10)); Products.Add(CreateProduct("Mouse", 59m, 30)); Products.Add(CreateProduct("Monitor", 899m, 5)); SelectedProduct = Products.FirstOrDefault(); } partial void OnSelectedProductChanged(ProductItem? value) { LoadSelectedProductIntoEditor(); if (value is not null) { Message = $"正在编辑:{value.Name}"; } } [RelayCommand] private void SaveEdit() { if (SelectedProduct is null) { Message = "请先选择商品。"; return; } string name = EditName.Trim(); if (string.IsNullOrWhiteSpace(name)) { Message = "商品名称不能为空。"; return; } if (name.Length > 50) { Message = "商品名称不能超过 50 个字符。"; return; } if (!decimal.TryParse(EditPriceText, out decimal price) || price <= 0) { Message = "价格必须是大于 0 的数字。"; return; } if (!int.TryParse(EditStockText, out int stock) || stock < 0) { Message = "库存必须是大于或等于 0 的整数。"; return; } var updated = new ProductItem { Id = SelectedProduct.Id, Name = name, Price = price, Stock = stock, CreatedAt = SelectedProduct.CreatedAt, UpdatedAt = DateTime.Now }; int index = Products.IndexOf(SelectedProduct); if (index < 0) { Message = "要保存的商品不存在。"; return; } Products[index] = updated; SelectedProduct = updated; Message = $"已保存:{updated.Name}"; } [RelayCommand] private void CancelEdit() { if (SelectedProduct is null) { Message = "没有可取消的编辑。"; return; } LoadSelectedProductIntoEditor(); Message = $"已取消编辑:{SelectedProduct.Name}"; } private void LoadSelectedProductIntoEditor() { if (SelectedProduct is null) { EditName = ""; EditPriceText = ""; EditStockText = ""; return; } EditName = SelectedProduct.Name; EditPriceText = SelectedProduct.Price.ToString(); EditStockText = SelectedProduct.Stock.ToString(); } private static ProductItem CreateProduct(string name, decimal price, int stock) { DateTime now = DateTime.Now; return new ProductItem { Id = Guid.NewGuid(), Name = name, Price = price, Stock = stock, CreatedAt = now, UpdatedAt = now }; } } public sealed class ProductItem { public Guid Id { get; set; } public string Name { get; set; } = ""; public decimal Price { get; set; } public int Stock { get; set; } public DateTime CreatedAt { get; set; } public DateTime UpdatedAt { get; set; } }

答案文件 2:Views/MainWindow.axaml

<Window xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:vm="using:ProductApp.ViewModels" x:Class="ProductApp.Views.MainWindow" x:DataType="vm:MainWindowViewModel" Width="860" Height="560" Title="Product Edit"> <Grid RowDefinitions="Auto,Auto,*" Margin="20" RowSpacing="12"> <TextBlock Text="商品详情与编辑" FontSize="24" FontWeight="Bold" /> <TextBlock Grid.Row="1" Text="{Binding Message}" /> <Grid Grid.Row="2" ColumnDefinitions="320,*" ColumnSpacing="16"> <ListBox ItemsSource="{Binding Products}" SelectedItem="{Binding SelectedProduct, Mode=TwoWay}"> <ListBox.ItemTemplate> <DataTemplate x:DataType="vm:ProductItem"> <Grid ColumnDefinitions="160,80,60" Margin="4"> <TextBlock Text="{Binding Name}" /> <TextBlock Grid.Column="1" Text="{Binding Price}" /> <TextBlock Grid.Column="2" Text="{Binding Stock}" /> </Grid> </DataTemplate> </ListBox.ItemTemplate> </ListBox> <StackPanel Grid.Column="1" Spacing="8"> <TextBlock Text="编辑商品" FontSize="20" FontWeight="Bold" /> <TextBlock Text="名称" /> <TextBox Text="{Binding EditName, Mode=TwoWay}" /> <TextBlock Text="价格" /> <TextBox Text="{Binding EditPriceText, Mode=TwoWay}" /> <TextBlock Text="库存" /> <TextBox Text="{Binding EditStockText, Mode=TwoWay}" /> <StackPanel Orientation="Horizontal" Spacing="8"> <Button Content="保存" Command="{Binding SaveEditCommand}" /> <Button Content="取消" Command="{Binding CancelEditCommand}" /> </StackPanel> </StackPanel> </Grid> </Grid> </Window>

验收结果

运行后必须看到:

  1. 选中商品后,右侧输入框显示对应数据。
  2. 修改名称并保存后,左侧列表同步更新。
  3. 价格输入 abc 时不能保存。
  4. 库存输入 -1 时不能保存。
  5. 名称清空时不能保存。
  6. 点击取消后,输入框恢复选中商品原数据。
  7. 保存成功后 UpdatedAt 被更新。

为什么这个答案是对的

这个答案把编辑流程拆清楚了:

动作代码作用
选择商品SelectedProduct确定当前编辑对象
加载草稿LoadSelectedProductIntoEditor把原数据复制到输入框
校验输入SaveEdit拦住错误名称、价格、库存
保存修改Products[index] = updated替换列表中的商品
取消修改CancelEdit恢复输入框,不改原数据

编辑页面的关键是:输入框里的内容是草稿,点击保存才真正写回商品。