在上篇中,我们分别用 Django 和 Nuxt 实现了后端和前端的雏形。在这一部分,我们将实现前后端之间的通信,使得前端可以从后端获取数据,并且将进一步丰富网站的功能。
在这一部分,我们将真正实现一个全栈应用——让前端能够向后端发起请求,从而获取想要的数据。
首先我们要配置一下 Django 服务器,使前端能够访问其静态文件。调整 api/api/urls.py 文件如下:
# ... from django.conf import settings from django.conf.urls.static import static urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('core.urls')), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
注意
这样配置静态文件路由的方式仅应当在开发环境下使用。在生产环境下(settings.py 中的
DEBUG
设为False
时),静态文件路由将自动失效(因为 Django 并不适合作为静态文件服务器,应该选用类似 Nginx 之类的服务器,在后续教程中我们将更深入地讨论)。
在客户端,我们先要对 Nuxt 进行全局配置。Nuxt 包括 axios 包,这是一个非常出色的基于 Promise 的 HTTP 请求库。在 nuxt.config.js 中的 axios
一项中添加 Django 服务器的 URL:
export default { // ... /* ** Axios module configuration ** See https://axios.nuxtjs.org/options */ axios: { baseURL: 'http://localhost:8000/api', }, // ... }
将食谱列表页面中暂时填充的假数据删去,通过 asyncData
方法获取数据。由于我们之前配置好了 axios,所以 asyncData
函数可以获取到 $axios
对象用于发起 HTTP 请求。我们实现页面加载的数据获取以及 deleteRecipe
事件,代码如下:
<template> <main class="container mt-5"> <div class="row"> <div class="col-12 text-right mb-4"> <div class="d-flex justify-content-between"> <h3>吃货天堂</h3> <nuxt-link to="/recipes/add" class="btn btn-info">添加食谱</nuxt-link> </div> </div> <template v-for="recipe in recipes"> <div :key="recipe.id" class="col-lg-3 col-md-4 col-sm-6 mb-4"> <recipe-card :onDelete="deleteRecipe" :recipe="recipe"></recipe-card> </div> </template> </div> </main> </template> <script> import RecipeCard from "~/components/RecipeCard.vue"; export default { head() { return { title: "食谱列表" }; }, components: { RecipeCard }, async asyncData({ $axios, params }) { try { let recipes = await $axios.$get(`/recipes/`); return { recipes }; } catch (e) { return { recipes: [] }; } }, data() { return { recipes: [] }; }, methods: { async deleteRecipe(recipe_id) { try { if (confirm('确认要删除吗?')) { await this.$axios.$delete(`/recipes/${recipe_id}/`); let newRecipes = await this.$axios.$get("/recipes/"); this.recipes = newRecipes; } } catch (e) { console.log(e); } } } }; </script> <style scoped> </style>
我们进一步实现食谱详情页面。在 pages/recipes 目录中创建 _id 目录,在其中添加 index.vue 文件,代码如下:
<template> <main class="container my-5"> <div class="row"> <div class="col-12 text-center my-3"> <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2> </div> <div class="col-md-6 mb-4"> <img class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="recipe.picture" alt > </div> <div class="col-md-6"> <div class="recipe-details"> <h4>食材</h4> <p>{{ recipe.ingredients }}</p> <h4>准备时间 ?</h4> <p>{{ recipe.prep_time }} mins</p> <h4>制作难度</h4> <p>{{ recipe.difficulty }}</p> <h4>制作指南</h4> <textarea class="form-control" rows="10" v-html="recipe.prep_guide" disabled/> </div> </div> </div> </main> </template> <script> export default { head() { return { title: "食谱详情" }; }, async asyncData({ $axios, params }) { try { let recipe = await $axios.$get(`/recipes/${params.id}`); return { recipe }; } catch (e) { return { recipe: [] }; } }, data() { return { recipe: { name: "", picture: "", ingredients: "", difficulty: "", prep_time: null, prep_guide: "" } }; } }; </script> <style scoped> </style>
为了测试前端页面能否真正从后端获取数据,我们先要在后端数据库中添加一些数据,而这对 Django 来说就非常方便了。进入 api 目录,运行 python manage.py runserver
打开服务器,然后进入后台管理页面(http://localhost:8000/admin),添加一些数据:
再运行前端页面,可以看到我们刚刚在 Django 后台管理中添加的项目:
有了前面的铺垫,实现食谱的添加和删除也基本上是按部就班了。我们在 pages/recipes/_id 中实现 edit.vue
(食谱编辑页面),代码如下:
<template> <main class="container my-5"> <div class="row"> <div class="col-12 text-center my-3"> <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2> </div> <div class="col-md-6 mb-4"> <img v-if="!preview" class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="recipe.picture"> <img v-else class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="preview"> </div> <div class="col-md-4"> <form @submit.prevent="submitRecipe"> <div class="form-group"> <label for>Recipe Name</label> <input type="text" class="form-control" v-model="recipe.name" > </div> <div class="form-group"> <label for>Ingredients</label> <input type="text" v-model="recipe.ingredients" class="form-control" name="Ingredients" > </div> <div class="form-group"> <label for>Food picture</label> <input type="file" @change="onFileChange"> </div> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for>Difficulty</label> <select v-model="recipe.difficulty" class="form-control" > <option value="Easy">Easy</option> <option value="Medium">Medium</option> <option value="Hard">Hard</option> </select> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for> Prep time <small>(minutes)</small> </label> <input type="text" v-model="recipe.prep_time" class="form-control" name="Ingredients" > </div> </div> </div> <div class="form-group mb-3"> <label for>Preparation guide</label> <textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea> </div> <button type="submit" class="btn btn-success">Save</button> </form> </div> </div> </main> </template> <script> export default { head(){ return { title: "编辑食谱" } }, async asyncData({ $axios, params }) { try { let recipe = await $axios.$get(`/recipes/${params.id}`); return { recipe }; } catch (e) { return { recipe: [] }; } }, data() { return { recipe: { name: "", picture: "", ingredients: "", difficulty: "", prep_time: null, prep_guide: "" }, preview: "" }; }, methods: { onFileChange(e) { let files = e.target.files || e.dataTransfer.files; if (!files.length) { return; } this.recipe.picture = files[0] this.createImage(files[0]); }, createImage(file) { let reader = new FileReader(); let vm = this; reader.onload = e => { vm.preview = e.target.result; }; reader.readAsDataURL(file); }, async submitRecipe() { let editedRecipe = this.recipe if (editedRecipe.picture.indexOf("http://") != -1){ delete editedRecipe["picture"] } const config = { headers: { "content-type": "multipart/form-data" } }; let formData = new FormData(); for (let data in editedRecipe) { formData.append(data, editedRecipe[data]); } try { let response = await this.$axios.$patch(`/recipes/${editedRecipe.id}/`, formData, config); this.$router.push("/recipes/"); } catch (e) { console.log(e); } } } }; </script> <style> </style>
实现之后的页面如下:
继续在 pages/recipes/_id 中实现 add.vue
(创建食谱页面)如下:
<template> <main class="container my-5"> <div class="row"> <div class="col-12 text-center my-3"> <h2 class="mb-3 display-4 text-uppercase">{{ recipe.name }}</h2> </div> <div class="col-md-6 mb-4"> <img v-if="preview" class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" :src="preview" alt > <img v-else class="img-fluid" style="width: 400px; border-radius: 10px; box-shadow: 0 1rem 1rem rgba(0,0,0,.7);" src="@/static/images/placeholder.png" > </div> <div class="col-md-4"> <form @submit.prevent="submitRecipe"> <div class="form-group"> <label for>食谱名称</label> <input type="text" class="form-control" v-model="recipe.name"> </div> <div class="form-group"> <label for>食材</label> <input v-model="recipe.ingredients" type="text" class="form-control"> </div> <div class="form-group"> <label for>图片</label> <input type="file" name="file" @change="onFileChange"> </div> <div class="row"> <div class="col-md-6"> <div class="form-group"> <label for>难度</label> <select v-model="recipe.difficulty" class="form-control"> <option value="Easy">容易</option> <option value="Medium">中等</option> <option value="Hard">困难</option> </select> </div> </div> <div class="col-md-6"> <div class="form-group"> <label for> 制作时间 <small>(分钟)</small> </label> <input v-model="recipe.prep_time" type="number" class="form-control"> </div> </div> </div> <div class="form-group mb-3"> <label for>制作指南</label> <textarea v-model="recipe.prep_guide" class="form-control" rows="8"></textarea> </div> <button type="submit" class="btn btn-primary">提交</button> </form> </div> </div> </main> </template> <script> export default { head() { return { title: "Add Recipe" }; }, data() { return { recipe: { name: "", picture: "", ingredients: "", difficulty: "", prep_time: null, prep_guide: "" }, preview: "" }; }, methods: { onFileChange(e) { let files = e.target.files || e.dataTransfer.files; if (!files.length) { return; } this.recipe.picture = files[0]; this.createImage(files[0]); }, createImage(file) { let reader = new FileReader(); let vm = this; reader.onload = e => { vm.preview = e.target.result; }; reader.readAsDataURL(file); }, async submitRecipe() { const config = { headers: { "content-type": "multipart/form-data" } }; let formData = new FormData(); for (let data in this.recipe) { formData.append(data, this.recipe[data]); } try { let response = await this.$axios.$post("/recipes/", formData, config); this.$router.push("/recipes/"); } catch (e) { console.log(e); } } } }; </script> <style scoped> </style>
实现的页面如下:
在这一节中,我们将演示如何在 Nuxt 中添加全局样式文件,来实现前端页面之间的跳转效果。
首先在 assets 目录中创建 css 目录,并在其中添加 transition.css 文件,代码如下:
.page-enter-active, .page-leave-active { transition: opacity .3s ease; } .page-enter, .page-leave-to { opacity: 0; }
在 Nuxt 配置文件中将刚才写的 transition.css 中添加到全局 CSS 中:
export default { // ... /* ** Global CSS */ css: [ '~/assets/css/transition.css', ], // ... }
欧耶,一个具有完整增删改查功能、实现了前后端分离的美食分享网站就完成了!
想要学习更多精彩的实战技术教程?来图雀社区逛逛吧。