投票项目 - 前端开发

投票项目 - 前端开发

投票项目的初步设计

Vuetify看了几天,发现先把组件过一遍,心里有数,就直接上吧。
  1. 前端设计
  2. 后端的继续改进
  3. 自动登录的逻辑
  4. Login组件
  5. 投票页面

前端设计

简单的View和组件设计

简单想了一下流程,分开两个视图,一个是登录视图(路径为/login),组件为登录表单;一个是展示投票页面的视图(路径为是根路径),组件是投票表单+登录展示。 主要业务逻辑如下:
  1. 项目初始化
    1. vuex从尝试从本地加载TOKEN和用户名
  2. 用户访问首页
    1. before导航守卫判断vuex中是否有TOKEN,没有则转至登录视图
    2. 如果有TOKEN,向后端发送TOKEN获取投票信息
      1. 正常获得响应,使用vote中的数据进行渲染--正常进入投票视图
      2. 出现401错误,表示TOKEN认证失败--转至登录视图
      3. 出现404错误,表示用户没有投过票。此时只渲染投票组件中投票部分,不展示投票数量,让用户进行投票之后再展示投票结果。
  3. 用户访问登录视图
    1. 导航守卫不做任何验证-->所有用户都可以访问登录视图
    2. 用户发送登录请求-->成功取得200响应-->将TOKEN和用户名,是否投票,上次投票日期等信息加载至Vuex并存储到本地存储中,然后转至首页。
    3. 用户发送登录请求-->返回任何错误代码-->依然返回登录视图,但是需要通过SLOT插入错误信息。
  4. 用户选择了组件之后点击发送按钮
    1. 如果获取201响应,则表示成功进行投票。
    2. 如果获取406页面,想办法在页面上提示每24小时只能投一次票,什么也不做,还是留在当前页面。
    3. 如果获取401响应,表示TOKEN出现问题,将用户转至登录页面。
这样设计之后,Vue开发的过程在我脑子里基本形成了URL--》视图逻辑--》载入数据--》分发给组件。仔细想了一下,其实和后端有异曲同工之妙,因为前端切换视图在传统后端里等于访问不同的URL,自然就是加载数据的过程,然后把数据放到页面上渲染出来的过程,就是载入数据然后分发给组件进行渲染的过程。 一对比,前端的逻辑就清晰好多了。 不过实际编写的时候,发现细节问题还是很多,所以决定重构一下后端,将所有的请求放行到控制器中来进行处理,这样可以比较方便的处理跨域问题,同时使用Spring Security来简单允许跨域。

后端的继续改进

主要是在返回投票结果的地方做了一些改进,不再返回错误码,而是如果用户未投过票,就返回一个-1的投票总数;此外还返回一个expireTime,表示整个投票的过期时间。 现在显示的是:
{
    "votes": [
        {
            "name": "VoteItemA",
            "score": 9
        },
        {
            "name": "VoteItemB",
            "score": 11
        },
        {
            "name": "VoteItemC",
            "score": 27
        },
        {
            "name": "VoteItemD",
            "score": 5
        },
        {
            "name": "VoteItemE",
            "score": 19
        }
    ],
    "expireTime": 1561824000000,
    "totalVotes": 71
}

自动登录

自动登录使用了Vuex,由于要自动登录,很显然要在应用启动的时候从localStorage中载入相关信息,在login的时候写入相关信息。 这两个动作都放到Vuex的actions中去,这样无论在任何时候都可以通过这两个函数来设置vuex的内容。 store.js的主要内容:
import Vue from 'vue'
import Vuex from 'vuex'
import axios from 'axios'


Vue.use(Vuex)

export default new Vuex.Store({
  state: {
    token: null,
    username: null,
    lastVote: null,
    expire: null,
    vote:null
  },
  //更新token,username和上次投票的mutations
  mutations: {
    setToken: function (state, token) {
      state.token = token;
    },
    setUsername: function (state, username) {
      state.username = username;
    },
    setLastVote: function (state, lastVote) {
      state.lastVote = lastVote;
    },
    setExpire: function (state, expire) {
      state.expire = expire;
    },
    setVote: function (state, vote) {
      state.vote = vote;
    }
  },
  //异步更新,用于登录请求
  actions: {
    tryAutoLogin: function (context) {
      const token = localStorage.getItem('token')
      const username = localStorage.getItem('username')
      const lastVote = localStorage.getItem('lastVote')
      const expire = localStorage.getItem('expire')
      const vote = localStorage.getItem('vote')
      const now = (new Date()).getTime()
      if(now<=expire){
        context.commit('setToken', token)
        context.commit('setUsername', username)
        context.commit('setLastVote', lastVote)
        context.commit('setExpire', expire)
        context.commit('setVote',vote)
      } else {
        context.commit('setExpire', -1);
      }
    },

    login:function({commit}, authData){
      return new Promise((resolve,reject)=>{
        axios.post('http://localhost:8080/api/token', {
          username: authData.username,
          password: authData.password
        }).then(res => {
          commit('setToken', res.headers.authorization)
          commit('setUsername', authData.username)
          commit('setLastVote', res.data.lastVotedAt)
          commit('setExpire', res.data.expire)
          localStorage.setItem('token', res.headers.authorization)
          localStorage.setItem('username', authData.username)
          localStorage.setItem('expire', res.data.expire)
          localStorage.setItem('lastVote', res.data.lastVotedAt);
          resolve()
        }).catch(err => {
          err.toString()
          reject()
        })
      })
    }
  }
})
这里的数据设置有些冗余。tryAutoLogin用于读入信息,然后马上就要被首页使用。login的方法则比较特殊,返回一个Promise对象,这样视图在调用这个方法的时候,能够根据结果来访问路由。

