[发财账簿] V1.0 开发日志

1,125 阅读3分钟

前言

简单的记录一下在发财账簿开发过程中所用的和遇到的一些技术和问题。

这一版的发财账簿RichAccount是由Vue2+TypeScript+SCSS编写的SPA应用,同时主要数据都存放在LocalStorage中,暂无任何线上功能。

主要功能:

  • 记账的标签、备注、时间
  • 新增和删除标签
  • 记录按日统计
  • 图表呈现
  • ……

链接

发财账簿-Pixso

源码仓库-GitHub

预览链接

主要运用的技术

  • Vue2
  • Vue Router
  • Vuex
  • Vue装饰器vue-property-decorator
  • 在线SVG symbols
  • Echarts
  • dayjs
  • SCSS

开发思路

(指截至文章撰写时,后续小改动不再更新。)最后更新:2022-07-19

  • 底部的导航栏导航到不同的页面
  • 新增记账时可以选择标签、填写备注、选择时间
  • 标签可以新增、改名、删除
  • 记录可以查看、按日期统计、通过图表呈现

Vue Rooter 使用

主要逻辑:

const routes: Array<RouteConfig> = [
  {
    path: '/',
    redirect: '/money',
  },
  {
    path: '/money',
    component: Money,
  },
  {
    path: '/labels',
    component: Labels,
  },
  {
    path: '/statistics',
    component: Statistics,
  },
  {
    path: '/labels/edit/:id',  //:id可以被读取
    component: EditLabel,
  },
  {
    path: '*',
    component: notFound,  //404页面
  },
];

一个类似于select case的结构,Vue Router会从上至下依次匹配

使用方法:

<template>
  <nav>
    <router-link to="/labels" class="item" active-class="selected">
      标签
    </router-link>

    <router-link to="/money" class="item" active-class="selected">
      记账
    </router-link>

    <router-link to="/statistics" class="item" active-class="selected">
      统计
    </router-link>
  </nav>
</template>

在template中通过router-link标签来实现跳转

获取id

  //Router 配置文件中
  {
    path: '/labels/edit/:id',  //:id可以被读取
    component: EditLabel,
  },
  
  //EditLabel.vue 组件中
  created() {
    const id = this.$route.params.id;  //获取到绑定的id
  }

通过id可以精确指向到对应的标签

用TS跳转

  //页面回退
  this.$router.back();
  
  //跳转到制定页面
  this.$router.replace('/404');

[scode type="yellow" size=""]这里要注意,.back()相当于浏览器中的后退,浏览器会返回到上一个页面,可能是别的网站[/scode]

Vuex 使用

Vuex在本项目中主要用于做 全局数据管理 ,好处是所有组件的数据都是同步的、统一的,所有操作数据的方法也都是在Vuex中声明的,规范且统一

Vue会自动将@/store/index.ts中的store作为$store挂载到当前的Vue实例(也就是this)上,我们可以通过this.$store访问到Vuex中的方法

const store = new Vuex.Store({
  state: {
    recordList: [],
    tagList: [],
    currentTag: undefined
  } as RootState,
  mutations: {
    //Records部分
    createRecord(state, record: RecordItem) {
      ...
      store.commit('saveRecords');
    },
    ...
    //Tags部分
    createTag(state, name: string) {
      ...
      store.commit('saveTags');
    },
    ...
    updateTag(state, {id, name}: { id: string, name: string }) {
      ...
    },
  }
});

Vuex中使用到了两个概念,statemutations,其实对应的是Vue实例中的datamethods

[scode type="yellow" size=""]

注意点:

  1. 在store中,Vuex会给所有的mutations传一个state参数,通过state来访问到store中的数据
  2. mutations只接受两个参数,state参数2,如果想要传多个参数,需要用对象的形式将多个参数合并为一个对象,这个对象被称之为payload
  3. 有时候我们会需要在一个mutation中调用另一个mutation方法,这时需要使用store.commit('方法名', 参数)

[/scode]

在组件中使用Vuex

  get recordList() {
    return this.$store.state.recordList;
  }

  created() {
    this.$store.commit('fetchRecords');
  }

  saveRecord() {
    this.$store.commit('createRecord', this.record);
    ...
  }

装饰器 vue-property-decorator

<script lang="ts">
import Vue from 'vue';
import {Component, Prop, Watch} from 'vue-property-decorator';

...

@Component
export default class Chart extends Vue {
  @Prop() options?: EChartsOption;  //装饰器中外部属性的使用方法

  @Watch('options', {immediate: true})  //装饰器中Watch的使用方法
  onOptionsChanged(newValue: EChartsOption) {
    ...
  }
  
  get recordList() {  //装饰器中的computed属性需要用get和set方法
    return (this.$store.state as RootState).recordList;
  }
}
</script>

在线SVG symbols

在本项目中,我主要使用的是SVG图片,通过在线导入的方式引入,没有在本地保存SVG图片。

<body>

<script src="IconPark生成的js链接"></script>

<svg class="icon">
  <use href="#consume"></use>
</svg>
  
</body>

Echarts使用

通过Echarts来做数据可视化。我封装了一个Chart.vue组件,从外部接受一个option,然后渲染图表

本项目中使用的是折线图,展示从今天起往前推30天的每日收入/支出情况,同时将图表的样式根据我的实际需要进行了调整。

<template>
  <div class="wrapper" ref="wrapper">chart</div>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component, Prop, Watch} from 'vue-property-decorator';
import * as echarts from 'echarts';

