使用 Laravel + Inertia.js (Vue 3) 构建任务管理器:CRUD、标签、过滤器和 Kanban Board

发布: (2026年1月3日 GMT+8 04:05)
7 分钟阅读
原文: Dev.to

Source: Dev.to

功能

  • ✅ 完整的 CRUD(创建、列出、编辑、删除任务)
  • ✅ 过滤器(状态、优先级、标签、逾期)
  • ✅ 搜索(标题 + 详细信息)
  • ✅ 排序(创建日期或截止日期,null 放在最后)
  • ✅ 标签(多对多、自定义颜色、即时创建)
  • ✅ 看板(在列之间拖放)
  • ✅ 放下时的小动画(Vue TransitionGroup
  • ✅ 通过 Inertia 共享的闪现消息(成功)

这是一款出色的 Laravel + Inertia “真实应用” 示例,因为它不仅仅是 CRUD——它开始像一个产品。

为什么选择 Inertia?

如果你喜欢 Laravel,但不想维护单独的 API + SPA,Inertia 是一个很好的折中方案:

  1. 你仍然使用 Laravel 的路由 / 控制器 / 验证
  2. 你构建 Vue 页面并实现无全页刷新的导航
  3. 你避免 “API 重复” 的问题,同时获得类似 SPA 的用户体验

Inertia 基本上是 服务器端路由 + 客户端渲染

安装

# Create a fresh Laravel project
composer create-project laravel/laravel task-manager
cd task-manager

# Install inertia‑laravel
composer require inertiajs/inertia-laravel

# Generate inertia middleware scaffold
php artisan inertia:middleware

# Front‑end dependencies
npm i vue @inertiajs/vue3 @vitejs/plugin-vue
npm i   # (install remaining deps)

Blade 布局 (resources/views/app.blade.php)

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    @vite('resources/js/app.ts')
    @inertiaHead
</head>
<body>
    @inertia
</body>
</html>

Inertia 入口点 (resources/js/app.ts)

import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/vue3";

createInertiaApp({
  resolve: (name) => import(`./Pages/${name}.vue`),
  // Tip: you can improve resolve() using Vite's import.meta.glob for better DX
  setup({ el, App, props, plugin }) {
    createApp({ render: () => h(App, props) })
      .use(plugin)
      .mount(el);
  },
});

中间件注册 (Laravel 11/12)

bootstrap/app.php(而非 Kernel.php):

use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web(append: [
        HandleInertiaRequests::class,
    ]);
});

数据库模式

tasksidtitle(必填)、details(可为空)、statustodo
tagsidname(唯一)、color
tag_task(枢纽)task_idtag_id(唯一复合)

任务模型

class Task extends Model
{
    protected $dates = [
        'due_date',
    ];

    protected $appends = [
        'is_overdue',
        'is_due_today',
    ];

    // ---------- Computed attributes ----------
    public function getIsOverdueAttribute(): bool
    {
        return $this->due_date instanceof Carbon
            ? $this->due_date->isBefore(Carbon::today())
            : false;
    }

    public function getIsDueTodayAttribute(): bool
    {
        return $this->due_date instanceof Carbon
            ? $this->due_date->isSameDay(Carbon::today())
            : false;
    }

    // ---------- Relationships ----------
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    // ---------- Query scopes ----------
    public function scopeSearch($query, ?string $term)
    {
        $term = trim((string) $term);
        if ($term === '') {
            return $query;
        }

        return $query->where(function ($q) use ($term) {
            $q->where('title', 'like', "%{$term}%")
              ->orWhere('details', 'like', "%{$term}%");
        });
    }

    public function scopeStatus($query, ?string $status)
    {
        if (! $status || ! in_array($status, self::STATUSES, true)) {
            return $query;
        }

        return $query->where('status', $status);
    }

    public function scopePriority($query, ?string $priority)
    {
        if (! $priority) {
            return $query;
        }

        return $query->where('priority', $priority);
    }

