前言: 学习慕课网Vue高级实战课程后,在实践中总结一些这个项目带给自己的收获,希望可以再次巩固关于Vue开发的知识。这一篇主要梳理:项目概况、项目准备、页面骨架搭建、推荐页面开发。项目github地址:https://github.com/66Web/ljq_vue_music,欢迎Star。


       项目目标: 开发一个媲美原生的移动端音乐App

      前端技术栈: 

  • Vue:用于构建用户界面的 MVVM 框架。它的核心是响应的数据绑定和组系统件
  • vue-router:为单页面应用提供的路由系统,项目上线前使用了 Lazy Loading Routes 技术来实现异步加载优化性能
  • vuex:Vue 集中状态管理,在多个组件共享某些状态时非常便捷
  • vue-lazyload:第三方图片懒加载库,优化页面加载速度
  • better-scroll:iscroll 的优化版,使移动端滑动体验更加流畅
  • stylus:css 预编译处理器
  • ES6:ECMAScript 新一代语法,模块化、解构赋值、Promise、Class 等方法非常好用

      后端技术栈:

  • Node.js:利用 Express 起一个本地测试服务器
  • jsonp:服务端通讯。抓取 QQ音乐(移动端)数据
  • axios:服务端通讯。结合 Node.js 代理后端请求,抓取 QQ音乐(PC端)数据

      自动化构建及其他工具:

  • webpack:项目的编译打包
  • vue-cli:Vue 脚手架工具,快速搭建项目
  • eslint:代码风格检查工具,规范代码格式
  • vConsole:移动端调试工具,在移动端输出日志

       业务层与支撑层:

 

       vue-cli安装

(sudo) npm install -g vue-cli  // sudo:mac环境下有关管理权限的命令
vue init webpack vue-music

    项目目录介绍及图标字体、公共样式等资源准备

  • api目录 : 和后端请求相关的代码,包括ajax和jsonp的请求
  • common目录 : fonts/image/js/stylus
  • components目录 : 业务组件
  • base目录 : 基础组件
  • router目录 : 路由相关文件
  • store目录 : 存放vuex相关的代码

       样式文件

  • base.styl : 一些基础的样式,并且引用variable.styl
  • variable.styl : 颜色定义规范、字体定义规范(组件要使用时引用)
  • icon.styl : 制作字体文件后要使用的样式
  • reset.styl : 重置样式
  • mixin.styl : 定义一些样式函数(组件要使用时引用)
  • index.styl : 引入 reset/base/icon.styl

       安装stylus stylus-loader

npm install stylus stylus-loader --save

       main.js入口文件中引入

