基于 Laravel + Vue + Vuex 实现博客应用文章发布功能


Laravel 后端接口准备

既然是博客应用,肯定要支持文章发布功能。

我们先在 Laravel 后端 PostController 控制器中编写相应的文章发布处理方法:

public function store(Request $request)
{
    // 表单验证
    $data = $request->validate([
        'title' => 'required|string|max:100',
        'category_id' => [
            'required',
            Rule::in(Category::query()->pluck('id')->toArray())
        ],
        'image' => 'required|image|max:1024',
        'content' => 'required|string',
        'summary' => 'required|string|max:200',
    ]);

    $post = new Post($data);

    // 封面图片上传处理
    if ($request->hasFile('image')) {
        $image = $request->file('image');
        $local_path = $image->storePublicly('images', ['disk' => 'public']);
        $post->image_url = '/storage/' . $local_path;
    }

    try {
        $post->user_id = 1;  // 默认为测试用户发布文章
        $post->save();
    } catch (\Exception $exception) {
        return response()->json(['success' => false, 'message' => '文章发布失败']);
    }

    return response()->json(['success' => true, 'data' => $post->id, 'message' => '文章发布成功']);
}

先验证文章发布表单上传的标题、摘要、详情、分类 ID 和封面图片字段,然后对上传图片进行保存,最后通过 Post 模型类保存文章信息到数据库完成文章发布。

为了让图片路径可以从浏览器访问,需要在 public 入口目录下为 storage/app/public 目录创建软链接:

php artisan storage:link  

此外,我们还要在这个控制器中实现一个获取分类列表的方法,以便在文章发布界面渲染分类选择下拉列表:

use App\Http\Resources\Categories as CategoryCollection;

...

public function categories()
{
    return new CategoryCollection(
        Category::all()
    );
}

这里,我们通过新建一个 Category 资源集合类来处理返回数据:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\ResourceCollection;

class Categories extends ResourceCollection
{
    public $collects = Category::class;

    /**
     * Transform the resource collection into an array.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return array
     */
    public function toArray($request)
    {
        return parent::toArray($request);
    }
}

Post 资源集合类实现一样,不再赘述。

最后,我们在 routes/api.php 中为这两个控制器方法注册 API 路由:

Route::get('/posts/categories', [PostController::class, 'categories']);
Route::post('/posts/store', [PostController::class, 'store']);

至此,就完成了后端接口的准备工作。

基于 Vuex 管理文章发布状态

接下来,我们在前端编写文章发布相关的页面交互代码,通过调用后端接口完成文章发布工作。

还是基于 Vuex 来管理文章发布相关的数据状态,首先在 resources/js/api.js 中编写后端接口请求方法:

// 获取分类列表 API
getPostsCategories() {
    return axios.get(base + '/categories');
},

// 文章发布 API
createNewPost(formData) {
    return axios.post(base + '/store', formData, {
        headers: {
            'Content-Type': 'multipart/form-data'
        }
    });
}

需要注意的是对于文章发布接口,由于表单中包含文件上传,所以需要指定内容类型为 multipart/form-data

然后打开 stores.js,为文章发布状态和分类列表数据编写相应的 stategettersmutationsactions 实现代码,参照上篇教程 Vuex Store 的实现思路照猫画虎即可:

export default new Vuex.Store({
    state: {
        ...
        postStoredStatus: 0,
        postsCategories: []
    },
    getters: {
        ...
        getPostStoredStatus(state) {
            return state.postStoredStatus;
        },
        getPostsCategories(state) {
            return state.postsCategories;
        }
    },
    mutations: {
        ...
        setPostStoredStatus(state, status) {
            state.postStoredStatus = status;
        },
        setPostsCategories(state, categories) {
            state.postsCategories = categories;
        }
    },
    actions: {
        ...
        loadPostsCategories(context) {
            PostAPI.getPostsCategories().then((resp) => {
                context.commit('setPostsCategories', resp.data.data);
            }).catch((err) => {
                console.log(err);
            });
        },
        publishNewPost(context, formData) {
            PostAPI.createNewPost(formData).then((resp) => {
                if (resp.data.success === true) {
                    context.commit('setPostStoredStatus', 1);  // 存储成功
                    dispatch('loadPosts');
                } else {
                    context.commit('setPostStoredStatus', 2);  // 存储失败
                }
            }).catch((err) => {
               console.log(err);
            });
        }
    }
});

