美文网首页Vue.js全栈工程师vue
带你进入异步Django+Vue的世界 - Didi打车实战(2

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

作者: 非梦nj | 来源:发表于2019-05-18 10:38 被阅读23次

    带你进入异步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)

    相关文章

      网友评论

        本文标题:带你进入异步Django+Vue的世界 - Didi打车实战(2

        本文链接:https://www.haomeiwen.com/subject/pednaqtx.html