【项目-喵内记账-meoney-05】Statistic.vue 组件 列表统计页面 -标记问题

大纲链接 §

[toc]


知识概要

  • 封装 Tabs,样式使用 deep 深度作用选择器,覆盖子组件内部元素样式
  • 用 JS 配置组件默认height
  • 用列表展示数据
  • 添加 Statistic.vue SCSS
  • ISO8601dayjs
  • 数据排序
  • 数据排序后分组
  • 完成统计页面
  • createdAt of undefined 的解决办法

封装 Tabs.vue

重构Types.vue 样式,使用 深度作用选择器 deep 语法

查看Statistic.vue结构, 可复用Types.vue组件

  • 注意样式加了scoped属性,只影响当前组件的标签,不会深入组件标签继续影响内部结构
    • 组件<Type class="x"/>d的样式只是加在表层的<div>
    • 即使写了.x li {...;}的样式,不对内部<li>有任何影响
  • 可以在不污染其他组件样式的同时,使用/deep/::v-deep(SCSS)可以深入影响内部结构的样式
  • 搜索site: vuejs.org deep: Scoped CSS 深度作用选择器 deep 语法
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<style lang="css" scoped>
.x /deep/ li {
  border: 1px solid red;
  }
</style>

<style lang="scss" scoped>
.x ::v-deep li {
  border: 1px solid red;
  }
</style>

Statistic.vue中,在<Type/>上,对Types.vue组件传入绑定属性:type.sync="yyy"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
<template>
  <Layout>
    <Types class="x" :type.sync="yyy"/>
  </Layout>
</template>

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

@Component({
  components: {Types}
})
export default class Statistics extends Vue {
  yyy = '-';
}
</script>

深度作用选择器 选中的样式&.selected {}

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<style lang="scss" scoped>
.x ::v-deep li {
  background: #fff;
  &.selected {
    background: #c4c4c4;
    &::after {
      display: none;
      }
    }
  }
</style>


还有一个缺点,就是如果多层嵌套,无法通过类选择器精确/动态控制样式

最佳实践:精准控制组件内部结构的样式

使用 对象控制绑定的样式(表驱动)动态前缀 来控制组件内部标签的样式

  • Class 与 Style 绑定
  • 从父组件传入一个 前缀字符串 到子组件中,由各不同子组件去拼接,就可实现精确/动态控制样式
  • Types.vue添加外部数据:属性前缀class-prefix
    • 注意写清外部数据类型:@Prop(String) readonly classPrefix?: string;
    • 注意数据由于是外部传来的,无法指定初始化类型,强行指定类型!:或者可选?:
  • 用对象的形式赋值给绑定的类属性:class="{selectorA: true, selectorB: false, selectorC: expressions, active: isActive}"
    • 当对象的属性值(一般是表达式)为true时,属性(键)对应的类选择器生效
    • 官方解释:active 这个 class 存在与否将取决于数据 property isActivetruthiness
    • 可绑定多个选择器
    • 可以与普通的 class attribute 共存:<div class="static active" :class="{dynamicClss: true}"></div>
    • 动态地切换 class:当 isActive 变化时,class 列表将相应地更新
      • isActive: trueclass 列表将变为 class="static active"
  • 动态属性前缀<li :class="{[classPrefix + '-item']: classPrefix, selected: value==='-'}">
    • 如果key里有变量,使用ES6对象动态属性 {[xxx]: 'yyy'}
    • 使用(表达式)作为对象的属性名,即把表达式放在方括号内
    • 还可以拼接字符串:{[classPrefix + '-item']: classPrefix}
  • 表驱动:将所有类名以表的形式给出

Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<template>
  <Layout class="statistics">
    <Types class-prefix="zzz" class="x" :type.sync="yyy"/>
  </Layout>
</template>

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

@Component({
  components: {Types}
})
export default class Statistics extends Vue {
  yyy = '-';
}
</script>

<style lang="scss" scoped>
.statistics{
  ::v-deep .zzz-item {
    background: #fff;
    &.selected {
      background: #c4c4c4;
      border: 1px solid green;
      &::after {
        display: none;
        }
      }
    }
  }
</style>

Type.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<template>
  <div class="div_types">
    <ul class="types">
      <li :class="{selected: type==='-', [classPrefix + '-item']: classPrefix}" @click="selectType('-')">支出</li>
      <li :class="{selected: type==='+', [classPrefix + '-item']: classPrefix}" @click="selectType('+')">收入</li>
    </ul>
  </div>
</template>

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

@Component
export default class Types extends Vue {
  @Prop(String) readonly type!: string;
  @Prop(String) readonly classPrefix?: string;

  selectType(type: string) {
    if (type !== '-' && type !== '+') {
      throw new Error('type is unknown');
    }
    this.$emit('update:type', type);
  }
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.types {
  background: #c4c4c4;
  display: flex;
  text-align: center;
  > li {
    font-size: 24px;
    width: 50%;
    height: 64px;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    &.selected {
      background: #c4c4c4;
      &::after {
        content: "";
        display: block;
        position: absolute;
        bottom: 0;
        left: 0;
        width: 100%;
        height: 4px;
        background: #333;
        }
      }
    }
  }
</style>

  • 可以用前缀精确地获取类名zzz-item,控制内部的元素
  • 可以直接用深度作用选择器作为最外部选择器,但会造成被子组件原来的样式干扰
  • 传入不同的前缀实现样式互相隔绝,BEM风格
  • 注意CSS权重计算,子组件比父组件后加载

可以声明一个变量,传对象,将该对象作为:class=""的绑定值


抽离Types.vueTabs.vue组件,可复用切换逻辑

可现实多项内容Tabs切换

  • 需要外部传参:对象数组包含显示文字和类型字符+/-、前缀字符
  • 内容为父组件传入@Prop({required: true, type: Array}) dataSource!: { text: string; type: string }[];
  • 声明类型type DataSource = { text: string; type: string }
  • dataSource改为@Prop({required: true, type: Array}) dataSource!: DataSource[];
  • type类型字符串@Prop(String) readonly type!: string;
  • classPrefix前缀字符串@Prop(String) readonly classPrefix?: string;

重构Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
<template>
  <ul class="tabs">
    <li v-for="item in dataSource"
        :key="item.type"
        :class="{selected: item.type === type}"
        @click="select(item)">
        {{ item.text }}
    </li>
  </ul>
</template>

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

type DataSource = { text: string; type: string }

@Component
export default class Tabs extends Vue {
  @Prop({required: true, type: Array}) dataSource!: DataSource[];
  @Prop(String) readonly type!: string;
  @Prop(String) readonly classPrefix?: string;

  select(item: DataSource) {
    this.$emit('update:type', item.type);
  }
}
</script>

<style lang="scss" scoped>
.tabs {
  background: #c4c4c4;
  display: flex;
  text-align: center;
  > li {
    font-size: 24px;
    width: 50%;
    height: 64px;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    &.selected {
      background: #c4c4c4;
      &::after {
        content: "";
        display: block;
        position: absolute;
        bottom: 0;
        left: 0;
        width: 100%;
        height: 4px;
        background: #333;
        }
      }
    }
  }
</style>


重构Statistic.vue

