第一个记账项目(小小怪记账)记录

521 阅读6分钟

项目介绍

视觉设计

使用Figma和ps显目展示

需求描述

平台:手机优先

功能:

  1. 记账:金额 ,标签,备注,收入还是支出
  2. 管理标签:添加,改名,删除
  3. 查看历史:

其他功能

  1. 页面关闭打开数据不消失(localStorage)
  2. 换一个手机,数据不消失(还未实现,需要使用数据库在线版)

在项目中使用的知识

  1. 组件:构造选项,类组件+装饰器(js or ts)
  2. mixin,computed,watch装饰器写法:prop,sync,v-modelM
  3. slot插槽,svg symbols,eslint,localStorage(5-10M)
  4. 表驱动编程,模块化思想,重构,命名的严谨性,
  5. 使用了vuex
  6. vue,大部分为tyscript(在学),css+html5,js;

在项目中的问题及一些知识点的分享

1. 首选安装好Node.js和vue,官网有安装步骤

2. 目录结构介绍

3. 一些小优化

  1. TS/JS可以通过@/目录名引入文件
  2. CSS/SASS可以通过~@/目录名引入文件
  3. 这样就不用自己计算相对路径 例如:
import Nav from '@/components/Nav.vue';
import Layout from '@/components/Layout.vue';
import Icon from '@/components/Icon.vue';

4.思路

1. 确定页面url

  • #/money记账
  • #/labels标签
  • #/statistic统计
  • 默认进入#/money
  • 添加一个404页面

2.添加代码

  • router/index.ts添加router,配置4个路径对应组件
  • 初始化组件
  • 在router传给new Vue()
  • 在app组件里用给出router渲染区域
Vue.use(VueRouter);
const routes = [
  {
    path: '/',//默认页面
    redirect: '/money'
  },
  {
    path: '/money',
    component: Money
  },
  {
    path: '/labels',
    component: Labels
  },
  {
    path: '/statistics',
    component: Statistics
  },
  {
    path: '/labels/edit/:id',//标签编辑页面
    component: EditLabel
  },
  {
    path: '*',//404页面
    component: NotFound
  }
];

5. 制作NAV(底部导航栏)组件(全局在main.ts引入)

全局引入后想在哪个页面引入就引入。

Vue.component('Nav', Nav);

1.导航栏的样式:

思路: 使用flex布局,尽量不要使用fixed,因为有很多问题,flex少。

2.Layout组件&slot插槽:

layout.vue:

<template>
  <div class="layout-wrapper" :class="classPrefix && `${classPrefix}-wrapper`">
    <div class="content" :class="classPrefix && `${classPrefix}-content`   ">
      <slot/>//********
    </div>
    <Nav/>
  </div>
</template>

money.vue:

 <Layout class-prefix="layout">
   ....
  </Layout>
定义好组件layout后就可以把money.vue中layout标签中....内容插入到layout.vue中通过 <slot/>

3.使用svg-sprite-loader引入icon

  1. iconfont.cn寻找合适的标签
  2. 下载svg标签(实际是一个XML文件)
  3. 复制到你的项目
  4. 在.d.ts后缀的文件中添加
declare module "*.svg" {
  const content: string;
  export default content;
}
  1. 引入svg标签在Nav.vue中
  2. 安装svg-sprite-loader 可以使用yarn或者npm安
  3. 在我没有webpack.config.js所以我需要在vue.config.js中配置(添加下面配置),加注释的地方是出现了报错,因该是Vue版本的问题,但是不影响使用
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path')
module.exports = {
    publicPath:process.env.NODE_ENV === 'production'
    ? '/money-show/'
        :'/',
    lintOnSave: false,
    chainWebpack: config => {
        const dir = path.resolve(__dirname, 'src/assets/icons')
        config.module
            .rule('svg-sprite')
            .test(/\.svg$/)
            .include.add(dir).end()
            .use('svg-sprite-loader').loader('svg-sprite-loader').options({extract:false}).end()
            // .use('svg-sprite-loader-mod').loader('svg').loader('svg-sprite-loader-mod').options({extract: false}).end()
            .use('svgo-loader').loader('svgo-loader')
            .tap(options => ({...options, plugins: [{removeAttrs: {attrs: 'fill'}}]})).end()
        // eslint-disable-next-line @typescript-eslint/no-var-requires
        config.plugin('svg-sprite').use(require('svg-sprite-loader/plugin'), [{plainSprite: true}])
        config.module.rule('svg').exclude.add(dir)
    }
}
  1. 然后使用svg标签
  <svg >
    <use :xlink:href="name"/>
  </svg>

