【项目-喵内记账-meoney-03】Labels 组件

大纲链接 §

[toc]


知识点

  • Surround with Emmet快捷键Ctrl Alt t 给每项添加标签li * n
  • iconfont 小技巧,编辑 SVG
  • custom.d.ts 怎么用
  • @click.native 怎么用
  • Vue Router 怎么用
  • props 怎么用
  • 每次完成一小节都要提交代码

Labels.vueHTML

  • IconFont下载一个右箭头图标,使用封装好的<Icon/>全局组件
  • 先写死大致的结构
 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
<template>
  <Layout>
    <ol class="tags">
      <li>
        <span></span>
        <Icon name="money_right"/>
      </li>
      <li>
        <span></span>
        <Icon name="money_right"/>
      </li>
      <li>
        <span></span>
        <Icon name="money_right"/>
      </li>
      <li>
        <span></span>
        <Icon name="money_right"/>
      </li>
    </ol>
    <div>
      <button>新建标签</button>
    </div>
  </Layout>
</template>


Labels.vue 之 SCSS 样式

 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
@import "~@/assets/style/global.scss";
.labels {
  .tags {
    background: white;
    font-size: 16px;
    padding-left: 16px;
    > li {
      min-height: 44px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      border-bottom: 1px solid #e6e6e6;
      > span {
        // text overflow mixins
        @include multiline-ellipsis(1, 44px, 4em);
        }
      > svg {
        font-size: 24px;
        color: #666;
        margin-right: 16px;
        }
      }
    }
  .createTag {
    background: #767676;
    color: #fff;
    border-radius: 4px;
    height: 40px;
    padding: 0 16px;
    &-wrapper {
      text-align: center;
      padding: 16px;
      margin-top:44-16px;
      }
    }
    
    /*
    .createTag-wrapper{
      text-align: center;
      padding: 16px;
      margin-top:44-16px;
      .createTag {
        background: #767676;
        color: #fff;
        border-radius: 4px;
        height: 40px;
        padding: 0 16px;
        &-wrapper {
        // 以下样式无效
          text-align: center;
          padding: 16px;
          margin-top:44-16px;
          }
        }
    }
    */
  }
  • <button></button>为内联元素,居中,text-align: center;加到父元素上
  • padding没有margin的上下合并问题,但会影响background的范围
  • SCSS 可以在子类中写父类的样式 .child { &-father {...}},但如果子类已经写在父类的嵌套中,这种写法无效
  • 使用最小高度限制每行的高度
  • 显示字数最多3个,多余的用...

global.scss中写@mixin multiline-ellipsis单行超出文字省略样式

 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;
  }

reset.scss

  • 由于多次用到border: none;,写到reset.scss

新建标签功能

  • 原来存储的标签是写死在数据中的tags = ['衣', '食', '住', '行', '理财'];
  • 需要另外的Model来存储添加的标签
  • 新建models目录,存放所有Model相关数据:recordListModel.tstagListModel.ts
  • 两个Model文件结构几乎一样,可提取Mixins

tagListModel.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const localStorageKeyName = 'tagList';
const tagListModel = {
  clone(data: RecordItem | RecordItem[]) {
    return JSON.parse(JSON.stringify(data)) as RecordItem;
  },
  fetchData() {
    return JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]') as RecordItem[];
  },
  saveData(data: RecordItem[]) {
    localStorage.setItem(localStorageKeyName, JSON.stringify(data));
  }
};
export default tagListModel;

修改Money.vue

  • 引入 Model 数据 import tagListModel from '@/models/tagListModel';
  • 声明标签列表 const tagList = tagListModel.fetchData();
  • 替换原来写死的数据 tags = tagList;
 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
// ...
<script lang="ts">
import Vue from 'vue';
import Tags from '@/components/Money/Tags.vue';
import Notes from '@/components/Money/Notes.vue';
import Types from '@/components/Money/Types.vue';
import Numpad from '@/components/Money/Numpad.vue';
import {Component, Watch} from 'vue-property-decorator';
import recordListModel from '@/models/recordListModel';
import tagListModel from '@/models/tagListModel';

const recordList = recordListModel.fetchData();
const tagList = tagListModel.fetchData();

@Component({
  components: {
    Numpad, Types, Notes, Tags
  }
})