  • <Tabs class-prefix="interval" :data-source="typeList" :type.sync="type"/>
    • 样式前缀:class-prefix="interval",子组件默认样式
    • dataSourceintervalList
      • 声明intervalList = [{text: '按天', type: 'day'}, {text: '按周', type: 'week'}, {text: '按月', type: 'month'}];
      • 传初始绑定的同步数据:type.sync="interval"interval = 'day';(按天)
  • Tabs.vue改造Types.vue: <Tabs class-prefix="type" :data-source="typeList" :type.sync="type"/>
    • 样式前缀:class-prefix="type"::v-deep .type-item {...}
    • 声明数据源:typeList = [{text: '支出', type: '-'}, {text: '收入', type: '+'}]
    • 传初始绑定的同步数据::type.sync="type"type = '-';(支出)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="typeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval"/>
  </Layout>
</template>

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

@Component({
  components: {Tabs, Types}
})
export default class Statistics extends Vue {
  type = '-';
  interval = 'day';

  intervalList = [
    {text: '按天', type: 'day'},
    {text: '按周', type: 'week'},
    {text: '按月', type: 'month'}
  ];

  typeList = [
    {text: '支出', type: '-'},
    {text: '收入', type: '+'}
  ]
}
</script>

<style lang="scss" scoped>
.statistics {
  ::v-deep .type-tabs-item {
    background: #fff;
    position: relative;
    &.selected {
      background: #c4c4c4;
      &::after {
        display: none;
        }
      }
    }
  }
</style>


动态属性liClass作为样式Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<template>
  <ul class="tabs">
    <li v-for="item in dataSource"
        :key="item.type"
        :class="liClass(item)"
        @click="select(item)">{{ item.text }}
    </li>
  </ul>
</template>

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

type DataSource = { text: string; type: string };

@Component
export default class Tabs extends Vue {
  @Prop({required: true, type: Array}) dataSource!: DataSource[];
  @Prop(String) readonly type!: string;
  @Prop(String) classPrefix?: string;

  liClass(item: DataSource) {
    return {
      [this.classPrefix +'-tabs-item']: this.classPrefix,
      selected: item.type === this.type
    };
  }

  select(item: DataSource) {
    this.$emit('update:type', item.type);
  }
}
</script>
//...
  • 遍历数据dataSource,绑定:key="item.type",插值{{ item.text }}
    • <li v-for="item in dataSource" :key="item.type">{{ item.text }}</li>
  • 注册事件@click="select(item)"
    • <li v-for="item in dataSource" :key="item.type" @click="select(item)">{{ item.text }}</li>
  • class绑定::class="{[this.classPrefix +'-tabs-item']: this.classPrefix, selected: item.type === type}"太长
    • 封装样式对象返回变量的函数,接受<template>中的数据传参item
    • <li v-for="item in dataSource" :key="item.type" :class="liClass(item)" @click="select(item)">{{ item.text }}</li>
    • 缩写为:class="liClass(item)"
    • 函数实现liClass(item: DataSource) {return {[this.classPrefix +'-tabs-item']: this.classPrefix, selected: item.type === this.type};}
    • 不可使用箭头函数,避免this指向
  • 发布事件
    • select(item: DataSource) {this.$emit('update:type', item.type);}
    • 注意类型声明DataSource

Statistic.vue 查看显示插值是否正确

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="typeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval"/>
    type: {{ type }}
    <br>
    interval: {{ interval }}
  </Layout>
</template>


模块化常量数据src/constants/intervalList.ts

  • 使数据不可使用栈方法,Object.freeze()
  • 禁止改变原对象,成为真正的常量

intervalList.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/*
const intervalList = Object.freeze([
  {text: '按天', type: 'day'},
  {text: '按周', type: 'week'},
  {text: '按月', type: 'month'}
]);

export default intervalList;
*/

export default Object.freeze([
  {text: '按天', type: 'day'},
  {text: '按周', type: 'week'},
  {text: '按月', type: 'month'}
]);

recordTypeList.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
/*
const recordTypeList = Object.freeze([
  {text: '支出', type: '-'},
  {text: '收入', type: '+'}
]);

export default recordTypeList;
*/

export default Object.freeze([
  {text: '支出', type: '-'},
  {text: '收入', type: '+'}
]);

重构Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
//...
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import Types from '@/components/Money/Types.vue';
import Tabs from '@/components/Tabs.vue';
import recordTypeList from '@/constants/recordTypeList';
import intervalList from '@/constants/intervalList';

@Component({
  components: {Tabs, Types}
})
export default class Statistics extends Vue {
  type = '-';
  interval = 'day';
  intervalList = intervalList;
  recordTypeList = recordTypeList;
}
</script>
...
  • 重构typeListrecordTypeList

重构Money.vue 使用Tabs.vue,并删除原 Types.vue

  • <Tabs :data-source="recordTypeList" :type.sync="record.type"/>

Money.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<template>
  <Layout class-prefix="layout">
    <Tags @update:selectedTags="pickTags"/>
    <FormItem class="form-item" field-name="备注" placeholder="在这里输入备注"
              @update:inputValue="onUpdateTips"/>
    <Tabs :data-source="recordTypeList" :type.sync="record.type"/>
    <Numpad :value.sync="record.amount"
            @submit="saveRecord"/>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import FormItem from '@/components/Money/FormItem.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component} from 'vue-property-decorator';
import recordTypeList from '@/constants/recordTypeList.ts';
import Tabs from '@/components/Tabs.vue';

@Component({
  components: {Tabs, Numpad, Types, FormItem, Tags}
})
export default class Money extends Vue {
  get recordList() {
    return this.$store.state.recordList;
  }

  record: RecordItem = {
    tags: [],
    tips: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };
  recordTypeList = recordTypeList;

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

  onUpdateTips(value: string) {
    this.record.tips = value;
  }

  pickTags(selectedTags: string[]) {
    this.record.tags = selectedTags;
  }

  saveRecord() {
    this.$store.commit('createRecord', this.record);
  }
}
</script>
// ...


调整高度样式,更改优先级

