懒人记账 项目总结

180 阅读4分钟

一、项目名称

懒人记账

二、用途

是一款极简的记账单页面应用

主页提供数字键盘,备注功能,添加标签等功能;

记账完毕,会自动跳转到明细页面,该页面可以切换日期以查看历史记账记录,除此之外,点击具体的款项还可进入修改页面;

该项目还使用了echarts进行数据可视化的展示以及支出排行榜与个款项支出或者收入占比。项目基本上能够满足我们对日常的记录。

三、技术栈

基于html,scss,typescript,vue,vuex,vue-router,echarts,表驱动编程,模块化思想

四、功能模块

  1. 记账功能

    1.1 支出与收入切换

    1.2 添加标签

    1.3 添加备注

    1.4 添加金额

    1.5 设置

  2. 查看添加记录功能

    2.1 自定义时间筛选查看

  3. 修改记录功能

    3.1 修改金额

    3.2 修改备注

    3.3 修改时间

    3.4 删除记录

  4. 查看图表明细

    4.1 切换月,年度查看明细

    4.2 echarts形式展开

    4.3 展示支出排行榜

  5. 查看标签

    5.1 删除标签

    5.2 添加标签

五、项目展示

项目源码 项目展示

未标题-1.png

编辑标签.png

六、代码踩坑

  1. 引入SVG
declare module "*.svg"{
  const content:string;
  export  default  content;
}
  • import引入的是一个路径,我们需要的是一个svg的use的一个使用方法
  • 安装svg插件 yarn add svg-sprite-loader -D,
  • 根据文档需要有webpack.config.js文件配置,但是在vue里没有,只有vue.config.js 我们需要将webpack.config.js文件翻译成vue中能用的配置
const path = require('path');//引入node.js的path模块
//按照vue cli 文档翻译 ,项目没有的webpack.config.js
module.exports = {
  publicPath: process.env.NODE_ENV === 'production'
    ? '/loon-account-website/'
    : '/',
  lintOnSave: false,
  chainWebpack: config =>{//是一个函数,接收一个config,返回。。。
    //icon所在的目录
    const dir = path.resolve(__dirname,'src/assets/icons');
    //vue封装webpack的api然后暴露出congfig

    config.module
      .rule('svg-sprite')//添加一个规则
      .test(/\.svg$/)//匹配上这个正则,就用这个规则
      //只包含这个 icons 目录才会走这个规则
      .include.add(dir).end()
      .use('svg-sprite-loader-mod').loader('svg-sprite-loader-mod').options({extract:false}).end()//extract:false不需要解析出文件

      .use('svgo-loader').loader('svgo-loader')//svg优化的loader
      //removeAttrs删除属性
      .tap(options=>({...options,plugins:[{removeAttrs:{attrs:'fill'}}]})).end()//解决svg自带颜色

    config.plugin('svg-sprite').use(require('svg-sprite-loader-mod/plugin'),[{plainSprite:true}])//配置插件
    config.module.rule('svg').exclude.add(dir)//其他 svg loader 排除 icons 目录
  }
}

  • 首先svg-sprite-loader引入了所有的Icon,把Icon变成了svg,所有的Icon在svg里面,作为一个symbol,这样就可以使用use svg了。

1.2 如何使用svg整个目录引用,不用一个一个引用

let importAll = (requireContext:__WebpackModuleApi.RequireContext) => {return requireContext.keys().forEach(requireContext)};

try{
  //指定要去哪个目录搜索
  importAll(require.context('../assets/icons', true, /\.svg$/));
} catch (error) {
  //如果有错,不会中断代码,会把错误打印出来
  console.log(error);
}
  1. 创建公用的样式,引入文件报错 @import "~@/assets/style/helper.scss";

webstorm无法识别,我们需要设置一下 file => Settings => languages&frameworks =>javascript => Webpack => 点击文件夹目录 => 找到当前项目 => node_modules => @vue => cli-service => webpack.config.js => ok

将vue.config.js里所有出现svg-sprite-loader改成svg-sprite-loader-mod

安装svg-sprite-loader-mod

  1. 当使用插卡卡槽时,如何控制内层元素

在需要改变样式的元素加上 classPrefix="layout",并在其另一元素上${classPrefix}-content就可以更改样式

  1. 使用TyptScript