type EChartsOption = echarts.EChartsOption;

@Component
export default class Chart extends Vue {
  @Prop() options?: EChartsOption;

  @Watch('options', {immediate: true})
  onOptionsChanged(newValue: EChartsOption) {
    //第一次加载图表的时候还未挂载
    //因此echarts.init(this.$refs.wrapper as HTMLDivElement)为undefined
    //通过setTimeout来进行第一次渲染
    setTimeout(() => {
      echarts.init(this.$refs.wrapper as HTMLDivElement).setOption(newValue);
    }, 0);
  }
}
</script>

<style lang="scss" scoped>
.wrapper {
  height: 400px;
}
</style>

dayjs使用

在本项目中,因为要对时间进行记录和格式化,因此选择了更为好用的dayjs取代原生的Date()api

formatDate(isoString: string) {
    return dayjs(isoString).format('YYYY-MM-DD');
}

beautify(date: string) {
  const day = dayjs(date);
  const now = dayjs();
  if (day.isSame(now, 'day')) {
    return '今天';
  } else if (day.isSame(now.subtract(1, 'day'), 'day')) {
    return '昨天';
  } else if (day.isSame(now.subtract(2, 'day'), 'day')) {
    return '前天';
  } else {
    return day.format('YYYY年M月D日');
  }
}

SCSS使用

因为组件比较多,为了更好的管理样式,本次项目使用了SCSS

@import '~@/assets/style/helper.scss'; //引入变量

%item {
  ...
}

.title {
  @extend %item;  //@extend 语法
}

.noResultWrapper {
  ...
  
  > .noResult {  // '>' 操作符可以获取子元素
    ...
    $color-noResult: #bbbbbb;
    color: $color-noResult;
    background: darken($color-noResult, 8%);  //通过darken加深颜色
  }
}

::v-deep {  
  .type-tabs-item {
    ...

    &.selected {  // '&' 操作符,复制自己,这里相当于 .type-tabs-item.selected
      ...

      &-wrapper {  //可以选中父元素
        ...
      }
    }
  }
}

遇到的一些小问题

两位小数

记账时只需要记录两位小数,输入一个两位小数后应该无法继续输入

我采取的方法是在每次输入数字时,检查小数点的位置,如果小数点是倒数第三个,说明已经是一个两位小数了

if (this.output.indexOf('.') === this.output.length - 3 ) { return; } //只能输入两位小数

出现了一个bug,只能输入两位数了,因为在没有输入小数点时,this.output.indexOf('.') === -1,所以添加一个条件 this.output.indexOf('.') ≥ 0

if (
    this.output.indexOf('.') === this.output.length - 3 &&
    this.output.indexOf('.') >= 0
) {
  return;
} //只能输入两位小数

更新图表

第一版是在chart挂载时就先初始化,然后监听options的变化,但是两处代码重复,考虑使用watchimmediate参数进行修改

//V1.0
mounted() {
  if (this.options === undefined) {return;}
  const chart = echarts.init(this.$refs.wrapper as HTMLDivElement);
  chart.setOption(this.options);
}

@Watch('options')
  echarts.init(this.$refs.wrapper as HTMLDivElement).setOption(newValue);
}

报错,提示setOption of undefined,后续通过在mounted()@Watch中进行log,发现@Watch的时机比mounted()更早,因此想到使用setTimeout()方法

//V2.0
@Watch('options',{immediate:true})
  echarts.init(this.$refs.wrapper as HTMLDivElement).setOption(newValue);
}

成功!

//V3.0
@Watch('options', {immediate: true})
  onOptionsChanged(newValue: EChartsOption) {
     setTimeout(() => {
        echarts.init(this.$refs.wrapper as HTMLDivElement).setOption(newValue);
  }, 0);
}

倒序数组

在为图表准备数据的过程中,需要用到桶排序,将所有的记录通过时间分类,将日期和金额存在一个数组内,但是生成的是一个日期从现在到过去的数组

//V1.0
get chartArray() {
  const today = new Date();
  const array = [];
  for (let i = 0; i <= 29; i++) {
    const dateString = dayjs(today).subtract(i, 'day');
    const found = _.find(this.groupedList, {title: dateString.format('YYYY-MM-DD')});
    array.push({
      key: dateString, value: found ? found.total : 0
    });
  }
  return array 
}

首先想到的是将数组重新排序,过去在前,现在在后

get chartArray() {
  const today = new Date();
  const array = [];
  for (let i = 0; i <= 29; i++) {
    const dateString = day(today).subtract(i, 'day').format('YYYY-MM-DD');
    const found = _.find(this.groupedList, {
      title: dateString
    });
    array.push({
      key: dateString, value: found ? found.total : 0
    });
  }
  array.sort((a, b) => {
    if (a.key > b.key) {
      return 1;
    } else if (a.key === b.key) {
      return 0;
    } else {
      return -1;
    }
  });
  return array;
}

后来又重新思考,可以让i递减,从29减到0,精简了代码,于是做出了最终版,同样的效果,更少的代码

//V3.0
get chartArray() {
  const today = new Date();
  const array = [];
  for (let i = 29; i >= 0; i--) {
    const dateString = dayjs(today).subtract(i, 'day');
    const found = _.find(this.groupedList, {title: dateString.format('YYYY-MM-DD')});
    array.push({
      key: dateString, value: found ? found.total : 0
    });
  }
  return array;
}

后记

本项目还有很多不足之处,后续还会继续调整,如果你发现了什么bug,也可以留言给我哦 (≖ᴗ≖)✧