  • 注意CSS权重计算
    • 子组件比父组件后加载
    • 更多使用具体的class选择器,精确控制样式
    • 特别是使用深度作用选择器时,尽量 不使用标签选择器,里层的标签未知
    • 解决样式被覆盖的方法:减少/增加 嵌套的选择器
      • 减少嵌套:去除后代/子选择器 > li,改用class选择器增加权重

Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<template>
  <ul class="tabs" :class="{[classPrefix + '-tabs']: classPrefix}">
    <li v-for="item in dataSource"
        :key="item.type"
        :class="liClass(item)"
        @click="select(item)"
        class="tabs-item">
      {{ item.text }}
    </li>
  </ul>
</template>
// ...
<style lang="scss" scoped>
.tabs {
  background: #c4c4c4;
  display: flex;
  text-align: center;
  &-item {
    font-size: 24px;
    width: 50%;
    height: 64px;
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    &.selected {
      background: #c4c4c4;
      &::after {
        content: "";
        display: block;
        position: absolute;
        bottom: 0;
        left: 0;
        width: 100%;
        height: 4px;
        background: #333;
        }
      }
    }
  }
</style>

Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval"/>
    type: {{ type }}
    <br>
    interval: {{ interval }}
  </Layout>
</template>
//...
<style lang="scss" scoped>
.statistics {
  ::v-deep {
    .type-tabs-item {
      background: #fff;
      position: relative;
      &.selected {
        background: #c4c4c4;
        &::after {
          display: none;
          }
        }
      }
    .interval-tabs-item {
      height: 48px;
      }
    }
  }
</style>


用 TS 配置组件默认 height

  • 使用外部数据传入高度属性值
  • 注意高度属性值类型是字符串,不是数字
  • 外部数据无需初始化,加上强制类型断言!: string
  • 注意内联样式style属性的权重

重构Tabs.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
<template>
  <ul class="tabs" :class="{[classPrefix + '-tabs']: classPrefix}">
    <li v-for="item in dataSource"
        :key="item.type"
        :class="liClass(item)"
        @click="select(item)"
        class="tabs-item"
        :style="{height: tabsHeight}">
      {{ item.text }}
    </li>
  </ul>
</template>

<script lang="ts">
//...
  @Prop({type: String, default: '64px'}) tabsHeight!: string;
//...
</script>

<style lang="scss" scoped>
.tabs {
  background: #c4c4c4;
  display: flex;
  text-align: center;
  &-item {
    font-size: 24px;
    width: 50%;
    // height: 64px; // 注释掉此行
    display: flex;
    justify-content: center;
    align-items: center;
    position: relative;
    &.selected {
      background: #c4c4c4;
      &::after {
        content: "";
        display: block;
        position: absolute;
        bottom: 0;
        left: 0;
        width: 100%;
        height: 4px;
        background: #333;
        }
      }
    }
  }
</style>

重构Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval" tabs-height="48px"/>
    type: {{ type }}
    <br>
    interval: {{ interval }}
  </Layout>
</template>

//...
<style lang="scss" scoped>
.statistics {
  ::v-deep {
    .type-tabs-item {
      background: #fff;
      position: relative;
      &.selected {
        background: #c4c4c4;
        &::after {
          display: none;
          }
        }
      }
    .interval-tabs-item {
      // height: 48px;
      }
    }
  }
</style>

  • 可以直接使用SCSS样式控制,也可以使用 TS 配置组件默认 height
  • 但不推荐,会造成逻辑与样式耦合

用列表展示数据

展示数据结构

结构类似于无根节点的树trees

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
;[
  {title:'今天', items: [r1, r2, ...]},
  {title:'明天', items: [r3, r4, r5, ...]},
  {title:'昨天', items: [r6, r7, ...]}
]
/*
recordList
1 r1
1 r2
2 r3
2 r4
2 r5
3 r6
3 r7
*/

;[
  {title:'20xx-xx-02', items: [r1, r2, ...]},
  {title:'20xx-xx-02', items: [r3, r4, r5, ...]},
  {title:'20xx-xx-01', items: [r6, r7, ...]}
]
// 桶1 r1 r2
// 桶2 r3 r4 r5
// 桶3 r6 r7

展示数据步骤

  • 首先使用 钩子获取数据
    • beforeCreate() {this.$store.commit('fetchRecords');}
  • 使用 计算属性操作数据,注意必须有返回值return ...
    • get recordList() {return ...}获取记录列表,注意返回值类型
    • get result() {return ...}
  • 使用 计数排序 桶排序
    • 使用hashTable存数据
    • 大致结构 :list: [] + type/interval: string = recordTrees: {title: string, items: RecordItem[]}[]
    • 按天分组排序,在计算属性中写排序逻辑
      • get result() {}

隐藏的bug

注意recordList的类型,即使已经声明类型,recordList: RecordItem[]recordList类型为any

  • Vuex类型上的bug,在得到this.$store时,并不能返回正确的类型,导致得到的store返回的类型永远是any
  • Vuex并不能像之前自己使用models里数据时声明数据类型tagListModel这是tsvuex结合不好的地方,即使用API会丢失类型信息(vue3的新状态管理pinia修复了这一缺陷)
  • 注意recordList[i].createdAt属性的类型
  • 每次都 强制类型断言 as RootState
    • get recorList() {return (this.$store.state as RootState).recordList;}
    • 此时的返回值recordList的类型显示正确为RecordItem[]
    • 属性recordList[i].createdAt的类型显示为Date | undefined,但需要的是字符串,字符串才可以分割操作

Date类型不能被JSON.stringify序列化

toISOString()特指ISO8601

  • 类型声明type RecordItem = {tags: string[]; tips: string; type: string; amount: number; createdAt?: Date;}中的createdAt?: Date;改为createdAt?: String;
  • 查看所有引用createdAt,TS 编译报错
  • 将所有改成*.createdAt = new Date().toISOSting()

标记问题:这是vueTS配合不好的地方

  • src/store/index.ts中声明的store类型是RootState
    • 之前将state的类型进行断言state: {tagList: [], recordList: [], currentTag: undefined} as RootState,
  • 查看源码vuex/types/vue.d.ts
    • declare module "vue/type/vue" {interface Vue {$store: Store<any>}}写死类型是<any>

使用计算属性computed操作展示数据

TS声明空对象类型

  • 分别声明空对象的键和值的类型
  • const hashTable: { [key: string]: RecordItem[] } = {};

使用hashTable即键值对的数据结构来存数据

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// ...
get result() {
    const {recordList} = this;
    const hashTable: { [key: string]: RecordItem[] } = {};
    for (let i = 0; i < recordList.length; i++) {
      // ISO8601 // new Date().toISOString()
      // recordList[i].createdAt!.split('T'); // 2021-02-24T04:00:00
      // const [date, time] = recordList[i].createdAt!.split('T');
      const [date,] = recordList[i].createdAt!.split('T');
      hashTable[date] = hashTable[date] || [];
      hashTable[date].push(recordList[i]);
      console.log("hashTable[date]");
      console.log(hashTable[date]);
      console.log('-------------');
    }
    return hashTable;
}
// ...
  • recordList[i].createdAt!.split('T');
    • 判断拿到数据后,取出的值createdAt一定存在,加上强制类型断言createdAt!.*
    • ESLint 报错就取消全局类型声明custom.d.tstype RecordItemcreatedAt?: string;,改为createdAt: string;
  • 解构const [date, time] = recordList[i].createdAt!.split('T');
    • T的前面是日期,后面是时间
    • ESLint 报错time未使用就先删除time
  • 类似计数排序:初始化hashTable[date] = hashTable[date] || [];
  • 推入数据hashTable[date].push(recordList[i]);
  • 尝试打印出console.log(hashTable)或在视图中临时写上数据{{xxx}}
  • 查看每项的内容

发现一开始的hashTable声明类型结构错误