Login视图

这里其实写的不是太好,就直接渲染了一个表单作为login视图的唯一组件。应该是将向后端提交数据的方法写在视图里,然后视图将传回的数据通知给组件。 不过由于比较简单,也就直接实现了,而是是通过Vuex中的login返回Promise来实现的。 登录的组件如下:
<template>
    <v-container fill-height>
        <v-layout justify-center align-center>
            <v-flex xs10 sm9 md7 lg6 xl4 class="mb-5">
                <h2 class="display-3 text-xs-center">Please Login</h2>
                <v-layout wrap row justify-center>
                    <v-flex text-xs-center xs8>
                        <v-alert type="error" :value="loginFailed" outline>用户名或密码错误</v-alert>
                    </v-flex>
                    <v-flex xs12>
                        <v-text-field
                                v-model="username"
                                label="Username"
                                type="text"
                                required
                                @blur="$v.username.$touch()"
                        ></v-text-field>
                    </v-flex>
                    <v-flex xs12>
                        <v-text-field
                                v-model="password"
                                label="Password"
                                type="password"
                                @blur="$v.password.$touch()"
                                required
                        ></v-text-field>
                    </v-flex>
                    <v-flex xs12 class="text-xs-center">
                        <v-btn :disabled="$v.$invalid" @click="handleLogin">Login</v-btn>
                    </v-flex>
                </v-layout>
            </v-flex>
        </v-layout>

    </v-container>
</template>

<script>
    import {required} from 'vuelidate/lib/validators'

    export default {
        name: "LoginForm",
        data: function () {
            return {
                username: "",
                password: ""
            }
        },
        computed: {
            loginFailed: function () {
                return window.location.href.endsWith('error')
            }
        },
        validations: {
            username: {
                required
            },

            password: {
                required
            }
        },
        methods: {
            handleLogin: function () {
                var _this=this
                this.$store.dispatch('login',{
                    username:this.username,
                    password:this.password
                }).then(()=>_this.$router.push('/')).catch(()=>_this.$router.push('/login?error'))
            }
        }
    }
</script>

<style scoped>

</style>
这个组件比较简单,就是使用了一个表单,然后将用户名和密码发送过去。如果不成功,就返回一个错误页面,这里实际上应该用个snack-bar来做一下提示,比较懒也没做。

投票页面

投票页面编写的时候,终于找到了Vue的套路,想想看不然为什么Vue CLI生成的项目,要先分出来视图,再分出来组件。 访问视图的时候,涉及到去请求数据的内容,都写在视图中,然后视图将获得数据传递给组件,组件进行渲染和操作。如果请求出错,视图可以直接控制跳转,无需再渲染视图。 组件获得数据后进行渲染和自己的处理,在需要提交投票的时候,抛出一个自定义事件给父组件=视图,然后视图去进行更新页面的操作。 这里把发送投票写在了组件里,发现更新页面就非常麻烦,后来才发现其实根本无需组件自己做这个,应该发送一个事件给视图,视图去更新,再把数据取回来。 依据这个思路,其实子组件发送请求的事件也不一定写在组件里,完全可以传给视图。不过不管怎么样,全局更新视图这个肯定要写在视图中比较好。 这是Home.vue的视图:
<template>
    <vote :vote="vote" :expire="vote.expireTime" @new-voted="handleVote"></vote>
</template>

<script>
    import vote from '../components/Vote.vue'
    import axios from 'axios'

    export default {
        components: {
            vote
        },
        data: function () {
            return {
                vote: {},
            }
        },

        created:function () {
            let token = this.$store.state.token
            axios.get('http://localhost:8080/api/vote', {
                headers: {
                    Authorization: token
                }
            }).then(res => {
                this.$store.commit('setVote', res.data)
                this.vote = res.data
            }).catch(() => {
                this.$router.push('/login')
            });
        },
        methods:{
            handleVote() {
                let token = this.$store.state.token
                axios.get('http://localhost:8080/api/vote', {
                    headers: {
                        Authorization: token
                    }
                }).then(res => {
                    /* eslint-disable */
                    // console.log(res)
                    this.$store.commit('setVote')
                    this.vote = res.data
                }).catch(() => {
                    this.$router.push('/login')
                });
            }
        }
    }