export default class Money extends Vue {
  tags = tagList;
  record: RecordItem = {
    tags: [],
    notes: '',
    type: '-',
    amount: 0,
    createdAt: new Date(),
  };

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

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

  saveRecord() {
    const clonedRecord = recordListModel.clone(this.record);
    clonedRecord.createdAt = new Date();
    this.recordList.push(clonedRecord);
  }

  @Watch('recordList')
  onRecordeChange() {
    recordListModel.saveData(this.recordList);
  }
}
</script>
// ...

同样地引入到Lables.vue

  • 先获取标签数据tagListModel.fetchData();
  • 内部数据引用tagListModel.data,不用去维护操作tags,所有数据让Model控制
 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
//...
<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import tagListModel from '@/models/tagListModel.ts';

tagListModel.fetchData();

@Component

export default class Labels extends Vue {
  tags = tagListModel.data;

  createTag() {
    const name = window.prompt('请输入标签名');
    if (name) {
      const message = tagListModel.create(name);
      if (message === 'duplicated') {
        window.alert('标签名重复了');
      } else if (message === 'success') {
        window.alert('添加成功');
      }
    }
  }
}
</script>
  • 判断标签的内容是否存在
  • 判断标签是否唯一
  • 通过返回提示字符串来判断
  • TS 联合类型(字符串的子类型 枚举)可以通过具体的可能值来声明类型,来防止写错

tagListModel.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
const localStorageKeyName = 'tagList';

type TagListModel = {
  data: string[];
  fetchData: () => string[];
  create: (name: string) => 'success' | 'duplicated';
  saveData: () => void;
}

const tagListModel: TagListModel = {
  data: [],
  fetchData() {
    this.data = JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]');
    return this.data;
  },
  saveData() {
    localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
  },
  create(name: string) {
    if (this.data.indexOf(name) >= 0) { return 'duplicated'; }
    this.data.push(name);
    this.saveData();
    return 'success';
  }
};
export default tagListModel;


编辑/删除标签功能

  • 在另一个编辑标签页面中实现
  • 实现点击Label.vue中的标签可跳转至标签编辑页面
  • 新建路由/labels/edit
  • 新建src/view/EditLabel.vue组件

src/router/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
import Vue from 'vue';
import VueRouter, {RouteConfig} from 'vue-router';
import Money from '@/views/Money.vue';
import Labels from '@/views/Labels.vue';
import Statistics from '@/views/Statistics.vue';
import NotFound from '@/views/NotFound.vue';
import EditLabel from '@/views/EditLabel.vue';

Vue.use(VueRouter);

const routes: Array<RouteConfig> = [
  {
    path: '/',
    redirect: '/money'
  },
  {
    path: '/money',
    component: Money
  },
  {
    path: '/labels',
    component: Labels
  },
  {
    path: '/statistics',
    component: Statistics
  },
  {
    path: '/labels/edit',
    component: EditLabel
  },
  {
    path: '*',
    component: NotFound
  }
];

const router = new VueRouter({
  routes
});

export default router;

EditLabel.vue & Vue Router

  • 每个循环渲染的标签需要一个id,通常是取自数据库中的,本地版将不重复的name当做id
  • tagListModel.ts中声明Tag类型,data变为Tag[]fetchData获取Tag[]

tagListModel.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
const localStorageKeyName = 'tagList';
type Tag = {
  id: string;
  name: string;
}
type TagListModel = {
  data: Tag[];
  fetchData: () => Tag[];
  create: (name: string) => 'success' | 'duplicated';
  saveData: () => void;
}

const tagListModel: TagListModel = {
  data: [],
  fetchData() {
    this.data = JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]');
    return this.data;
  },
  saveData() {
    localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
  },
  create(name: string) {
    // this.data = [{id: '1', name: '1'}, {id: '2', name: '2'}]
    const names = this.data.map(d => d.name);
    if (names.indexOf(name) >= 0) { return 'duplicated'; }
    this.data.push({id: name, name: name}); // 将不重复的数据推入,后期改为id生成器
    this.saveData();
    return 'success';
  }
};
export default tagListModel;

Lables.vue中相应的模板数据{{tag}}:key也改变

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<template>
  <Layout class="labels">
    <ul class="tags">
      <li v-for="tag in tags" :key="tag.id">
        <span>{{ tag.name }}</span>
        <Icon name="money_right"/>
      </li>
    </ul>
    <div class="createTag-wrapper">
      <button class="createTag" @click="createTag">新建标签</button>
    </div>
  </Layout>