注意事项:

  • 将lang="ts"改写

  • 用class export default class Money extends Vue

  • 使用装饰器 @Component ipmort 引入使用 vue-property-decorator

  • @Prop @Prop() xxx:number!undefined 用来装饰参数

    Prop告诉Vue xxx不是data,是number; xxx是名称; number!undefined 是编译类型;

  • @Prop用来接收父组件传递的数据;

  • @Prop({required: true,type: Object}) selectedTag!: Tag; required: true 强制需要传递数据 type: Object类型是一个对象 !不需要初始化

  • ts的使用比js多了一个类型声明

    4.2 ts的类型声明

      如果全局很多地方用到这个类型,可以封装抽离到根目录上;类型定义的文件需要以d.ts结尾,导入即可使用。
    
type RecordItem = {
  id?: number,
  tags: Tag;
  notes: string;
  type: string;
  amount: number;//数据类型
  createdDate?: Date | string;//类/构造函数
}
  1. 子组件如何向父组件传递数据
  • 把notes,tags,type,分别封装成组件,在money组件中分别引入这些子组件,
money => type组件
 <Tabs :dataSource="recordTypeList" :value.sync="record.type"  class-prefix="tabs"/>

:value 表示我要传一个value,.sync 表示我要同步更新

:value.sync="record.type" 等价于 :value="record.type",v-on:unpdate:value="record.type=$event"

子组件如果没有this.$emit('update:value', item);写,那么数据也传递不过去

封装的type组件
 <ul class="tabs" :class="`${classPrefix}-content`">
    <li v-for="(item,index) in dataSource" :key="index"
        :class="liClass(item)" @click="select(item.value)">
      {{ item.name }}
    </li>
  </ul>
  --------省略部分代码
  
  type DataSourceItem = { name: string, value: string }
  
  @Prop({required: true, type: Array}) dataSource!: DataSourceItem[];
  @Prop() readonly value!: string;//!不要关心初始值
  
  --------省略部分代码
  select(item: string) {
    this.$emit('update:value', item);
  }
  • 使用$emit监听dataSource的update,如果触发就将选中的value,通过.sync,更新父组件的数组当中。
  1. 当我们保存记录的时候,总是会覆盖之前的记录

原来我们保存的时候要复制一遍再保存,我们称为深拷贝; 还需要给record创建一个id,id最大值是17位,ID原则:一旦给了ID就不要修改,id不能重复。

在src下创建liB文件夹,创建一个文件idCreateor.ts,

let id: number = parseInt(window.localStorage.getItem('_idMax') ||'0')||0;
//第一个0,保底有字符串;第二个0 ,保底是个0,不至于食Nan,null;
function createId(){
  id++;
  //保存ID到localStorage
  window.localStorage.setItem('_idMax',id.toString())
  return id
}
export default createId

在lib里创建一个clone.ts,封装克隆深拷贝的方法

function clone<T>(data:T):T{
  
  return JSON.parse(JSON.stringify(data));
}
export  default clone;

在创建记录的时候 将数组变换成字符串,再将字符串变换成数组


createRecord(state, record: RecordItem) {
  record.id = idCreator();
  //深拷贝
  const deepClone = clone(record);
  deepClone.createdDate = new Date();
  state.recordList.push(deepClone); //更新数据
  store.commit("saveRecords");
},
  1. 创建vuex全局状态管理(数据读写工具) 引入Vuex
import Vuex from 'vuex';

使用vuex

Vue.use(Vuex);

把store绑定在Vue.prototype上,因此可以不用导入,直接使用this.$store;

由于vuex使用use,所以他的数据可以被Vue实例双向绑定

vueX 中

  • state是data
  • Mutation是method
  • action是调用method
  1. 数据的存储问题

懒人记账是本地app,没有数据库,只能将数据存储在 localStorage

  • 存数据
window.localStorage.setItem("tagList", JSON.stringify(state.tagList));

使用setItem,JSON.stringify(),将数组转成JSON字符串

  • 取数据
state.tagList = JSON.parse(window.localStorage.getItem('tagList') || '[]');

使用getItem获取,JSON.parse(),将JSON字符串转换js对象

  1. 标签的增删查

通过路由ID查找到当前标签ID,并通过ID进行操作

setCurrentTag(state, id: number) {
  //找到路由ID
  state.currentTag = state.tagList.filter(t => t.id === id)[0];
},

9.1 增

  • store里封装添加标签的API