  • 避免类型声明结构混乱,定义一个中间类型,先声明哈希表值的类型:type HashTableValue = { title: string; items: RecordItem[] };
  • 初始化声明哈希表改为const hashTable: { [HashTableKey: string]: HashTableValue } = {};
    • HashTableKey命名可为任意字符串,但从所需的结构上看,表示的是日期date
    • 按日期分组
  • 遍历取到的计算属性recordListfor (let i = 0; i < recordList.length; i++) {...}
    • 取出日期const [date,] = recordList[i].createdAt.split('T');
    • 将每次取出的日期date作为hashTable的属性名hashTable[date]
    • 初始化hashTablehashTable[date] = hashTable[date] || {title: date, items: []};
      • hashTable[date]对应的属性值赋值到hashTable[date]属性上
      • 保底值为{title: date, items: []}
    • 在每项的items中推入遍历的数据(recordList[i]数组)
      • hashTable[date].items.push(recordList[i]);
  • 返回值hashTable

Statistics.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ...
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import Tabs from '@/components/Tabs.vue';
import recordTypeList from '@/constants/recordTypeList';
import intervalList from '@/constants/intervalList';

@Component({
  components: {Tabs}
})
export default class Statistics extends Vue {
  type = '-';
  interval = 'day';
  intervalList = intervalList;
  recordTypeList = recordTypeList;

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

  get result() {
    const {recordList} = this;
    type HashTableValue = { title: string; items: RecordItem[] };
    const hashTable: { [HashTableKey: string]: HashTableValue } = {};
    for (let i = 0; i < recordList.length; i++) {
      const [date,] = recordList[i].createdAt.split('T');
      hashTable[date] = hashTable[date] || {title: date, items: []};
      hashTable[date].items.push(recordList[i]);
      console.log(hashTable);
    }
    return hashTable;
  }

