带你进入异步Django+Vue的世界 - Didi打车实战(2)

带你进入异步Django+Vue的世界 - Didi打车实战(1)
Demo: https://didi-taxi.herokuapp.com/

本篇来完成前端的框架和注册登录页面。

UI框架大家随意选择,符合自己需求就行。
比如你只需要桌面端,那iView比较合适。如果只需要手机端,那选Framework7、Element等等。如果要同时适配桌面+手机端,Vuetify、Bootstrap比较合适。
我们这里使用Github 18k星的Vuetify

添加Vuetify到前端:

vue-cli命令行添加就行:
vue add vuetify
然后,会自动更新main.js, App.vue, package.json等文件。

打开新终端,运行:
yarn lint --fix
yarn serve
浏览器打开http://localhost:8080,就能看到Vuetify的demo页面了:

image.png

UI设计

编写前端代码之前,先对我们的设计目标进行规划。大家可以先画蓝图,发挥自己的想像力,对用户要友好。

总体UI

  1. 最上面为导航条
  • 按钮:商标、登录、注册、退出登录、叫车/接单
  • 桌面使用时,显示完整按钮名称,手机端只显示图标。Vuetify会自动调整。
  • 针对注册和未注册用户,显示不同菜单

桌面版:


image.png

手机版:


image.png
  1. 内容区
    在导航条下方,通过Vue-Router来导航。
    首页显示当前进行中的打车,和打车历史

  2. 注册页面:


    image.png
  3. 登录页面:


    image.png
  4. 全局提示:
    对于操作成功、失败,有明显的提示:


    image.png
  5. 打车页面:
    使用Modal弹出框来实现,TBD

前端代码

我们在第一篇里,已经导入了前端代码的框架,支持Vue-Router, Vuex, axios。可以方便地以此为基础开发。

1. 静态主页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" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
2. 导航条

写到主组件App.vue即可。根据用户是否已经登录,显示不同的菜单。

# /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里的提示数据。

# /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:
添加相应的state/mutations/actions。

  • state:全局变量
  • mutations:更新变量的值,必须是同步的
  • actions:操作事务,是异步的,可以操作多个mutations
  • getters:在返回变量值之前,可以添加其它运算,比如只返回部分符合条件的数值
# /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
    }
  }
})

我们把上面文件里的注释去掉,测试一下:

  state: {
    //alert: null
    alert: { type: 'success', msg: 'Login success!' }
  },

你应该能成功看到提示:


image.png
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>

后续会使用服务器返回的数据,来更新显示。

5. 注册路由

注册页面,显示三条输入行:username, password1, password2
针对两次密码,进行对比提示

# /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>

当点击Register注册时,发送请求到后端。
这里的最佳实践是,所有跟后端的API交互,都统一提取出来放在Vuex,方便更新和管理。
Vuex添加signUserUp 注册action:

  • 更新loading - 按钮的状态在交互时,会提示正在跟后台通信
  • 通过messageService.signUserUp()发送POST
  • 更新setAlert - 显示注册成功提示
  • 注册成功后,转向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统一放在/src/services/messageService.js

import api from '@/services/api'

export default {
  signUserUp (payload) {
    return api.post(`sign_up/`, payload)
      .then(response => response.data)
  },

确保后台Django程序运行中:
python manage.py runserver
测试一下,应该能顺利注册新用户了。

但是,当前对异常处理没有任何处理,用户不知道为什么注册失败了。
我们可以对后端返回值处理,然后提示。
但对于100个API呢?也一次次处理么?太低效了!我们来归纳一下。

axios统一处理header和异常

对于后端,可能要前端提供一些额外的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 解决跨域产生的相关问题
    config.timeout = 10000 // 10s

    config.headers = {
      'Content-Type': 'application/json',
      'X-CSRFToken': Cookies.get('csrftoken')
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 在 response 拦截器实现
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服务器地址,我们顺便把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
}

再次测试,如果有任何ajax出错,用户都能看到提示:
比如:


image.png
6. 登录路由

有了前面的铺垫,就很简单了
先创建view页面:

  • 显示两条输入行:username, password
  • 点击登录时,执行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调用messageService.signUserIn(payload)
  • 为了保存登录状态,我们使用LocalStorage来保存。这样,用户登录过后,关闭浏览器,再打开浏览器,直接为已登录状态,直到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)
  },

登录后,会显示用户头像,叫车和退出按钮:


image.png
7. 注销登录

这个不需要创建新的vue页面文件。
更新Vuex:

  • ajax调用messageService.signUserOut()
  • 清除LocalStorage里保存的登录状态
# /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)
  },

导航栏的退出按钮,添加方法:

# /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')
      }
    }
  }
image.png

总结

这套鉴权系统,非常通用,其它项目都可以借鉴使用。
下一篇,会进入到Django后台数据库设计。

带你进入异步Django+Vue的世界 - Didi打车实战(3)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,158评论 4 370
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,600评论 1 307
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,785评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,655评论 0 220
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,075评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,002评论 1 225
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,146评论 2 318
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,918评论 0 211
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,671评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,838评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,318评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,636评论 3 263
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,343评论 3 244
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,187评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,982评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,126评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,934评论 2 279