</template>
...

动态子路由

  • 点击Label.vue中的标签跳转至编辑页面
  • 路由到../labels/edit/1
  • '/labels/edit/:id'中的:id表示占位
  • Vue-Router 数据获取文档

src/router/index.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import ...;
import EditLabel from '@/views/EditLabel.vue';

Vue.use(VueRouter);

const routes: Array<RouteConfig> = [
...
  {
    path: '/labels/edit/:id',
    component: EditLabel
  },
...
];

const router = new VueRouter({
  routes
});

export default router;

EditLable.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>
  <Layout>
    <div class="edit">
      <Icon name="money_right"/>
      编辑标签
    </div>
  </Layout>
</template>

<script lang="ts">
import Vue from 'vue';
import {Component} from 'vue-property-decorator';
import tagListModel from '@/models/tagListModel';

@Component
export default class EditLabel extends Vue {
  created() {
    const id = this.$route.params.id;
    tagListModel.fetchData();
    const tags = tagListModel.data;
    // const tag = tags.filter(t => t.id === id)[0];
    const tag = tags.find(t => t.id === id);
    if (tag) {
      console.log(tag);
    } else {
      this.$router.replace('/404');
    }
  }
}
</script>

<style lang="scss" scoped>
.edit {
  > svg {
    transform: rotate3d(0, 1, 0, 180deg);
    font-size: 24px;
    color: #666;
    margin-right: 16px;
    }
  }
</style>

  • 钩子created(){}
  • this.$route路由相关:可获取路由相关信息
    • 是由库中vue/types/vue.d.ts中声明而来 ctrl+ 点击查看源文件
    • 查看console.log(this.$route.params)
    • 浏览器地址栏输入http://localhost:8080/#/labels/edit/1
    • 控制台显示{id: "1"}
    • 将路由改为path: '/labels/edit/:fuck',
    • 控制台显示{fuck: "1"}
  • 获取id(动态无需声明类型)const id = this.$route.params.id;
  • 获取标签数据
    • 更新数据tagListModel.fetchData();
    • 获取const tags = tagListModel.data;
    • 获取id对应的标签const tag = tags.filter(t => t.id === id)[0];
      • 也可用const tag = tags.find(t => t.id === id);,但可能会遍历到稀疏数组的未定义值,效率没filter高-
  • this.$router路由器相关:进行路由的相关操作(转发、重定向等)
    • 重定向this.$router.push('/404');不太好,回退时会重复重定向
    • 改为this.$router.replace('/404');

如何封装通用组件

  • 封装原本Notes.vue中的<input>标签
  • 封装Button.vue按钮组件

EditLable.vue里复用原本Notes.vue中的<input>标签

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<template>
  <div class="div_notes">
    <label class="notes">
      <span class="name">备注</span>
      <input type="text"
             v-model="inputValue"
             placeholder="在这里输入备注"/>
    </label>
  </div>
</template>
  • 封装为外部数据@Prop() 自动引入import {Component, Prop, Watch} from 'vue-property-decorator';
    • 字段名 @Prop({required: true}) fieldName!: string; (必要,无初始值,类型为字符串)
      • 模板中改为<span class="name">{{this.fieldName}}</span>
      • 值由外部组件传入<Notes field-name="备注" @update:value="onUpdateNotes"/>\
    • placeholder占位符 @Prop({default: ''}) placeholder?: string;(非必要,可以无初始值,类型为字符串,可能为空值)
      • 模板中改为 <input type="text" v-model="inputValue" :placeholder="this.placeholder"/>
      • 值由外部组件传入 <Notes field-name="标签名" placeholder="在这里输入标签名"/>
  • 由于Notes.vue组件的作用已经改变,需重命名为FormItem.vue
    • 重构之重命名FormItem.vue
    • 重构之组件内部的类export default class FormItem extends Vue {...}
    • 注意原来引用Notes.vue的组件中@Component({components: {Notes: FormItem} })做了映射
    • 手动全局搜索Notes,全部改为FormItem
  • 注意到一个Bug: 在Numpad中输入完所有信息后点击 OK 未清空 备注信息

添加删除按钮前,可封装Button.vue按钮组件

  • 创建src/components/Button.vue
  • 点击事件失效:目前绑定事件在<Button>上,用户点击的是<button>
    • 需要传递点击事件,点击<button>时触发<Button>上的事件
    • Button.vue中的<button>标签传递触发事件到外部@click="$emit('click', $event)"
    • <button>被点击时,事件传递至<Button>

src/components/Button.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>
  <button class="button"
          @click="$emit('click', $event)">
    <slot/>
  </button>
</template>

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

@Component
export default class Button extends Vue {

}
</script>

<style lang="scss" scoped>
.button {
  background: #767676;
  color: #fff;
  border-radius: 4px;
  height: 40px;
  padding: 0 16px;
  &-wrapper {
    text-align: center;
    padding: 16px;
    margin-top: 44-16px;
    }
  }
</style>
  • 处理<button>其他事件
    • 怎么用自定义组件模拟一个真实<button>
    • 可以不用一个一个地暴露事件出去
    • 在父组件上使用.native修饰符 <Button class="createTag" @click.native="createTag">新建标签</Button>
    • 可省略以上子组件内传递原生事件的过程: @click="$emit('click', $event)"

相应地修改Lable.vue

  • 原来的<button class="createTag" @click="createTag">新建标签</button>改为<Button class="createTag" @click="createTag">新建标签</Button>
    • 注意webStorm的代码提示与自动引入import Button from '@/components/Button.vue';
    • 自动引入装饰器@Component({ components: {Button} })
    • 首先删除小写的字符button,在删除的地方输入Button,按Tab自动补全
  • 删除原来<button>的样式
 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="labels">
    <ul class="tags">
      <li v-for="tag in tags" :key="tag.id">
        <router-link class="tag" :to="`/labels/edit/${tag.id}`">
          <span class="tag-text">{{ tag.name }}</span>
          <Icon class="tag-icon" name="money_right"/>
        </router-link>
      </li>
    </ul>
    <div class="createTag-wrapper">
      <Button @click.native="createTag">新建标签</Button>
    </div>
  </Layout>
</template>

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

tagListModel.fetchData();
@Component({
  components: {Button}
})

export default class Labels extends Vue {
  tags = tagListModel.data; // [{id: '1', name: '1'}, {id: '2', name: '2'}]

  createTag() {
    const name = window.prompt('请输入标签名');
    if (name) {
      const message = tagListModel.create(name);
      if (message === 'duplicated') {
        window.alert('标签名重复了');
      } else if (message === 'success') {
        window.alert('添加成功');
      }
    }
  }
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.labels {
  .tags {
    background: white;
    font-size: 16px;
    padding-left: 16px;
    > li {
      > .tag {
        min-height: 44px;
        display: flex;
        align-items: center;
        justify-content: space-between;
        border-bottom: 1px solid #e6e6e6;
        > span {
          // text overflow mixins
          @include multiline-ellipsis(1, 44px, 4em);
          }
        > svg {
          font-size: 24px;
          color: #666;
          margin-right: 16px;
          }
        }
      }
    }
  }

EditLabel.vue 添加返回功能

  • this.$router.back()
  • 相同原理,点击事件失效:目前绑定事件在<Icon/>上,用户点击的是<svg>
    • 需要传递点击事件,点击<svg>时触发组件<Icon/>上的事件
    • Icon.vue中的<svg>标签传递触发事件到外部@click="$emit('click', $event)"
    • <svg>被点击时,事件传递至组件<Icon/>
  • 处理<svg>其他事件
    • 可以不用一个一个地暴露不同类型的事件出去
    • 在父组件上使用.native修饰符 <Icon @click.native="goBack"/>
    • 可省略以上子组件内传递原生事件的过程: @click="$emit('click', $event)"
 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
<template>
  <Layout>
    <div class="headerBar">
      <Icon class="left-icon"
            name="money_right"
            @clcik.native="goBack"/>
      <span class="title">编辑标签</span>
    </div>
    <FormItem class="form-item"
              field-name="标签名"           
              placeholder="在这里输入标签名"/>
    <div class="button-wrapper">
      <Button>删除标签</Button>
    </div>
  </Layout>
</template>

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

@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  created() {
    const id = this.$route.params.id;
    tagListModel.fetchData();
    const tags = tagListModel.data;
    // const tag = tags.filter(t => t.id === id)[0];
    const tag = tags.find(t => t.id === id);
    if (tag) {
      console.log(tag);
    } else {
      this.$router.replace('/404');
    }
  }
  goBack() {
    this.$router.back();
  }
}
</script>
// ...


EditLabel.vue 之 SCSS

 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
.headerBar {
  text-align: center;
  font-size: 16px;
  padding: 12px 16px;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: space-between;
  > .left-icon {
    transform: rotate3d(0, 1, 0, 180deg);
    color: #666;
    font-size: 24px;
    }
  > .title {
    }
  &::after {
    content: '';
    display: inline;
    width: 24px;
    height: 24px;
    }
  }
.form-item {
  margin-top: 8px;
  background: #fff;
  -webkit-box-shadow: 0 1px 1px 0 #BCBBC1;
  -moz-box-shadow: 0 1px 1px 0 #BCBBC1;
  box-shadow: 0 1px 1px 0 #BCBBC1;
  }
.button-wrapper {
  text-align: center;
  padding: 16px;
  margin-top: 44-16px;
  }

  • 之后需要抽取顶部栏组件headerBar
  • 布局: 让图标绝对定位,让标题居中
  • 顶部栏高度用line-height撑开,并不好
  • 图标绝对定位,难以居中
  • 改用flex,加border: 1px solid red;确认位置

EditLabel.vue 功能实现

  • 实现编辑标签的功能
    • 可以点击查看标签具体内容的页面,包括标签名
    • 可以修改标签名
    • 可以删除标签

修改FormItem.vue

  • 从外部获取数据inputValue,此时会提示Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders...
  • v-model 改了外部数据
  • v-model="inputValue"等价于:value="inputValue" @input="inputValue = $event.target.value"(在input标签内) 对此时的外部数据进行直接操作是不合法的
  • 改为监听自定义事件oninputValueChanged($event.target.value),发布修改的信息给父组件,通知父组件修改数据
  • 用户在修改inputValue时,子组件不会直接对inputValue进行变更,而是将inputValue的值通过自定义方法传递出去,通知给父组件
  • oninputValueChanged(newValue: string) { this.$emit('update:InputValue', newValue); }
    • 注意事件名统一:
      • 子组件this.$emit('update:inputValue', newValue);
      • 父组件监听@update:inputValue="updateTag"

FormItem.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
<template>
  <div class="form-wrapper">
    <label class="form-item">
      <span class="name">{{ this.fieldName }}</span>
      <input type="text"
             @input="oninputValueChanged($event.target.value)"
             :value="inputValue"
             :placeholder="this.placeholder"/>
    </label>
  </div>
</template>
<script lang="ts">
import Vue from 'vue';
import {Component, Prop, Watch} from 'vue-property-decorator';

@Component
export default class FormItem extends Vue {
  @Prop({default: ''}) inputValue!: string;
  @Prop({required: true}) fieldName!: string;
  @Prop({default: ''}) placeholder?: string;

  @Watch('inputValue')
  oninputValueChanged(newValue: string) {
    this.$emit('update:InputValue', newValue);
  }
}
</script>

<style lang="scss" scoped>
@import "~@/assets/style/global.scss";
.form-wrapper {
  background: transparent;
  .form-item {
    align-items: center;
    display: flex;
    font-size: 14px;
    padding-left: 16px;
    .name {
      padding-right: 16px;
      }
    input {
      height: 40px;
      flex-grow: 1;
      background: transparent;
      padding-right: 16px;
      }
    }
  }
</style>


EditLabel.vue

  • 取到标签的数据,赋值给内部数据tag
  • 先声明数据类型inputValue?: {id: string, name: string} = undefined;
  • 标签创建完成时的钩子created(){...},从本地数据库中得到的数据赋值给tag
    • 获取页面url中的id,通过id在所有标签中找到对应的tag,将tag赋值给inputvalue
  • 得到tag后,在模板中展示:value="tag.name"
 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>
  <Layout>
    <div class="headerBar">
      <Icon class="left-icon" name="money_right" @click.native="goBack"/>
      <span class="title">编辑标签</span>
    </div>
    <FormItem :inputValue="tag.name" class="form-item" field-name="标签名" placeholder="在这里输入标签名"/>
    <div class="button-wrapper">
      <Button>删除标签</Button>
    </div>
  </Layout>