import './common/stylus/index.styl'

       页面入口+header组件编写

  • index.html 添加<meta>标签 : 移动端常见的基本设置
    <meta name="viewport"
    content="width=device-width,initial-scale=1.0,maximum-scale=1.0,minimum-scale=1.0,user-scalable=no">
  • 安装依赖:

    //babel-runtime —— 对es6语法做一些转义
    //fastclick —— 解决移动端点击延迟300ms的问题
    //babel-polyfill —— 【补丁】 对es6中promise等API的转义
    npm install babel-runtime fastclick babel-polyfill --save
  • main.js 中引入: 
    //babel-polyfill 一定要写在最前面
    import ‘babel-polyfill’
    import fastclick from 'fastclick'
    
    //fastclick推荐用法:使document.body下面所有的点击都没有300ms的延迟
    fastclick.attach(document.body)
  • 删掉 Helloword.vue , 修改router->index.js 中的配置,删掉 Helloword 相关
  • 创建 m-header.vue 组件,坑:<style>中不能使用@代表src目录,会报错,还是使用../
  • 在 App.vue 中删掉Logo,修改样式,引入variable.styl,注册MHeader组件

       路由配置+顶部导航栏组件开发

  • 4个路由,对应要创建4个组件:
  1. rank(排行页面)
  2. recommend(推荐页面)
  3. search(搜索页面)
  4. singer(歌手页面)
  • router->index.js中引入,并配置路由routes:
    import Recommend from '@/components/recommend/recommend'
    import Singer from '@/components/singer/singer'
    import Rank from '@/components/rank/rank'
    import Search from '@/components/search/search'
    
    routes: [
           {
                path: '/',
                redirect: '/recommend' //默认页面重定向到recommend路由中
           },
           {
                path: '/recommend',
                component: Recommend
           },
           {
                path: '/rank',
                component: Rank
           },
           {
                path: '/search',
                component: Search
           },
          {
                path: '/singer',
                component: Singer
           }
    ]
  • App.vue 中使用<router-view></router-view>
  • 创建 tab.vue 导航栏组件,通过<router-link>切换路由
    <router-link tag="div" class="tab-item" to="/recommend">
            <span class="tab-link">推荐</span>
    </router-link>
    <router-link tag="div" class="tab-item" to="/singer">
            <span class="tab-link">歌手</span>
    </router-link>
    <router-link tag="div" class="tab-item" to="/rank">
            <span class="tab-link">排行</span>
    </router-link>
    <router-link tag="div" class="tab-item" to="/search">
            <span class="tab-link">搜索</span>
    </router-link>
  • 设置点击高亮样式:&.router-link-active
  • App.vue 中引入并注册tab.vue,使用<tab></tab>

       页面简介+轮播图数据分析

  • 数据:从QQ音乐抓取的真实数据

       JSONP原理介绍+Promise封装

  • 一句话解释JSONP原理:动态生成一个JavaScript标签,其src由接口url、请求参数、callback函数名拼接而成;利用js标签没有跨域限制的特性实现跨域请求
  • 有几点需要注意:
  1. callback函数要绑定在window对象上
  2. 服务端返回数据有特定格式要求:callback函数名+’(‘+JSON.stringify(返回数据) +’)’
  3. 不支持post,因为js标签本身就是一个get请求
  • 什么是Promise:
  1. 简单说就是一个容器,里面保存着某个未来才会结束的事件 (通常是一个异步操作)的结果
  2. 从语法上说,Promise是一个对象,从它可以获取异步操作的消息
  • Promise基本用法:
  1. ES6规定,Promise对象是一个构造函数,用来生成Promise实例
    var promise = new Promise(function(resolve,reject){
                // ... some code
                if(/* 异步操作成功 */){
                       resolve(value);
                }else{
                       reject(error);
                }
    });
  2. Promise构造函数接受一个函数作为参数,该函数的两个参数分别是resolve和reject。它们是两个函数,由JavaScript引擎提供,不是自己部署。
  3. Promise实例生成以后,可以用then方法分别制定Resolved状态和Rejected状态的回调函数:
    promise.then(function(value){
             // sucess
    },function(error){
             // failure
    });

       JSONP

  • 安装JSONP依赖:
    npm install jsonp --save

       封装JSONP、Primise

  • common->js目录下: 创建 jsonp.js
    import originJSONP from 'jsonp'
    
    export default function jsonp(url, data, option) {
              url += (url.indecOf('?') < 0 ? '?' : '&') + param(data);
    
              return new Promise((resolve, reject) => {
                   originJSONP(url, option, (err, data) => {
                        if(!err){
                             resolve(data)
                        }else{
                             reject(err)
                        }
                  })
             })
    }
    
    
    function param(data) {
            let url = ""
            for(var k in data){
                 let value = data[k] !== undefined ? data[k] : ''
                 url += `&${k}=${encodeURIComponent(value)}`
            }
            return url ? url.substring(1) : ''
    }

       JSONP的应用+轮播图数据抓取

  • api目录下创建 config.js:配置与接口统一的参数
    /**
    * 为了和QQ音乐接口一致,配置一些公用的参数、options和err_num码
    */
    export const commonParams = {
              g_tk: 5381,  //会变,以实时数据为准
              inCharset: 'utf-8',
              outCharset: 'utf-8',
              notice: 0,
              format: 'jsonp'
    }
    
    export const options = {
              param: 'jsonpCallback'
    }
    
    export const ERR_OK = 0
  • api目录下创建 recommend.js
    import jsonp from '@/common/js/jsonp'
    import {commonParames, options} from './config'
    
    export function getRecommend() {
              const url = 'https://c.y.qq.com/musichall/fcgi-bin/fcg_yqqhomepagerecommend.fcg'
    
              const data = Object.assign({}, commonParames, {
                        platfrom: 'h5',
                        uin: 0,
                        needNewCode: 1
              })
              return jsonp(url, data, options)
    }

       recommend.vue中调用并获取数据

