帶你進(jìn)入異步Django+Vue的世界 - Didi打車實(shí)戰(zhàn)(1)
Demo: https://didi-taxi.herokuapp.com/
本篇來完成前端的框架和注冊(cè)登錄頁面残制。
UI框架大家隨意選擇,符合自己需求就行之众。
比如你只需要桌面端盲链,那iView比較合適蝇率。如果只需要手機(jī)端,那選Framework7刽沾、Element等等瓢剿。如果要同時(shí)適配桌面+手機(jī)端,Vuetify悠轩、Bootstrap比較合適间狂。
我們這里使用Github 18k星的Vuetify。
添加Vuetify到前端:
vue-cli命令行添加就行:
vue add vuetify
然后火架,會(huì)自動(dòng)更新main.js
, App.vue
, package.json
等文件鉴象。
打開新終端,運(yùn)行:
yarn lint --fix
yarn serve
瀏覽器打開http://localhost:8080
何鸡,就能看到Vuetify的demo頁面了:
UI設(shè)計(jì)
編寫前端代碼之前纺弊,先對(duì)我們的設(shè)計(jì)目標(biāo)進(jìn)行規(guī)劃。大家可以先畫藍(lán)圖骡男,發(fā)揮自己的想像力淆游,對(duì)用戶要友好。
總體UI
- 最上面為導(dǎo)航條
- 按鈕:商標(biāo)、登錄犹菱、注冊(cè)拾稳、退出登錄、叫車/接單
- 桌面使用時(shí)腊脱,顯示完整按鈕名稱访得,手機(jī)端只顯示圖標(biāo)。Vuetify會(huì)自動(dòng)調(diào)整陕凹。
- 針對(duì)注冊(cè)和未注冊(cè)用戶悍抑,顯示不同菜單
桌面版:
手機(jī)版:
內(nèi)容區(qū)
在導(dǎo)航條下方,通過Vue-Router來導(dǎo)航杜耙。
首頁顯示當(dāng)前進(jìn)行中的打車搜骡,和打車歷史-
注冊(cè)頁面:
-
登錄頁面:
-
全局提示:
對(duì)于操作成功、失敗佑女,有明顯的提示:
打車頁面:
使用Modal彈出框來實(shí)現(xiàn)记靡,TBD
前端代碼
我們?cè)诘谝黄铮呀?jīng)導(dǎo)入了前端代碼的框架珊豹,支持Vue-Router, Vuex, axios簸呈。可以方便地以此為基礎(chǔ)開發(fā)店茶。
1. 靜態(tài)主頁index.html
添加icon
鏈接蜕便,你也可以選擇font-awesome等其它icon
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="/static/favicon.ico">
<title>Didi Taxi</title>
<link rel="stylesheet" >
<link rel="stylesheet" >
2. 導(dǎo)航條
寫到主組件App.vue
即可。根據(jù)用戶是否已經(jīng)登錄贩幻,顯示不同的菜單轿腺。
# /src/App.vue
<template>
<v-app>
<v-toolbar app>
<v-avatar v-if="userIsAuthenticated">
<img src="https://randomuser.me/api/portraits/men/95.jpg" :title="user.username" />
</v-avatar>
<v-toolbar-title class="headline text-uppercase">
<v-btn flat to="/">
<span>Didi</span>
</v-btn>
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items v-for="item in menuItems" :key="item.id">
<v-btn flat :key="item.title" :to="item.route" @click.prevent="menu_click(item.title)">
<v-icon left>{{ item.icon }}</v-icon>
<div class="hidden-xs-only">{{ item.title }}</div>
</v-btn>
</v-toolbar-items>
</v-toolbar>
。丛楚。族壳。
</v-app>
</template>
3. 全局提示
我們通過v-alert
組件,顯示Vuex store里的提示數(shù)據(jù)趣些。
# /src/App.vue
<template>
<v-app>
仿荆。。坏平。
<v-content>
<v-layout row v-if="alert != null">
<v-flex xs12 sm8 offset-sm2>
<v-alert @input="clearAlert" dismissible :value="true" :type="alert.type">
{{ alert != null ? alert.msg : '' }}
</v-alert>
</v-flex>
</v-layout>
<v-container fluid>
<router-view></router-view>
</v-container>
</v-content>
</v-app>
</template>
Vuex里拢操,alert
為這種格式:
alert: { type: 'success', msg: 'Sign up success!' }
type: success/error/info/warning
需要更新Vuex store:
添加相應(yīng)的state/mutations/actions。
- state:全局變量
- mutations:更新變量的值舶替,必須是同步的
- actions:操作事務(wù)令境,是異步的,可以操作多個(gè)mutations
- getters:在返回變量值之前顾瞪,可以添加其它運(yùn)算舔庶,比如只返回部分符合條件的數(shù)值
# /src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import messages from './modules/messages'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
messages
},
state: {
loading: false,
alert: null,
// alert: { type: 'success', msg: 'Login success!' },
// user: { id: 1, username: 'admin', first_name: '', last_name: '' }
user: null
},
mutations: {
setLoading (state, payload) {
state.loading = payload
},
setAlert (state, payload) {
state.alert = payload
},
clearAlert (state) {
state.alert = null
},
setUser (state, payload) {
state.user = payload
}
},
actions: {
setUserInfo ({ commit }) {
let u = localStorage.getItem('user')
if (u) {
u = JSON.parse(u)
} else {
console.log('>>> no user info found in localStorage')
}
commit('setUser', u)
},
clearAlert ({ commit }) {
commit('clearAlert')
}
},
getters: {
loading (state) {
return state.loading
},
alert (state) {
return state.alert
},
user (state) {
return state.user
}
}
})
我們把上面文件里的注釋去掉抛蚁,測(cè)試一下:
state: {
//alert: null
alert: { type: 'success', msg: 'Login success!' }
},
你應(yīng)該能成功看到提示:
4. home路由
更新路由文件,支持以下路由:
# /src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import My404 from './views/My404.vue'
import Signup from './views/Signup.vue'
import Signin from './views/Signin.vue'
Vue.use(Router)
export default new Router({
mode: 'history',
base: process.env.BASE_URL,
routes: [
{
path: '/',
name: 'home',
component: Home
},
{
path: '/sign_up',
name: 'sign_up',
component: Signup
},
{
path: '/log_in',
name: 'log_in',
component: Signin
},
{
path: '/messages',
name: 'messages',
// route level code-splitting
// this generates a separate chunk (xxx.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "messages" */ './views/Messages.vue')
},
{ path: '*', name: 'my404', component: My404 }
]
})
編輯頁面文件home.vue惕橙,先顯示空白打車記錄:
<template>
<v-layout row wrap>
<v-flex xs12 sm6 offset-sm3>
<v-card class="mb-4">
<v-img
src="https://cdn.vuetifyjs.com/images/parallax/material2.jpg"
aspect-ratio="5" class="white--text">
<v-container fill-height fluid>
<span class="display-2">On-going Trip</span>
</v-container>
</v-img>
<v-card-title primary-title>
<div class="grey--text"> {{ card_text }} </div>
</v-card-title>
<v-card-actions>
<v-btn flat color="red">Cancel</v-btn>
<v-spacer></v-spacer>
<v-btn flat color="blue">View</v-btn>
</v-card-actions>
</v-card>
</v-flex>
<v-flex xs12 sm6 offset-sm3>
<v-card class="mb-4">
<v-img
src="https://cdn.vuetifyjs.com/images/cards/docks.jpg"
aspect-ratio="5" class="white--text">
<v-container fill-height fluid>
<span class="display-2">Trip History</span>
</v-container>
</v-img>
<v-card-title primary-title>
<div class="grey--text"> {{ card_text }} </div>
</v-card-title>
<v-card-actions>
<v-spacer />
<v-btn flat color="blue">View ALL</v-btn>
</v-card-actions>
</v-card>
</v-flex>
</v-layout>
</template>
<script>
export default {
data () {
return {
card_text: 'No data'
}
}
}
</script>
后續(xù)會(huì)使用服務(wù)器返回的數(shù)據(jù)瞧甩,來更新顯示。
5. 注冊(cè)路由
注冊(cè)頁面吕漂,顯示三條輸入行:username, password1, password2
針對(duì)兩次密碼亲配,進(jìn)行對(duì)比提示
# /src/views/Signup.vue
<template>
<v-container>
<v-layout row>
<v-flex xs12 sm6 offset-sm3>
<v-card>
<v-card-text>
<v-container>
<form @submit.prevent="onSignup">
<v-layout row>
<v-flex xs12>
<v-text-field
name="username"
label="Username"
id="username"
v-model="username"
type="text"
required></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
name="password"
label="Password"
id="password"
v-model="password"
type="password"
required></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
name="confirmPassword"
label="Validate Password"
id="confirmPassword"
v-model="confirmPassword"
type="password"
:rules="[comparePasswords]"></v-text-field>
</v-flex>
</v-layout>
<v-layout>
<v-flex xs12>
<v-card-actions>
<v-spacer />
<v-btn round type="submit" :loading="loading" class="orange">Register</v-btn>
</v-card-actions>
</v-flex>
</v-layout>
</form>
</v-container>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
export default {
data () {
return {
username: '',
password: '',
confirmPassword: ''
}
},
computed: {
comparePasswords () {
return this.password !== this.confirmPassword ? 'Passwords do not match.' : true
},
user () {
return this.$store.getters.user
},
alert () {
return this.$store.getters.alert
},
loading () {
return this.$store.getters.loading
}
},
watch: {
user (value) {
if (value !== null && value !== undefined) {
this.$router.push('/')
}
}
},
methods: {
onSignup () {
this.$store.dispatch('messages/signUserUp', { username: this.username, password2: this.confirmPassword, password1: this.password })
},
onDismissed () {
this.$store.dispatch('clearAlert')
}
}
}
</script>
當(dāng)點(diǎn)擊Register
注冊(cè)時(shí)尘应,發(fā)送請(qǐng)求到后端惶凝。
這里的最佳實(shí)踐是,所有跟后端的API交互犬钢,都統(tǒng)一提取出來放在Vuex苍鲜,方便更新和管理。
Vuex添加signUserUp
注冊(cè)action:
- 更新
loading
- 按鈕的狀態(tài)在交互時(shí)玷犹,會(huì)提示正在跟后臺(tái)通信 - 通過
messageService.signUserUp()
發(fā)送POST - 更新
setAlert
- 顯示注冊(cè)成功提示 - 注冊(cè)成功后混滔,轉(zhuǎn)向Home路由
# /src/store/modules/messages.js
const actions = {
signUserUp ({ commit }, payload) {
commit('setLoading', true, { root: true })
messageService.signUserUp(payload)
.then(messages => {
commit('setAlert', { type: 'success', msg: 'Sign up success!' }, { root: true })
commit('setLoading', false, { root: true })
router.push('/')
})
},
API統(tǒng)一放在/src/services/messageService.js
:
import api from '@/services/api'
export default {
signUserUp (payload) {
return api.post(`sign_up/`, payload)
.then(response => response.data)
},
確保后臺(tái)Django程序運(yùn)行中:
python manage.py runserver
測(cè)試一下,應(yīng)該能順利注冊(cè)新用戶了歹颓。
但是坯屿,當(dāng)前對(duì)異常處理沒有任何處理,用戶不知道為什么注冊(cè)失敗了巍扛。
我們可以對(duì)后端返回值處理领跛,然后提示。
但對(duì)于100個(gè)API呢撤奸?也一次次處理么吠昭?太低效了!我們來歸納一下胧瓜。
axios統(tǒng)一處理header和異常
對(duì)于后端矢棚,可能要前端提供一些額外的header信息,比如csrf
, token
府喳。
前端收到返回值蒲肋,也要提示用戶。
- header加上csrf:
'X-CSRFToken': Cookies.get('csrftoken')
- error信息钝满,通過Vuex提示給用戶:
store.commit('setAlert', { type: 'error', msg: error.response.data })
# /src/services/api.js
import axios from 'axios'
import Cookies from 'js-cookie'
import vueconfig from '@/config'
import store from '@/store'
axios.interceptors.request.use(
config => {
config.baseURL = `${vueconfig.baseUrl}/api/`
config.withCredentials = true // 允許攜帶token 解決跨域產(chǎn)生的相關(guān)問題
config.timeout = 10000 // 10s
config.headers = {
'Content-Type': 'application/json',
'X-CSRFToken': Cookies.get('csrftoken')
}
return config
},
error => {
return Promise.reject(error)
}
)
// 在 response 攔截器實(shí)現(xiàn)
axios.interceptors.response.use(
response => {
// console.log(response)
return response
},
error => {
console.log(error.response)
if (error.response.status === 400) {
// Bad Request. within module: { root: true } ??
store.commit('setAlert', { type: 'error', msg: error.response.data })
} else if (error.response.status === 403) {
// Forbidden 403
store.commit('setAlert', { type: 'error', msg: error.response.data.detail })
localStorage.removeItem('user')
store.commit('setUser', null)
} else if ([405].includes(error.response.status)) {
// Method Not Allowed 405
store.commit('setAlert', { type: 'error', msg: error.response.data.detail })
} else {
console.log(`>>> un-handled error code! ${error.response.status}`)
}
store.commit('setLoading', false)
return Promise.reject(error)
}
)
export default axios
axios配置文件:
配置后端的Django服務(wù)器地址兜粘,我們順便把Websockets也加上
# /src/config.js
const wsProtocol = location.protocol === 'http:' ? 'ws:' : 'wss:'
let baseUrl = location.origin
let wsUrl = `${wsProtocol}//${location.host}`
if (process.env.NODE_ENV === 'development') {
baseUrl = 'http://localhost:8080'
wsUrl = 'ws://localhost:8080'
}
export default {
baseUrl,
wsUrl
}
再次測(cè)試,如果有任何ajax出錯(cuò)舱沧,用戶都能看到提示:
比如:
6. 登錄路由
有了前面的鋪墊妹沙,就很簡(jiǎn)單了
先創(chuàng)建view頁面:
- 顯示兩條輸入行:username, password
- 點(diǎn)擊登錄時(shí),執(zhí)行Vuex
signUserIn
action
# /src/views/Sigin.vue
<template>
<v-container>
<v-layout row>
<v-flex xs12 sm6 offset-sm3>
<v-card>
<v-card-text>
<v-container>
<form @submit.prevent="onSignin">
<v-layout row>
<v-flex xs12>
<v-text-field
name="username"
label="Username"
id="username"
v-model="username"
type="text"
required></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-text-field
name="password"
label="Password"
id="password"
v-model="password"
type="password"
required></v-text-field>
</v-flex>
</v-layout>
<v-layout row>
<v-flex xs12>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn type="submit" :loading="loading" round class="primary">Login</v-btn>
</v-card-actions>
</v-flex>
</v-layout>
</form>
</v-container>
</v-card-text>
</v-card>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
export default {
data () {
return {
username: '',
password: ''
}
},
computed: {
user () {
return this.$store.getters.user
},
loading () {
return this.$store.getters.loading
}
},
watch: {
user (value) {
if (value !== null && value !== undefined) {
this.$router.push('/')
}
}
},
methods: {
onSignin () {
this.$store.dispatch('messages/signUserIn', { username: this.username, password: this.password })
}
}
}
</script>
更新Vuex:
- ajax調(diào)用
messageService.signUserIn(payload)
- 為了保存登錄狀態(tài)熟吏,我們使用
LocalStorage
來保存距糖。這樣玄窝,用戶登錄過后,關(guān)閉瀏覽器悍引,再打開瀏覽器恩脂,直接為已登錄狀態(tài),直到Django session過期趣斤。
# /src/store/modules/message.js
const actions = {
signUserIn ({ commit }, payload) {
commit('setLoading', true, { root: true })
messageService.signUserIn(payload)
.then(messages => {
commit('setAlert', { type: 'success', msg: 'Login success!' }, { root: true })
commit('setUser', messages, { root: true })
localStorage.setItem('user', JSON.stringify(messages))
commit('setLoading', false, { root: true })
router.push('/')
})
},
ajax交互 /src/services/messageService.js
:
export default {
signUserIn (payload) {
return api.post(`log_in/`, payload)
.then(response => response.data)
},
登錄后俩块,會(huì)顯示用戶頭像,叫車和退出按鈕:
7. 注銷登錄
這個(gè)不需要?jiǎng)?chuàng)建新的vue頁面文件浓领。
更新Vuex:
- ajax調(diào)用
messageService.signUserOut()
- 清除
LocalStorage
里保存的登錄狀態(tài)
# /src/store/modules/message.js
const actions = {
signUserOut ({ commit }) {
commit('setLoading', true, { root: true })
messageService.signUserOut()
.then(messages => {
commit('setAlert', { type: 'info', msg: 'Log-out success!' }, { root: true })
commit('setUser', null, { root: true })
localStorage.removeItem('user')
commit('setLoading', false, { root: true })
})
},
ajax交互 /src/services/messageService.js
:
export default {
signUserOut () {
return api.post(`log_out/`, '')
.then(response => response.data)
},
導(dǎo)航欄的退出按鈕玉凯,添加方法:
# /src/App.vue
computed: {
...mapState(['alert', 'user']),
menuItems () {
let items = [
{ icon: 'face', title: 'Register', route: '/sign_up' },
{ icon: 'lock_open', title: 'Login', route: '/log_in' }
]
if (this.userIsAuthenticated) {
items = [
{ icon: 'local_taxi', title: 'Call', route: '' },
{ icon: 'exit_to_app', title: 'Exit', route: '' }
]
}
return items
},
userIsAuthenticated () {
return this.$store.getters.user !== null && this.$store.getters.user !== undefined
}
},
methods: {
...mapActions(['clearAlert']),
menu_click (title) {
if (title === 'Exit') {
this.$store.dispatch('messages/signUserOut')
} else if (title === 'Call') {
this.$store.dispatch('messages/callTaxi')
}
}
}
總結(jié)
這套鑒權(quán)系統(tǒng),非常通用联贩,其它項(xiàng)目都可以借鑒使用漫仆。
下一篇,會(huì)進(jìn)入到Django后臺(tái)數(shù)據(jù)庫設(shè)計(jì)泪幌。