一、项目名称
懒人记账
二、用途
是一款极简的记账单页面应用
主页提供数字键盘,备注功能,添加标签等功能;
记账完毕,会自动跳转到明细页面,该页面可以切换日期以查看历史记账记录,除此之外,点击具体的款项还可进入修改页面;
该项目还使用了echarts进行数据可视化的展示以及支出排行榜与个款项支出或者收入占比。项目基本上能够满足我们对日常的记录。
三、技术栈
基于html,scss,typescript,vue,vuex,vue-router,echarts,表驱动编程,模块化思想
四、功能模块
-
记账功能
1.1 支出与收入切换
1.2 添加标签
1.3 添加备注
1.4 添加金额
1.5 设置
-
查看添加记录功能
2.1 自定义时间筛选查看
-
修改记录功能
3.1 修改金额
3.2 修改备注
3.3 修改时间
3.4 删除记录
-
查看图表明细
4.1 切换月,年度查看明细
4.2 echarts形式展开
4.3 展示支出排行榜
-
查看标签
5.1 删除标签
5.2 添加标签
五、项目展示
六、代码踩坑
- 引入SVG
- 在iconfont-阿里巴巴矢量图标库下载svg
- 将下载的svg import 引入组件中报错
- 在shims-vue.d.ts加入
declare module "*.svg"{
const content:string;
export default content;
}
- import引入的是一个路径,我们需要的是一个svg的use的一个使用方法
- 安装svg插件
yarn add svg-sprite-loader -D, - 根据文档需要有webpack.config.js文件配置,但是在vue里没有,只有vue.config.js 我们需要将webpack.config.js文件翻译成vue中能用的配置
const path = require('path');//引入node.js的path模块
//按照vue cli 文档翻译 ,项目没有的webpack.config.js
module.exports = {
publicPath: process.env.NODE_ENV === 'production'
? '/loon-account-website/'
: '/',
lintOnSave: false,
chainWebpack: config =>{//是一个函数,接收一个config,返回。。。
//icon所在的目录
const dir = path.resolve(__dirname,'src/assets/icons');
//vue封装webpack的api然后暴露出congfig
config.module
.rule('svg-sprite')//添加一个规则
.test(/\.svg$/)//匹配上这个正则,就用这个规则
//只包含这个 icons 目录才会走这个规则
.include.add(dir).end()
.use('svg-sprite-loader-mod').loader('svg-sprite-loader-mod').options({extract:false}).end()//extract:false不需要解析出文件
.use('svgo-loader').loader('svgo-loader')//svg优化的loader
//removeAttrs删除属性
.tap(options=>({...options,plugins:[{removeAttrs:{attrs:'fill'}}]})).end()//解决svg自带颜色
config.plugin('svg-sprite').use(require('svg-sprite-loader-mod/plugin'),[{plainSprite:true}])//配置插件
config.module.rule('svg').exclude.add(dir)//其他 svg loader 排除 icons 目录
}
}
- 首先
svg-sprite-loader引入了所有的Icon,把Icon变成了svg,所有的Icon在svg里面,作为一个symbol,这样就可以使用use svg了。
1.2 如何使用svg整个目录引用,不用一个一个引用
let importAll = (requireContext:__WebpackModuleApi.RequireContext) => {return requireContext.keys().forEach(requireContext)};
try{
//指定要去哪个目录搜索
importAll(require.context('../assets/icons', true, /\.svg$/));
} catch (error) {
//如果有错,不会中断代码,会把错误打印出来
console.log(error);
}
- 创建公用的样式,引入文件报错
@import "~@/assets/style/helper.scss";
webstorm无法识别,我们需要设置一下 file => Settings => languages&frameworks =>javascript => Webpack => 点击文件夹目录 => 找到当前项目 => node_modules => @vue => cli-service => webpack.config.js => ok
将vue.config.js里所有出现svg-sprite-loader改成svg-sprite-loader-mod
安装svg-sprite-loader-mod
- 当使用插卡卡槽时,如何控制内层元素
在需要改变样式的元素加上 classPrefix="layout",并在其另一元素上${classPrefix}-content就可以更改样式
- 使用TyptScript
注意事项:
-
将lang="ts"改写
-
用class
export default class Money extends Vue -
使用装饰器
@Componentipmort 引入使用vue-property-decorator -
@Prop
@Prop() xxx:number!undefined用来装饰参数Prop告诉Vue xxx不是data,是number; xxx是名称; number!undefined 是编译类型;
-
@Prop用来接收父组件传递的数据;
-
@Prop({required: true,type: Object}) selectedTag!: Tag;required: true强制需要传递数据type: Object类型是一个对象!不需要初始化 -
ts的使用比js多了一个类型声明
4.2 ts的类型声明
如果全局很多地方用到这个类型,可以封装抽离到根目录上;类型定义的文件需要以d.ts结尾,导入即可使用。
type RecordItem = {
id?: number,
tags: Tag;
notes: string;
type: string;
amount: number;//数据类型
createdDate?: Date | string;//类/构造函数
}
- 子组件如何向父组件传递数据
- 把notes,tags,type,分别封装成组件,在money组件中分别引入这些子组件,
money => type组件
<Tabs :dataSource="recordTypeList" :value.sync="record.type" class-prefix="tabs"/>
:value 表示我要传一个value,.sync 表示我要同步更新
:value.sync="record.type" 等价于 :value="record.type",v-on:unpdate:value="record.type=$event"
子组件如果没有this.$emit('update:value', item);写,那么数据也传递不过去
封装的type组件
<ul class="tabs" :class="`${classPrefix}-content`">
<li v-for="(item,index) in dataSource" :key="index"
:class="liClass(item)" @click="select(item.value)">
{{ item.name }}
</li>
</ul>
--------省略部分代码
type DataSourceItem = { name: string, value: string }
@Prop({required: true, type: Array}) dataSource!: DataSourceItem[];
@Prop() readonly value!: string;//!不要关心初始值
--------省略部分代码
select(item: string) {
this.$emit('update:value', item);
}
- 使用$emit监听dataSource的update,如果触发就将选中的value,通过.sync,更新父组件的数组当中。
- 当我们保存记录的时候,总是会覆盖之前的记录
原来我们保存的时候要复制一遍再保存,我们称为深拷贝;
还需要给record创建一个id,id最大值是17位,ID原则:一旦给了ID就不要修改,id不能重复。
在src下创建liB文件夹,创建一个文件idCreateor.ts,
let id: number = parseInt(window.localStorage.getItem('_idMax') ||'0')||0;
//第一个0,保底有字符串;第二个0 ,保底是个0,不至于食Nan,null;
function createId(){
id++;
//保存ID到localStorage
window.localStorage.setItem('_idMax',id.toString())
return id
}
export default createId
在lib里创建一个clone.ts,封装克隆深拷贝的方法
function clone<T>(data:T):T{
return JSON.parse(JSON.stringify(data));
}
export default clone;
在创建记录的时候 将数组变换成字符串,再将字符串变换成数组
createRecord(state, record: RecordItem) {
record.id = idCreator();
//深拷贝
const deepClone = clone(record);
deepClone.createdDate = new Date();
state.recordList.push(deepClone); //更新数据
store.commit("saveRecords");
},
- 创建vuex全局状态管理(数据读写工具) 引入Vuex
import Vuex from 'vuex';
使用vuex
Vue.use(Vuex);
把store绑定在Vue.prototype上,因此可以不用导入,直接使用this.$store;
由于vuex使用use,所以他的数据可以被Vue实例双向绑定
vueX 中
- state是data
- Mutation是method
- action是调用method
- 数据的存储问题
懒人记账是本地app,没有数据库,只能将数据存储在 localStorage
- 存数据
window.localStorage.setItem("tagList", JSON.stringify(state.tagList));
使用setItem,JSON.stringify(),将数组转成JSON字符串
- 取数据
state.tagList = JSON.parse(window.localStorage.getItem('tagList') || '[]');
使用getItem获取,JSON.parse(),将JSON字符串转换js对象
- 标签的增删查
通过路由ID查找到当前标签ID,并通过ID进行操作
setCurrentTag(state, id: number) {
//找到路由ID
state.currentTag = state.tagList.filter(t => t.id === id)[0];
},
9.1 增
- store里封装添加标签的API
insertTag: function (state, tag: Tag) {
//传入的参数为tag类型,
const names = state.tagList.map((item) => item.name);
if (names.indexOf(tag.name) >= 0) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
state.createTagError = new Error("duplicated");
return; //插入的标签如果重名,则返回
}
tag.id = idCreator();
const deepClone = clone(tag);
state.tagList.push(deepClone);
store.commit("saveTags");
},
- 在需要使用的地方引用添加API
ok() {
this.$store.commit('insertTag', this.tag);
if (this.$store.state.createTagError) {
if (this.$store.state.createTagError.message === "duplicated") {
this.$router.replace('/tag/SettingTag');
return;
}
}
else {
this.$router.replace('/tag/SettingTag');
}
}
调用state的$store.commit insertTag方法,添加tag
9.2 删
- 在store里封装删除标签的api
removeTag(state, id:number) {
let index = -1;
for (let i = 0; i < state.tagList.length; i++) {
if (state.tagList[i].id === id) {
index = i;
break;
}
}
if (index >= 0) {
state.tagList.splice(index, 1);
store.commit("saveTags");
} else {
window.alert("删除失败");
}
},
- 在需要使用的地方引用删除API
remove(id: number) {
if (id) {
this.$store.commit('removeTag', id);
}
}
调用state的$store.commit``removeTag方法,删除tag
9.3 查
通过第8个数据存储的功能,赋值给state
使用computed 中的get方法,获取添加的标签记录。
get tagList() {
return this.$store.state.tagList;
}
调用state的$store.state 获取tag
9.4 保存
通过第8个数据存储的功能,保存本地localStorage
saveTags(state) {
window.localStorage.setItem("tagList", JSON.stringify(state.tagList));
},
- 记账记录的增删查改
原理和添加标签是一样的
setCurrentRecord(state, id: number) {
//找到路由ID
state.currentRecord = state.recordList.filter((t) => t.id === id)[0];
},
10.1 增
- 在stroe里封装添加记账记录的api
createRecord(state, record: RecordItem) {
record.id = idCreator();//将id添加记录
const deepClone = clone(record); //深拷贝
deepClone.createdDate = new Date();//添加时间
state.recordList.push(deepClone); //更新数据
store.commit("saveRecords");
},
在需要添加记录的地方调用添加记录的api
//收集的数据记录record
record: RecordItem = {tags: {name:'other',value:'其他'}, notes: '', type: '_', amount: 0};
saveRecord() {
if(!this.record.tags){
return window.alert('请选择标签');
}
this.$store.commit('createRecord',this.record);
this.record.notes= '';
if(this.$store.state.createRecordError===null){
window.alert('已保存');
this.$router.replace('/labels');
}
}
10.2 删
removeRecord(state, id: number) {
let index = -1;
for (let i = 0; i < state.recordList.length; i++) {
if (state.recordList[i].id === id) {
index = i;
break;
}
}
if (index >= 0) {
state.recordList.splice(index, 1);
store.commit("saveRecords");
} else {
window.alert("删除失败");
}
},
在需要删除记录的地方调用删除记录的api
remove() {
if (this.record) {
this.$store.commit('removeRecord', this.record.id);
if (this.$store.state.recordListErroe === 'notfound') {
window.alert('该记录不存在');
} else {
this.$router.replace('/labels');
}
}
}
10.3 查
get recordList() {
return (this.$store.state as RootState).recordList;
}
通过路由的id,拿到记录的id
//拿到每条记录的明细
created() {
this.$store.commit('setCurrentRecord', parseInt(this.$route.params.id));
this.record = clone<RecordItem>(this.$store.state.currentRecord);
if (!this.record) { //这一步防止拿不到当前页面数据
this.record = {
id: 0,
tags: {name: 'other', value: '其他'},
type: '_',
notes: '',
amount: 0,
createdDate: new Date()
};
this.$router.replace('/error'); //TODO
}
}
拿到所有记录的明细
type Group = {
name: string;
items: RecordItem[];
}
get groupList() {
const {recordList} = this;
//进行排序
const newList = clone(recordList).filter(item => (dayjs(item.createdDate)
.year() === parseInt(this.year)) && (dayjs(item.createdDate).month() + 1 === parseInt(this.month)))
.sort((a, b) => dayjs(b.createdDate).valueOf() - dayjs(a.createdDate).valueOf());
if (newList.length === 0) {return [];}
const result: Group[] = [];//存数据
const names: string[] = [];//存时间
let record: RecordItem;
for (record of newList) {
let date: string; //存储
if (this.year === dayjs().year().toString()) {
date = dayjs(record.createdDate).toISOString().split('T')[0];
} else {
date = dayjs(record.createdDate).format('YYYY-MM-DD');
}
const index = names.indexOf(date);
if (index < 0) {
names.push(date);
result.push({name: date, items: [record]});
} else {
result[index].items.push(record);
}
}
return result;
}
-
groupList比作一个大筒 -
将
recordList按照从近到远的时间进行排序放入newList -
result是数据,names是时间标签 -
循环
newList,判断时间是否今年,是的就将时间以‘T’分割,去前面部分存入date字符串;否则直接以年月日的格式存入date。 -
names.indexOf(date)判断names时间筒里是否和date的时间一样,不一样就将date时间push到names时间筒里,对应数据放入result里,否则就直接将record,push进去。 -
10.31 计算总收入
get totalIncome() {
let total = 0;
let group: Group;
for (group of this.groupList) {
let record: RecordItem;
for (record of group.items) {
if (record.type === '+') {
total += record.amount;
} else {
continue;
}
}
}
return total;
}
- 10.32 计算总支出入
get totalExpense() {
let total = 0;
let group: Group;
for (group of this.groupList) {
let record: RecordItem;
for (record of group.items) {
if (record.type === '_') {
total += record.amount;
} else {
continue;
}
}
}
return total;
}
- 10.33 判断金额是收入还是支出
getAmount(record: RecordItem) {
if (record.type === '+') {
return record.amount;
} else {
return -record.amount;
}
}
- 10.34 封装的年份日期选择器
get years() { //自己封装一个日期选择器;
const endYear = dayjs().year();
let y = 1970;
const result: number[] = [];
while (y <= endYear) {
result.unshift(y);
y++;
}
return result;
}
- 10.35 装饰月份10以下添加0
beautifyMonth(m: number) {
return m < 10 ? '0' + m.toString() : m.toString();
}
- 10.36 日期标签装饰器
beautify(group: Group) {
const day = dayjs(group.name);
const now = dayjs();
if (day.isSame(now, 'day')) {
return '今天';
} else if (day.isSame(now.subtract(1, 'day'), 'day')) {
return '昨天';
} else if (day.isSame(now.subtract(2, 'day'), 'day')) {
return '前天';
} else if (day.isSame(now, 'year')) {
return day.format('M月D日');
} else {
return day.format('YYYY年MM月DD日');
}
}
10.4 改
updateRecord(state, { id, record }: { id: number; record: RecordItem }) {
for (let index = 0; index < state.recordList.length; index++) {
if (state.recordList[index].id === id) {
state.recordList[index] = record;
break;
}
}
store.commit("saveRecords");
},
在需要使用的地方引用修改API
ok() {
if (this.record) {
this.record.amount = parseFloat(this.record.amount.toString());
this.$store.commit('updateRecord', {id: this.record.id, record: this.record});
}
this.$router.replace('/labels');
}
- 使用echart
- 安装echart
npm install echarts --save - 引用echart
import * as echarts from 'echarts';
- 在项目中使用
draw(data: Map<string, number>) {
// 提取变量
const x = [...data.keys()];
const y = [...data.values()];
// 基于准备好的dom,初始化echarts实例
const figure = echarts.init(document.getElementById('figure') as HTMLDivElement);
// 绘制图表
figure.setOption({
//直角坐标底板
grid: {
top: '5%',
bottom: '15%'
},
//x坐标
xAxis: {
data: x,
axisTick: {
lineStyle: {
opacity: 0,//x轴的坐标刻度
}
},
axisLabel: {
interval: 0,//X轴坐标分割线的间隔
fontSize: 8,
color: '#999999'
}
},
//y坐标
yAxis: {
// axisLine: {
// interval: 0,
// lineStyle: {
// opacity: 0
// }
// },
splitLine: {
lineStyle: {
opacity: 0
}
},
axisLabel: undefined,
axisTick: undefined,
},
series: [{
type: 'line',
data: y,
}
, {
name: '最大值',
type: 'line',
data: this.toArray(Math.max(...y), x.length),
symbol: 'none',
lineStyle: {
color: '#999999',
width: 1,
opacity: 0.5
}
}
]
});
}