import {getRecommend} from '@/api/recommend'
import {ERR_OK} from '@/api/config'

export default {
    created() {
           this._getRecommend();
    },
    methods: {
           _getRecommend() {
                   getRecommend().then((res) => {
                         if(res.code === ERR_OK) {
                              console.log(res.data.slider)
                         }
                   })
             }
     }
}

       轮播图组件实现

  • base目录下: 创建slider.vue组件
  • 插槽<slot></slot>:外部引用slider.vue时,<slider></slider>里面包裹的DOM,会被插入到插槽的部分
    <div class="slider-group">
           <slot></slot>
    </div>
  • recommend.vue 中编写插槽中的DOM:
    <slider>
          <div v-for="(item, index) in recommends" :key="index">
                 <a :href="item.linkUrl">
                         <img :src="item.picUrl">
                 </a>
           </div>
    </slider> 
  • slider.vue 中指定需要从父组件接收的属性:loop是否循环、autoPlay是否自动播放、interval间隔时间
    props: {
         loop: {
              type: Boolean,
              default: true
        },
        autoPlay: {
              type: Boolean,
              default: true
        },
       interval: {
              type: Number,
              default: 4000
       }
    }
  • 横向滚动:使用better-scroll
  1. 安装better-scroll依赖:
    npm install better-scroll --save
  2. slider.vue 中引用:
    import BScroll from 'better-scroll'
  3. ref引用外层容器和内层元素:

    <div class="slider" ref="slider">
    <div class="slider-group" ref="sliderGroup">
  4. common->js目录下创建 dom.js:封装一些DOM操作相关的代码

    //为元素添加Class、判断元素是否有指定class
    export function addClass(el, className){
              if(hasClass(el, className)){
                      return
              }
             let newClass = el.className.split(' ')
             newClass.push(className)
             el.className = newClass.join(' ')
    }
    
    export function hasClass(el, className){
            let reg = new RegExp('(^|\\s)' + className + '(\\s|$)')
            return reg.test(el.className)
    }
  5. slider.vue 中引用:
    import {addClass, hasClass} from '@/common/js/dom'
  6. 在methods中定义两个方法:设置slider宽度、初始化slider

    methods: {
         _setSliderWidth() {
                this.children = this.$refs.sliderGroup.children
    
                let width = 0
                let sliderWidth = this.$refs.slider.clientWidth
                for(let i=0; i < this.children.length; i++) {
                     let child = this.children[i]
                     addClass(child, 'slider-item')//为循环生成的slider子元素,动态添加slider-item class
                     child.style.width = sliderWidth + 'px'//不要忘记加单位!
                     width += sliderWidth
                }
    
                if(this.loop){ //如果loop为true,BScroll的snap属性会左右克隆两个DOM,保证循环切换
                    width += 2 * sliderWidth
                }
    
                this.$refs.sliderGroup.style.width = width + 'px'//不要忘记加单位!
         },
         _initSilder() {
                this.slider = new BScroll(this.$refs.slider,{
                      scrollX: true, //横向滚动
                      scrollY: false, //禁止纵向滚动
                      momentum: false,//禁止惯性运动
                      snap: {
                           loop: this.loop,
                           threshold: 0.3,
                           speed: 400
                     }
               })
        }
    }
  7. 初始化BScroll的时机:必须保证组件已经渲染好了,DOM高度已经被撑开
    //在mouted生命钩子中通过setTimeout调用:
    mouted() {
         setTimeout(() => {
                this._setSliderWidth()
                this._initSlider()
          }, 20)
    }
  8. 坑:recommend.vue中直接引用了<slider>,recommends的引用时机是在created()中调用了_getRecommend(),_getRecommend()的这个时间是一个异步过程,可能会有延迟,因为它取的是真实数据因此,当recommends还没有get到时,即还没有填入任何数据时,slider.vue中的mouted()实际上已经执行了。
  9. 解决:recommend.vue中为slider-wrapper添加v-if="recommends.length",确保recommends数组中有内容时,才渲染<slider>
    <div v-if="recommends.length" class="slide-wrapper">
  • 添加dots区块,实现自动轮播
  1. data中维护一个数据dots,默认是一个空数组
    dots: []
  2. methods中初始化Dots:
    _initDots() {
        this.dots = new Array(this.children.length)
    }
  3. 渲染dots:
    <span class="dot" v-for="(item, index) in dots" :key="index"></span>
  4. 选中高亮:
    /**  data中维护一个数据currentPageIndex:0,表示当前默认是第一页
     *   v-bind动态绑定 :class="{active: currentPageIndex === index}">
     *   在_initSlider()方法中给slider添加事件:
    */
    
    this.slider.on('scrollEnd', () => { //当一个页面滚动完毕后,会派发一个scrollEnd事件
             let pageIndex = this.slider.getCurrentPage().pageX //获得slider的pageIndex
             if(this.loop) { //如果是循环,snap会默认给子元素前面增加一个拷贝
                pageIndex -= 1 //要得到实际的pageIndex,pageInde需要-1
             }
             this.currentPageIndex = pageIndex
    })
  5. 自动播放:
    //mounted()->setTimeout中判断autoplay属性,调用_play(): 
    if(this.autoplay) {
       this._play()
    }
    
    //methods中定义_play():
    _play() {
       let pageIndex = this.currentPageIndex + 1;//this.currentPageIndex从0开始的
       if(this.loop) {
          pageIndex += 1//loop为true时,最开始有一个复制的副本,实际的pageIndex需要+1
       }
       this.timer = setTimeout(() => { //页面的切换,利用BScroll的接口goToPage
            this.slider.goToPage(pageIndex, 0, 400) //参数:X方向、Y方向、时间间隔
       },this.interval)
    }
  6. 坑:使用setTimeout,只会执行一次,从第一张自动滚动到第二张就停止了。
  7. 解决:scrollEnd事件中添加:
    if(this.autoPlay) {
       this._play()
    }
  8. 坑:自动滚动后不到400ms时,手动滑动后又执行了自动滚动,体验效果会很奇怪
  9. 解决:slider 添加 beforeScrollStart事件
    this.slider.on('beforeScrollStart', () => {
          if (this.autoPlay) {
              clearTimeout(this.timer)
          }
    })
  10. 坑:在滚动中,改变视口大小,图片会同时显示两张,因为之前设置好的width都没变
  11. 解决:mounted中监听window的resize事件 —— 窗口改变事件,当窗口改变时,重新调用_setSlideWidth()
  12. 坑:如果窗口变和不变时都调用_setSlideWidth(),就会执行两次width += 2 * sliderWidth,这一定是不对的
  13. 解决:调用_setSlideWidth(),需要同时传入一个参数,用来判断窗口是否改变了
    window,addEventListener('resize',(() => {
          if(!this.slider) {
              return
          }
          this._setSliderWidth(true)
          this.slider.refresh()
    }))
    
    _setSliderWidth(isResize) {
    //其它代码
    if(this.loop && !isResize){
          width += 2 * sliderWidth
    }
  14. App.vue 中优化:缓存DOM到内存中,不用重新发送请求,这样slider就不会有闪动的现象

    <keep-alive>
          <router-view></router-view>
    </keep-alive> 
  15. slider中优化:当组件中有定时器,一定要记得在组件销毁时清理掉这些定时器,使用生命周期destroyed()
    destroyed() {
        clearTimeout(this.timer)
    }

       歌单数据接口分析

问题: QQ音乐歌单数据的请求头中有域名Host、来源Referer,所以请求的接口应该是有加上该域名和来源,直接请求就会报HTTP-500错误。
原因: 前端不能直接修改request header,所以要通过后端代理的方式解决。
解决: 采用 axios 在node.js中发送http请求

  •  安装axios: 
    npm install axios --save
  • build->webpack.dev.conf.js
  1. 定义路由,通过axios发送一个Http请求,同时修改header中的和QQ相关的Host、Referer,
  2. 将浏览器传递过来的参数全部传给服务端,然后通json响应的内容输出到浏览器端。
  3. 在 const portfinder = require('portfinder') 后添加:
    const express = require('express')
    const axios = require('axios')
    const app = express()
    var apiRoutes = express.Router()
    app.use('/api', apiRoutes)
  4. devServer 中添加:
    before(app) {
       //定义getDiscList接口,回调传入两个参数,前端请求这个接口
       app.get('/api/getDiscList', function(req, res){
             var url = "https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg"
             axios.get(url, {
                  headers: { //通过node请求QQ接口,发送http请求时,修改referer和host
                  referer: 'https://y.qq.com/',
                  host: 'c.y.qq.com'
            },
            params: req.query //把前端传过来的params,全部给QQ的url
       }).then((response) => { //成功与失败的回调
            res.json(response.data)
       }).catch((e) => {
            console.log(e)
       })
    })
  • recommend.js中:
    import axios from 'axios';
    
    export function getDiscList() {
            // const url = 'https://c.y.qq.com/splcloud/fcgi-bin/fcg_get_diss_by_tag.fcg'
            const url = '/api/getDiscList' //调用自定义的接口
    
            const data = Object.assign({}, commonParams, {
                    platform: 'yqq',
                    hostUin: 0,
                    sin: 0,
                    ein: 29,
                    sortId: 5,
                    needNewCode: 0,
                    categoryId: 10000000,
                    rnd: Math.random(),
                    format: 'json' //使用的时axios,所以format使用的是json,不是jsonp
           })
    
           // return jsonp(url, data, options)
           return axios.get(url, {
                   params: data
           }).then((res) => {
                  return Promise.resolve(res.data) //es6新语法,返回一个以给定值解析后的Promise对象
           })
    }
  • recommend.vue中:定义和调用获取数据的方法
    //created()中:
     this._getDiscList();
    
    //methods中:
     _getDiscList() {
        getDiscList().then((res) => {
             if(res.code === ERR_OK) {
                console.log(res.data)
             }
       })
    }

       歌单列表组件开发和数据的应用

  • data中定义数据:  
    discList: []
  •  _getDiscList()中将返回的数据list赋给discList:
    this.discList = res.data.list
  • 使用 v-html="item.creator.name" 给html字符做转义
    <div class="recommend-list">
         <h1 class="list-title">热门歌单推荐</h1>
         <ul>
             <li v-for="(item, index) in discList" :key="index" class="item">
                 <div class="icon">
                       <img :src="item.imgurl" width="60" height="60">
                 </div>
                 <div class="text">
                       <h2 class="name" v-html="item.creator.name"></h2>
                       <p class="desc" v-html="item.dissname"></p>
                 </div>
             </li>
         </ul>
    </div>
  • CSS样式:经典flex布局
  1. 左边固定宽高,右边根据手机视口宽度自适应
  2. 右侧:
    .item
        display: flex
        align-items:center //水平方向居中
  3. 右侧文字内容:
    .text
        display: flex
        flex-direction: column //纵向排列
        justify-content: center //垂直居中
  4. 一个元素,既可以是flex布局的item,同时也可做flex布局

       scroll组件的抽象和应用

  • better-scroll滚动布局:只会滚动父元素下的第一个子元素 —— 想要slider和recommend-list同时可以滚动,需要在外层再嵌套一个<div>,将两个元素包裹起来
  •  抽象出scorll组件 -- 基础组件
  1. base->scroll目录下: 创建 scroll.vue
  2. 布局DOM:一个wrapper加一个插槽
    <template>
       <div ref="wrapper">
           <slot></slot>
       </div>
    </template>
  3. 引入BScroll:

    import BScroll from 'better-scroll'
  4. 需要传入props参数:
    props: {
       //probeType: 1 滚动的时候会派发scroll事件,会截流。2 滚动的时候实时派发scroll事件,不会截流 。3 除了实时派发scroll事件,在swipe的情况下仍然能实时派发scroll事件
       probeType: {
                type: Number,
                default: 1
       },
      // click: true 是否派发click事件,通常判断浏览器派发的click还是betterscroll派发的click,可以用event._constructed,若是bs派发的则为true
       click: {
                type: Boolean,
                default: true
       },
       data: {
                type: Array,
                default: null
       }
    }
  5. 确保DOM已经渲染,再执行_initScroll:
    mouted() {
        setTimeout(() => { //确保DOM已经渲染
             this. _initScroll()
        }, 20)
    }
  6. methods中定义初始化scroll的方法,并代理几个必需的方法:
    methods: {
          _initScroll() {
                if(!this.$refs.wrapper){
                         return
                }
                this.scroll = new BScroll(this.$refs.wrapper, {
                      probeType : this.probeType,
                      click: this.click
                })
          },
          enable() {
                // 启用 better-scroll,默认开启
                this.scroll && this.scroll.enable()
          },
          disable() {
               // 禁用better-scroll, 如果不加,scroll的高度会高于内容的高度
               this.scroll && this.scroll.disable()
          },
          refresh() {
               // 强制 scroll 重新计算,当 better-scroll 中的元素发生变化的时候调用此方法
               this.scroll && this.scroll.refresh()
          }
    }
  7. watch监听data数据:
    watch: {
         data() { //监测data的变化
              setTimeout(() => {
                   this.refresh()
              }, 20)
         }
    }
  8. 后面在项目的开发中,可以根据需要再随时添加props参数和methods代理方法
  • recommend.vue 中使用:
  1. 引用scroll组件:
    import Scroll from '@/base/scroll/scroll'
  2. 把class="recommend-content"的<div>改成<scroll>
  3. 坑:此时scroll已经初始化了,但还不能滚动
  4. 原因:scroll初始化的时机,是在scroll组件的mounted();但<scroll>包含的DOM是由获取到的data数据填充撑开高度才可以滚动,此时还没撑开,就滚动不了;当数据改变后,scroll应该改变
  5. 解决:<scroll>传入一个数据 :data="discList";当数据discList接收到时,scroll组件中的watch监听到这个变化,就会强制scroll重新计算
  6. 坑:因为整个页面会有两个部分都是请求数据,当_getRecommend()的请求时间大于this._getDiscList()的时候,页面的高度就不够
  7. 如果:如下 ↓ 滚动的高度就会差一个slider的高度,滚不到底部。
    因为refresh()之前,slider的数据还没有渲染出来,scroll会认为,需要滚动的高度,只是列表的高度
    created() {
        setTimeout(() => {
             this._getRecommend();
        }, 1000)
        this._getDiscList();
    }
  8. 实际中,并不能知道两个部分,哪一个会先出现,需要注意还有一个坑:不能用计算属性计算两个部分的数据
  9. 原因:与图片的加载,视口的大小(实时图片的宽高)有关。
  10. 解决:给<img>添加onload事件
    <img :src="item.picUrl" @load="loadImage">
    loadImage() {
         if(!this.checkloaded){ //添加一个标志位,如果load一次了,就不再执行onload事件了
             this.checkloaded = true
             this.$refs.scroll.refresh()
         }
    }

       lazyload懒加载插件介绍和应用

  • 歌单优化:歌单是由很多张图片组成的,使用vue-lazyload插件 解决图片懒加载 的问题
  • vue-lazyload github地址: https://github.com/hilongjw/vue-lazyload
  • 安装插件: 
    npm install vue-lazyload --save
  • 引用注册: main.js
    import VueLazyload from 'vue-lazyload'
    
    Vue.use(VueLazyload, {
    loading: require('@/common/image/default.png') //loading时默认显示的图片
    })
  • 使用插件:recommend.vue 中把歌单列表<img>中原来的 :src替换为v-lazy
    <img v-lazy="item.imgurl" width="60" height="60">
  • 这样,只有用户滚动过的地方,图片才会加载,没有看的地方,就不会进行加载
  • 问题:fastclick和better-scroll的click会有冲突.
  • 解决:slider中的<img>添加一个class="needsclick",这是fastclick中的一个属性
    <img class="needsclick" :src="item.picUrl" @load="loadImage">

       loading基础组件的开发和应用

  • 优化体验:在歌单列表没有渲染好之前,展示一个转圈loading
  • 布局DOM:
    <div class="loading">
           <img width="24" height="24" src="./loading.gif">
           <p class="desc">{{title}}</p>
    </div>
  • props参数:
    props: {
         title: {
             type: String,
             default: '正在载入...'
         }
    }
  • CSS样式:
    【音乐App】—— Vue2.0开发移动端音乐WebApp项目爬坑(一)-LMLPHP【音乐App】—— Vue2.0开发移动端音乐WebApp项目爬坑(一)-LMLPHP
    @import '../../common/stylus/variable'
    
    .loading
        width: 100%
        text-align: center
       .desc
            line-height: 20px
            font-size: $font-size-small
            color: $color-text-l
    View Code
  • recommend.vue 中引用注册,在<scroll>中使用:
    <div class="loading-container" v-show="!disList.length">
           <loading></loading>
    </div>

 

12-08 22:31