</template>

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

@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  tag?: { id: string; name: string } = undefined;

  created() {
    const id = this.$route.params.id;
    tagListModel.fetchData();
    const tags = tagListModel.data;
    // const tag = tags.filter(t => t.id === id)[0];
    const tag = tags.find(t => t.id === id);
    if (tag) {
      this.tag = tag;
    } else {
      this.$router.replace('/404');
    }
  }
}
...
</script>
...

用户在EditLabel.vue的输入框中修改标签名 updateTag()

  • 监听@update:inputValue事件
    • @update:inputValue="update"
  • 值由FormItem.vue@input="oninputValueChanged($event.target.value)this.$emit('update:inputValue', newValue); 而传来,类型为字符串
  • 传给方法update(name: string) {...}
 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
<template>
  <Layout>
    <div class="headerBar">
      <Icon class="left-icon"
            name="money_right"
            @click.native="goBack"/>
      <span class="title">编辑标签</span>
    </div>
    <FormItem :inputValue="tag.name"
              @update:inputValue="updateTag"
              class="form-item"
              field-name="标签名"
              placeholder="在这里输入标签名"/>
    <div class="button-wrapper">
      <Button>删除标签</Button>
    </div>
  </Layout>
</template>

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

@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  tag?: { id: string; name: string } = undefined;

  created() {
    const id = this.$route.params.id;
    tagListModel.fetchData();
    const tags = tagListModel.data;
    // const tag = tags.filter(t => t.id === id)[0];
    const tag = tags.find(t => t.id === id);
    if (tag) {
      this.tag = tag;
    } else {
      this.$router.replace('/404');
    }
  }
  updateTag(name: string) {
    console.log(name);
  }
}
...
</script>

  • 查看updateTag()方法打印出的参数
  • 重命名为update()方法

tagListModel.ts添加方法

  • 更新数据 updateData(id, name) {...}
  • 删除数据 removeData(id: string) {...}

将修改的name保存到本地数据tag.name

  • tagListModel.ts中添加update(id: string, name: string) {...}方法
  • tagListModel.ts中添加removeData(id: string) {...}方法
 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
const localStorageKeyName = 'tagList';
type Tag = {
  id: string;
  name: string;
}
type TagListModel = {
  data: Tag[];
  fetchData: () => Tag[];
  create: (name: string) => 'success' | 'duplicated';
  saveData: () => void;
  updateData: (id: string, name: string) => 'success' | 'not found' | 'duplicated';
  removeData: (id: string) => boolean;
}

// return this.data | 'success' | 'duplicated' | 'not found'
const tagListModel: TagListModel = {
  data: [],
  fetchData() {
    this.data = JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]');
    return this.data;
  },
  create(name: string) {
    // this.data = [{id: '1', name: '1'}, {id: '2', name: '2'}]
    const names = this.data.map(d => d.name);
    if (names.indexOf(name) >= 0) { return 'duplicated'; }
    this.data.push({id: name, name: name});
    this.saveData();
    return 'success';
  },
  saveData() {
    localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
  },
  updateData(id: string, name: string) {
    const idList = this.data.map(item => item.id);
    if (idList.indexOf(id) >= 0) {
      const nameList = this.data.map(item => item.name);
      if (nameList.indexOf(name) >= 0) {
        return 'duplicated';
      } else {
        const tag = this.data.filter(item => item.id === id)[0];
        tag.name = name;
        tag.id = name;
        this.saveData();
        return 'success';
      }
    } else {
      return 'not found';
    }
  },
  removeData(id: string) {
    let index = -1;
    for (let i = 0; i < this.data.length; i++) {
      if (this.data[i].id === id) {
        index = i;
        break;
      }
    }
    this.data.splice(index, 1);
    this.saveData();
    return true;
  }
};
export default tagListModel;

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
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
<template>
  <Layout>
    <div class="headerBar">
      <Icon class="left-icon" name="money_right" @click.native="goBack"/>
      <span class="title">编辑标签</span>
    </div>
    <FormItem :inputValue="tag.name"
              @update:inputValue="update"
              class="form-item" field-name="标签名" placeholder="在这里输入标签名"/>
    <div class="button-wrapper">
      <Button @click="remove">删除标签</Button>
    </div>
  </Layout>
