跳到主要内容

6.1 Vue 3 + ASP.NET Core 任务管理

本章目标: 把前面的知识放到同一条数据流中,独立完成任务新增、筛选、完成、删除以及失败恢复。

先运行完整项目​

源码在当前仓库的 examples/task-board/。它与 Docusaurus 文档站独立,不用在文档站中安装 Vue,也不用把 Vue 组件改成 React。

需要 Node.js 22.18+ 和 .NET 10 SDK。后端代码采用 Minimal API 和内存存储;若使用其他 .NET 版本,应修改目标框架并确认 SDK 支持。

cd D:\Docs\vue-study\examples\task-board\api
dotnet run

后端地址为 http://localhost:5080。可以直接访问 /api/tasks 检查初始 JSON。

路径不同的机器在对应目录执行即可。后端端口改变时同步修改 web/vite.config.ts 的 proxy target。

任务管理实战页面:新增表单、状态筛选、搜索和任务列表

数据说明

初始包含两条任务。API 重启后改动会重置;这个示例没有数据库或登录功能。前端先等待服务端保存成功,再确认列表状态,便于初学时理解失败边界。

目录与责任​

examples/task-board/
api/
Program.cs # API、校验、内存存储
TaskBoard.Api.csproj # .NET 10 项目
web/
src/
App.vue # 输入、筛选、搜索、页面状态
components/TaskRow.vue # 单条任务显示与事件
composables/useTasks.ts # 加载与保存状态协调
api/tasks.ts # HTTP 与响应验证
types/task.ts # 模型、标题校验、筛选规则
style.css # 页面布局
vite.config.ts # 本地 /api 代理

状态流是:输入/点击 → 页面或 TaskRow 事件 → useTasks → API 模块 → .NET → 成功响应 → ref 更新 → 模板刷新。

这个页面没有跨页面共享数据,所以不强制加入 Router 和 Pinia。完成基础流程后,可以把列表和详情拆成路由,把用户偏好放进 store。

第一步:对齐数据和业务规则​

前端任务字段为 id、title、isDone,后端 C# record 使用 Id、Title、IsDone,通过默认 Web JSON 命名策略连接。

标题去掉首尾空白后须为 1~100 个字符。前端用于提前反馈;后端独立检查,不能信任浏览器已校验。外部 JSON 通过 isTask 检查,不能只靠 as TaskItem。

第二步:用 computed 筛选和统计​

tasks 是源状态;visibleTasks 根据任务状态和关键词派生;doneCount 根据完成字段派生。改变筛选不改源数组,不必发请求。

App.vue 中的派生值
const visibleTasks = computed(() => filterTasks(tasks.value, filter.value, keyword.value));
const doneCount = computed(() => tasks.value.filter(task => task.isDone).length);

当前任务规模很小,用本地筛选合适。数据变成服务端分页时,关键词与状态应成为服务端查询参数,不能只过滤当前页。

第三步:新增、完成和删除​

新增提交时 await add(...),只有成功才清空输入。完成操作发送目标 isDone 值;删除等待 204 成功后移除本地任务。

所有保存操作都经过 useTasks 中的 save,统一管理 saving 和错误。保存或加载期间禁用操作,避免这个入门示例出现加载结果覆盖保存结果的竞争。

TaskRow 只接收任务和禁用状态,发出 toggle / remove 事件。它不直接修改父组件数据,也不自行猜测 API 路径。原生 checkbox 在 change 前已经切换,因此事件处理中先恢复已确认的勾选状态,等 API 成功后由 prop 更新,避免保存失败时出现错误勾选。

第四步:失败也有明确结果​

场景页面行为
空白标题显示校验信息,不新增
API 验证失败从字段 errors 提取错误,保留输入
网络中断显示失败提示,可以修复后重新操作
刷新失败保留原任务,显示错误
没有匹配结果显示“没有匹配的任务”
组件卸载AbortController 取消未完成的客户端等待

取消客户端请求不代表回滚已经到达服务端的写入。重要生产动作要依靠服务端事务、幂等或更具体的请求协调方案。

第五步:看完整关键源码​

以下代码直接读取仓库中的实际文件,避免文档示例与运行代码各维护一份。

web/src/App.vue
<script setup lang="ts">
import { computed, ref } from 'vue';
import { ClipboardList, Plus, RefreshCw, Search } from 'lucide-vue-next';
import TaskRow from './components/TaskRow.vue';
import { useTasks } from './composables/useTasks';
import { filterTasks, type TaskFilter } from './types/task';