并且在<script>中添加这样就直接引入一个文件,不用把每个标签引入一遍'../assets/icons'这是文件路径

const importAll = (requireContext: __WebpackModuleApi.RequireContext) => requireContext.keys().forEach(requireContext);
  try {importAll(require.context('../assets/icons', true, /\.svg$/));} catch (error) {console.log(error);}

4.然后把标签封装为一个icno.vue文件

<template>
  <svg class="icon" @click="$emit('click', $event)">
    <use :xlink:href="'#'+name"/>
  </svg>
</template>

<script lang="ts">
  const importAll = (requireContext: __WebpackModuleApi.RequireContext) => requireContext.keys().forEach(requireContext);
  try {importAll(require.context('../assets/icons', true, /\.svg$/));} catch (error) {console.log(error);}
  export default {
    props: ['name'],
    name: 'Icon'
  };
</script>
//此处的css代码来自于iconfont的help,svg标签使用方法
<style lang="scss" scoped>
  .icon {
    width: 1em; height: 1em;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
  }
</style>

5.去淘宝抄一个viewport确保基本可以确保手机使用不会有问题

 <meta name="viewport" content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">

6.在svg标签中的问题(小坑)

怎么去除svg默认附带的颜色,首先下载最好不要颜色。 或者在vue.config.js中end()后添加

 .use('svgo-loader').loader('svgo-loader')
            .tap(options => ({...options, plugins: [{removeAttrs: {attrs: 'fill'}}]})).end()

也可以去除。需要安装一个svgo-loader的插件,同样使用yarn或者npm就可以。

7.在引入scss中出现的问题(报错,无法识别)

比如我们将一些经常要用的颜色放入一个helper.scss文件然后我们想将该文件引入就会出现问题:例如:在money.vue中引入

 @import "~@/assets/style/helper.scss";

webstorm最近修复了该问题

6.money.vue组件

1.html

对money组件的布局进行实现,使用基本的html5语法,最后将每一个组件进行模块化(部分代码如下)<NumberPad /> <Tabs/> <Tags/>将这三个写成单独的vue组件引入,减少本页的代码量,可以让页面显得简单。否者处理问题难以修改

<template>
  <Layout class-prefix="layout">//布局模板
    <NumberPad />//输入键盘
    <Tabs/>//切换按钮
    <div class="notes">//备注框
      <FormItem field-name="备注"
                placeholder="在这里输入备注
      />
    </div>
    <Tags/>//标签
  </Layout>
</template>

2.scss

<style lang="scss" scoped>
  ::v-deep .layout-content {
    display: flex;
    flex-direction: column-reverse;
  }
  .notes {
    padding: 12px 0;
  }
</style>

3.js(使用ts好一些)

写Vue组件的三种方式

  1. 用js对象
export default {data,props,methods.... }
  1. 用ts类<script lang='ts'>
@Component
export default class XXX extends Vue{
xxx: string = 'hello';
@Prop(Number) xxx: number | undefined;
}
  1. 用js类<script lang='js'>
@Component
export default class XXX extends Vue{
xxx = 'hello';
}

图

4.写好四个组件

以types组件为例:

import Vue from 'vue'
@component//装饰器
export default class Types extends Vue{
type = '- ' //'-表示支出'+'表示收入
'''这里直接写方法
}

@Component作用是告诉Tyscrtpt下面内容是一个Vue组件,里面的东西会自动被处理成data和methods。在vue中使用prop:

@Prop(type) xxx: type | undefined

5.将4个组件的Value收集起来放入recoed;

revordItem是自定义类型:

type RecordItem = {
  tags: Tag[];
  notes: string;
  type: string;
  amount: number; // 数据类型 object | string
  createdAt?: string;  // 类 / 构造函数
}
   record: RecordItem = {
      tags: [], notes: '', type: '-', amount: 0
    };

最后将record保存到localStorage

   saveRecords(state) {
      window.localStorage.setItem('recordList',
        JSON.stringify(state.recordList));
    },

7.label.vue

1.html

写好基本页面,封装一个button组件,后来编辑页面也要使用,减少重复,让代码变得好看。

 <Layout>
    <div class="tags">
      <router-link class="tag"
                   v-for="tag in tags" :key="tag.id"
                   :to="`/labels/edit/${tag.id}`">
        <span>{{tag.name}}</span>
        <Icon name="right"/>
      </router-link>
    </div>
    <div class="createTag-wrapper">
      <Button class="createTag"
              @click="createTag">
        新建标签
      </Button>
    </div>
  </Layout>

2.scss布局


<style lang="scss" scoped>
  .tags {
    background: white;
    font-size: 16px;
    padding-left: 16px;
    > .tag {
      min-height: 44px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      border-bottom: 1px solid #E6E6E6;
      svg {
        width: 18px;
        height: 18px;
        color: #666;
        margin-right: 16px;
      }
    }
  }
  .createTag {
    background: #767676;
    color: white;
    border-radius: 4px;
    border: none;
    height: 40px;
    padding: 0 16px;
    &-wrapper {
      text-align: center;
      padding: 16px;
      margin-top: 44-16px;
    }
  }
</style>

3. js

后来我把所有的关于数据的操作都封装在了store目录下的三个文件中,并且将store声明为全局属性。

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

    beforeCreate() {
      this.$store.commit('fetchTags');
    }
  }

在store文件下的index.ts中,使用了vuex

 fetchTags(state) {
      state.tagList = JSON.parse(window.localStorage.getItem('tagList') || '[]');
      if(!state.tagList || state.tagList.length===0){
        store.commit('createTag','衣')
        store.commit('createTag','食')
        store.commit('createTag','住')
        store.commit('createTag','行')

      }

在这个组件中遇到的一个问题:我的每个数据必须要有一个id,如果没有id我就不能准确定位到它,id不能重复。后来我想到申明一个组件将一个id一直增加存到licalstorage中,这样每次存一个数据,我就给他一个id且不会重复。

function createId() {
  id++;
  window.localStorage.setItem('_idMax', id.toString());
  return id;
}

😝使用全局数据管理:可以直接读写。 所以之后用window来管理数据,封装好api。但是呢这样会导致全局变量太多,使得main.ts文件变得冗长,因此直接将这些api封装到store中单独管理。 (●ˇ∀ˇ●)

🤶全局状态管理(也叫全局数据管理)的好处是什么?

解耦:将所有数据相关的逻辑放入 store(也就是 MVC 中的 Model,换了个名字而已) 数据读写更方便:任何组件不管在哪里,都可以直接读写数据 控制力更强:组件对数据的读写只能使用 store 提供的 API 进行(当然也不排除有猪队友直接对 tagList 和 recordList 进行 push 等操作,这是没有办法禁止的

4.Vuex的使用

vuex学习路径。官方文档靠谱(≧∀≦)ゞ

试着在我的项目里使用vuex首先在money.vue中使用之后把labels和tags一起重构了::

Vue.use(Vuex);

const store = new Vuex.Store({
  state: {
    recordList: [],
    createRecordError: null,
    createTagError: null,
    tagList: [],
    currentTag: undefined
  } as RootState,
  mutations: {
    setCurrentTag(state, id: string) {
      state.currentTag = state.tagList.filter(t => t.id === id)[0];
    },
    updateTag(state, payload: { id: string; name: string }) {
      const {id, name} = payload;
      const idList = state.tagList.map(item => item.id);
      if (idList.indexOf(id) >= 0) {
        const names = state.tagList.map(item => item.name);
        if (names.indexOf(name) >= 0) {
          window.alert('标签名重复了');
        } else {
          const tag = state.tagList.filter(item => item.id === id)[0];
          tag.name = name;
          store.commit('saveTags');
        }
      }
    },
    removeTag(state, id: string) {
      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');
        router.back();
      } else {
        window.alert('删除失败');
      }

    },
    fetchRecords(state) {
      state.recordList = JSON.parse(window.localStorage.getItem('recordList') || '[]') as RecordItem[];
    },
    createRecord(state, record: RecordItem) {
      const record2: RecordItem = clone(record);
      record2.createdAt = new Date().toISOString();
      state.recordList.push(record2);
      store.commit('saveRecords');

    },
    saveRecords(state) {
      window.localStorage.setItem('recordList',
        JSON.stringify(state.recordList));
    },
    fetchTags(state) {
      state.tagList = JSON.parse(window.localStorage.getItem('tagList') || '[]');
      if(!state.tagList || state.tagList.length===0){
        store.commit('createTag','衣')
        store.commit('createTag','食')
        store.commit('createTag','住')
        store.commit('createTag','行')

      }

    },
    createTag(state, name: string) {
      const names = state.tagList.map(item => item.name);
      if (names.indexOf(name) >= 0) {
       state.createTagError = new Error('tag name duplicated')
        return
      }
      const id = createId().toString();
      state.tagList.push({id, name: name});
      store.commit('saveTags');

    },
    saveTags(state) {
      window.localStorage.setItem('tagList', JSON.stringify(state.tagList));
    },
  }
});

export default store;

5.在ts里使用mixin

必须让该类继承mixin,括号里填写你要导入的mixin组件,wbstorm会自动导入需要的包和文件

  import {mixins} from 'vue-class-component';
  import TagHelper from '@/mixins/TagHelper';
 export default class Labels extends mixins(TagHelper)

6.在ts里使用computed要使用getter和setter语法

例如我要拿taglist数组的数据

  get tags() {//得到vuex中的数据
      return this.$store.state.tagList;
    }

8.statistic组件

1.html

<Layout>
    <Tabs class-prefix="type" :data-source="recordTypeList" :value.sync="type"/>
    <ol v-if="groupedList.length>0">
      <li v-for="(group, index) in groupedList" :key="index">
        <h3 class="title">{{beautify(group.title)}} <span>¥{{group.total}}</span></h3>
        <ol>
          <li v-for="item in group.items" :key="item.id"
              class="record">
            <span>{{tagString(item.tags)}}</span>
            <span class="notes">{{item.notes}}</span>
            <span>¥{{item.amount}} </span>
          </li>
        </ol>
      </li>
    </ol>
    <div v-else class="no-result">目前没有记录</div>
  </Layout>

2.scss


<style scoped lang="scss">
.no-result{
  padding: 20px;
  text-align: center;
}
  ::v-deep {
    .type-tabs-item {
      background: #C4C4C4;
      &.selected {
        background: white;
        &::after {
          display: none;
        }
      }
    }
    .interval-tabs-item {
      height: 48px;
    }
  }
  %item {
    padding: 8px 16px;
    line-height: 24px;
    display: flex;
    justify-content: space-between;
    align-content: center;
  }
  .title {
    @extend %item;
  }
  .record {
    background: white;
    @extend %item;
  }
  .notes {
    margin-right: auto;
    margin-left: 16px;
    color: #999;
  }
</style>

3.js

采用iso8601和dayjs去处理时间显示问题。

 get groupedList() {
      const {recordList} = this;

      const newList = clone(recordList)
        .filter(r => r.type === this.type)
        //排序
        .sort((a, b) => dayjs(b.createdAt).valueOf() -dayjs(a.createdAt).valueOf());
      if (newList.length=== 0) {return [];}
      type Result = { title: string; total?: number; items: RecordItem[] }[]
      const result: Result = [{title: dayjs(newList[0].createdAt).format('YYYY-MM-DD'), items: [newList[0]]}];
      for (let i = 1; i < newList.length; i++) {
        const current = newList[i];
        const last = result[result.length - 1];
        if (dayjs(last.title).isSame(dayjs(current.createdAt), 'day')) {
          last.items.push(current);
        } else {
          result.push({title: dayjs(current.createdAt).format('YYYY-MM-DD'), items: [current]});
        }
      }
      });
      return result;
    }

4.ISO时间表示

  1. 年由4位数字组成YYYY,或者带正负号的四或五位数字表示±YYYYY。以公历公元1年为0001年,以公元前1年为0000年,公元前2年为-0001年,其他以此类推。应用其他纪年法要换算成公历,但如果发送和接受信息的双方有共同一致同意的其他纪年法,可以自行应用。月、日用两位数字表示:MM、DD。

5.采用day.js只有2kb,表较小好用为什么使用 Day.js ?

1.2kB 下载、解析和执行更少的 JavaScript,为您的代码留出更多时间。

2.简易 Day.js 是一个轻量的处理时间和日期的 JavaScript 库,和 Moment.js 的 API 设计保持完全一样。 如果您曾经用过 Moment.js, 那么您已经知道如何使用 Day.js 。

3.不可变的 所有的 API 操作都将返回一个新的 Dayjs 对象。 这种设计能避免 bug 产生,节约调试时间。

4.国际化 Day.js 对国际化支持良好。但除非手动加载,多国语言默认是不会被打包到工程里的

9总结:

 小小怪记账是一个很简单的记账网页,也是一款基于Vue,vueRount.vuex,ts的单页面应用, 这个项目是本人参考记账软件自己设计和实现的期间遇到了很多webpack和ts方面的问题,具体实现过程中有问题的都在以上进行了记录,源代码几乎全部使用了we装饰器语法。webzhuan

10源代码: