主题
文章管理(Post)
介绍
本节将一步一步实现文章功能,包括:数据表迁移、模型、表单验证、控制器、资源路由、Blade 视图以及 Seeder。完成后你可以完成文章的新增、编辑、查看、删除、列表筛选搜索,以及“我的文章”列表。
创建迁移(Migration)
使用命令创建迁移文件:
shell
php artisan make:migration create_posts_table
该命令会在 database/migrations/
下生成一个以时间戳为前缀的文件,例如:xxxx_xx_xx_xxxxxx_create_posts_table.php
。 打开该文件,写入如下内容(主要看 up 方法):
php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title')->comment('文章标题');
$table->text('content')->comment('文章内容');
$table->foreignId('user_id')->constrained()->onDelete('cascade')->comment('作者ID');
$table->foreignId('category_id')->nullable()->constrained()->onDelete('set null')->comment('分类ID');
$table->enum('status', ['draft', 'published'])->default('draft')->comment('文章状态');
$table->timestamps();
// 索引优化
$table->index(['user_id', 'status']);
$table->index(['category_id', 'status']);
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
};
写好之后,使用下面的命令运行迁移:
shell
php artisan migrate
创建模型(Model)
使用命令创建模型文件:
shell
php artisan make:model Post
模型文件路径:app/Models/Post.php
然后写入下面的内容:
php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
use HasFactory;
protected $fillable = [
'title',
'content',
'user_id',
'category_id',
'status',
];
// 作者
public function user()
{
return $this->belongsTo(User::class);
}
// 分类
public function category()
{
return $this->belongsTo(Category::class);
}
// 已发布作用域
public function scopePublished($query)
{
return $query->where('status', 'published');
}
// 草稿作用域
public function scopeDraft($query)
{
return $query->where('status', 'draft');
}
}
表单验证(FormRequest)
使用命令创建请求验证类:
shell
php artisan make:request PostRequest
文件路径:app/Http/Requests/PostRequest.php
然后写入下面的内容:
php
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PostRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|max:255',
'content' => 'required|min:10',
'category_id' => 'nullable|exists:categories,id',
'status' => 'required|in:draft,published',
];
}
public function messages(): array
{
return [
'title.required' => '请输入文章标题',
'title.max' => '文章标题不能超过255个字符',
'content.required' => '请输入文章内容',
'content.min' => '文章内容至少需要10个字符',
'category_id.exists' => '选择的分类不存在',
'status.required' => '请选择文章状态',
'status.in' => '文章状态无效',
];
}
}
资源路由(Routes)
编辑 routes/web.php
,注册文章相关路由:
php
// 文章
// 列表公开访问
Route::get('/posts', [\\App\\Http\\Controllers\\PostController::class, 'index'])->name('posts.index');
// 需要登录的文章路由
Route::middleware('auth')->group(function () {
Route::get('/posts/create', [\\App\\Http\\Controllers\\PostController::class, 'create'])->name('posts.create');
Route::post('/posts', [\\App\\Http\\Controllers\\PostController::class, 'store'])->name('posts.store');
Route::get('/posts/{post}', [\\App\\Http\\Controllers\\PostController::class, 'show'])->name('posts.show');
Route::get('/posts/{post}/edit', [\\App\\Http\\Controllers\\PostController::class, 'edit'])->name('posts.edit');
Route::put('/posts/{post}', [\\App\\Http\\Controllers\\PostController::class, 'update'])->name('posts.update');
Route::delete('/posts/{post}', [\\App\\Http\\Controllers\\PostController::class, 'destroy'])->name('posts.destroy');
Route::get('/my-posts', [\\App\\Http\\Controllers\\PostController::class, 'myPosts'])->name('posts.my-posts');
});
控制器(Controller)
使用命令创建控制器:
shell
php artisan make:controller PostController
控制器文件路径:app/Http/Controllers/PostController.php
然后写入下面的内容:
php
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Category;
use App\Http\Requests\PostRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PostController extends Controller
{
// 中间件在 routes/web.php 中声明,控制器无需再声明
// 列表(发布可见,支持分类筛选与标题搜索)
public function index(Request $request)
{
$query = Post::with(['user', 'category'])->published();
if ($request->has('category') && $request->category) {
$query->where('category_id', $request->category);
}
if ($request->has('search') && $request->search) {
$query->where('title', 'like', '%'.$request->search.'%');
}
$posts = $query->orderBy('created_at', 'desc')->paginate(10);
$categories = Category::all();
return view('posts.index', compact('posts', 'categories'));
}
// 创建表单
public function create()
{
$categories = Category::all();
return view('posts.create', compact('categories'));
}
// 保存文章
public function store(PostRequest $request)
{
$post = new Post();
$post->title = $request->title;
$post->content = $request->content;
$post->category_id = $request->category_id;
$post->status = $request->status;
$post->user_id = Auth::id();
$post->save();
return redirect()->route('posts.show', $post)->with('success', '文章创建成功!');
}
// 详情(草稿仅作者可见)
public function show(Post $post)
{
if ($post->status === 'draft' && $post->user_id !== Auth::id()) {
abort(404);
}
$post->load(['user', 'category']);
return view('posts.show', compact('post'));
}
// 编辑表单(仅作者可编辑)
public function edit(Post $post)
{
if ($post->user_id !== Auth::id()) {
abort(403, '您没有权限编辑此文章');
}
$categories = Category::all();
return view('posts.edit', compact('post', 'categories'));
}
// 更新文章(仅作者可更新)
public function update(PostRequest $request, Post $post)
{
if ($post->user_id !== Auth::id()) {
abort(403, '您没有权限编辑此文章');
}
$post->title = $request->title;
$post->content = $request->content;
$post->category_id = $request->category_id;
$post->status = $request->status;
$post->save();
return redirect()->route('posts.show', $post)->with('success', '文章更新成功!');
}
// 删除文章(仅作者可删除)
public function destroy(Post $post)
{
if ($post->user_id !== Auth::id()) {
abort(403, '您没有权限删除此文章');
}
$post->delete();
return redirect()->route('posts.index')->with('success', '文章删除成功!');
}
// 我的文章
public function myPosts()
{
$posts = Auth::user()->posts()->with('category')->orderBy('created_at', 'desc')->paginate(10);
return view('posts.my-posts', compact('posts'));
}
}
视图(Blade Views)
列表页
使用命令创建视图文件:
shell
php artisan make:view posts/index
然后找到 resources/views/posts/index.blade.php
, 写入下面的内容:
php
@extends('components.layout')
@section('content')
<div class="container px-4 py-8 mx-auto">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">文章列表</h1>
@auth
<a href="{{ route('posts.create') }}" class="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">
写文章
</a>
@endauth
</div>
<!-- 搜索和筛选 -->
<div class="p-6 mb-8 bg-white rounded-lg shadow-md">
<form method="GET" action="{{ route('posts.index') }}" class="flex gap-4">
<div class="flex-1">
<input type="text" name="search" value="{{ request('search') }}"
placeholder="搜索文章标题..."
class="px-3 py-2 w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="w-48">
<select name="category" class="px-3 py-2 w-full rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">所有分类</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ request('category') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
<button type="submit" class="px-4 py-2 font-bold text-white bg-gray-500 rounded hover:bg-gray-700">
搜索
</button>
</form>
</div>
<!-- 文章列表 -->
<div class="space-y-6">
@forelse($posts as $post)
<article class="overflow-hidden bg-white rounded-lg shadow-md">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-4">
<img src="{{ $post->user->avatar }}" alt="{{ $post->user->name }}"
class="w-10 h-10 rounded-full">
<div>
<p class="text-sm text-gray-600">{{ $post->user->name }}</p>
<p class="text-xs text-gray-500">{{ $post->created_at->format('Y-m-d H:i') }}</p>
</div>
</div>
@if($post->category)
<span class="px-2.5 py-0.5 text-xs font-medium text-blue-800 bg-blue-100 rounded">
{{ $post->category->name }}
</span>
@endif
</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900">
<a href="{{ route('posts.show', $post) }}" class="hover:text-blue-600">
{{ $post->title }}
</a>
</h2>
<p class="mb-4 text-gray-600">
{{ Str::limit($post->content, 200) }}
</p>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span>状态: {{ $post->status === 'published' ? '已发布' : '草稿' }}</span>
</div>
<a href="{{ route('posts.show', $post) }}"
class="font-medium text-blue-600 hover:text-blue-800">
阅读全文 →
</a>
</div>
</div>
</article>
@empty
<div class="py-12 text-center">
<p class="text-lg text-gray-500">暂无文章</p>
@auth
<a href="{{ route('posts.create') }}" class="inline-block px-4 py-2 mt-4 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">
写第一篇文章
</a>
@endauth
</div>
@endforelse
</div>
<!-- 分页 -->
@if($posts->hasPages())
<div class="mt-8">
{{ $posts->appends(request()->query())->links() }}
</div>
@endif
</div>
@endsection
访问 posts
路由,会看到如图列表页面,支持分类选择与标题搜索。如下图所示
新增文章
使用命令创建视图文件:
shell
php artisan make:view posts/create
然后找到 resources/views/posts/create.blade.php
, 写入下面的内容:
php
@extends('components.layout')
@section('content')
<div class="container px-4 py-8 mx-auto">
<div class="mx-auto max-w-4xl">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">写文章</h1>
<a href="{{ route('posts.index') }}" class="text-blue-600 hover:text-blue-800">
← 返回文章列表
</a>
</div>
<div class="p-6 bg-white rounded-lg shadow-md">
<form method="POST" action="{{ route('posts.store') }}">
@csrf
<div class="mb-6">
<label for="title" class="block mb-2 text-sm font-medium text-gray-700">
文章标题 <span class="text-red-500">*</span>
</label>
<input type="text" id="title" name="title" value="{{ old('title') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('title') border-red-500 @enderror"
placeholder="请输入文章标题">
@error('title')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="category_id" class="block mb-2 text-sm font-medium text-gray-700">
分类
</label>
<select id="category_id" name="category_id"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('category_id') border-red-500 @enderror">
<option value="">选择分类(可选)</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="content" class="block mb-2 text-sm font-medium text-gray-700">
文章内容 <span class="text-red-500">*</span>
</label>
<textarea id="content" name="content" rows="12"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('content') border-red-500 @enderror"
placeholder="请输入文章内容...">{{ old('content') }}</textarea>
@error('content')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="status" class="block mb-2 text-sm font-medium text-gray-700">
发布状态 <span class="text-red-500">*</span>
</label>
<select id="status" name="status"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('status') border-red-500 @enderror">
<option value="draft" {{ old('status') == 'draft' ? 'selected' : '' }}>保存为草稿</option>
<option value="published" {{ old('status') == 'published' ? 'selected' : '' }}>立即发布</option>
</select>
@error('status')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="flex justify-between items-center">
<button type="submit" class="px-6 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">
保存文章
</button>
<a href="{{ route('posts.index') }}" class="text-gray-600 hover:text-gray-800">
取消
</a>
</div>
</form>
</div>
</div>
}</div>
@endsection
访问 posts/create
路由,会看到如下页面,注意 posts/create
需要登陆 点击保存文章后,提交成功后将跳转到文章详情页。会看到下面的页面
WARNING
如果提示 login
找不到的话,回到 routes/web.php
找到 login
路由,做以下修改,添加 name
方法
php
Route::get('/login', [\App\Http\Controllers\LoginController::class, 'index'])->name('login');
INFO
文章详情继续往下看
详情页
使用命令创建视图文件:
shell
php artisan make:view posts/show
然后找到 resources/views/posts/show.blade.php
, 写入下面的内容:
php
@extends('components.layout')
@section('content')
<div class="container px-4 py-8 mx-auto">
<div class="mx-auto max-w-4xl">
<div class="mb-6">
<a href="{{ route('posts.index') }}" class="text-blue-600 hover:text-blue-800">← 返回文章列表</a>
</div>
<article class="overflow-hidden bg-white rounded-lg shadow-md">
<div class="p-8">
<header class="mb-8">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-4">
<img src="{{ $post->user->avatar }}" alt="{{ $post->user->name }}" class="w-12 h-12 rounded-full">
<div>
<p class="text-lg font-medium text-gray-900">{{ $post->user->name }}</p>
<p class="text-sm text-gray-500">{{ $post->created_at->format('Y年m月d日 H:i') }}</p>
</div>
</div>
@if($post->category)
<span class="px-3 py-1 text-sm font-medium text-blue-800 bg-blue-100 rounded-full">
{{ $post->category->name }}
</span>
@endif
</div>
<h1 class="mb-4 text-3xl font-bold text-gray-900">{{ $post->title }}</h1>
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span>状态: {{ $post->status === 'published' ? '已发布' : '草稿' }}</span>
<span>更新时间: {{ $post->updated_at->format('Y-m-d H:i') }}</span>
</div>
</header>
<div class="mb-8 max-w-none prose">
<div class="leading-relaxed text-gray-700 whitespace-pre-wrap">
{{ $post->content }}
</div>
</div>
@auth
@if($post->user_id === auth()->id())
<div class="flex items-center pt-6 space-x-4 border-t border-gray-200">
<a href="{{ route('posts.edit', $post) }}" class="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">
编辑文章
</a>
<form method="POST" action="{{ route('posts.destroy', $post) }}" onsubmit="return confirm('确定要删除这篇文章吗?')" class="inline">
@csrf
@method('DELETE')
<button type="submit" class="px-4 py-2 font-bold text-white bg-red-500 rounded hover:bg-red-700">
删除文章
</button>
</form>
</div>
@endif
@endauth
</div>
</article>
@if($post->category)
<div class="p-6 mt-8 bg-white rounded-lg shadow-md">
<h3 class="mb-4 text-lg font-semibold text-gray-900">分类信息</h3>
<div class="flex items-center space-x-4">
<span class="px-3 py-1 text-sm font-medium text-blue-800 bg-blue-100 rounded-full">
{{ $post->category->name }}
</span>
@if($post->category->description)
<p class="text-gray-600">{{ $post->category->description }}</p>
@endif
</div>
</div>
@endif
</div>
</div>
@endsection
编辑页
使用命令创建视图文件:
shell
php artisan make:view posts/edit
然后找到 resources/views/posts/edit.blade.php
, 写入下面的内容:
php
@extends('components.layout')
@section('content')
<div class="container px-4 py-8 mx-auto">
<div class="mx-auto max-w-4xl">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">编辑文章</h1>
<a href="{{ route('posts.show', $post) }}" class="text-blue-600 hover:text-blue-800">
← 返回文章详情
</a>
</div>
<div class="p-6 bg-white rounded-lg shadow-md">
<form method="POST" action="{{ route('posts.update', $post) }}">
@csrf
@method('PUT')
<div class="mb-6">
<label for="title" class="block mb-2 text-sm font-medium text-gray-700">文章标题 <span class="text-red-500">*</span></label>
<input type="text" id="title" name="title" value="{{ old('title', $post->title) }}" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('title') border-red-500 @enderror" placeholder="请输入文章标题">
@error('title')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="category_id" class="block mb-2 text-sm font-medium text-gray-700">分类</label>
<select id="category_id" name="category_id" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('category_id') border-red-500 @enderror">
<option value="">选择分类(可选)</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ old('category_id', $post->category_id) == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
@error('category_id')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="content" class="block mb-2 text-sm font-medium text-gray-700">文章内容 <span class="text-red-500">*</span></label>
<textarea id="content" name="content" rows="12" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('content') border-red-500 @enderror" placeholder="请输入文章内容...">{{ old('content', $post->content) }}</textarea>
@error('content')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="mb-6">
<label for="status" class="block mb-2 text-sm font-medium text-gray-700">发布状态 <span class="text-red-500">*</span></label>
<select id="status" name="status" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 @error('status') border-red-500 @enderror">
<option value="draft" {{ old('status', $post->status) == 'draft' ? 'selected' : '' }}>保存为草稿</option>
<option value="published" {{ old('status', $post->status) == 'published' ? 'selected' : '' }}>立即发布</option>
</select>
@error('status')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div class="flex justify-between items-center">
<button type="submit" class="px-6 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">更新文章</button>
<a href="{{ route('posts.show', $post) }}" class="text-gray-600 hover:text-gray-800">取消</a>
</div>
</form>
</div>
</div>
</div>
@endsection
我的文章
使用命令创建视图文件:
shell
php artisan make:view posts/my-posts
然后找到 resources/views/posts/my-posts.blade.php
, 写入下面的内容:
php
@extends('components.layout')
@section('content')
<div class="container px-4 py-8 mx-auto">
<div class="flex justify-between items-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">我的文章</h1>
<a href="{{ route('posts.create') }}" class="px-4 py-2 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">写文章</a>
</div>
<div class="space-y-6">
@forelse($posts as $post)
<article class="overflow-hidden bg-white rounded-lg shadow-md">
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center space-x-4">
<span class="bg-{{ $post->status === 'published' ? 'green' : 'yellow' }}-100 text-{{ $post->status === 'published' ? 'green' : 'yellow' }}-800 text-xs font-medium px-2.5 py-0.5 rounded">
{{ $post->status === 'published' ? '已发布' : '草稿' }}
</span>
@if($post->category)
<span class="px-2.5 py-0.5 text-xs font-medium text-blue-800 bg-blue-100 rounded">{{ $post->category->name }}</span>
@endif
</div>
<div class="text-sm text-gray-500">{{ $post->created_at->format('Y-m-d H:i') }}</div>
</div>
<h2 class="mb-2 text-xl font-semibold text-gray-900">
<a href="{{ route('posts.show', $post) }}" class="hover:text-blue-600">{{ $post->title }}</a>
</h2>
<p class="mb-4 text-gray-600">{{ Str::limit($post->content, 200) }}</p>
<div class="flex justify-between items-center">
<div class="flex items-center space-x-4">
<a href="{{ route('posts.show', $post) }}" class="font-medium text-blue-600 hover:text-blue-800">查看</a>
<a href="{{ route('posts.edit', $post) }}" class="font-medium text-green-600 hover:text-green-800">编辑</a>
<form method="POST" action="{{ route('posts.destroy', $post) }}" onsubmit="return confirm('确定要删除这篇文章吗?')" class="inline">
@csrf
@method('DELETE')
<button type="submit" class="font-medium text-red-600 hover:text-red-800">删除</button>
</form>
</div>
</div>
</div>
</article>
@empty
<div class="py-12 text-center">
<p class="text-lg text-gray-500">您还没有写过文章</p>
<a href="{{ route('posts.create') }}" class="inline-block px-4 py-2 mt-4 font-bold text-white bg-blue-500 rounded hover:bg-blue-700">写第一篇文章</a>
</div>
@endforelse
</div>
@if($posts->hasPages())
<div class="mt-8">{{ $posts->links() }}</div>
@endif
</div>
@endsection
输入 my-posts
路由,你会看到如下图所示的页面
数据填充(Seeder 可选)
为了快速准备演示数据,使用命令创建 Seeder:
shell
php artisan make:seeder PostSeeder
然后在 database/seeders/PostSeeder.php
写入如下逻辑(会随机关联已有用户与分类):
php
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\Post;
use App\Models\User;
use App\Models\Category;
class PostSeeder extends Seeder
{
public function run(): void
{
$users = User::all();
$categories = Category::all();
if ($users->isEmpty() || $categories->isEmpty()) {
return;
}
$posts = [
[
'title' => 'Laravel 入门指南',
'content' => '...(省略长文)',
'category_id' => $categories->where('name', '学习笔记')->first()->id,
'status' => 'published',
],
// 其余示例略...
];
foreach ($posts as $postData) {
Post::create([
'title' => $postData['title'],
'content' => $postData['content'],
'user_id' => $users->random()->id,
'category_id' => $postData['category_id'],
'status' => $postData['status'],
]);
}
}
}
执行:
shell
php artisan db:seed --class=PostSeeder
验收与演示
- 访问
/posts
查看文章列表,尝试搜索与分类筛选 - 登录后访问
/posts/create
创建文章,提交后跳转到详情页 - 在详情页点击“编辑文章”修改并保存
- 访问
/my-posts
查看本人文章,进行查看/编辑/删除
小结
本节完成了文章功能的从 0 到 1 实现:迁移、模型、请求验证、控制器、资源路由、视图与测试数据填充。要点:
- 列表仅展示已发布文章,草稿仅作者本人可见
- 支持标题搜索与按分类筛选
- 权限控制:仅作者可编辑/删除
- “我的文章”用于个人文章的集中管理