const title = ref('');
const keyword = ref('');
const filter = ref<TaskFilter>('all');
const { tasks, loading, saving, busy, error, load, add, toggle, remove } = useTasks();
const visibleTasks = computed(() => filterTasks(tasks.value, filter.value, keyword.value));
const doneCount = computed(() => tasks.value.filter(task => task.isDone).length);
const filters: { value: TaskFilter; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '未完成' },
{ value: 'done', label: '已完成' },
];

async function submit() {
if (await add(title.value)) title.value = '';
}
</script>

<template>
<header class="app-header">
<div class="header-inner">
<span class="app-brand"><ClipboardList :size="21" aria-hidden="true" /> 任务管理</span>
<span class="workspace-name">个人工作区</span>
</div>
</header>

<main class="workspace">
<div class="page-heading">
<div><p class="eyebrow">工作清单</p><h1>我的任务</h1></div>
<button type="button" class="icon-button" title="刷新任务" aria-label="刷新任务"
:disabled="busy" @click="load"><RefreshCw :size="19" aria-hidden="true" /></button>
</div>
<p class="summary">共 {{ tasks.length }} 项任务,{{ doneCount }} 项已完成</p>

<form class="add-form" @submit.prevent="submit" novalidate>
<label class="visually-hidden" for="new-task">任务名称</label>
<input id="new-task" v-model="title" placeholder="添加一项新任务" maxlength="100"
autocomplete="off" :disabled="busy" />
<button type="submit" class="primary-button" :disabled="busy">
<Plus :size="18" aria-hidden="true" /> 添加任务
</button>
</form>

<div class="toolbar">
<div class="filter-group" role="radiogroup" aria-label="任务状态">
<label v-for="item in filters" :key="item.value" :class="{ active: filter === item.value }">
<input v-model="filter" type="radio" name="filter" :value="item.value" />
{{ item.label }}
</label>
</div>
<label class="search-field">
<Search :size="17" aria-hidden="true" />
<input v-model="keyword" type="search" placeholder="搜索任务" aria-label="搜索任务" />
</label>
</div>

<p v-if="error" class="error-message" role="alert">{{ error }}</p>
<p v-if="loading" class="state-message" role="status">正在加载任务…</p>
<template v-else>
<ul v-if="visibleTasks.length" class="task-list" :aria-busy="saving">
<TaskRow v-for="task in visibleTasks" :key="task.id" :task="task" :disabled="busy"
@toggle="toggle" @remove="remove" />
</ul>
<p v-else-if="!error" class="state-message">
{{ tasks.length ? '没有匹配的任务' : '还没有任务' }}
</p>
</template>
<p v-if="saving" class="save-status" role="status">正在保存…</p>
</main>
</template>

验证项目​

在前端目录运行 npm test、npm run build;在后端目录运行 dotnet build。API 启动后还可在 examples/task-board/ 运行 node verify-api.mjs,自动验证契约与输入边界,完成后清理检查创建的任务。

手动验收:新增合法标题;输入空白;搜索大小写不同的关键词;切换状态;完成后刷新;删除后刷新;关闭 API 再新增,确认错误和保留输入;重新启动 API 并点击刷新;在手机宽度完成同样操作。

Network 是联调依据

观察 GET 200、POST 201、PUT 200、DELETE 204;直接发送非法标题应返回 400,访问不存在的任务应返回 404。前端成功和后端成功应有一致证据。

分层练习​

练习一: 添加“清空筛选”操作,将 keyword 设为空、filter 设为 all,不修改 tasks。

参考思路

使用一个普通函数修改这两个源状态,visibleTasks 自动重新计算,不需要手动重新赋值,也不需要请求 API。

练习二: 加入优先级 normal/high,更新后端 DTO、前端类型、运行时验证、表单和任务行。先决定旧任务的默认优先级。

参考思路

在契约中明确枚举传字符串,后端验证值域;已有任务默认 normal。前端 isTask 加入值域检查,测试非法优先级。不要只增加界面选项而忽略服务端。

练习三: 将内存存储改为 EF Core,保持 HTTP 契约不变。前端是否应该知道你换了数据库?

参考思路

相同契约下前端无需知道数据库类型。新后端负责异步查询、持久化、并发和授权;前端继续按约定的 JSON 和状态码协作。

完成后阅读部署指南,分别发布文档、Vue 前端与 .NET 服务。