    public function scopeTagged($query, $tagId)
    {
        if (! $tagId) {
            return $query;
        }

        return $query->whereHas('tags', fn($q) => $q->where('tags.id', $tagId));
    }

    public function scopeOverdue($query, $overdue)
    {
        if ($overdue === null) {
            return $query;
        }

        return $overdue
            ? $query->whereDate('due_date', 'toDateString())
            : $query->whereDate('due_date', '>=', now()->toDateString())
                  ->orWhereNull('due_date');
    }

    public function scopeOrderByDueDateNullsLast($query, $direction = 'asc')
    {
        return $query
            ->orderByRaw('case when due_date is null then 1 else 0 end')
            ->orderBy('due_date', $direction);
    }

    public function scopeSortBy($query, $field, $direction = 'asc')
    {
        return match ($field) {
            'due_date' => $query->orderByDueDateNullsLast($direction),
            default    => $query->orderBy($field, $direction),
        };
    }
}

将业务规则保留在模型中,使 Vue UI 非常简洁。

控制器示例

public function index(Request $request)
{
    $tasks = Task::with('tags')
        ->search($request->input('q'))
        ->status($request->input('status'))
        ->priority($request->input('priority'))
        ->tagged($request->input('tag_id'))
        ->overdue($request->boolean('overdue'))
        ->sortBy(
            $request->input('sort', 'created_at'),
            $request->input('direction', 'desc')
        )
        ->paginate($request->input('per_page', 15))
        ->withQueryString();

    return inertia('Tasks/Index', [
        'tasks' => $tasks,
        // other props like filters, sorts, etc.
    ]);
}

// store, update, destroy, etc. use FormRequests (see below)

路由

use App\Http\Controllers\TaskController;

Route::middleware(['web', 'auth'])->group(function () {
    Route::get('/tasks',               [TaskController::class, 'index'])->name('tasks.index');
    Route::get('/tasks/create',        [TaskController::class, 'create'])->name('tasks.create');
    Route::post('/tasks',              [TaskController::class, 'store'])->name('tasks.store');
    Route::get('/tasks/{task}/edit',   [TaskController::class, 'edit'])->name('tasks.edit');
    Route::put('/tasks/{task}',        [TaskController::class, 'update'])->name('tasks.update');
    Route::delete('/tasks/{task}',     [TaskController::class, 'destroy'])->name('tasks.destroy');

    // Kanban board
    Route::get('/tasks/board',          [TaskController::class, 'board'])->name('tasks.board');
    Route::patch('/tasks/{task}/status', [TaskController::class, 'updateStatus'])
        ->name('tasks.updateStatus');
});

表单请求(验证)

// app/Http/Requests/StoreTaskRequest.php
class StoreTaskRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'title'    => 'required|string|max:255',
            'details'  => 'nullable|string',
            'status'   => 'required|in:todo,doing,done',
            'priority' => 'required|in:low,medium,high',
            'due_date' => 'nullable|date',
            'tags'     => 'array',
            'tags.*'   => 'exists:tags,id',
        ];
    }
}

// app/Http/Requests/UpdateTaskRequest.php
class UpdateTaskRequest extends StoreTaskRequest
{
    // same rules; you could add "sometimes" if needed
}

// app/Http/Requests/UpdateTaskStatusRequest.php
class UpdateTaskStatusRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'status' => 'required|in:todo,doing,done',
        ];
    }
}

通过 Inertia 中间件的闪存消息

// app/Http/Middleware/HandleInertiaRequests.php
public function share(Request $request): array
{
    return array_merge(parent::share($request), [
        'flash' => [
            'success' => fn () => $request->session()->get('success'),
        ],
    ]);
}

在控制器中成功操作后:

$request->session()->flash('success', 'Task updated successfully.');

然后在 AppLayout.vue 中,如果闪存消息存在,你可以显示它。

前端文件结构

resources/
├─ js/
│  ├─ Components/
│  │   └─ TaskForm.vue
│  ├─ Layouts/
│  │   └─ AppLayout.vue
│  ├─ Pages/
│  │   └─ Tasks/
│  │       ├─ Index.vue
│  │       ├─ Create.vue
│  │       ├─ Edit.vue
│  │       └─ Board.vue
│  └─ app.ts

TaskForm.vue(创建 & 编辑)

<script setup lang="ts">
import { useForm } from "@inertiajs/vue3";
import { computed, defineProps } from "vue";

const props = defineProps<{
  task?: {
    id: number;
    title: string;
    details: string;
    status: string;
    priority: string;
    due_date: string | null;
    tags: Array<{ id: number; name: string }>;
  };
}>();

const form = useForm({
  title:    props.task?.title ?? "",
  details:  props.task?.details ?? "",
  status:   props.task?.status ?? "todo",
  priority: props.task?.priority ?? "medium",
  due_date: props.task?.due_date ?? null,
  tags:     props.task?.tags?.map(t => t.id) ?? [],
});

function submit() {
  if (props.task) {
    form.put(route("tasks.update", props.task.id));
  } else {
    form.post(route("tasks.store"));
  }
}
</script>

<template>
  <form @submit.prevent="submit">
    <!-- title -->
    <input v-model="form.title" placeholder="Title" required />

    <!-- details -->
    <textarea v-model="form.details" placeholder="Details"></textarea>

    <!-- status -->
    <select v-model="form.status">
      <option value="todo">Todo</option>
      <option value="doing">Doing</option>
      <option value="done">Done</option>
    </select>

    <!-- priority -->
    <select v-model="form.priority">
      <option value="low">Low</option>
      <option value="medium">Medium</option>
      <option value="high">High</option>
    </select>

    <!-- tags (chip input) -->
    <div v-for="tag in form.tags" :key="tag">
      {{ tag.name }}
    </div>

    <button type="submit">
      {{ props.task ? "Update" : "Create" }} Task
    </button>
  </form>
</template>

看板列

  • todo
  • doing
  • done

当卡片被拖放到另一列时:

  1. 立即更新 UI(乐观更新)
  2. 发送 PATCH /tasks/{id}/status
  3. 出错时回滚
const previous = cloneColumns(columns.value);

const [task] = source.splice(index, 1);
task.status = targetStatus;
target.unshift(task);

router.patch(
  `/tasks/${taskId}/status`,
  { status: targetStatus },
  {
    preserveState: true,
    preserveScroll: true,
    onError: () => {
      columns.value = previous;   // revert UI on failure
    },
  }
);

平滑重新排序与列移动

将列表包装在 <TransitionGroup> 中,使移动更流畅:

<TransitionGroup name="task" tag="div" class="task-list">
  <TaskCard
    v-for="task in tasks"
    :key="task.id"
    :task="task"
  />
</TransitionGroup>

CSS 过渡效果

.task-enter-active,
.task-leave-active {
  transition: opacity 180ms ease, transform 180ms ease;
}

.task-enter-from {
  opacity: 0;
  transform: translateY(8px) scale(0.98);
}

.task-leave-to {
  opacity: 0;
  transform: translateY(-8px) scale(0.98);
}

.task-move {
  transition: transform 180ms ease;
}

拖放高亮

  • 在 drop 处理函数中设置 droppedId
  • 对被拖放的卡片应用一个小的 CSS 动画。

这使得 UI 具有“成品”般的感觉,且代码量极少。

后端:同步标签并创建新标签

// Normalise incoming tag IDs
$tagIds = collect($data['tag_ids'] ?? [])
    ->filter()
    ->map(fn ($id) => (int) $id)
    ->unique()
    ->values();

// Create any new tags the user typed
foreach (collect($data['new_tags'] ?? []) as $tagData) {
    $name = trim((string) ($tagData['name'] ?? ''));
    if ($name
Back to Blog

相关文章

阅读更多 »