insertTag: function (state, tag: Tag) {
  //传入的参数为tag类型,
  const names = state.tagList.map((item) => item.name);
  if (names.indexOf(tag.name) >= 0) {
    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
    // @ts-ignore
    state.createTagError = new Error("duplicated");
    return; //插入的标签如果重名,则返回
  }
  tag.id = idCreator();
  const deepClone = clone(tag);
  state.tagList.push(deepClone);
  store.commit("saveTags");
},
  • 在需要使用的地方引用添加API
ok() {
  this.$store.commit('insertTag', this.tag);
  if (this.$store.state.createTagError) {
    if (this.$store.state.createTagError.message === "duplicated") {
      this.$router.replace('/tag/SettingTag');
      return; 
    }
  }
  else {
    this.$router.replace('/tag/SettingTag');
  }
}

调用state的$store.commit insertTag方法,添加tag

9.2 删

  • 在store里封装删除标签的api
  removeTag(state, id:number) {
    let index = -1;
    for (let i = 0; i < state.tagList.length; i++) {
      if (state.tagList[i].id === id) {
        index = i;
        break;
      }
    }
    if (index >= 0) {
      state.tagList.splice(index, 1);
      store.commit("saveTags");
    } else {
      window.alert("删除失败");
    }
  },
  • 在需要使用的地方引用删除API
    remove(id: number) {
      if (id) {
        this.$store.commit('removeTag', id);
      }
    }

调用state的$store.commit``removeTag方法,删除tag

9.3 查

通过第8个数据存储的功能,赋值给state

使用computed 中的get方法,获取添加的标签记录。

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

调用state的$store.state 获取tag 9.4 保存

通过第8个数据存储的功能,保存本地localStorage

saveTags(state) {
 window.localStorage.setItem("tagList", JSON.stringify(state.tagList));
},
  1. 记账记录的增删查改

原理和添加标签是一样的

setCurrentRecord(state, id: number) {
  //找到路由ID
  state.currentRecord = state.recordList.filter((t) => t.id === id)[0];
},
10.1 增
  • 在stroe里封装添加记账记录的api
createRecord(state, record: RecordItem) {
  record.id = idCreator();//将id添加记录
  const deepClone = clone(record);  //深拷贝
  deepClone.createdDate = new Date();//添加时间
  state.recordList.push(deepClone); //更新数据
  store.commit("saveRecords");
},

在需要添加记录的地方调用添加记录的api

//收集的数据记录record
record: RecordItem = {tags: {name:'other',value:'其他'}, notes: '', type: '_', amount: 0};

saveRecord() {
  if(!this.record.tags){
    return window.alert('请选择标签');
  }
  this.$store.commit('createRecord',this.record);
  this.record.notes= '';
  if(this.$store.state.createRecordError===null){
    window.alert('已保存');
    this.$router.replace('/labels');
  }
}
10.2 删
removeRecord(state, id: number) {
  let index = -1;
  for (let i = 0; i < state.recordList.length; i++) {
    if (state.recordList[i].id === id) {
      index = i;
      break;
    }
  }
  if (index >= 0) {
    state.recordList.splice(index, 1);
    store.commit("saveRecords");
  } else {
    window.alert("删除失败");
  }
},

在需要删除记录的地方调用删除记录的api

remove() {
  if (this.record) {
    this.$store.commit('removeRecord', this.record.id);
    if (this.$store.state.recordListErroe === 'notfound') {
      window.alert('该记录不存在');
    } else {
      this.$router.replace('/labels');
    }
  }
}
10.3 查
get recordList() {
  return (this.$store.state as RootState).recordList;
}

通过路由的id,拿到记录的id

//拿到每条记录的明细
created() {
  this.$store.commit('setCurrentRecord', parseInt(this.$route.params.id));
  this.record = clone<RecordItem>(this.$store.state.currentRecord);
  if (!this.record) {   //这一步防止拿不到当前页面数据
    this.record = {
      id: 0,
      tags: {name: 'other', value: '其他'},
      type: '_',
      notes: '',
      amount: 0,
      createdDate: new Date()
    };
    this.$router.replace('/error');    //TODO
  }
}

拿到所有记录的明细

