Vue.js 记账回顾

203 阅读3分钟

用Vue Router实现底部导航

1. 确定页面URL

1.1 编写router

import Vue from 'vue';
import VueRouter, {RouteConfig} from 'vue-router';

Vue.use(VueRouter);

const routes: Array<RouteConfig> = [
{
    path:'/money',
    component:Money
},

const router = new VueRouter({
    routes
});

export default router;

1.2 将router写到全局变量里

new Vue({
    router: router,
    render: h => h(App)
}).$mount('#app');

1.3 在App.vue中用展示router

<template>
  <div>
    <router-view/> //展示对应路由
  <div>
</template> 

1.4 在Nav.vue中写对应路由

<template>
    <div>
      <router-link to="/money">记账</router-link>
      |
      <router-link to="/labels">标签</router-link>
      |
      <router-link to="/statistics">统计</router-link>
    </div>
   </div>
</template>

1.5 分别建4个vue文件(Money、Labels、Statistics、NotFound),分别引入Nav.vue

<template>
    <div>money.vue
        <Nav/>
    <div>
<template>

1.6 将Nav写到全局变量里

Vue.component('Nav',Nav)  

2. 给导航栏添加样式

2.1 新增一个存放共同样式的文件Layout.vue

2.2 使用<slot>插槽来自动获取<Layout>里面的内容

Money实现渲染的过程为:先是解析出<Layout>,可以再main.js中找到索引,找到Layout.vue这个文件,它首先是渲染一个<div class="nav-wrapper">,再渲染<div class="content">,接着解析<slot/>,可以把Money.vue里的<Layout>内容<p>Money.vue</p>,直接填到里面.

2.3 引入SVG Icon图标

  • 新建icon.vue,用来封装重复的导航代码

2.4 引入Icon全局组件

2.5 给icon加样式

2.6 路由激活

  • 即点击导航栏图标,图标即高亮显示:在需要高亮显示的标签中,加入属性active-class="x"

第一部分 写Money.vue组件

3. 写Money.vue组件

3.1 写Money.vue的页面

它是由TagsNotesTagsNumberPad这四个子模块组成的,可以先把这四个子模块写在一起,然后再分模块写

3.2 写Money.vue的SCSS样式

  • 全局样式重置assets/style/reset.scss
  • 全局样式设置assets/style/helper.scss,放变量和函数

3.3 写Money.vueTypeScript

在精简后的Money.vue中,分别再去引入刚才分出去的组件:

<template>
<!--    class前缀-->
    <Layout class-prefix="layout">
<!--        由于scss样式写的column-reverse布局,所以组件由下往上堆叠-->
        <NumberPad/>
        <Types/>
        <Notes/>
        <Tags/>
    </Layout>
</template>

<script lang="ts">
    import Notes from '@/components/Money/Notes.vue';
    import NumberPad from '@/components/Money/NumberPad.vue';
    import Tags from '@/components/Money/Tags.vue';
    import Types from '@/components/Money/Types.vue';

    export default {
        name: 'Money.vue',
        components: {Types, Tags, NumberPad, Notes},
        // components: {Nav}
    };
</script>

<style lang="scss">
    .layout-content {
        display: flex;
        flex-direction: column-reverse;  //从下面往上布局
    }
</style>

4. 写Types.vue组件

JS是用构造选项options来写组件,构造选项没有类型,任意添加东西;

<template>
    <div>
        <ul class="types">
            <li :class="type === '-' && 'selected'"
            @click="selectType('-')">支出
            </li>
            <li :class="type === '+' && 'selected'"
            @click="selectType('+')">收入
            </li>
        </ul>
    </div>
</template>

<script>

    export default {
        name: 'Types',
        props:['xxx'],  //外部接收的属性
        // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
        data(){         //用来保存当前选中的类型,内部属性
            return{
                type:'-'  //'-'表示支出,'+'表示收入
            }
        },
        // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
        mounted() {
            console.log(this.xxx);
        },
        methods:{            //用于切换 Type 的方法
            // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
            selectType(type){     //type 只能是'-'和'+'中的一个
                if(type!=='-' && type !=='+'){
                    throw new Error('type is unknown')
                }
                this.type= type
            }
        }
    };
</script>

TS是用class类、装饰器写组件;

<template>
    <div>
        <ul class="types">
            <li :class="type === '-' && 'selected'"
            @click="selectType('-')">支出
            </li>
            <li :class="type === '+' && 'selected'"
            @click="selectType('+')">收入
            </li>
        </ul>
    </div>
</template>

<script lang="ts">
    import Vue from 'vue'
    import {Component} from 'vue-property-decorator';  //自第三方库引入Component装饰器
    //声明以下为 TS 写的组件,type='-'会自动处理为data,selectType会自动处理为methods
    @Component      //将此装饰器修饰到class上;@Component由vue-class-component官方库提供
    export default class Types extends Vue {     // TS 写组件的引入
        type = '-'                         
        selectType(type: string){         // 此行为 TS 语法
            if (type !== '-' && type !== '+'){
                throw new Error('type is unknown')
            }
            this.type = type;
        }
    }
</script>

5. 写NumberPad.vue组件

5.1 写页面

页面就只有2层结构,1层是输出框output,另一层是14个按钮button,代码为:

<template>
    <div class="numberPad">
        <div class="output">{{output}}</div>
        <div class="buttons">
            <button>1</button>
            <button>2</button>
            <button>3</button>
            <button>删除</button>
            <button>4</button>
            <button>5</button>
            <button>6</button>
            <button>清空</button>
            <button>7</button>
            <button>8</button>
            <button>9</button>
            <button>OK</button>
            <button>0</button>
            <button>.</button>
        </div>
    </div>
</template>

5.2 用class类、装饰器写组件

<script lang="ts">
    import Vue from 'vue';    //ts行间加分号';'
    import {Component} from 'vue-property-decorator';
    
    @Component
    export default class NumberPad extends Vue{
    
    }

功能实现。总体的响应是点击14个按钮和输出框显示内容(默认显示字符串0),都会有对应的响应。分为不同的功能实现:

  1. 数字区0-9,和小数点.要实现点击每个数字显示在输出框中,且数字区的细节流程控制有不可以输入两次0,两次小数点.,还有输出框的最大输出长度;
  2. 删除,实现每次点击,输出框数字都缩进1个数字;
  3. 清空,实现点击后,数据清空显示0
  4. ok,实现记账后,把数据提交到本地存储,功能后面实现。

事件绑定有:@click、inputContent

6. 数据处理:收集四个组件的value

需求是把用户填的东西,包括新增标签、备注、支出或收入下的金额输入信息,收集起来,单击ok键的时候,变成一个大的对象。

6.1 收集tags组件的value

  • 怎么知道用户选中的哪个tag呢?比如监听它的一个事件(@update:value),触发这个事件时,就得到要收集的value,再把这个value放在对应的方法(onUpdateTags)上。
//Money.vue
<template>
    <Layout class-prefix="layout">
        <NumberPad/>
        <Types/>
        <Notes/>
        <Tags :data-source.sync="tags" @update="onUpdateTags"/>    //监听xxx事件
    </Layout>
</template>

<script lang="ts">
    import Vue from 'vue';
    import NumberPad from '@/components/Money/NumberPad.vue';
    import Types from '@/components/Money/Types.vue';
    import Notes from '@/components/Money/Notes.vue';
    import Tags from '@/components/Money/Tags.vue';
    import {Component} from 'vue-property-decorator';

    @Component({
      components: {Tags, Notes, Types, NumberPad}
    })
    export default class Money extends Vue {
        tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
        yyy() {              //获得内容放在yyy方法中
        }
    };
</script>

6.2 收集NotesTypesNumberPad组件的value

  • 先完成获取用户输入的value值的事件绑定:
//Money.vue
<NumberPad @update:value="onUpdateAmount"/>   //触发update:value事件
<Types @update:value="onUpdateType"/>   //触发update:value事件
<Notes @update:value="onUpdateNotes"/>   //触发update:value事件
...
onUpdateAmount(value:string) {              //获得内容放在onUpdateAmount方法中
}
onUpdateType(value:string) {              //获得内容放在onUpdateType方法中
}
onUpdateNotes(value:string) {              //获得内容放在onUpdateNotes方法中
}
  • 写对应的方法:
//Notes.vue
<script lang="ts">
    import Vue from 'vue';
    import {Component, Watch} from 'vue-property-decorator';

    @Component
    export default class Notes extends Vue {
        value = '';

        @Watch('value')     //Watch适用于发生变化时触发事件
        onValueChange(value: string, oldValue: string) {
            this.$emit('update:value', value);
        }
    }
</script>
//Types.vue
<script lang="ts">
    import Vue from 'vue';
    import {Component, Prop,Watch} from 'vue-property-decorator';  
    @Component
    export default class Types extends Vue {     
        type = '-';      
        @Prop(Number) xxx: number | undefined   
        selectType(type: string) {       
            if (type !== '-' && type !== '+') {
                throw new Error('type is unknown');
            }
            this.type = type;
        }

        @Watch('type')
        onTypeChange(value:string){
            this.$emit('update:value',value)
        }
    }
</script>

6.3 把value收集到一个对象中

收集到的value命名为record,并做类型声明,写对应的方法:

//Money.vue
<script lang="ts">
    import Vue from 'vue';
    import NumberPad from '@/components/Money/NumberPad.vue';
    import Types from '@/components/Money/Types.vue';
    import Notes from '@/components/Money/Notes.vue';
    import Tags from '@/components/Money/Tags.vue';
    import {Component} from 'vue-property-decorator';

    type Record = {  //TS类型声明只写类型,JS类型声明写具体内容
        tags: string[]
        notes: string
        type: string
        amount: number
    }

    @Component(
        {
            components: {Tags, Notes, Types, NumberPad}
        })
    export default class Money extends Vue {
        tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
        record: Record = {
            tags: [], notes: '', type: '-', amount: 0
        };

        onUpdateAmount(value: string) {
            this.record.amount = parseFloat(value);
        }

        onUpdateType(value: string) {
            this.record.type = value;
        }

        onUpdateNotes(value: string) {
            this.record.notes = value;
        }

        onUpdateTags(value: string[]) {
            this.record.tags = value;
        }
    };
</script>  

6.4 使用LocalStorage 保存用户数据

初步实现记账,不能接续排列为一个数组:

在用户点击ok时,把数据放到LocalStorage,绑定一个@submit事件,并监听,提交时触发saveRecord方法,在NumberPad.vue组件中添加ok的saveRecord方法,此步骤为初步实现记一笔账,点击ok提交后,就把这条账目推到数组中,每次都是保存相同的内存地址,将上一次的覆盖掉。

//NumberPad.vue
ok() {
    this.$emit('update:value', this.output);
    this.$emit('submit', this.output);
    this.output = '0';
}
//Money.vue
export default class Money extends Vue {
    tags = ['餐饮', '购物', '交通', '娱乐', '医疗'];
    recordList: Record[] = [];    //创建一个recordList,类型是Record数组,默认为空数组
    record: Record = {
        tags: [], notes: '', type: '-', amount: 0
    };
    
saveRecord() {
    this.recordList.push(this.record);  //触发saveRecord方法时,把this.record推到recordList数组里
}

@Watch('recordlist')
onRecordListChange(){
    window.LocalStorage.setItem('recordList',JSON.stringfy(this.recordList));
    // JSON.stringfy() 用于从一个对象解析出字符串
}

用深拷贝的方法实现接续列出数组的每次push数据,把 record 的复制保存到recordlist,List初始化时,不能赋值为空数组,要赋值,然后用插入语法在html页面中引入{{recordList}}

recordList: Record[] = JSON.parse(window.localStorage.getItem('recordList') || '[]');
  saveRecord(){
      const record2 = JSON.parse(JSON.stringify(this.record));
      // JSON.parse() 用于从一个字符串中解析出json对象
      this.recordList.push(record2);
  }

7. MVC重构

所有数据层面的集中到一个Model文件,所有视图View放在一起。

  • 视图层面View,也就是每个组件中<template>的内容,用于页面渲染;
  • 控制层面Control,也就是所有的方法API接口,如何更新数据等;
  • 数据层面Model,都分散在各处,没有集中到一起:
//数据层面

//初始化数据,把数据从数据库中拿出来
const recordList:Record[] = JSON.parse(window.localStorage.getItem('recordList')||'[]'); 

//更新数据,往数据库中添加
this.recordList.push(record2);

//保存数据
window.localStorage.setItem('recordList',JSON.stringify(this.recordList));

7.1 用JS重构数据层面的东西 model.js

  • 新建src/model.js(先尝试用js写数据,然后在组件中引用):
const model = {
    fetch(){       //获取数据
        return JSON.parse(window.localStorage.getItem('recordList')||'[]'); 
    },  
    save(){       //保存数据
        window.localStorage.setItem('recordList',JSON.stringify(this.recordList));
    },
    //可写成传参data的形式
    save(data){       //保存数据,传参data,序列化以后放在recordList里,
        window.localStorage.setItem('recordList',JSON.stringify(data));
    },
    //把recordList定义为一个常量
    const localStorageKeyName = 'recordList';
}

export default model;
  • Money.vue中引入model,有两种写法,一种是:
//model.js
export default model
//Money.vue
const model = require('@/model.js').default;
  • 另一种写法是具名导出,到导入的时候就不需要加.default了,而是直接写model;还有一种写法更屌,就是用析构语法,连后面的model都省了:
//model.js
export {model}
//Money.vue
const model = require('@/model.js').model;
const {model} = require('@/model.js')
  • 然后获取数据:
const recordList:Record[] = model.fetch();

7.2 用TS重构数据层面的东西 mdoel.ts

//Money.vue
const recordList: Record[] = []   //recordList是一个大对象,收纳Record的所有内容

type Record = {      //声明Record的类型
    tags:string[]    //最上部的标签,每次新增都push到数组中
    notes:string     //备注内容
    type:string      //支出 or 收入的类型
    amount:number    //数字区输出的结果
    createAt?:Date
}
//model.ts
const localStorageKeyName = 'recordList';
const model = {
    fetch(){       //获取数据
        return JSON.parse(window.localStorage.getItem('recordList')||'[]'); 
    }, 
    save(data:Record[]){       //
        window.localStorage.setItem('recordList',JSON.stringify(data));
    },
    //把recordList定义为一个常量
  • 声明data数据类型为Record:改完ts类型以后,save(data)中的data会报错,ts要声明数据类型,也就是一个大对象,里面是空数组。
  • 再去声明Record的类型:由于Record是默认就有的全局变量类型(已经被占用了),需要重定向为别的名:RecordItem,然后去根目录创建一个custom.d.ts自定义全局声明:
type RecordItem = {  //TS类型声明只写类型,JS类型声明写具体内容
    tags: string[]
    notes: string
    type: string
    amount: number
    createdAt?: Date
}
  • 声明fetch()的返回值类型,用断言语句as强制类型声明为RecordItem数组:
fetch(){
    const x: RecordItem[] = JSON.parse(window.localStorage.getItem('recordList')||[]);
    return x
    //可简写为
    return JSON.parse(window.localStorage.getItem('recordList')||[]) as RecordItem[];
}
  • 直接就可以在组件中处理数据了:
//Money.vue
const recordList = model.fetch();    //类型可以直接省略了,因为已经全局声明了类型

第二部分 写Label.vue组件

8.1 写Label.vue的HTML页面

<template>
  <Layout>
    <ol>
        <li><span></span><Icon name="right"></li>
        <li><span></span><Icon name="right"></li>
        <li><span></span><Icon name="right"></li>
        <li><span></span><Icon name="right"></li>
    <ol>
    <div>
        <button>新建标签</button>
    </div>
    
  <Layout>
</template>

8.2 写Label.vue的SCSS样式

8.3 写新建标签功能:

  • 实现的功能与之前的model.ts不同,是用来处理Label.vue的数据文件。新建model文件夹,把原来的model.ts重构名为recordListModel.ts,再单独新建tagsListModel.ts
//tagsListModel.ts
const localStorageKeyName = 'tagList';
const tagListModel = {
    fetch(){       //获取数据
        return JSON.parse(window.localStorage.getItem('tagList')||'[]'); 
    }, 
    save(data:Record[]){       //
        window.localStorage.setItem('tagList',JSON.stringify(data));
    },
    //把recordList定义为一个常量
    
export default tagListmodel;
  • 在组件中引用:
//Money.vue
const recordList = recordListModel.fetch();
const tagList = tagListModel.fetch();

export default class Money extends Vue{
    tags:tagList;
    recordList:RecordItem[] = recordList;
    record:RecordItm[] = {
        tags:[], notes:'', type:'-', amount:0
    };
}
//Labels.vue
<script lang="ts">

import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import tagListModel from '@/models/tagListModel';
const tags = tagListModel.fetch();

@Component
export default class Money extends Vue{

}
  • 删掉页面中的固定的内容:
<template>
  <Layout>
    <ol>
        <li v-for:"tag in tags" :key="tag">
        <span>{{tag}} </span>
        <Icon name="right"></li>
    <ol>
    <div>
        <button class="createTag" @click="createTag">
        新建标签</button>
    </div>
    
  <Layout>
</template>
...
tagListModel.fetch();
@Component
export default class Money extends Vue{
    tags = tagListModel.data;
    createTag(){
        const name = window.prompt('请输入标签名');
        if(name){
            tagListModel.create(name)
        }
    }
//tagListModel.ts
const localStorageKeyName = 'tagList';

type TagListModel {
    data:string[]
    fetch:()=>string[]
    create:(name:string)=>string
    save:()=>void
}
const tagListModel:TagListModel = {
    data:[],
    fetch(){       //获取数据
        return JSON.parse(window.localStorage.getItem('tagList')||'[]'); 
    }, 
    create(name){
        this.data.push(name)
        this.save();
        return name;
    }
    save(){       
        window.localStorage.setItem('tagList',JSON.stringify(this.data));
    },
    
export default tagListmodel;

8.4 写删除标签功能:

  • 新建一个路由,获取页面的id:
router/index.ts

import EditLabel from '@/views/EditLabel.vue';
{
    path:'/labels/edit/:id'//占位符
    component:EditLabel
}
  • EditLabel的页面:

  • 用VueRouter实现EditLabel获取路由path的id内容:

create(){
    const id = this.$route.params.id;  //获取路由path的id内容
    tagListModel.fetch();
    const tags = tagListModel.data;
    const tag = tags.filter(t=>t.id === id)[0]
    if(tag){
    
    }else{
        this.$router.replace('/404');
    }
}
  • 封装通用组件,因为EditLabel.vue和Money.vue两个组件都要用到Notes,请输入备注信息,这个组件,也就可以把Notes这个组件封装一下,变为通用组件FormItem.vue

第三部分 全局数据管理之【手写store】

背景是Money.vue和Labels.vue要实现同步数据显示,其优点如下:

  1. 解耦:将所有数据相关的逻辑放入 store(也就是 MVC 中的 Model)
  2. 数据读写更方便:任何组件不管在哪里,都可以直接读写数据
  3. 控制力更强:组件对数据的读写只能使用 store 提供的 API 进行

9. 手写store的实现:

  • 再次封装recordListModel
  • 用window来容纳数据
  • 用window来封装API
  • 消除对window的依赖
  • 将model融合进store

第四部分 全局数据管理之【Vuex】

  • 用Vuex重构组件

第五部分 写统计页面

  • 页面效果图:由两个tab标签和数据结构组成

110.png

5.1 首先是第一个type组件,支出收入在Money页面做过这个,可以拿来用;

  • 具体的样式需要使用deep语法::v-deep(可被sass识别)或/deep/
  • 通过classPrefix来灵活定义类; 原来的写法为:
<li :class="value ==='-' && 'selected'"
    @click="selectType('-')">支出
</li>

改为对象的形式,即为表驱动,把所有的类都用表的形式写下来,如果为true,那就要这个class:

<li :class="{selected:value ==='-'}"
</li>

再次写为完整格式:

<li :class="{'xxx-item':classPrefix, selected:value ==='-'}"
</li>

还可以写成ES6的新语法,如果key('xxx-item')里面有变量,就用中括号把这个key包起来:

<li :class="{[classPrefix+'-item']:classPrefix, selected:value ==='-'}"
</li>

引用的组件Statistics.vue写为:

<Types class-prefix="xxx" :value.sync="yyy">

得到的选中元素,就是li.xxx-item.selected,样式选择器就可以写为:::v-deep .xxx-item

5.2 第二个type组件

//Staistics.vue
<Types class-prefix="zzz" :value.sync="yyy">
<Types class-prefix="zzz3" :value.sync="yyy">

-这时候就重复使用相似组件了,可以新建一个tabs.vue组件,先来写实现按天 按周 按月type效果:

<template>
  <ul class="types">
   <li></li>
  </ul>
</template>

<script lang="ts">
    import Vue from 'vue';
    import {Component} from 'vue-property-decorator';
    
    @Component
    export default class Tabs extends Vue{
        @Prop({required:true, type:Array}) 
        dataSource!:{text:string, value:string}[] 
    }

ISO 8601 日期时间表示法

  • 屎一样的Date( ) API:一般人不用
var d = new Date()
d.toISOString()  // '2022-08-19T07:45:15.081Z' 零时区标准时间
Date.parse('2022-08-19T07:45:15.081Z')  // 1660895115081
new Date(1660895115081)  // Fri Aug 19 2022 15:45:15 GMT+0800 (GMT+08:00)
  • moment.js:体积16k过大,也不常用

  • day.js:轻量化的moment.js,常用

dayjs()
  .startOf('month')
  .add(1, 'day')
  .set('year', 2018)
  .format('YYYY-MM-DD HH:mm:ss')