用ASP.NET Core MVC 和 EF Core 构建Web应用 (七)
上一节显示出了相关数据,本节将通过更新外键字段和导航属性来更新相关数据。
自定义课程的创建和编辑页面
创建新的课程实体时,新实体必须与现有院系有关系。 为此,基架代码需包括控制器方法、创建视图和编辑视图,且视图中应包括用于选择院系的下拉列表。 下拉列表设置了 Course.DepartmentID
外键属性,而这正是 Entity Framework 使用适当的 Department 实体加载 Department
导航属性所需要的。 将用到基架代码,但需对其稍作更改,以便添加错误处理和对下拉列表进行排序。
在 CoursesController.cs 中,删除四种 Create 和 Edit 方法,并将其替换为以下代码:
public IActionResult Create() { PopulateDepartmentsDropDownList(); return View(); }
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Create([Bind("CourseID,Credits,DepartmentID,Title")] Course course) { if (ModelState.IsValid) { _context.Add(course); await _context.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } PopulateDepartmentsDropDownList(course.DepartmentID); return View(course); }
public async Task<IActionResult> Edit(int? id) { if (id == null) { return NotFound(); } var course = await _context.Courses .AsNoTracking() .SingleOrDefaultAsync(m => m.CourseID == id); if (course == null) { return NotFound(); } PopulateDepartmentsDropDownList(course.DepartmentID); return View(course); }
[HttpPost, ActionName("Edit")] [ValidateAntiForgeryToken] public async Task<IActionResult> EditPost(int? id) { if (id == null) { return NotFound(); } var courseToUpdate = await _context.Courses .SingleOrDefaultAsync(c => c.CourseID == id); if (await TryUpdateModelAsync<Course>(courseToUpdate, "", c => c.Credits, c => c.DepartmentID, c => c.Title)) { try { await _context.SaveChangesAsync(); } catch (DbUpdateException /* ex */) { //Log the error (uncomment ex variable name and write a log.) ModelState.AddModelError("", "Unable to save changes. " + "Try again, and if the problem persists, " + "see your system administrator."); } return RedirectToAction(nameof(Index)); } PopulateDepartmentsDropDownList(courseToUpdate.DepartmentID); return View(courseToUpdate); }
在 Edit
HttpPost 方法之后,新建一个方法来为下拉列表加载院系信息。
private void PopulateDepartmentsDropDownList(object selectedDepartment = null) { var departmentsQuery = from d in _context.Departments orderby d.Name select d; ViewBag.DepartmentID = new SelectList(departmentsQuery.AsNoTracking(), "DepartmentID", "Name", selectedDepartment); }
PopulateDepartmentsDropDownList
方法获取按名称排序的所有院系的列表,为下拉列表创建 SelectList
集合,并将该集合传递给 ViewBag
中的视图。 该方法可以使用可选的 selectedDepartment
参数,而调用的代码可以通过该参数来指定呈现下拉列表时被选择的项。 视图将 DepartmentID 名称传递给 <select>
标记帮助器,该帮助器就知道在 ViewBag
对象中查找名为 DepartmentID 的 SelectList
。
HttpGet Create
方法调用 PopulateDepartmentsDropDownList
方法,但不会设置选定项,因为对于新课程而言,其院系尚未建立:
public IActionResult Create() { PopulateDepartmentsDropDownList(); return View(); }
HttpGet Edit
方法根据正在编辑的课程已分配到的院系 ID 设置选定项:
public async Task<IActionResult> Edit(int? id) { if (id == null) { return NotFound(); } var course = await _context.Courses .AsNoTracking() .SingleOrDefaultAsync(m => m.CourseID == id); if (course == null) { return NotFound(); } PopulateDepartmentsDropDownList(course.DepartmentID); return View(course); }
Create
和 Edit
这二者的 HttpPost 方法还包括一段代码,用于在错误后重新显示页面时设置选定项。 这样可以确保当页面重新显示出现错误消息时,选择的任何院系都将保持选中状态。
将 .AsNoTracking 添加到 Details 和 Delete 方法
为优化“课程详细信息”和“删除”页面的性能,请在 Details
和 HttpGet Delete
方法中添加 AsNoTracking
调用。
public async Task<IActionResult> Details(int? id) { if (id == null) { return NotFound(); } var course = await _context.Courses .Include(c => c.Department) .AsNoTracking() .SingleOrDefaultAsync(m => m.CourseID == id); if (course == null) { return NotFound(); } return View(course); }
public async Task<IActionResult> Delete(int? id) { if (id == null) { return NotFound(); } var course = await _context.Courses .Include(c => c.Department) .AsNoTracking() .SingleOrDefaultAsync(m => m.CourseID == id); if (course == null) { return NotFound(); } return View(course); }
修改课程视图
在 Views/Courses/Create.cshtml 中,向“院系”下拉列表添加一个“选择院系”选项,将标题从 DepartmentID 更改为 Department,并添加一条验证消息。
<div class="form-group"> <label asp-for="Department" class="control-label"></label> <select asp-for="DepartmentID" class="form-control" asp-items="ViewBag.DepartmentID"> <option value="">-- Select Department --</option> </select> <span asp-validation-for="DepartmentID" class="text-danger" />
在 Views/Courses/Edit.cshtml 中,对“院系”字段进行与 Create.cshtml 中相同的更改。
另外,在 Views/Courses/Edit.cshtml 中,在“标题”字段之前添加一个课程编号字段。 课程编号是主键,因此只会显示,无法更改。
<div class="form-group"> <label asp-for="CourseID" class="control-label"></label> <div>@Html.DisplayFor(model => model.CourseID)</div> </div>
“编辑”视图中已有一个隐藏的课程编号字段(<input type="hidden">
。 添加 <label>
标记帮助器后仍然需要该隐藏字段,因为添加标记帮助器后,用户在“编辑”页面上单击“保存”时,已发布数据中并不会包含课程编号。
在 Views/Courses/Delete.cshtml 中,在顶部添加一个课程编号字段,并将院系 ID 更改为院系名称。
@model ContosoUniversity.Models.Course @{ ViewData["Title"] = "Delete"; } <h2>Delete</h2> <h3>Are you sure you want to delete this?</h3> <div> <h4>Course</h4> <hr /> <dl class="dl-horizontal"> <dt> @Html.DisplayNameFor(model => model.CourseID) </dt> <dd> @Html.DisplayFor(model => model.CourseID) </dd> <dt> @Html.DisplayNameFor(model => model.Title) </dt> <dd> @Html.DisplayFor(model => model.Title) </dd> <dt> @Html.DisplayNameFor(model => model.Credits) </dt> <dd> @Html.DisplayFor(model => model.Credits) </dd> <dt> @Html.DisplayNameFor(model => model.Department) </dt> <dd> @Html.DisplayFor(model => model.Department.Name) </dd> </dl> <form asp-action="Delete"> <div class="form-actions no-color"> <input type="submit" value="Delete" class="btn btn-default" /> | <a asp-action="Index">Back to List</a> </div> </form> </div>
在 Views/Courses/Details.cshtml 中,进行对 Delete.cshtml 所作相同的更改。
测试“课程”页
运行应用,选择“课程”选项卡,单击“新建”,然后输入新课程的数据:
单击 “创建”。 课程索引页面随即显示,并且新课程已添加在列表中。 索引页列表中的院系名称来自导航属性,表明已正确建立关系。
在课程索引页中的课程上,单击“编辑”。
更改页面上的数据,然后单击“保存”。 含有更新后的课程数据的“课程索引”页面随即显示。
添加讲师的编辑页面
编辑讲师记录时,有时希望能更新讲师的办公室分配。 Instructor 实体和 OfficeAssignment 实体之间存在一对零或一的关系,这意味着代码必须处理一下情况:
-
如果用户清除了办公室分配,并且办公室分配最初具有一个值,则删除 OfficeAssignment 实体。
-
如果用户输入了办公室分配值,并且该值最初为空,则创建一个新的 OfficeAssignment 实体。
-
如果用户更改了办公室分配的值,则更改现有 OfficeAssignment 实体中的值。
更新讲师控制器
在 InstructorsController.cs 中,更改 HttpGet Edit
方法中的代码,使其加载 Instructor 实体的 OfficeAssignment
导航属性并调用 AsNoTracking
:
public async Task<IActionResult> Edit(int? id) { if (id == null) { return NotFound(); } var instructor = await _context.Instructors .Include(i => i.OfficeAssignment) .AsNoTracking() .SingleOrDefaultAsync(m => m.ID == id); if (instructor == null) { return NotFound(); } return View(instructor); }
将 HttpPost Edit
方法更新为以下代码,以便处理办公室分配更新:
[HttpPost, ActionName("Edit")] [ValidateAntiForgeryToken] public async Task<IActionResult> EditPost(int? id) { if (id == null) { return NotFound(); } var instructorToUpdate = await _context.Instructors .Include(i => i.OfficeAssignment) .SingleOrDefaultAsync(s => s.ID == id); if (await TryUpdateModelAsync<Instructor>( instructorToUpdate, "", i => i.FirstMidName, i => i.LastName, i => i.HireDate, i => i.OfficeAssignment)) { if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment?.Location)) { instructorToUpdate.OfficeAssignment = null; } try { await _context.SaveChangesAsync(); } catch (DbUpdateException /* ex */) { //Log the error (uncomment ex variable name and write a log.) ModelState.AddModelError("", "Unable to save changes. " + "Try again, and if the problem persists, " + "see your system administrator."); } return RedirectToAction(nameof(Index)); } return View(instructorToUpdate); }
该代码执行以下操作:
-
将方法名称更改为
EditPost
,因为现在的签名与 HttpGetEdit
方法相同(ActionName
特性指定仍然使用/Edit/
URL)。 -
使用
OfficeAssignment
导航属性的预先加载从数据库获取当前的 Instructor 实体。此操作与在 HttpGetEdit
方法中进行的操作相同。 -
将检索出的 Instructor 实体更新为模型绑定器中的值。 通过
TryUpdateModel
重载可以将想包括的属性列入到允许列表。 这样可以防止以前所述的过度发布。
if (await TryUpdateModelAsync<Instructor>( instructorToUpdate, "", i => i.FirstMidName, i => i.LastName, i => i.HireDate, i => i.OfficeAssignment)
-
如果办公室位置为空,请将 Instructor.OfficeAssignment 属性设置为 NULL,以便删除 OfficeAssignment 表中的相关行。
if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment?.Location)) { instructorToUpdate.OfficeAssignment = null; }
-
将更改保存到数据库。
更新讲师编辑视图
在 Views/Instructors/Edit.cshtml 中,在“保存”按钮之前的末尾处,添加一个用于编辑办公室位置的新字段:
<div class="form-group"> <label asp-for="OfficeAssignment.Location" class="control-label"></label> <input asp-for="OfficeAssignment.Location" class="form-control" /> <span asp-validation-for="OfficeAssignment.Location" class="text-danger" /> </div>
运行应用,选择“讲师”选项卡,然后单击讲师页面上的“编辑”。 更改“办公室位置”,然后单击“保存”。
向“讲师编辑”页添加课程分配
讲师可能教授任意数量的课程。 现在可以通过使用一组复选框来更改课程分配,从而增强讲师编辑页面的性能
Course 和 Instructor 实体之间是多对多的关系。 若要添加和删除关系,可以向 CourseAssignments 联接实体集添加实体和从中删除实体。
用于更改讲师所对应的课程的 UI 是一组复选框。 该复选框中会显示数据库中的所有课程,选中讲师当前对应的课程即可。 用户可以通过选择或清除复选框来更改课程分配。如果课程的数量过大,建议使用其他方法在视图中呈现数据,但创建或删除关系的方法与操作联接实体的方法相同。
更新讲师控制器
若要为复选框列表的视图提供数据,将使用视图模型类。
在 SchoolViewModels 文件夹中创建 AssignedCourseData.cs,并将现有代码替换为以下代码:
using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; namespace ContosoUniversity.Models.SchoolViewModels { public class AssignedCourseData { public int CourseID { get; set; } public string Title { get; set; } public bool Assigned { get; set; } } }
在 InstructorsController.cs 中,将 HttpGet Edit
方法替换为以下代码。 突出显示所作更改。
public async Task<IActionResult> Edit(int? id) { if (id == null) { return NotFound(); } var instructor = await _context.Instructors .Include(i => i.OfficeAssignment) .Include(i => i.CourseAssignments).ThenInclude(i => i.Course) .AsNoTracking() .SingleOrDefaultAsync(m => m.ID == id); if (instructor == null) { return NotFound(); } PopulateAssignedCourseData(instructor); return View(instructor); } private void PopulateAssignedCourseData(Instructor instructor) { var allCourses = _context.Courses; var instructorCourses = new HashSet<int>(instructor.CourseAssignments.Select(c => c.CourseID)); var viewModel = new List<AssignedCourseData>(); foreach (var course in allCourses) { viewModel.Add(new AssignedCourseData { CourseID = course.CourseID, Title = course.Title, Assigned = instructorCourses.Contains(course.CourseID) }); } ViewData["Courses"] = viewModel; }
该代码为 Courses
导航属性添加了预先加载,并调用新的 PopulateAssignedCourseData
方法使用 AssignedCourseData
视图模型类为复选框数组提供信息。
PopulateAssignedCourseData
方法中的代码会读取所有 Course 实体,以便使用视图模型类加载课程列表。 对每门课程而言,该代码都会检查讲师的 Courses
导航属性中是否存在该课程。 为高效检查某门课程是否被分配给了讲师,可将分配给该讲师的课程放置于 HashSet
集合中。 对于讲师分配到的课程,Assigned
属性则设置为 true。 视图将使用此属性来确定应将哪些复选框显示为选中状态。 最后,该列表会被传递给 ViewData
中的视图。
接下来,添加用户单击“保存”时执行的代码。 将 EditPost
方法替换为以下代码,并添加一个新方法,用于更新 Instructor 实体的 Courses
导航属性。
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Edit(int? id, string[] selectedCourses) { if (id == null) { return NotFound(); } var instructorToUpdate = await _context.Instructors .Include(i => i.OfficeAssignment) .Include(i => i.CourseAssignments) .ThenInclude(i => i.Course) .SingleOrDefaultAsync(m => m.ID == id); if (await TryUpdateModelAsync<Instructor>( instructorToUpdate, "", i => i.FirstMidName, i => i.LastName, i => i.HireDate, i => i.OfficeAssignment)) { if (String.IsNullOrWhiteSpace(instructorToUpdate.OfficeAssignment?.Location)) { instructorToUpdate.OfficeAssignment = null; } UpdateInstructorCourses(selectedCourses, instructorToUpdate); try { await _context.SaveChangesAsync(); } catch (DbUpdateException /* ex */) { //Log the error (uncomment ex variable name and write a log.) ModelState.AddModelError("", "Unable to save changes. " + "Try again, and if the problem persists, " + "see your system administrator."); } return RedirectToAction(nameof(Index)); } UpdateInstructorCourses(selectedCourses, instructorToUpdate); PopulateAssignedCourseData(instructorToUpdate); return View(instructorToUpdate); }
private void UpdateInstructorCourses(string[] selectedCourses, Instructor instructorToUpdate) { if (selectedCourses == null) { instructorToUpdate.CourseAssignments = new List<CourseAssignment>(); return; } var selectedCoursesHS = new HashSet<string>(selectedCourses); var instructorCourses = new HashSet<int> (instructorToUpdate.CourseAssignments.Select(c => c.Course.CourseID)); foreach (var course in _context.Courses) { if (selectedCoursesHS.Contains(course.CourseID.ToString())) { if (!instructorCourses.Contains(course.CourseID)) { instructorToUpdate.CourseAssignments.Add(new CourseAssignment {
InstructorID = instructorToUpdate.ID,
CourseID = course.CourseID
}); } } else { if (instructorCourses.Contains(course.CourseID)) { CourseAssignment courseToRemove = instructorToUpdate.CourseAssignments
.SingleOrDefault(i => i.CourseID == course.CourseID); _context.Remove(courseToRemove); } } } }
现在的方法签名与 HttpGet Edit
方法不同,因此方法名称将从 EditPost
变回 Edit
。
视图没有 Course 实体的集合,因此模型绑定器无法自动更新 CourseAssignments
导航属性。 可在新的 UpdateInstructorCourses
方法中更新 CourseAssignments
导航属性,而不必使用模型绑定器。 为此,需要从模型绑定中排除 CourseAssignments
属性。 此操作无需对调用 TryUpdateModel
的代码进行任何更改,因为使用的是允许列表重载,并且 CourseAssignments
不包括在该列表中。
如果未选中任何复选框,则 UpdateInstructorCourses
中的代码将使用空集合初始化 CourseAssignments
导航属性,并返回以下内容:
if (selectedCourses == null)
{
instructorToUpdate.CourseAssignments = new List<CourseAssignment>();
return;
}
之后,代码会循环访问数据库中的所有课程,并逐一检查当前分配给讲师的课程和视图中处于选中状态的课程。 为便于高效查找,后两个集合存储在 HashSet
对象中。
如果某课程的复选框处于选中状态,但该课程不在 Instructor.CourseAssignments
导航属性中,则会将该课程添加到导航属性中的集合中。
if (selectedCoursesHS.Contains(course.CourseID.ToString()))
{
if (!instructorCourses.Contains(course.CourseID))
{
instructorToUpdate.CourseAssignments.Add(new CourseAssignment {
InstructorID = instructorToUpdate.ID,
CourseID = course.CourseID
});
}
}
如果某课程的复选框未处于选中状态,但该课程存在 Instructor.CourseAssignments
导航属性中,则会从导航属性中删除该课程。
else { if (instructorCourses.Contains(course.CourseID)) { CourseAssignment courseToRemove = instructorToUpdate.CourseAssignments
.SingleOrDefault(i => i.CourseID == course.CourseID); _context.Remove(courseToRemove); } }
更新讲师视图
在 Views/Instructors/Edit.cshtml 中,通过在“办公室”字段的 div
元素之后和“保存”按钮的 div
元素之前添加以下代码,以便添加带有一系列复选框的“课程”字段。
备注: 将代码粘贴到 Visual Studio 中时,换行符会发生更改,从而导致代码中断。 按 Ctrl+Z 一次可撤消自动格式设置。 这样可以修复换行符,使其看起来如此处所示。 缩进不一定要完美,但 @</tr><tr>
、@:<td>
、@:</td>
和 @:</tr>
行必须各成一行(如下所示),否则会出现运行时错误。 选中新的代码块后,按 Tab 三次,使新代码与现有代码对齐。
1 <div class="form-group"> 2 <div class="col-md-offset-2 col-md-10"> 3 <table> 4 <tr> 5 @{ 6 int cnt = 0; 7 List<ContosoUniversity.Models.SchoolViewModels.AssignedCourseData> courses = ViewBag.Courses; 8 9 foreach (var course in courses) 10 { 11 if (cnt++ % 3 == 0) 12 { 13 @:</tr><tr> 14 } 15 @:<td> 16 <input type="checkbox" 17 name="selectedCourses" 18 value="@course.CourseID" 19 @(Html.Raw(course.Assigned ? "checked=\"checked\"" : "")) /> 20 @course.CourseID @: @course.Title 21 @:</td> 22 } 23 @:</tr> 24 } 25 </table> 26 </div> 27 </div>
此代码将创建一个具有三列的 HTML 表。 每个列中都有一个复选框,随后是一段由课程编号和标题组成的描述文字。 所有复选框都具有相同的名称,即 selectedCourses,以告知模型绑定器将它们视为一组。 每个复选框的值特性被设置为 CourseID
的值。 发布页面时,模型绑定器会向控制器传递一个数组,其中只包括所选复选框的 CourseID
值。
这些复选框最开始呈现时,对于分配给讲师的课程的复选框,其特性处于选中状态。
运行应用,选择“讲师”选项卡,然后单击讲师页面上的“编辑”以查看“编辑”页面。更改某些课程分配并单击“保存”。 所作更改将反映在索引页上。
更新“删除”页
在 InstructorsController.cs 中,删除 DeleteConfirmed
方法,并在其位置插入以下代码。
[HttpPost, ActionName("Delete")] [ValidateAntiForgeryToken] public async Task<IActionResult> DeleteConfirmed(int id) { Instructor instructor = await _context.Instructors .Include(i => i.CourseAssignments) .SingleAsync(i => i.ID == id); var departments = await _context.Departments .Where(d => d.InstructorID == id) .ToListAsync(); departments.ForEach(d => d.InstructorID = null); _context.Instructors.Remove(instructor); await _context.SaveChangesAsync(); return RedirectToAction(nameof(Index)); }
此代码会更改以下内容:
-
对
CourseAssignments
导航属性执行预先加载。 必须包括此内容,否则 EF 不知道相关的CourseAssignment
实体,也不会删除它们。 为避免在此处阅读它们,可以在数据库中配置级联删除。 -
如果要删除的讲师被指派为任何系的管理员,则需从这些系中删除该讲师分配。
向创建页添加办公室位置和课程
在 InstructorsController.cs 中,删除 HttpGet 和 HttpPost Create
方法,然后在其位置添加以下代码:
public IActionResult Create() { var instructor = new Instructor(); instructor.CourseAssignments = new List<CourseAssignment>(); PopulateAssignedCourseData(instructor); return View(); } // POST: Instructors/Create [HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Create([Bind("FirstMidName,HireDate,LastName,OfficeAssignment")] Instructor instructor,
string[] selectedCourses) { if (selectedCourses != null) { instructor.CourseAssignments = new List<CourseAssignment>(); foreach (var course in selectedCourses) { var courseToAdd = new CourseAssignment { InstructorID = instructor.ID, CourseID = int.Parse(course) }; instructor.CourseAssignments.Add(courseToAdd); } } if (ModelState.IsValid) { _context.Add(instructor); await _context.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } PopulateAssignedCourseData(instructor); return View(instructor); }
此代码与 Edit
方法中所示内容类似,只是最开始未选择任何课程。 HttpGet Create
方法调用 PopulateAssignedCourseData
方法不是因为可能有课程处于选中状态,而是为了在视图中为 foreach
循环提供空集合(否则该视图代码将引发空引用异常)。
检查是否存在验证错误并向数据库添加新讲师之前,HttpPost Create
方法会将每个选定课程添加到 CourseAssignments
导航属性。 即使存在模型错误也会添加课程,因此出现模型错误(例如用户键入了无效的日期)并且页面重新显示并出现错误消息时,所作的任何课程选择都会自动还原。
请注意,为了能够向 CourseAssignments
导航属性添加课程,必须将该属性初始化为空集合:
instructor.CourseAssignments = new List<CourseAssignment>();
除了在控制器代码中进行此操作之外,还可以在“导师”模型中进行此操作,方法是将该属性更改为不存在集合时自动创建集合,如以下示例所示:
private ICollection<CourseAssignment> _courseAssignments; public ICollection<CourseAssignment> CourseAssignments { get { return _courseAssignments ?? (_courseAssignments = new List<CourseAssignment>()); } set { _courseAssignments = value; } }
如果通过这种方式修改 CourseAssignments
属性,则可以删除控制器中的显式属性初始化代码。
在 Views/Instructor/Create.cshtml 中,添加一个办公室位置文本框和课程的复选框,然后按“提交”按钮。
1 <div class="form-group"> 2 <label asp-for="OfficeAssignment.Location" class="control-label"></label> 3 <input asp-for="OfficeAssignment.Location" class="form-control" /> 4 <span asp-validation-for="OfficeAssignment.Location" class="text-danger" /> 5 </div> 6 7 <div class="form-group"> 8 <div class="col-md-offset-2 col-md-10"> 9 <table> 10 <tr> 11 @{ 12 int cnt = 0; 13 List<ContosoUniversity.Models.SchoolViewModels.AssignedCourseData> courses = ViewBag.Courses; 14 15 foreach (var course in courses) 16 { 17 if (cnt++ % 3 == 0) 18 { 19 @:</tr><tr> 20 } 21 @:<td> 22 <input type="checkbox" 23 name="selectedCourses" 24 value="@course.CourseID" 25 @(Html.Raw(course.Assigned ? "checked=\"checked\"" : "")) /> 26 @course.CourseID @: @course.Title 27 @:</td> 28 } 29 @:</tr> 30 } 31 </table> 32 </div> 33 </div>
通过运行应用并创建讲师来进行测试。
处理事务
Entity Framework 隐式实现事务。
总结
处理相关数据的介绍至此已告一段落。