</script>
创建和每次事件发生的时候都去调一次,其实重复了。 vote组件的内容:
<template>
    <v-container>
        <v-layout v-if="voted" justify-center>
            <v-flex xs12 class="text-xs-center">
                <h2 class="display-3">投票结果</h2>
            </v-flex>
        </v-layout>
        <v-layout>
            <v-flex xs12 class="text-xs-center">
                <h3 class="display-2" v-if="this.expire-(new Date()).getTime()>=0">还有{{day}}天{{hr}}小时{{min}}分钟{{sec}}秒投票截止</h3>
            </v-flex>
        </v-layout>
        <v-layout v-if="!voted">
            <v-flex xs12 class="text-xs-center">
                <h2 class="display-3">请先投票然后查看投票结果</h2>
            </v-flex>
        </v-layout>
        <v-layout v-for="(voteItem, index) in vote.votes" :key="index" justify-center align-center wrap>
            <v-flex xs12 sm3 md2 class="pa-0 ma-0">
                <v-layout justify-end>
                    <v-flex offset-xs-1>
                        <label v-if="voted">{{voteItem.name}} {{voteItem.score}}票
                            <input type="checkbox" v-if="voted" v-model="votenames" :value="voteItem.name" >

                        </label>
                        <label v-if="!voted">{{voteItem.name}}
                            <input type="checkbox" v-if="!voted" v-model="votenames" :value="voteItem.name" >
                        </label>
                    </v-flex>
                </v-layout>
            </v-flex>
            <v-flex xs11 sm6 md7>
                <v-progress-linear
                        v-if="voted"
                        color="red"
                        height="10"
                        :value="voteItem.score"
                        background-color="rgba(250,250,250,0)"
                ></v-progress-linear>
            </v-flex>
        </v-layout>
        <v-layout justify-center>
            <v-btn color="primary" @click="comeToVote">投票</v-btn>
        </v-layout>
        <v-snackbar
                v-model="errorOccured"
                :timeout="3000"
                top
                auto-height

        >您已经投过票</v-snackbar>
    </v-container>
</template>

<script>
    import axios from 'axios'

    function VoteItem(name,score=0) {
        this.name = name
        this.score = score;
    }
    // 代码重写,抛自定义事件,还是接受prop
    export default {
        name: "Vote",
        props :{
            vote:{
                type: Object
            },
            expire: {
                type: Number,
                default: 0
            }

        },
        data: function () {
            return {
                votenames:[],
                errormessage: "",
                errorOccured: false,
                day: 0, hr: 0, min: 0, sec: 0
            }
        },
        computed:{
            voted:function () {
                return this.vote.totalVotes !== -1;
            },
            voteSentToBackend: function () {
                var temp = [];
                this.votenames.map(votename => temp.push(
                    new VoteItem(votename, 0)
                ));

                var o = {};
                o.votes = temp;
                return o;
            },
        },
        methods: {
            comeToVote: function () {
                // eslint-disable-next-line
                console.log("当前的votes对象是" + this.vote)
                // eslint-disable-next-line
                console.log('发送的投票对象是' + JSON.stringify(this.voteSentToBackend))
                axios.post('http://localhost:8080/api/vote', this.voteSentToBackend, {
                    headers: {
                        Authorization: this.$store.state.token
                    }
                }).then(res => {
                    // eslint-disable-next-line
                    console.log(res)
                    this.$emit('new-voted', res.data);
                }).catch((err) => {
                    // eslint-disable-next-line
                    if (err.response.status === 406) {
                        this.errorOccured = true;
                        // eslint-disable-next-line
                        console.log(this.errorOccured)
                    } else {
                        this.$router.push('/login')
                    }
                });
            },
            countdown: function() {
                let mesc = this.expire-(new Date()).getTime()
                let day = parseInt(mesc / 1000 / 60 / 60 / 24);
                let hr = parseInt(mesc / 1000 / 60 / 60 % 24)
                let min = parseInt(mesc / 1000 / 60 % 60)
                let sec = parseInt(mesc / 1000 % 60)
                this.day = day
                this.hr = hr > 9 ? hr : '0' + hr
                this.min = min > 9 ? min : '0' + min
                this.sec = sec > 9 ? sec : '0' + sec
                const that = this
                setTimeout(function () {
                    that.countdown()
                }, 1000)
            }
        },
        mounted() {
            this.countdown();
        }
    }
</script>

<style scoped>
    .v-input__control {
        width: 100%!important;
    }
</style>
这样就基本写完了最简单的投票核心逻辑。 果然书看得再多,不实际写也是不行的。写了这一个东西,终于把Vue的开发套路也摸清楚了。
LICENSED UNDER CC BY-NC-SA 4.0
Comment