type Group = {
  name: string;
  items: RecordItem[];
}
get groupList() {
  const {recordList} = this;
  //进行排序
  const newList = clone(recordList).filter(item => (dayjs(item.createdDate)
      .year() === parseInt(this.year)) && (dayjs(item.createdDate).month() + 1 === parseInt(this.month)))
      .sort((a, b) => dayjs(b.createdDate).valueOf() - dayjs(a.createdDate).valueOf());

  if (newList.length === 0) {return [];}

  const result: Group[] = [];//存数据
  const names: string[] = [];//存时间

  let record: RecordItem;

  for (record of newList) {
    let date: string; //存储
    if (this.year === dayjs().year().toString()) {
      date = dayjs(record.createdDate).toISOString().split('T')[0];
    } else {
      date = dayjs(record.createdDate).format('YYYY-MM-DD');
    }
    const index = names.indexOf(date);
    if (index < 0) {
      names.push(date);
      result.push({name: date, items: [record]});
    } else {
      result[index].items.push(record);
    }
  }
  return result;
}
  • groupList比作一个大筒

  • recordList按照从近到远的时间进行排序放入newList

  • result是数据,names是时间标签

  • 循环newList,判断时间是否今年,是的就将时间以‘T’分割,去前面部分存入date字符串;否则直接以年月日的格式存入date。

  • names.indexOf(date)判断names时间筒里是否和date的时间一样,不一样就将date时间push到names时间筒里,对应数据放入result里,否则就直接将record,push进去。

  • 10.31 计算总收入

get totalIncome() {
  let total = 0;
  let group: Group;
  for (group of this.groupList) {
    let record: RecordItem;
    for (record of group.items) {
      if (record.type === '+') {
        total += record.amount;
      } else {
        continue;
      }
    }
  }
  return total;
}
  • 10.32 计算总支出入
get totalExpense() {
  let total = 0;
  let group: Group;
  for (group of this.groupList) {
    let record: RecordItem;
    for (record of group.items) {
      if (record.type === '_') {
        total += record.amount;
      } else {
        continue;
      }
    }
  }

  return total;
}
  • 10.33 判断金额是收入还是支出
getAmount(record: RecordItem) {
  if (record.type === '+') {
    return record.amount;
  } else {
    return -record.amount;
  }
}
  • 10.34 封装的年份日期选择器
get years() {   //自己封装一个日期选择器;
  const endYear = dayjs().year();
  let y = 1970;
  const result: number[] = [];
  while (y <= endYear) {
    result.unshift(y);
    y++;
  }
  return result;
}
  • 10.35 装饰月份10以下添加0
beautifyMonth(m: number) {
  return m < 10 ? '0' + m.toString() : m.toString();
}
  • 10.36 日期标签装饰器
beautify(group: Group) {
  const day = dayjs(group.name);
  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 if (day.isSame(now, 'year')) {
    return day.format('M月D日');
  } else {
    return day.format('YYYY年MM月DD日');
  }
}
 10.4 改
updateRecord(state, { id, record }: { id: number; record: RecordItem }) {
  for (let index = 0; index < state.recordList.length; index++) {
    if (state.recordList[index].id === id) {
      state.recordList[index] = record;
      break;
    }
  }
  store.commit("saveRecords");
},

在需要使用的地方引用修改API

ok() {
  if (this.record) {
    this.record.amount = parseFloat(this.record.amount.toString());
    this.$store.commit('updateRecord', {id: this.record.id, record: this.record});
  }
  this.$router.replace('/labels');
}
  1. 使用echart
  • 安装echart npm install echarts --save
  • 引用echart
import * as echarts from 'echarts';
  • 在项目中使用
draw(data: Map<string, number>) {
  // 提取变量
  const x = [...data.keys()];
  const y = [...data.values()];

// 基于准备好的dom,初始化echarts实例

  const figure = echarts.init(document.getElementById('figure') as HTMLDivElement);

// 绘制图表

  figure.setOption({
    //直角坐标底板
    grid: {
      top: '5%',
      bottom: '15%'
    },
    //x坐标
    xAxis: {
      data: x,
      axisTick: {
        lineStyle: {
          opacity: 0,//x轴的坐标刻度
        }
      },
      axisLabel: {
        interval: 0,//X轴坐标分割线的间隔
        fontSize: 8,
        color: '#999999'
      }
    },
    //y坐标
    yAxis: {
      // axisLine: {
      //   interval: 0,
      //   lineStyle: {
      //     opacity: 0
      //   }
      // },
      splitLine: {
        lineStyle: {
          opacity: 0
        }
      },
      axisLabel: undefined,
      axisTick: undefined,
    },

    series: [{
      type: 'line',
      data: y,

    }
      , {
        name: '最大值',
        type: 'line',
        data: this.toArray(Math.max(...y), x.length),
        symbol: 'none',
        lineStyle: {
          color: '#999999',
          width: 1,
          opacity: 0.5
        }
      }
    ]

  });
}

具体可参考ECharts