【项目-喵内记账-meoney-03】Labels 组件
大纲链接 §
[toc]
知识点
Surround with Emmet快捷键Ctrl Alt t给每项添加标签li * niconfont小技巧,编辑SVGcustom.d.ts怎么用@click.native怎么用Vue Router怎么用props怎么用- 每次完成一小节都要提交代码
Labels.vue 之 HTML
- 去
IconFont下载一个右箭头图标,使用封装好的<Icon/>全局组件- 查看 封装 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<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 样式
|
|
<button></button>为内联元素,居中,text-align: center;加到父元素上padding没有margin的上下合并问题,但会影响background的范围SCSS可以在子类中写父类的样式.child { &-father {...}},但如果子类已经写在父类的嵌套中,这种写法无效- 使用最小高度限制每行的高度
- 显示字数最多3个,多余的用
...
global.scss中写@mixin multiline-ellipsis单行超出文字省略样式
|
|
reset.scss
- 由于多次用到
border: none;,写到reset.scss
新建标签功能
- 原来存储的标签是写死在数据中的
tags = ['衣', '食', '住', '行', '理财']; - 需要另外的
Model来存储添加的标签 - 新建
models目录,存放所有Model相关数据:recordListModel.ts、tagListModel.ts - 两个
Model文件结构几乎一样,可提取Mixins
tagListModel.ts
|
|
修改
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// ... <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
|
|
编辑/删除标签功能
- 在另一个编辑标签页面中实现
- 实现点击
Label.vue中的标签可跳转至标签编辑页面 - 新建路由
/labels/edit - 新建
src/view/EditLabel.vue组件
src/router/index.ts
|
|
EditLabel.vue & Vue Router
- 每个循环渲染的标签需要一个
id,通常是取自数据库中的,本地版将不重复的name当做id - 在
tagListModel.ts中声明Tag类型,data变为Tag[],fetchData获取Tag[]
tagListModel.ts
|
|
Lables.vue中相应的模板数据{{tag}}和:key也改变
|
|
动态子路由
- 点击
Label.vue中的标签跳转至编辑页面 - 路由到
../labels/edit/1 '/labels/edit/:id'中的:id表示占位Vue-Router 数据获取文档
src/router/index.ts
|
|
EditLable.vue
|
|
- 钩子
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>标签
|
|
- 封装为外部数据
@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
|
|
- 处理
<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<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<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
|
|
- 之后需要抽取顶部栏组件
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
|
|
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<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 63const 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
|
|
- 之后改成:加入编辑按钮,用户输入提示后才可编辑
bug: 在改
name时,对应修改id为name但可能会生成两个相同的id
- 需要一个
ID生成器 - 引入数据库后就可以不用
id 生成器
id 的原则
- id 用来定位数据
- 一旦给了 id,就不要修改
- id 不能重复
- id 自增需要注意爆栈问题,JS不能显示超过17位数字
12345678912345678 + 1,精度不够 - 当标签为空时,id 可清零重新计算
- 使用了闭包的原理
添加自定义库
src/lib/idCreator.ts
读取/存入
localStorage1 2 3 4 5 6 7 8 9let id: number = parseInt(window.localStorage.getItem('_idMax') || '0') || 0; function createId() { id++; window.localStorage.setItem('_idMax', id.toString()); return id; } export default createId;
在创建标签时引入
tagListModel.tscreateId()
重构重命名了方法
fetchuodateremove1 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 66import 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会触发两遍 Vue的template里没必要写this.
还有许多功能性的bug,需引入全局状态
- 标签组件和记账组件不共享标签数据
- 在记账组件中新增标签的数据不会同步到标签组件中
- 切换标签组件和记账组件时,新增标签的数据丢失
- 用户手动清空
FormItem.vue时,返回报错