</template>

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

@Component({
  components: {Button, FormItem}
})
export default class EditLabel extends Vue {
  tag?: { id: string; name: string } = undefined;

  created() {
    const id = this.$route.params.id;
    window.tagList;
    const tags = tagListModel.data;
    // const tag = tags.filter(t => t.id === id)[0];
    const tag = tags.find(t => t.id === id);
    if (tag) {
      this.tag = tag;
    } else {
      this.$router.replace('/404');
    }
  }

  update(name: string) {
    if (this.tag) {
      tagListModel.update(this.tag.id, name);
    }
  }

  remove() {
    if (this.tag) {
      tagListModel.remove(this.tag.id);
      window.alert(`成功删除标签:${this.tag.name}`);
      tagListModel.fetch();
      this.$router.back();
    }
  }

  goBack() {
    this.$router.back();
  }
}
</script>
...

  • 之后改成:加入编辑按钮,用户输入提示后才可编辑

bug: 在改name时,对应修改idname但可能会生成两个相同的id

  • 需要一个 ID 生成器
  • 引入数据库后就可以不用

id 生成器

id 的原则

  • id 用来定位数据
  • 一旦给了 id,就不要修改
  • id 不能重复
  • id 自增需要注意爆栈问题,JS不能显示超过17位数字12345678912345678 + 1,精度不够
  • 当标签为空时,id 可清零重新计算
  • 使用了闭包的原理

添加自定义库src/lib/idCreator.ts

  • 读取/存入 localStorage
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
let id: number = parseInt(window.localStorage.getItem('_idMax') || '0') || 0;

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

export default createId;

在创建标签时引入 tagListModel.ts createId()

  • 重构重命名了方法fetch uodate remove
 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
import createId from '@/lib/createId';

const localStorageKeyName = 'tagList';
type Tag = {
  id: string;
  name: string;
}
type TagListModel = {
  data: Tag[];
  fetch: () => Tag[];
  create: (name: string) => 'success' | 'duplicated';
  save: () => void;
  update: (id: string, name: string) => 'success' | 'not found' | 'duplicated';
  remove: (id: string) => boolean;
}

// return this.data | 'success' | 'duplicated' | 'not found'
const tagListModel: TagListModel = {
  data: [],
  save() {
    localStorage.setItem(localStorageKeyName, JSON.stringify(this.data));
  },
  fetch() {
    this.data = JSON.parse(window.localStorage.getItem(localStorageKeyName) ?? '[]');
    return this.data;
  },
  create(name: string) {
    // this.data = [{id: '1', name: '1'}, {id: '2', name: '2'}]
    const names = this.data.map(d => d.name);
    if (names.indexOf(name) >= 0) { return 'duplicated'; }
    const id = createId().toString();
    this.data.push({ id, name: name });
    this.save();
    return 'success';
  },
  update(id: string, name: string) {
    const idList = this.data.map(item => item.id);
    if (idList.indexOf(id) >= 0) {
      const nameList = this.data.map(item => item.name);
      if (nameList.indexOf(name) >= 0) {
        return 'duplicated';
      } else {
        const tag = this.data.filter(item => item.id === id)[0];
        tag.name = name;
        tag.id = name;
        this.save();
        return 'success';
      }
    } else {
      return 'not found';
    }
  },
  remove(id: string) {
    let index = -1;
    for (let i = 0; i < this.data.length; i++) {
      if (this.data[i].id === id) {
        index = i;
        break;
      }
    }
    this.data.splice(index, 1);
    this.save();
    return true;
  }
};
export default tagListModel;


纠错

FormItem.vue 纠错

  • FormItem.vue 对于输入的inputValue 不必使用 @Watch('inputValue'),这一行可以删掉
  • input 的值一旦被用户变化,就会触发自定义事件 @update:inputValue ,所以就没必要再加一个 watch
  • watch 会触发两遍
  • Vuetemplate 里没必要写 this.

还有许多功能性的bug,需引入全局状态

  • 标签组件和记账组件不共享标签数据
    • 在记账组件中新增标签的数据不会同步到标签组件中
    • 切换标签组件和记账组件时,新增标签的数据丢失
  • 用户手动清空FormItem.vue时,返回报错