新增文章发布 Vue 组件

完成后端接口调用和相关数据状态管理的准备工作后,我们就可以在 resources/js/components 目录下新建一个 Vue 组件 NewPost.vue,作为新文章发布的交互界面。

在这个页面组件中,主要实现一个文章发布表单功能即可:

<template>
    <div class="bg-white shadow-md rounded mt-2 px-8 pt-6 pb-8 mb-6 flex flex-col">
        <flash-message v-if="publishedStatus" :success="publishedStatus === 1">
            <template slot="success">New Post Publish Successfully! </template>
            <template slot="error">New Post Publish Failed! </template>
        </flash-message>
        <div class="flex flex-wrap -mx-3 mb-6">
            <div class="w-full px-3">
                <label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
                    Title
                </label>
                <input required v-model="title" class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker" type="text" placeholder="在这里输入文章标题">
            </div>
        </div>
        <div class="flex flex-wrap -mx-3 mb-6">
            <div class="w-full px-3 md:w-1/2">
                <label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
                    Category
                </label>
                <select required v-model="category_id" class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker">
                    <option v-for="category in categories" :value="category.id">{{ category.name }}</option>
                </select>
            </div>
        </div>
        <div class="flex flex-wrap -mx-3 mb-6">
            <div class="w-full px-3 md:w-1/2">
                <label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
                    Cover Image
                </label>
                <input required ref="image" class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker" type="file">
            </div>
        </div>
        <div class="flex flex-wrap -mx-3 mb-6">
            <div class="w-full px-3">
                <label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
                    Content
                </label>
                <textarea required v-model="content" class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker h-48" placeholder="在这里输入正文内容..."></textarea>
            </div>
        </div>
        <div class="flex flex-wrap -mx-3 mb-6">
            <div class="w-full px-3">
                <label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2">
                    Summary
                </label>
                <textarea required v-model="summary" class="shadow appearance-none border rounded w-full py-2 px-3 text-grey-darker" placeholder="在这里输入文章摘要..."></textarea>
            </div>
        </div>
        <div class="flex items-center">
            <button @click="publishNewPost" type="button" class="border border-green-500 bg-green-500 text-white rounded-md px-4 py-2 m-2 transition duration-500 ease select-none hover:bg-green-600 focus:outline-none focus:shadow-outline">
                立即发布
            </button>
        </div>
    </div>
</template>

<script>
import FlashMessage from "./common/FlashMessage";
export default {
    components: {FlashMessage},
    data() {
        return {
            title: '',
            category_id: '',
            content: '',
            summary: ''
        }
    },
    created() {
        this.$store.dispatch('loadPostsCategories');
    },
    computed: {
        categories() {
            return this.$store.getters.getPostsCategories;
        },
        publishedStatus() {
            return this.$store.getters.getPostStoredStatus;
        }
    },
    methods: {
        publishNewPost() {
            let formData = new FormData();
            formData.append('title', this.title);
            formData.append('category_id', this.category_id);
            formData.append('image', this.$refs.image.files[0]);
            formData.append('content', this.content);
            formData.append('summary', this.summary);
            this.$store.dispatch('publishNewPost', formData);
        }
    }
}
</script>

在表单顶部,是一个从外部引入的 Flash 消息子组件(resources/js/components/common/FlashMessage.vue),它会根据文章发布状态(通过计算属性读取)进行条件渲染:

<template>
    <div v-if="success" class="block text-sm text-green-600 bg-green-200 border border-green-400 h-12 flex items-center p-4 mb-4 rounded-sm relative" role="alert">
        <slot name="success"></slot>
        <button type="button" data-dismiss="alert" aria-label="Close" onclick="this.parentElement.remove();">
            <span class="absolute top-0 bottom-0 right-0 text-2xl px-3 py-1 hover:text-green-900" aria-hidden="true" >×</span>
        </button>
    </div>
    <div v-else class="block text-sm text-red-600 bg-red-200 border border-red-400 h-12 flex items-center p-4 mb-4 rounded-sm relative" role="alert">
        <slot name="error"></slot>
        <button type="button" data-dismiss="alert" aria-label="Close" onclick="this.parentElement.remove();">
            <span class="absolute top-0 bottom-0 right-0 text-2xl px-3 py-1 hover:text-red-900" aria-hidden="true" >×</span>
        </button>
    </div>
</template>

<script>
export default {
    props: ['success']
}
</script>

然后是文章发布所需的表单字段,包括文章标题、分类、封面图片、正文和摘要等,这里使用了数据绑定与 Vue 组件数据属性进行同步(封面图片除外),分类下拉选择列表是在这个 Vue 组件创建之初通过 Vuex 的 loadPostsCategories 加载的:

created() {
    this.$store.dispatch('loadPostsCategories');
},

而后通过计算属性从 Vuex 读取:

categories() {
    return this.$store.getters.getPostsCategories;
}

最后,在填写完表单数据后,点击「立即发布」按钮,就可以触发 publishNewPost 方法提交这个文章发布表单:

publishNewPost() {
    let formData = new FormData();
    formData.append('title', this.title);
    formData.append('category_id', this.category_id);
    formData.append('image', this.$refs.image.files[0]);
    formData.append('content', this.content);
    formData.append('summary', this.summary);
    this.$store.dispatch('publishNewPost', formData);
}

计算属性中定义的文章发布状态 publishedStatus 会根据 Vuex Store actions 中定义的 publishNewPost 方法执行结果进行动态变更,再反馈到 Flash 消息组件的文案来告知用户文章是否发布成功。

PS:这里没有对表单验证失败做处理,作为课后练习,你可以结合前面 Vue 组件实战中的实现自行去处理。

新增文章发布导航菜单

最后,我们需要在 routes.js 中为文章发布页面注册前端路由(放到 /:name 前面,否则会被它拦截):

{
    path: '/new',
    component: require('./components/NewPost').default
}

并且在 resources/views/app.blade.php 中新增文章发布导航菜单:

<nav class="flex-grow md:block px-4 pb-4 md:pb-0 md:overflow-y-auto">
    ... // 其他导航菜单
    <router-link class="block px-4 py-2 mt-2 text-sm font-semibold text-gray-900 bg-transparent rounded-lg dark-mode:bg-transparent dark-mode:hover:bg-gray-600 dark-mode:focus:bg-gray-600 dark-mode:focus:text-white dark-mode:hover:text-white dark-mode:text-gray-200 hover:text-gray-900 focus:text-gray-900 hover:bg-gray-200 focus:bg-gray-200 focus:outline-none focus:shadow-outline" to="/new">New Post</router-link>
</nav>

这样一来,重新编译前端资源,就可以在博客首页看到文章发布导航菜单了:

-w1239

测试文章发布功能

点击这个导航菜单,就可以进入文章发布界面了:

-w1214

填写这个文章发布表单,点击「立即发布」按钮,就可以发布这篇文章了,如果发布成功,会在顶部通过 Flash 消息显示发布成功文案。

-w1197

点击「Home」导航菜单,就可以在首页看到这篇新发布的文章了:

-w1227

好了,关于文章发布功能实现学院君就简单介绍到这里,不过,文章发布功能通常不是开放的,而是需要用户认证之后才能使用,为了简化流程,我们这里限定只有博客应用管理员才能发布新文章,下篇教程,我们就来给大家演示如何在单页面应用中实现用户认证功能。

本项目教程所有代码可以从 Github 代码仓库获取:https://github.com/nonfu/demo-spa


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 引入 Vuex Store 管理 Vue 组件数据状态的更新和获取

>> 下一篇: 基于 Laravel Sanctum + Vuex + Vue 路由导航守卫实现 SPA 用户认证