  beforeCreate() {
    this.$store.commit('fetchRecords');
  }
}
</script>
// ...

循环渲染数据

Statistics.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval" tabs-height="48px"/>
    type: {{ type }}
    <br>
    interval: {{ interval }}
    <hr>
    <div>
      <ol>
        <li v-for="(value, name, index) in result" :key="name">
          <h3>{{ index + 1 }}{{ value.title }}</h3>
          <ol>
            <li v-for="item in value.items" :key="item.id">
              {{ item.amount }} {{ item.createdAt }}
            </li>
          </ol>
        </li>
      </ol>
    </div>
  </Layout>
</template>
// ...
  • result返回的是hashTable在 v-for 里使用对象,遍历hashTableproperty
  • <li v-for="(value, name) in result" :key="index">{{ value }}</li>
  • 必须写:key DOM diff 算法让Vue正确识别不同的DOM,正确的复用
    • 给 Vue 一个提示,以便它能跟踪每个节点的身份,从而复用和重新排序现有元素,需要为每项提供一个唯一 key attribute
    • 这里可以使用唯一属性名作为:key
  • key值是必须唯一的,如果重复就会报错,Dupliacated key detected NaN
  • 取到的value也是一个对象(表示RecordItem
  • 第二层循环<li v-for="item in group.items" :key="item.id">{{ item.amount }} {{ item.createdAt }}</li>

暂时未实现按类型(支出/收入)和排序的逻辑


完善并添加Statistic.vue SCSS

  • 注意样式加了scope属性,只影响当前组件的标签,不会深入组件标签继续影响内部结构
  • 使用深度作用选择器/deep/(CSS)或::v-deep(SCSS)
  • 不用min-height来撑开,改用line-heightpadding来撑开高度

格式化显示item.tags

  • custom.d.ts中改为type RecordItem = { tags: {id: string; name: string}, ...} }[];

显示xx年xx月xx日,重构Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval" tabs-height="48px"/>
    <ol>
      <li v-for="(value, name, index) in result" :key="name">
        <h3 class="title">{{ index + 1 }}{{ value.title }}</h3>
        <ol>
          <li class="record" v-for="item in value.items" :key="item.id">
            <span class="recordTag">{{ tagToString(item.tags) }}</span>
            <div class="notes">
              <span class="tips">备注:</span><span class="text">{{ item.tips }}</span>
            </div>
            <span> {{ item.amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
  </Layout>
</template>
// ...
<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.statistics {
  %item {
    padding: 8px 16px;
    line-height: 24px;
    //min-height: 40px;
    display: flex;
    justify-content: space-between;;
    align-items: center;
    }
  ::v-deep {
    .type-tabs-item {
      background: #fff;
      position: relative;
      &.selected {
        background: #c4c4c4;
        &::after {
          display: none;
          }
        }
      }
    .interval-tabs-item {
      // height: 48px;
      }
    }
  .title {
    @extend %item;
    }
  .record {
    background: #fff;
    @extend %item;
    .recordTag {
      @include multiline-ellipsis(2, 40px, 5em);
      }
    .notes {
      display: flex;
      margin-right: auto;
      margin-left: 16px;
      color: #999;
      .tips {
        @include multiline-ellipsis(1, 40px, 3em);
        }
      .text {
        @include multiline-ellipsis(1, 40px, 3em);
        }
      }
    }
  }
</style>

是否超出 存储最大值

  • localStorage的最大存储为5MB左右
  • 使用IndexedDB

是否超出最大显示行数

  • 引入global.scss使用mixin封装的超出文字省略
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
//...
// mixins text overflow
@mixin multiline-ellipsis($line: 2, $line-height: 1.6em, $max-width: 5em) {
  //white-space: nowrap;
  word-break: break-word;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: $line;
  -webkit-box-orient: vertical;
  line-height: $line-height;
  max-height: $line-height * $line;
  max-width: $max-width;
  }


ISO8601dayjs

  • ISO8601
  • 得到ISO8601
    • const time = new Date().toISOString()
    • const myTime = new Date(Date.parse(time))
    • myTime.getHours()

Moment.js 或者Luxon.js体积较大

使用Day.js

  • 安装yarn add dayjs@1.8.20
  • 引用包import dayjs from 'dayjs';
  • 查看API: const api = dayjs();

正确显示toISOString()修正时差,得到本地时间

src/store/index.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// import ...;

const store = new Vuex.Store<RootState>({
  state: {
    tagList: [],
    recordList: [],
    currentTag: undefined,
    localTimeStamp: ''
  } as RootState,
  mutations: {
    //...
    getLocalTimeStamp(state) {
      const date = new Date();
      return state.localTimeStamp = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
        .toISOString();
    },
    //...
});
export default store;

src/custom.d.ts

1
2
3
4
5
6
7
8
//...
type RootState = {
  tagList: Tag[];
  recordList: RecordItem[];
  currentTag?: Tag;
  localTimeStamp: string
}
//...

Statistic.vue 改显示xx年xx月xx日为显示今天、明天、昨天、上周、上月、以前

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <Tabs class-prefix="interval" :data-source="intervalList" :type.sync="interval" tabs-height="48px"/>
    <ol>
      <li v-for="(value, name, index) in result" :key="index">
        <h3 class="title">{{ showDay(value.title) }}</h3>
        <ol>
          <li class="record" v-for="item in value.items" :key="item.id">
            <span class="recordTag">{{ tagToString(item.tags) }}</span>
            <div class="notes">
              <span class="tips">备注:</span><span class="text">{{ item.tips }}</span>
            </div>
            <span>¥ {{ item.amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
  </Layout>
</template>

<script lang="ts">
// ...
import dayjs from 'dayjs';
// ...
export default class Statistics extends Vue {
  //...
  /*
  // 对比原生 Date 写法
  showDay(string: string) {
    const dt = new Date(Date.parse(string));
    const y = dt.getFullYear();
    const m = dt.getMonth();
    const d = dt.getDate();
    const now = new Date();
    if (now.getFullYear() === y && now.getMonth() === m && now.getDate() === d) {
        return '今天';
    } else {
        return string;
    }
  }
  */
  // ...
  showDay(someday: string) {
    const now = dayjs();
    const thatDay = dayjs(someday);
    if (dayjs(someday).isSame(now, 'day')) {
      return '今天';
    } else if (thatDay.isSame(now.subtract(1, 'day'), 'day')) {
      return '昨天';
    } else if (thatDay.isSame(now.subtract(2, 'day'), 'day')) {
      return '前天';
    } else if (thatDay.isSame(now, 'year')) {
      return thatDay.format('M月D日');
    } else {
      return thatDay.format('YYYY年M月D日');
    }
  }
  beforeCreate() {
    this.$store.commit('fetchRecords');
  }
}
</script>

  • const now = dayjs();
  • 判断相符.isSame(now, 'day')
  • 得到昨天:now.valueOf() - 86400*1000
    • 使用Dayjs提供的APIdayjs(someday).isSame(now.subtract(1, 'day'))
    • 减一天.subtract(1, 'day')
  • 格式化thatDay.format('YYYY年M月D日');

模块化状态管理

  • 分为tagStore.tsrecordStore.ts
  • 在组件中执行状态变更的方法不变this.$store.commit('typeXXX', payloadXXX);
  • 访问状态需要加一层模块名的属性this.$store.state.tagStore.currentTag;
  • 原来的this.$store.state.someState改为this.$store.state.yourModuleName.someState
  • 参考 vue状态管理之vuex(十六)

重构custom.d.ts分开声明类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type RecordItem = {
  tags: Tag[];
  tips: string;
  type: string;
  amount: number;
  createdAt: string;
}
type Tag = {
  id: string;
  name: string;
}
type DataSource = { text: string; type: string }
/*
type RootState = {
  tagList: Tag[];
  recordList: RecordItem[];
  currentTag?: Tag;
  localTimeStamp: string;
  createRecordError?: Error;
}
*/
// RootState 拆开分为 tagState 和 recordState
type tagState = {
  tagList: Tag[];
  currentTag: Tag;
}
type recordState = {
  recordList: RecordItem[];
  localTimeStamp: string;
  createRecordError?: Error | null;
}

重构src/store/index.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
import Vue from 'vue';
import Vuex from 'vuex';
import tagStore from '@/store/modules/tagStore.ts';
import recordStore from '@/store/modules/recordStore.ts';

Vue.use(Vuex);

const store = new Vuex.Store(
  {
    /*
    state: {
      tagList: [],
      recordList: [],
      currentTag: undefined,
      localTimeStamp: ''
    } as RootState,
    
    mutations: {
      fetchRecords(state) {
        state.recordList = JSON.parse(window.localStorage.getItem('recordList') ?? '[]') as RecordItem[];
      },
      saveRecords(state) {
        window.localStorage.setItem('recordList',
          JSON.stringify(state.recordList));
      },
      getLocalTimeStamp(state) {
        const date = new Date();
        return state.localTimeStamp = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
          .toISOString();
      },
      createRecord(state, record) {
        const clonedRecord = clone(record);
        store.commit('getLocalTimeStamp');
        clonedRecord.createdAt = store.state.localTimeStamp;
        state.recordList.push(clonedRecord);
        store.commit('saveRecords');
      },
      fetchTags(state) {
        return state.tagList = JSON.parse(window.localStorage.getItem('tagList') ?? '[]');
      },
      saveTags(state) {
        localStorage.setItem('tagList', JSON.stringify(state.tagList));
      },
      createTag(state, name: string) {
        const names = state.tagList.map(d => d.name);
        if (names.indexOf(name) >= 0) {
          window.alert('标签名重复了');
          // return 'duplicated';
        }
        const id = operateId.createId().toString();
        state.tagList.push({id, name});
        store.commit('saveTags');
        window.alert('添加成功');
        // return 'success';
      },
      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');
        } else {
          window.alert('删除失败');
        }
      },
      updateTag(state, tag: { id: string; name: string }) {
        const {id, name} = tag;
        const idList = state.tagList.map(item => item.id);
        if (idList.indexOf(id) >= 0) {
          const nameList = state.tagList.map(item => item.name);
          if (nameList.indexOf(name) >= 0) {
            window.alert('标签名重复类');
          } else {
            const tagItem = state.tagList.filter(item => item.id === id)[0];
            tagItem.name = name;
            store.commit('saveTags');
          }
        }
      },
      setCurrentTag(state, id: string) {
        // state.currentTag = state.tagList.find(t => t.id === id);
        state.currentTag = state.tagList.filter(t => t.id === id)[0];
      },
    },
    */
    
    modules: {
      recordStore,
      tagStore
    }
  });
export default store;

tagStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import store from '@/store';
import operateId from '@/lib/operateId';

const tagStore = {
  namespace: true,
  state: {
    tagList: [],
    currentTag: {},
  },
  mutations: {
    fetchTags(state: tagState) {
      state.tagList = JSON.parse(window.localStorage.getItem('tagList') ?? '[]');
    },
    saveTags(state: tagState) {
      localStorage.setItem('tagList', JSON.stringify(state.tagList));
    },
    createTag(state: tagState, name: string) {
      const names = state.tagList.map(d => d.name);
      if (names.indexOf(name) >= 0) {
        window.alert('标签名重复了');
        // return 'duplicated';
      }
      const id = operateId.createId().toString();
      state.tagList.push({id, name});
      store.commit('saveTags');
      window.alert('添加成功');
      // return 'success';
    },
    removeTag(state: tagState, 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');
      } else {
        window.alert('删除失败');
      }
    },
    updateTag(state: tagState, tag: { id: string; name: string }) {
      const {id, name} = tag;
      const idList = state.tagList.map(item => item.id);
      if (idList.indexOf(id) >= 0) {
        const nameList = state.tagList.map(item => item.name);
        if (nameList.indexOf(name) >= 0) {
          window.alert('标签名重复类');
        } else {
          const tagItem = state.tagList.filter(item => item.id === id)[0];
          tagItem.name = name;
          store.commit('saveTags');
        }
      }
    },
    setCurrentTag(state: tagState, id: string) {
      // state.currentTag = state.tagList.find(t => t.id === id);
      state.currentTag = state.tagList.filter(t => t.id === id)[0];
    }
  }
};
export default tagStore;

recoredStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
import clone from '@/lib/clone';
import store from '@/store';

const recordStore = {
  namespace: true,
  state: {
    recordList: [],
    localTimeStamp: ''
  },
  mutations: {
    fetchRecords(state: recordState) {
      state.recordList = JSON.parse(window.localStorage.getItem('recordList') ?? '[]') as RecordItem[];
    },
    saveRecords(state: recordState) {
      window.localStorage.setItem('recordList',
        JSON.stringify(state.recordList));
    },
    getLocalTimeStamp(state: recordState) {
      const date = new Date();
      return state.localTimeStamp = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
        .toISOString();
    },
    createRecord(state: recordState, record: RecordItem) {
      const clonedRecord = clone(record);
      store.commit('getLocalTimeStamp');
      clonedRecord.createdAt = state.localTimeStamp;
      state.recordList.push(clonedRecord);
      store.commit('saveRecords');
    },
  }
}

export default recordStore;


重构Money.vueStatistic.vueLabels.vue等相关组件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ...

<script lang="ts">
// import ...;

@Component({
  components: {Tabs, FormItem, Tags, Numpad}
})
export default class Money extends Vue {
  //...
  created() {
    this.$store.commit('fetchRecords');
  }
  get recordList() {
    return this.$store.state.recordStore.recordList as RecordItem[];
  }
  onUpdateTips(value: string) {...}
  pickTags(selectedTags: {id: string; name: string }[]) {...}
  //...
  saveRecord() {
    this.$store.commit('createRecord', this.record);
  }
}
</script>

<style lang="scss">
//...
</style>

<style lang="scss" scoped>
//...
</style>

重构Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ...

<script lang="ts">
// import ...;

@Component({
  components: {Tabs},
})
export default class Statistics extends Vue {
  //...
  beforeCreate() {
    this.$store.commit('fetchRecords');
  }
  get recordList() {
    return this.$store.state.recordStore.recordList;
  }
  get result() {/*...*/}
  tagToString(tags: Tag[]) {/*...*/}
  showDay(someday: string) {/*...*/}
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
//...
</style>

重构Labels.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// ...

<script lang="ts">
// import ...;

@Component({
  components: {Button}
})

export default class Labels extends mixins(tagHelper) {
  get tags() {return this.$store.state.tagStore.tagList;}
  beforeCreate() {this.$store.commit('fetchTags');}
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
//...
</style>

重构EditLabel.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<template>...</template>

<script lang="ts">
// import ...;

@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  get currentTag() {
    return this.$store.state.tagStore.currentTag;
  }

  created() {
    this.$store.commit('fetchTags');
    this.$store.commit('setCurrentTag', this.$route.params.id);
    ...
  }

  update(name: string) {
    ...
    this.$store.commit('updateTag', {id: this.currentTag.id, name});
  }

  remove() {
    if (this.currentTag) {
      this.$store.commit('removeTag', this.currentTag.id);
      ...}
  }
  goBack() {...}
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
...
</style>

重构Tags.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// ...

<script lang="ts">
// import ...;

@Component
export default class Tags extends mixins(tagHelper) {
  //...
  created() {
    this.$store.commit('fetchTags');
  }

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

  toggle(tag: Tag) {...}
}
</script>
<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
//...
</style>


数据排序(计数排序的变形)

首先了解数据result的类型

  • 目前resulthashTable(对象)
  • 遍历对象时,遍历的顺序是否固定
    • 遍历对象的key,遍历的顺序是否固定
    • hashTable中的顺序是用户输入得到的,输入顺序不一定符合预期

JS 中对象的 key 是有顺序的

  • JS 中对象的字段遍历顺序是没有保证的,例如 Object.keys函数产生的数组顺序没有保证
  • 实际上在 ES2015 之后标准规定了 key 的顺序(准确的说是规定了 [[OwnPropertyKeys]]()这个内部方法返回的 key 的顺序)

结论:keys 数组分为三个部分:

  • 可以作为数组索引的 key 按照升序排列,例如 1、2、3
  • 是字符串不是 symbol 的 key,按照创建顺序排列。
  • symbol 类型的 key 也按照创建顺序排列。
  • 参考链接 ECMAScript 9.1.11
1
2
3
4
5
6
7
8
9
Reflect.ownKeys({
  [Symbol('88888')]: '',
  18: '',
  star: '☆★',
  4: '',
  hahaha: '',
})

// ['4', '18', 'star', 'hahaha', Symbol(88888)]

必须转换为一个数组,才能排序

  • 按时间排序,近的排在前面
  • 显示的顺序为 今天 昨天 (本年内)具体日期 (去年以及之前)具体日期
1
2
3
4
5
6
7
/*
* [
*   {title, items},
*   {title, items},
*   {title, items},
* ]
* */

clone.ts

1
2
3
4
5
6
function clone<T>(data: T): T {
  return JSON.parse(JSON.stringify(data));
}

export default clone;

  • TS 声明类型:
    • type HashTableValue = { title: string; items: RecordItem[] };
  • 查询到对应的title,将recordList排序,依次推入数组
  • recordListRecordItem的数组,进行排序.sort((a, b) => {})
    • const n = recordList.sort((a, b) => {a.createdAt});
    • a.createdAt的值为字符串,按ASCII顺序比较大小,不是预期的顺序,'a' - 'b' // NaN不能用字符串的减法
    • 使用.sort()必须变为数字类型,用.valueOf()
      • dayjs(a.createdAt).valueOf()
  • const newList = recordList.sort((a: RecordItem, b: RecordItem) => ( dayjs(a.createdAt).valueOf() - dayjs(b.createdAt).valueOf() ));
  • 需要逆向从近期到远期的( dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf() )
  • 注意.sort()改变原数组自身,保留原数组,使用 深克隆
    • 导入之前的工具函数import clone from '@/lib/clone.ts';clone()
    • function clone(data: any) { return JSON.parse(JSON.stringify(data)) as RecordItem; }
    • 由于接受的参数类型是any,而JSON.parse()返回值类型也是any
    • 使用泛型function clone<T>(data: T): T {...} 统一入参和返回值的类型
      • 在尖括号中声明类型
    • 之后使用.sort() TS 会自动推断类型

重构Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  //...
  get result() {
    const {recordList} = this;
    type HashTableValue = { title: string; items: RecordItem[] };
    const hashTable: HashTableValue[] = [];
    const newList = clone(recordList).sort((a: RecordItem, b: RecordItem) => (
        dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()
    ));
    console.log(newList.map(i => i.createdAt));
    return [];
  }
  //...
  • 先将局部数据recordList排序,再push到数据中

数据排序后分组

  • 判断数组长度if(recordList.length === 0) {return []};,确保存在可操作数据
  • 取出新的newList的第一个数据
    • const x = [{title: dayjs(recordList[0].createdAt).format('YYYY-MM-DD'), items: [recordList[0]]}];
  • 从第二个数据的.createdAt和第一个数据的title和开始循环比较
    • 新的数据和分组的title是否一致
      • 一致放入当前组的items
      • 不一致,作为新的一组的title,放入新分组的items
1
2
3
4
5
6
7
8
/*
* [
*   {title: '11', items: [{11...}, {11...}]},
*   {title: '12', items: [{12...}, {12...}, {12...}]},
*   {title: '13', items: [{13...}, {13...}]},
*   {title: <string>, items: [{13...}, {13...}]},
* ]
* */

统一显示时间戳比较 - 标记问题ok

  • const localDay = dayjs(current.createdAt.split('T')[0]);
  • 由于有时区的概念 dayjs(current.createdAt)算上时间部分的日期会延后一天
  • 封装倒时差函数clearJetLag(new Date(), '-')

src/store/modules/recordStore.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import clone from '@/lib/clone';
import store from '@/store';
import clearJetLag from '@/lib/clearJetLag.ts';

const recordStore = {
  namespace: true,
  state() {
    return {
      recordList: [],
      localTimeStamp: ''
    };
  },
  mutations: {
    getLocalTimeStamp(state: recordState) {
      state.localTimeStamp = clearJetLag(new Date(), '-');
    },
    fetchRecords(state: recordState) {...},
    saveRecords(state: recordState) {...},
    createRecord(state: recordState, record: RecordItem) {
      const clonedRecord = clone(record);
      store.commit('getLocalTimeStamp');
      clonedRecord.createdAt = state.localTimeStamp;
      state.recordList.push(clonedRecord);
      store.commit('saveRecords');
    },
  }
};

export default recordStore;

src/lib/clearJetLag.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function clearJetLag(isoDate: Date = new Date(), offsetType: '-' | '+' | '' = '') {
  let localClock;
  if (offsetType === '') {
    localClock = new Date(isoDate.getTime()).toISOString();
  }else if(offsetType === '-'){
    localClock = new Date(isoDate.getTime() - (isoDate.getTimezoneOffset() * 60000)).toISOString();
  }else{
    localClock = new Date(isoDate.getTime() + (isoDate.getTimezoneOffset() * 60000)).toISOString();
  }
  return localClock
}

export default clearJetLag;

重构命名

  • get groupedList() { ... return rusult;}

更改template 循环渲染的:key

  • 数据由对象变为数组 方便排序
  • li v-for="(group, index) in groupedList" :key="index">

重构Statistic.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <ol>
      <li v-for="(group, index) in groupedList" :key="index">
        <h3 class="title">{{ showDay(group.title) }}</h3>
        <ol>
          <li class="record" v-for="item in group.items" :key="item.id">
            <span class="recordTag">{{ tagToString(item.tags) }}</span>
            <div class="notes">
              <span class="tips">备注:</span><span class="text">{{ item.tips }}</span>
            </div>
            <span>¥ {{ item.amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import Tabs from '@/components/Tabs.vue';
import recordTypeList from '@/constants/recordTypeList.ts';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import clone from '@/lib/clone.ts';

dayjs.locale('zh-cn');

@Component({
  components: {Tabs},
})
export default class Statistics extends Vue {
  type = '-';
  recordTypeList = recordTypeList;

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

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

  get groupedList() {
    const {recordList} = this;
    if (recordList.length === 0) {return [];}
    type groupedType = { title: string; items: RecordItem[] };
    const newList = clone(recordList).sort((a: RecordItem, b: RecordItem) => (
        dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()
    ));
    /* newList
    { tags: Tag[];
      tips: string;
      type: string;
      amount: number;
      createdAt: string;
    }[]
  */
    // 排序后的第一项 newList[0] 处理后 作为初始项
    const result: groupedType[] = [{
      title: dayjs(newList[0].createdAt).format('YYYY-MM-DD'),
      items: [newList[0],]
    }];
    // 判断 newList[i] 从第二项开始的每一项的title: '20XX-XX-XX'  是否符合当前 分组项
    for (let i = 1; i < newList.length; i++) {
      const current = newList[i]; // 当前项
      const lastGroupItem = result[result.length - 1]; // 分组数据的最后一项
      const localDay = dayjs(current.createdAt.split('T')[0]);
      if (dayjs(lastGroupItem.title).isSame(localDay, 'day')) {
        lastGroupItem.items.push(current);
      } else {
        result.push({
          title: localDay.format('YYYY-MM-DD'),
          items: [current]
        });
      }
    }
    return result;
  }

  tagToString(tags: Tag[]) {
    const names = [];
    for (let i = 0; i < tags.length; i++) {
      names.push(tags[i].name);
    }
    return names.length === 0 ? '无' : names.join(',');
  }

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

}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
//...
</style>


完成统计页面

区分 支出和收入

在排序前加 filter

  • 查找对应类型相匹配的
  • .filter(r=> r.type === this.type)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//...
    const {recordList} = this;
    type groupedType = { title: string; total?: number; items: RecordItem[] };
    // newList: { tags: Tag[]; tips: string; type: string; amount: number; createdAt: string; }[]
    if (recordList.length === 0) {return [] as groupedType[];};
    const newList = clone(recordList)
        .filter((r: RecordItem) => r.type === this.type)
        .sort((a: RecordItem, b: RecordItem) => (
            dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()
        ));
//...

注意 .filter()可能会使返回的新数组长度变短为空数组,进行之后的操作的时候访问内部属性为空值undefined

  • 判断经过筛选后的数组长度是否为 0 ,返回空数组,结束函数
  • if (newList.length === 0) {return [] as groupedType[];}

显示总额

  • result添加total属性
    • 初始化{ title: ..., total: 0, items: ...}
    • 或者声明类型:type groupedType = { title: string; total?: number; items: RecordItem[] };
    • total?: number; 表示赋值时,total属性可以不存在
    • const result: groupedType[] = {...}
  • 需要统计的是每组group各项itemsamount属性
  • 使用.map遍历每组groupmap是有返回值的forEachforEach是没有返回值的map
    • 使用reduce对每组groupitems进行归纳统计
      • 初始值0
      • 形参为 统计结果sum 项目item
      • amount进行加减,统计到resulttotal属性
      • 注意amount类型
    • 将统计结赋值给group.total
  • total显示到template
    • <h3 class="title">{{ showDay(group.title) }} <span> ¥{{ group.total }}</span></h3>

标记问题 记录bug:

  • amount显示为字符串拼接
  • 从输入得到的类型为字符串而非数字
  • Numpad.vue中传入的外部数据@Prop() readonly value!: number;没有声明类型
  • 必须提前声明传入的类型@Prop(Number) readonly value!: number;
  • amount<Numpad :value.sync="record.amount" @submit="saveRecord"/>处更新
  • Numpad.vue中方法confirmNum() {this.$emit(...)}发布的第二个参数output类型是any,必须转为数字类型
  • 查看localStoragerecordList的数据amount属性值都是字符串,应为数字

重构Statistic.vue

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<template>
  <Layout class="statistics">
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <ol>
      <li v-for="(group, index) in groupedList" :key="index">
        <h3 class="title">{{ showDay(group.title) }} <span> {{ group.total }}</span></h3>
        <ol>
          <li class="record" v-for="item in group.items" :key="item.id">
            <span class="recordTag">{{ tagToString(item.tags) }}</span>
            <div class="notes">
              <span class="tips">备注:</span><span class="text">{{ item.tips }}</span>
            </div>
            <span>¥ {{ item.amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import Tabs from '@/components/Tabs.vue';
import recordTypeList from '@/constants/recordTypeList.ts';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import clone from '@/lib/clone.ts';

dayjs.locale('zh-cn');

@Component({
  components: {Tabs},
})
export default class Statistics extends Vue {
  type = '-';
  recordTypeList = recordTypeList;

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

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

  get groupedList() {
    const {recordList} = this;
    type groupedType = { title: string; total?: number; items: RecordItem[] };
    // newList: { tags: Tag[]; tips: string; type: string; amount: number; createdAt: string; }[]
    const newList = clone(recordList)
        .filter((r: RecordItem) => r.type === this.type)
        .sort((a: RecordItem, b: RecordItem) => (
            dayjs(b.createdAt).valueOf() - dayjs(a.createdAt).valueOf()
        ));
    if (newList.length === 0) {return [] as groupedType[];}
    // 排序后的第一项 newList[0] 处理后 作为初始项
    const result: groupedType[] = [{
      title: dayjs(newList[0].createdAt).format('YYYY-MM-DD'),
      items: [newList[0],]
    }];
    // 判断 newList[i] 从第二项开始的每一项的title: '20XX-XX-XX'  是否符合当前 分组项
    for (let i = 1; i < newList.length; i++) {
      const current = newList[i]; // 当前项
      const lastGroupItem = result[result.length - 1]; // 分组数据的最后一项
      const localDay = dayjs(current.createdAt.split('T')[0]);
      if (dayjs(lastGroupItem.title).isSame(localDay, 'day')) {
        lastGroupItem.items.push(current);
      } else {
        result.push({
          title: localDay.format('YYYY-MM-DD'),
          items: [current]
        });
      }
    }
    // result.group.items: { tags: Tag[]; tips: string; type: string; amount: number; createdAt: string; }[]
    result.map(group => {
      group.total = group.items.reduce((sum, item) => sum + item.amount, 0);
    });
    return result;
  }

  tagToString(tags: Tag[]) {
    const names = [];
    for (let i = 0; i < tags.length; i++) {
      names.push(tags[i].name);
    }
    return names.length === 0 ? '无' : names.join(',');
  }

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

}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
//...
</style>


VueTypeScript第二个结合不好的地方:this.$emit(event, ...args)

  • TS 没有在this.$emit()处警告...argsany类型
1
2
3
4
5
6
//...
    // result.group.items: { tags: Tag[]; tips: string; type: string; amount: number; createdAt: string; }[]
    result.map(group => {
      group.total = group.items.reduce((sum, item) => sum + item.amount, 0);
    });
//...

一些 bug

Money.vueTags.vue显示传值$event

  • <Tags @update:selectedTags="pickTags($event)"/>或不传值<Tags @update:selectedTags="pickTags"/>,使用方法隐式传值,默认传$event
    • pickTags(eventValue: Tag[]) {this.record.tags = eventValue;}
  • <Tags @update:selectedTags="record.tags = $event"/>内联语法,需要显示传值
    • pickTags(selectedTags: Tag[]) {this.record.tags = selectedTags;}
  • 参考
    • v-on $event
      • 用在 普通元素上时,只能 监听原生 DOM 事件
      • 用在自定义元素组件上时,也可以监听子组件触发的自定义事件
      • 监听原生 DOM 事件时,方法以事件为唯一的参数。如果使用内联语句,语句可以访问一个 $event property:v-on:click="handle('ok', $event)"
    • 使用事件抛出一个值
      • 子组件使用 $emit 的第二个参数 payload 来提供这个值<button v-on:click="$emit('enlarge-text', 0.1)">Enlarge text</button>
      • 父级组件监听这个事件时,可通过 $event 访问到被抛出的这个值<blog-post v-on:enlarge-text="postFontSize += $event">
      • 如果这个事件处理函数是一个方法,这个值将会作为第一个参数传入这个方法
    • 内联处理器中的方法
      • 有时也需要在内联语句处理器中访问原始的 DOM 事件。可以用特殊变量 $event 把它传入方法
    • 事件处理方法 v-on 还可以接收一个需要调用的Methods里方法名称
      • 原生 DOM 事件 的event 作为参数传给方法
        • 例如获取输入事件的输入值@input="oninputValueChanged($event.target.value)"
        • 例如点击事件获取点击的目标值@click="toggle(tag, $event.target.value)" 其中参数tag由循环渲染列表的数据提供v-for="tag in tagList"

实现tooltip加个尖角标


空白 无记录 显示提示文字 Statistic.vue

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
  <Layout class="statistics">
    <HeaderBar :header-title="'统计'" router-path="/money"></HeaderBar>
    <Tabs class-prefix="type" :data-source="recordTypeList" :type.sync="type"/>
    <ol v-if="groupedList.length > 0">
      <li v-for="(group, index) in groupedList" :key="index">
        <h3 class="title">{{ showDay(group.title) }} <span>共计: ¥{{ group.total }}</span></h3>
        <ol>
          <li class="record" v-for="{amount, id, tags, tips} in group.items" :key="id">
            <span class="recordTag">{{ tagToString(tags) }}</span>
            <div class="notes">
              <span class="tips">备注</span><span class="text">{{ tips }}</span>
            </div>
            <span> {{ amount }}</span>
          </li>
        </ol>
      </li>
    </ol>
    <div v-else class="noResult">
      目前没有相关记录
    </div>
  </Layout>
</template>
// ...
  • 同样地Money.vue缺 “添加至少一个标签” 逻辑
  • 确认保存后 缺 重置备注 和标签 逻辑
    • <FormItem class="form-item" field-name="备注" placeholder="在这里输入备注" @update:inputValue="onUpdateTips" :value="record.tips"/> 需要绑定:value="record.tips"才会起效

逻辑耦合

  • createTag方法中当store提交saveTags成功保存完标签后,window.alert('xxx')
  • 收集错误提示,alert处理所有报错

createdAt of undefined 的解决办法

  • 即使返回空值有时也需声明类型if (newList.length === 0) {return [] as groupedType[];}

  • 核心痛点:每次调用 this.$store.dispatch / this.$store.commit / this.$store.state / this.$store.getters 都会伴随着类型丢失。

  • 所有代码:https://github.com/FrankFang/morney-live-list
  • 所有 commits:https://github.com/FrankFang/morney-live-list/commits/master

参考文档