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 支持。
- 终端 1 · 后端
- 终端 2 · 前端
cd D:\Docs\vue-study\examples\task-board\api
dotnet run
后端地址为 http://localhost:5080。可以直接访问 /api/tasks 检查初始 JSON。
cd D:\Docs\vue-study\examples\task-board\web
npm ci
npm run dev
打开终端打印的地址,默认 http://localhost:5173/。
路径不同的机器在对应目录执行即可。后端端口改变时同步修改 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 根据完成字段派生。改变筛选不改源数组,不必发请求。
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 取消未完成的客户端等待 |
取消客户端请求不代表回滚已经到达服务端的写入。重要生产动作要依靠服务端事务、幂等或更具体的请求协调方案。
第五步:看完整关键源码
以下代码直接读取仓库中的实际文件,避免文档示例与运行代码各维护一份。
- 页面
- 任务行
- 状态协调
- HTTP
- .NET API
<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>
<script setup lang="ts">
import { Trash2 } from 'lucide-vue-next';
import type { TaskItem } from '../types/task';
const props = defineProps<{ task: TaskItem; disabled: boolean }>();
const emit = defineEmits<{
toggle: [task: TaskItem];
remove: [id: number];
}>();
function requestToggle(event: Event) {
// 原生 checkbox 会先变化;恢复为已确认状态,等待 API 成功后再更新。
if (event.target instanceof HTMLInputElement) event.target.checked = props.task.isDone;
emit('toggle', props.task);
}
</script>
<template>
<li class="task-row">
<label class="task-label">
<input type="checkbox" :checked="props.task.isDone" :disabled="props.disabled"
@change="requestToggle" />
<span :class="{ completed: props.task.isDone }">{{ props.task.title }}</span>
</label>
<span class="task-status">{{ props.task.isDone ? '已完成' : '待完成' }}</span>
<button type="button" class="icon-button delete-button" :disabled="props.disabled"
:aria-label="`删除任务:${props.task.title}`" title="删除任务"
@click="emit('remove', props.task.id)">
<Trash2 :size="17" aria-hidden="true" />
</button>
</li>
</template>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as api from '../api/tasks';
import { validateTitle, type TaskItem } from '../types/task';
export function useTasks() {
const tasks = ref<TaskItem[]>([]);
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const busy = computed(() => loading.value || saving.value);
const controller = new AbortController();
async function load() {
if (busy.value) return;
loading.value = true;
error.value = '';
try {
const result = await api.listTasks(controller.signal);
if (!controller.signal.aborted) tasks.value = result;
} catch (cause) {
if (!controller.signal.aborted) {
error.value = cause instanceof Error ? cause.message : '加载失败';
}
} finally {
loading.value = false;
}
}
async function save(action: () => Promise<void>): Promise<boolean> {
if (busy.value) return false;
saving.value = true;
error.value = '';
try {
await action();
return !controller.signal.aborted;
} catch (cause) {
if (!controller.signal.aborted) {
error.value = cause instanceof Error ? cause.message : '保存失败';
}
return false;
} finally {
saving.value = false;
}
}
async function add(title: string): Promise<boolean> {
const message = validateTitle(title);
if (message) {
error.value = message;
return false;
}
return save(async () => {
const task = await api.createTask(title.trim(), controller.signal);
if (!controller.signal.aborted) tasks.value.unshift(task);
});
}
async function toggle(task: TaskItem): Promise<boolean> {
return save(async () => {
const updated = await api.updateTask(task.id, !task.isDone, controller.signal);
if (!controller.signal.aborted) {
tasks.value = tasks.value.map(item => item.id === updated.id ? updated : item);
}
});
}
async function remove(id: number): Promise<boolean> {
return save(async () => {
await api.deleteTask(id, controller.signal);
if (!controller.signal.aborted) tasks.value = tasks.value.filter(task => task.id !== id);
});
}
onMounted(load);
onUnmounted(() => controller.abort());
return { tasks, loading, saving, busy, error, load, add, toggle, remove };
}
import { isTask, type TaskItem } from '../types/task';
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
async function request(path: string, options: RequestInit = {}): Promise<Response> {
const response = await fetch(`/api/tasks${path}`, options);
if (response.ok) return response;
const problem: unknown = await response.json().catch(() => null);
let message = `请求失败:HTTP ${response.status}`;
if (isRecord(problem)) {
const errors = isRecord(problem.errors)
? Object.values(problem.errors).flat().filter((item): item is string => typeof item === 'string')
: [];
if (errors.length) message = errors.join(';');
else if (typeof problem.detail === 'string') message = problem.detail;
else if (typeof problem.title === 'string') message = problem.title;
}
throw new Error(message);
}
async function readTask(response: Response): Promise<TaskItem> {
const data: unknown = await response.json();
if (!isTask(data)) throw new Error('服务端返回的任务格式不正确');
return data;
}
export async function listTasks(signal?: AbortSignal): Promise<TaskItem[]> {
const response = await request('', { signal });
const data: unknown = await response.json();
if (!Array.isArray(data) || !data.every(isTask)) {
throw new Error('服务端返回的任务格式不正确');
}
return data;
}
export async function createTask(title: string, signal?: AbortSignal): Promise<TaskItem> {
return readTask(await request('', {
method: 'POST', signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}));
}
export async function updateTask(id: number, isDone: boolean, signal?: AbortSignal): Promise<TaskItem> {
return readTask(await request(`/${id}`, {
method: 'PUT', signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isDone }),
}));
}
export async function deleteTask(id: number, signal?: AbortSignal): Promise<void> {
await request(`/${id}`, { method: 'DELETE', signal });
}
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
var tasks = new ConcurrentDictionary<int, TaskDto>();
tasks[1] = new(1, "完成 JavaScript 基础练习", true);
tasks[2] = new(2, "实现 Vue 任务筛选", false);
var nextId = 2;
app.MapGet("/api/tasks", () => Results.Ok(tasks.Values.OrderByDescending(task => task.Id)));
app.MapGet("/api/tasks/{id:int}", (int id) =>
tasks.TryGetValue(id, out var task) ? Results.Ok(task) : Results.NotFound());
app.MapPost("/api/tasks", (CreateTaskRequest request) =>
{
var title = request.Title?.Trim();
if (string.IsNullOrEmpty(title) || title.Length > 100)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["title"] = ["任务名称必须包含 1~100 个字符"]
});
}
var task = new TaskDto(Interlocked.Increment(ref nextId), title, false);
tasks[task.Id] = task;
return Results.Created($"/api/tasks/{task.Id}", task);
});
app.MapPut("/api/tasks/{id:int}", (int id, UpdateTaskRequest request) =>
{
if (request.IsDone is null)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["isDone"] = ["必须提供完成状态"]
});
}
// 条件更新避免并发删除后又把旧任务重新插入。
while (tasks.TryGetValue(id, out var current))
{
var updated = current with { IsDone = request.IsDone.Value };
if (tasks.TryUpdate(id, updated, current)) return Results.Ok(updated);
}
return Results.NotFound();
});
app.MapDelete("/api/tasks/{id:int}", (int id) =>
tasks.TryRemove(id, out _) ? Results.NoContent() : Results.NotFound());
app.Run();
public record TaskDto(int Id, string Title, bool IsDone);
public record CreateTaskRequest(string? Title);
public record UpdateTaskRequest(bool? IsDone);
验证项目
在前端目录运行 npm test、npm run build;在后端目录运行 dotnet build。API 启动后还可在 examples/task-board/ 运行 node verify-api.mjs,自动验证契约与输入边界,完成后清理检查创建的任务。
手动验收:新增合法标题;输入空白;搜索大小写不同的关键词;切换状态;完成后刷新;删除后刷新;关闭 API 再新增,确认错误和保留输入;重新启动 API 并点击刷新;在手机宽度完成同样操作。
观察 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 服务。