@tmsfe/tms-core 0.0.61 → 0.0.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/index.js +6 -0
- package/src/report/README.md +201 -0
- package/src/report/proxy/component.ts +6 -12
- package/src/report/proxy/helper.ts +1 -1
- package/src/report/proxy/page.ts +5 -6
- package/src/storage.js +10 -4
- package/src/stringUtils.js +14 -9
- package/src/timeUtils.js +139 -48
- package/src/timeUtils.test.js +131 -0
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -25,11 +25,14 @@ import {
|
|
|
25
25
|
round,
|
|
26
26
|
} from './numUtils';
|
|
27
27
|
import {
|
|
28
|
+
groupTimeDuration,
|
|
29
|
+
formatTimeDuration,
|
|
28
30
|
formatTime,
|
|
29
31
|
formatTimeStr,
|
|
30
32
|
formatTimeWithDetails,
|
|
31
33
|
formatDateTime,
|
|
32
34
|
dateToString,
|
|
35
|
+
parseDateTime,
|
|
33
36
|
} from './timeUtils';
|
|
34
37
|
import {
|
|
35
38
|
ipxInit,
|
|
@@ -157,11 +160,14 @@ const api = {
|
|
|
157
160
|
round,
|
|
158
161
|
|
|
159
162
|
/* 时间方法 */
|
|
163
|
+
groupTimeDuration,
|
|
164
|
+
formatTimeDuration,
|
|
160
165
|
formatTime,
|
|
161
166
|
formatTimeStr,
|
|
162
167
|
formatTimeWithDetails,
|
|
163
168
|
formatDateTime,
|
|
164
169
|
dateToString,
|
|
170
|
+
parseDateTime,
|
|
165
171
|
|
|
166
172
|
/* IPX方法 */
|
|
167
173
|
ipxInit,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# 埋点
|
|
2
|
+
|
|
3
|
+
### 目前支持的数据能力
|
|
4
|
+
<img width="700" src="https://tai-static-1251316161.cos.ap-chengdu.myqcloud.com/reportcos/tms-data.png"/>
|
|
5
|
+
|
|
6
|
+
### 埋点展示网站:https://tmsgo.testsite.woa.com/reportManage/list
|
|
7
|
+
|
|
8
|
+
### 目录结构
|
|
9
|
+
```
|
|
10
|
+
┌─ proxy # 全埋点逻辑
|
|
11
|
+
│ ├─ clone # 负责对象的克隆
|
|
12
|
+
│ ├─ component # 负责组件的全埋点
|
|
13
|
+
│ ├─ helper # 全埋点辅助类
|
|
14
|
+
│ ├─ index # 负责页面和组件的全埋点初始化
|
|
15
|
+
│ ├─ page # 负责页面的全埋点
|
|
16
|
+
│ └─ types # 全埋点ts类型定义
|
|
17
|
+
│
|
|
18
|
+
├─ formatV1 # 格式化旧埋点
|
|
19
|
+
├─ formatV2 # 格式化新埋点
|
|
20
|
+
├─ helper # 埋点辅助函数
|
|
21
|
+
├─ index # 埋点入口
|
|
22
|
+
├─ sender # 负责发送埋点
|
|
23
|
+
└─ types # ts类型定义
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### 埋点的基础字段有哪些?
|
|
27
|
+
不管是全埋点还是手动埋点,都会携带相同的基础字段,具体请查看```formatV2.ts```代码
|
|
28
|
+
|
|
29
|
+
### 手动添加埋点
|
|
30
|
+
```coffeescript
|
|
31
|
+
1. const reporter = getApp().tms.getReporter();
|
|
32
|
+
// 点击登录按钮|用户名
|
|
33
|
+
2. reporter.report2('login_btn_click', this.data.username);
|
|
34
|
+
```
|
|
35
|
+
#### 说明:
|
|
36
|
+
1. 新埋点函数名为```report2()```,旧埋点函数名为```report()```,不建议再用旧埋点
|
|
37
|
+
2. 埋点上方一定要写注释说明埋点每个参数的定义,用"|"隔开
|
|
38
|
+
3. 构建平台在每次构建时会调用工具分析代码提取出埋点列表更新到展示网站数据库
|
|
39
|
+
4. 埋点函数第一个参数为事件名,必须为string,其他参数为该事件附带数据,可以是基础类型,也可以是json对象
|
|
40
|
+
|
|
41
|
+
#### 问题
|
|
42
|
+
1. 代码中已有的旧埋点怎么办?可以保留,但是不建议再新增旧埋点
|
|
43
|
+
2. 埋点发送机制是怎么样的?普通埋点采用合并上报的方式,通常3s上报一次,如果小程序关闭则会立即上报
|
|
44
|
+
3. 如果有埋点需要立即上报该怎么办?可以把```report2()```改为调用```fastReport2()```
|
|
45
|
+
4. ```report2()```与```fastReport2()```的区别是什么?```fastReport2()```上报时携带的"省"、"市"等需要异步请求的基础字段会从缓存中读取,如果无缓存则为空,```report2()```则一定会携带这些字段
|
|
46
|
+
|
|
47
|
+
### 全埋点
|
|
48
|
+
#### 页面自动上报事件
|
|
49
|
+
##### 一、必须上报的生命周期事件:
|
|
50
|
+
1. onLoad: 页面加载
|
|
51
|
+
2. onShow: 页面显示
|
|
52
|
+
3. onReady: 页面初次渲染完成
|
|
53
|
+
4. onHide: 页面隐藏
|
|
54
|
+
5. onUnload: 页面卸载
|
|
55
|
+
|
|
56
|
+
##### 二、可能会上报的生命周期事件(取决于页面是否实现该函数):
|
|
57
|
+
1. onPullDownRefresh: 用户下拉动作
|
|
58
|
+
2. onReachBottom: 页面上拉触底
|
|
59
|
+
3. onShareAppMessage: 用户点击右上角转发
|
|
60
|
+
4. onShareTimeline: 用户点击右上角转发到朋友圈
|
|
61
|
+
5. onAddToFavorites: 用户点击右上角收藏
|
|
62
|
+
6. onReportPageTouch: 用户触摸页面 - 如果页面wxml有view节点则会注入该事件
|
|
63
|
+
|
|
64
|
+
##### 三、页面的其他bind事件,通过分析wxml得出
|
|
65
|
+
#### 组件自动上报事件
|
|
66
|
+
1. ready: 组件加载(会携带组件data)
|
|
67
|
+
2. 组件的其他bind事件,通过分析wxml得出(会携带组件data)
|
|
68
|
+
|
|
69
|
+
#### 其他自动上报事件
|
|
70
|
+
1. App_onLaunch: 小程序启动
|
|
71
|
+
2. wx_navigateTo_before: 页面即将跳转
|
|
72
|
+
3. wx_redirectTo_before: 页面即将重定向
|
|
73
|
+
4. wx_reLaunch_before: 小程序即将重启
|
|
74
|
+
|
|
75
|
+
#### 注意事项
|
|
76
|
+
1. bind事件函数上方要写注释,工具提取该事件时会把注释也提取出来,用作该事件说明
|
|
77
|
+
2. Page或者Component的data要写注释,工具会提取data代码附到埋点说明中
|
|
78
|
+
3. 某个事件触发很频繁觉得上报没意义,在函数注释中加上```ignoreReport```这个单词就不会上报该函数
|
|
79
|
+
```coffeescript
|
|
80
|
+
/**
|
|
81
|
+
* 拖动组件
|
|
82
|
+
* ignoreReport
|
|
83
|
+
*/
|
|
84
|
+
onTouchMove(e) {
|
|
85
|
+
...
|
|
86
|
+
}
|
|
87
|
+
or
|
|
88
|
+
// ignoreReport
|
|
89
|
+
onTouchMove(e) {
|
|
90
|
+
...
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
#### 哪种bind事件的写法会使全埋点上报时把最新的data也带上?
|
|
95
|
+
ps:tms-core会先执行事件函数,然后判断返回值是否promise,如果时则会等promise.then或catch之后再上报,非promise则直接上报
|
|
96
|
+
1. 函数中直接setData
|
|
97
|
+
```coffeescript
|
|
98
|
+
/**
|
|
99
|
+
* 点击关闭按钮
|
|
100
|
+
*/
|
|
101
|
+
onCloseBtnTap() {
|
|
102
|
+
this.setData({ show: false });
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
2. 函数返回promise的
|
|
106
|
+
```coffeescript
|
|
107
|
+
/**
|
|
108
|
+
* 点击登录按钮
|
|
109
|
+
*/
|
|
110
|
+
onLoginBtnTap() {
|
|
111
|
+
return tms.login().then((res) => {
|
|
112
|
+
this.setData({ userName: res.userName });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
or
|
|
116
|
+
/**
|
|
117
|
+
* 点击登录按钮
|
|
118
|
+
*/
|
|
119
|
+
async onLoginBtnTap() {
|
|
120
|
+
const res = await tms.login();
|
|
121
|
+
this.setData({ userName: res.userName });
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
#### 哪种bind事件的写法会使全埋点上报时不把最新的data也带上?
|
|
126
|
+
1. 函数中异步setData但是没有返回promise的
|
|
127
|
+
```coffeescript
|
|
128
|
+
/**
|
|
129
|
+
* 点击登录按钮
|
|
130
|
+
*/
|
|
131
|
+
onLoginBtnTap() {
|
|
132
|
+
tms.login().then((res) => {
|
|
133
|
+
this.setData({ userName: res.userName });
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### 如何在开发过程中开启全埋点?
|
|
139
|
+
1. 切到新脚手架的小程序目录下
|
|
140
|
+
```
|
|
141
|
+
cd miniprogram/programs/sinan/dist
|
|
142
|
+
```
|
|
143
|
+
2. 运行工具
|
|
144
|
+
```
|
|
145
|
+
node ../../../../tools/tms-prebuild/main.js
|
|
146
|
+
```
|
|
147
|
+
3. 等待工具跑完
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
#### 如何判断全埋点是否满足产品需求?
|
|
151
|
+
1. 目标事件是页面生命周期函数触发时上报,且不需要带data
|
|
152
|
+
2. 目标事件是组件挂载(ready),并且需要携带的参数在组件data中
|
|
153
|
+
3. 目标事件是页面或者组件的bind事件,并且需要携带的参数在data或者函数的首个参数中(event的dataset、mark、detail)
|
|
154
|
+
|
|
155
|
+
#### 埋点提取工具AST匹配规则
|
|
156
|
+
```coffeescript
|
|
157
|
+
/**
|
|
158
|
+
* 提取旧埋点
|
|
159
|
+
* @param content {string}
|
|
160
|
+
* @param rootAst {GoGoAST}
|
|
161
|
+
* @returns {DataInfo[]}
|
|
162
|
+
*/
|
|
163
|
+
function extractV1(content, rootAst) {
|
|
164
|
+
const paramsKey = '$$$0';
|
|
165
|
+
const selector = [
|
|
166
|
+
`report({ ${paramsKey} })`,
|
|
167
|
+
`Report({ ${paramsKey} })`,
|
|
168
|
+
`reportLog({ ${paramsKey} })`,
|
|
169
|
+
`fastReport({ ${paramsKey} })`,
|
|
170
|
+
`$_$1.report({ ${paramsKey} })`,
|
|
171
|
+
`$_$1.Report({ ${paramsKey} })`,
|
|
172
|
+
`$_$1.reportLog({ ${paramsKey} })`,
|
|
173
|
+
`$_$1.fastReport({ ${paramsKey} })`,
|
|
174
|
+
];
|
|
175
|
+
const ast = rootAst.find(selector);
|
|
176
|
+
const result = [];
|
|
177
|
+
ast.each((node) => { 处理逻辑... });
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* 提取新埋点
|
|
184
|
+
* @param content
|
|
185
|
+
* @param rootAst {GoGoAST}
|
|
186
|
+
* @returns {DataInfo[]}
|
|
187
|
+
*/
|
|
188
|
+
function extractV2(content, rootAst) {
|
|
189
|
+
const paramsKey = '$$$0';
|
|
190
|
+
const selector = [
|
|
191
|
+
`report2(${paramsKey})`,
|
|
192
|
+
`fastReport2(${paramsKey})`,
|
|
193
|
+
`$_$1.report2(${paramsKey})`,
|
|
194
|
+
`$_$1.fastReport2(${paramsKey})`,
|
|
195
|
+
];
|
|
196
|
+
const ast = rootAst.find(selector);
|
|
197
|
+
const result = [];
|
|
198
|
+
ast.each((node) => { 处理逻辑... });
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
```
|
|
@@ -12,9 +12,12 @@ let originalComponent: any = null;
|
|
|
12
12
|
// 劫持Component的生命周期
|
|
13
13
|
function proxyLifeMethod(componentName: string, componentOptions: any, methodName: string): void {
|
|
14
14
|
/* eslint-disable */
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
let obj = componentOptions;
|
|
16
|
+
if (componentOptions.lifetimes?.hasOwnProperty(methodName)) {
|
|
17
|
+
obj = componentOptions.lifetimes;
|
|
18
|
+
}
|
|
19
|
+
let original = obj[methodName] || helper.emptyFunc;
|
|
20
|
+
obj[methodName] = function (...args: any[]): any {
|
|
18
21
|
// 生命周期函数先发埋点,避免次过程用户退出而丢失埋点
|
|
19
22
|
const data = clone.deepClone(this.data);
|
|
20
23
|
const eventName = `Component_${componentName}_${methodName}`;
|
|
@@ -22,15 +25,6 @@ function proxyLifeMethod(componentName: string, componentOptions: any, methodNam
|
|
|
22
25
|
// 执行原函数
|
|
23
26
|
return helper.executeFunc(this, original, args, helper.emptyFunc);
|
|
24
27
|
}
|
|
25
|
-
if (componentOptions.lifetimes[methodName]) {
|
|
26
|
-
original = componentOptions.lifetimes[methodName];
|
|
27
|
-
componentOptions.lifetimes[methodName] = proxyMethod;
|
|
28
|
-
} else if (componentOptions[methodName]) {
|
|
29
|
-
original = componentOptions[methodName];
|
|
30
|
-
componentOptions[methodName] = proxyMethod;
|
|
31
|
-
} else {
|
|
32
|
-
componentOptions.lifetimes[methodName] = proxyMethod;
|
|
33
|
-
}
|
|
34
28
|
/* eslint-enable */
|
|
35
29
|
}
|
|
36
30
|
|
package/src/report/proxy/page.ts
CHANGED
|
@@ -29,9 +29,8 @@ function proxyLifeMethod(pageOptions: any, methodName: string): void {
|
|
|
29
29
|
// eslint-disable-next-line no-param-reassign
|
|
30
30
|
pageOptions[methodName] = function (...args: any[]): any {
|
|
31
31
|
// 生命周期函数先发埋点,避免次过程用户退出而丢失埋点
|
|
32
|
-
const options = methodName === pageTouchEvent ? '' : clone.deepClone(args[0], 0, 1);
|
|
33
32
|
const extra = getPageLifeExtra(this, methodName, args);
|
|
34
|
-
helper.reportData(`Page_${methodName}`,
|
|
33
|
+
helper.reportData(`Page_${methodName}`, extra);
|
|
35
34
|
|
|
36
35
|
// 执行原函数
|
|
37
36
|
return helper.executeFunc(this, original, args, helper.emptyFunc);
|
|
@@ -126,11 +125,11 @@ function proxyNavigateApi(api: string): void {
|
|
|
126
125
|
enumerable: true,
|
|
127
126
|
configurable: true,
|
|
128
127
|
value(...args: any) {
|
|
129
|
-
originalApi.apply(this, args);
|
|
130
|
-
|
|
131
128
|
const { url = '' } = args[0] || {};
|
|
132
129
|
const [path, params = ''] = url.split('?');
|
|
133
|
-
helper.reportData(
|
|
130
|
+
helper.reportData(`wx_${api}_before`, path, params);
|
|
131
|
+
|
|
132
|
+
originalApi.apply(this, args);
|
|
134
133
|
},
|
|
135
134
|
});
|
|
136
135
|
}
|
|
@@ -139,8 +138,8 @@ function proxyNavigateApi(api: string): void {
|
|
|
139
138
|
function init(): void {
|
|
140
139
|
proxyPage();
|
|
141
140
|
proxyNavigateApi('navigateTo');
|
|
142
|
-
proxyNavigateApi('reLaunch');
|
|
143
141
|
proxyNavigateApi('redirectTo');
|
|
142
|
+
proxyNavigateApi('reLaunch');
|
|
144
143
|
}
|
|
145
144
|
|
|
146
145
|
export default {
|
package/src/storage.js
CHANGED
|
@@ -46,10 +46,14 @@ const cleanQueue = [];
|
|
|
46
46
|
let cleanTimerId = 0;
|
|
47
47
|
|
|
48
48
|
function cleanTask() {
|
|
49
|
+
const { keys } = wx.getStorageInfoSync();
|
|
49
50
|
while (cleanQueue.length > 0) {
|
|
50
51
|
const { key, version } = cleanQueue.pop();
|
|
51
52
|
for (let i = version - 1; i > version - 10 && i > 0; i--) {
|
|
52
|
-
|
|
53
|
+
const str = `${key}_v${i}`;
|
|
54
|
+
if (keys.includes(str)) {
|
|
55
|
+
removeItem(str);
|
|
56
|
+
}
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
cleanTimerId = 0;
|
|
@@ -64,9 +68,11 @@ function cleanTask() {
|
|
|
64
68
|
*/
|
|
65
69
|
function setCacheData(key, version, data) {
|
|
66
70
|
// 异步清理旧数据,不要阻塞当前线程
|
|
67
|
-
cleanQueue.
|
|
68
|
-
|
|
69
|
-
cleanTimerId
|
|
71
|
+
if (!cleanQueue.some(q => q.key === key && q.version === version)) {
|
|
72
|
+
cleanQueue.push({ key, version });
|
|
73
|
+
if (cleanTimerId === 0) {
|
|
74
|
+
cleanTimerId = setTimeout(cleanTask, 2000);
|
|
75
|
+
}
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
const str = `${key}_v${version}`;
|
package/src/stringUtils.js
CHANGED
|
@@ -125,19 +125,21 @@ const isValidPlate = (plate) => {
|
|
|
125
125
|
* @returns {string} 返回四舍五入后的字符串,异常情况下返回空字符串''
|
|
126
126
|
*/
|
|
127
127
|
const roundStr = (x, n = 2, removeTrailingZero = false) => {
|
|
128
|
-
let xNum = Number(x); // x转换为数字
|
|
129
128
|
const nNum = Math.floor(Number(n)); // n转换为数字,且只保留整数部分
|
|
129
|
+
let xNum = Number(x); // x转换为数字
|
|
130
130
|
|
|
131
131
|
// 异常情况,返回''
|
|
132
|
-
if (isNaN(xNum) || isNaN(nNum) || nNum < 0)
|
|
132
|
+
if (isNaN(xNum) || isNaN(nNum) || nNum < 0) {
|
|
133
|
+
return '';
|
|
134
|
+
}
|
|
133
135
|
|
|
134
136
|
// 仅保留整数的情况
|
|
135
|
-
if (nNum === 0)
|
|
137
|
+
if (nNum === 0) {
|
|
138
|
+
return Math.round(xNum).toString();
|
|
139
|
+
}
|
|
136
140
|
|
|
137
|
-
// 保留n位小数的情况
|
|
138
|
-
const xStr = xNum.toString();
|
|
139
141
|
const rexExp = new RegExp(`\\.\\d{${nNum}}5`);
|
|
140
|
-
|
|
142
|
+
// 保留n位小数的情况
|
|
141
143
|
// 1. 大部分情况下,四舍五入使用Number.toFixed即可
|
|
142
144
|
// 2. 然而,Number.toFixed方法在某些情况下对第n+1位是5的四舍五入存在问题,如1.325保留2小数时结果为1.32(期望为1.33)
|
|
143
145
|
// 对此种情况下,有两种处理方式:
|
|
@@ -146,18 +148,21 @@ const roundStr = (x, n = 2, removeTrailingZero = false) => {
|
|
|
146
148
|
// 2.2 Number.toFixed是四舍6入,对于第n+1位是5的情况,增加2*10^(-n-1),保证满足第n+1位>6
|
|
147
149
|
// 增加2*10^(-n-1)而不是增加1*10^(-n-1)是因为后者不能保证第n+1位>=6,例如1.325+0.001=1.32599999...第n+1位仍然为5
|
|
148
150
|
// 此处,采用2.2方式,解决Number.toFixed的问题,又能避免2.1方式中数字超过Infinity的问题
|
|
149
|
-
|
|
151
|
+
// 情况2,处理方式2.1:如果小数部分第n+1位是5,增加2*10^(-n-1)
|
|
152
|
+
if (rexExp.test(xNum.toString())) {
|
|
150
153
|
xNum += 2 * (10 ** (-nNum - 1));
|
|
151
154
|
}
|
|
152
155
|
|
|
153
156
|
const str = xNum.toFixed(nNum);
|
|
154
|
-
if (!removeTrailingZero)
|
|
157
|
+
if (!removeTrailingZero) {
|
|
158
|
+
return str;
|
|
159
|
+
}
|
|
155
160
|
|
|
156
161
|
// 去除末尾的0
|
|
157
162
|
if (/^\d+\.0*$/.test(str)) { // 小数部分全是0
|
|
158
163
|
return str.replace(/^(\d+)(\.0*)$/, (_m, s1) => s1);
|
|
159
164
|
}
|
|
160
|
-
return str.replace(/^(\d+\.\d*[1-9]
|
|
165
|
+
return str.replace(/^(\d+\.\d*[1-9])(0*)$/, (_m, s1) => s1);
|
|
161
166
|
};
|
|
162
167
|
|
|
163
168
|
export {
|
package/src/timeUtils.js
CHANGED
|
@@ -7,6 +7,86 @@
|
|
|
7
7
|
* 2017-07-26 @davislu modify.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { round } from './numUtils';
|
|
11
|
+
import { roundStr } from './stringUtils';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 将时间段进行聚合计算,得出不同时间单位的计数值,例如:3601秒 -> { hour: 1, minute: 0, second: 1 },即:1小时0分1秒
|
|
15
|
+
* @param {Number} seconds 时间段的总秒数
|
|
16
|
+
* @param {String} maxUnit 最大计数单位:second-秒,minute-分,hour-小时,day-天,month-月,year-年;默认 year
|
|
17
|
+
* 单位大小:year > Month > day > hour > month > second
|
|
18
|
+
* @param {String} minUnit 最小计数单位:second-秒,minute-分,hour-小时,day-天,month-月,year-年;默认 second
|
|
19
|
+
* @param {Number} decimal 有不足最小单位数值的情况下,保留几位小数;默认 2
|
|
20
|
+
* @returns {Object} result 各计数单位下的数值:
|
|
21
|
+
* {Number} result.year - 年; 当maxUnit < 'year' 时,此字段永远为0;此字段为-1时,表示 seconds 参数非法 或 minUnit > maxUnit等异常情况
|
|
22
|
+
* {Number} result.month - 月; 当maxUnit < 'month' 时,此字段永远为0;
|
|
23
|
+
* {Number} result.day - 天; 当maxUnit < 'day' 时,此字段永远为0;
|
|
24
|
+
* {Number} result.hour - 小时;当maxUnit < 'hour' 时,此字段永远为0;
|
|
25
|
+
* {Number} result.minute - 分钟;当maxUnit < 'minute' 时,此字段永远为0;
|
|
26
|
+
* {Number} result.second - 秒;
|
|
27
|
+
* {Number} result.decimal - 不足最小单位的部分,具体含义与 minUnit 参数相关;例如 minUnit = day 时,此字段表示不足1天的部分
|
|
28
|
+
*/
|
|
29
|
+
const groupTimeDuration = (seconds, maxUnit = 'year', minUnit = 'second', decimal = 2) => {
|
|
30
|
+
// 时间参数(seconds)检查
|
|
31
|
+
const sec = typeof seconds === 'number' ? seconds : parseFloat(seconds);
|
|
32
|
+
if (isNaN(sec)) return { year: -1, month: 0, day: 0, hour: 0, minute: 0, second: 0, decimal: 0 };
|
|
33
|
+
|
|
34
|
+
// 各时间单位对应的秒数
|
|
35
|
+
const unitSeconds = { year: 31536000, month: 2592000, day: 86400, hour: 3600, minute: 60, second: 1 };
|
|
36
|
+
|
|
37
|
+
// 时间单位参数(minUnit,maxUnit)检查
|
|
38
|
+
const minSeconds = unitSeconds[minUnit] || unitSeconds.second;
|
|
39
|
+
const maxSeconds = unitSeconds[maxUnit] || unitSeconds.year;
|
|
40
|
+
if (maxSeconds < minSeconds) return { year: -1, month: 0, day: 0, hour: 0, minute: 0, second: 0, decimal: 0 };
|
|
41
|
+
|
|
42
|
+
// 截取在 minUnit maxUnit 之间的计数单位,并按从大到小排序
|
|
43
|
+
const allUnitKeys = Object.keys(unitSeconds);
|
|
44
|
+
const groupUnits = allUnitKeys
|
|
45
|
+
.filter(unit => unitSeconds[unit] >= minSeconds && unitSeconds[unit] <= maxSeconds) // 过滤超出范围的单位
|
|
46
|
+
.sort((a, b) => unitSeconds[b] - unitSeconds[a]) // 排序
|
|
47
|
+
.map(unit => ({ name: unit, seconds: unitSeconds[unit] })); // 转换为 { name, seconds } 的形式,方便后面用
|
|
48
|
+
|
|
49
|
+
// 各返回字段初始赋值
|
|
50
|
+
const result = { decimal: 0 };
|
|
51
|
+
allUnitKeys.forEach(unit => result[unit] = 0);
|
|
52
|
+
// 从大到小逐个计算赋值
|
|
53
|
+
// const periodArr = [];
|
|
54
|
+
let restSeconds = sec; // 剩余待统计描述
|
|
55
|
+
groupUnits.forEach((unit) => {
|
|
56
|
+
const curUnitNum = Math.floor(restSeconds / unit.seconds); // 高于当前计数单位的数值
|
|
57
|
+
result[unit.name] = curUnitNum; // 例如 { year: 1 }
|
|
58
|
+
restSeconds %= unit.seconds;
|
|
59
|
+
if (unit.seconds === minSeconds) result.decimal = round(restSeconds / minSeconds, decimal);
|
|
60
|
+
});
|
|
61
|
+
return result;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 将时间段进行聚合计算并格式化,得出方便人阅读的文案,例如:3601秒 -> 1小时0分1秒
|
|
66
|
+
* @param {Number} seconds 时间段的总秒数
|
|
67
|
+
* @param {String} maxUnit 最大计数单位:second-秒,minute-分,hour-小时,day-天,month-月,year-年;默认 year
|
|
68
|
+
* 单位大小:year > Month > day > hour > month > second
|
|
69
|
+
* @param {String} minUnit 最小计数单位:second-秒,minute-分,hour-小时,day-天,month-月,year-年;默认 second
|
|
70
|
+
* @param {Number} decimal 有不足最小单位数值的情况下,保留几位小数;默认 2
|
|
71
|
+
* @returns {String} 示例见测试用例
|
|
72
|
+
*/
|
|
73
|
+
const formatTimeDuration = (seconds, maxUnit = 'year', minUnit = 'second', decimal = 2) => {
|
|
74
|
+
const result = groupTimeDuration(seconds, maxUnit, minUnit, decimal);
|
|
75
|
+
if (result.year === -1) return ''; // seconds 不合法 或 maxUnit < minUnit
|
|
76
|
+
const unitWording = { year: '年', month: '月', day: '天', hour: '小时', minute: '分钟', second: '秒' };
|
|
77
|
+
const periodArr = [];
|
|
78
|
+
['year', 'month', 'day', 'hour', 'minute', 'second'].forEach((unit) => {
|
|
79
|
+
if (unit !== minUnit) {
|
|
80
|
+
if (result[unit] > 0) periodArr.push(`${result[unit]}${unitWording[unit]}`); // 如2小时
|
|
81
|
+
} else { // 当前unit是最小单位,处理不足最小单位的部分
|
|
82
|
+
const min = round(result[unit] + result.decimal, decimal);
|
|
83
|
+
if (min > 0) periodArr.push(`${roundStr(min, 2, true)}${unitWording[unit]}`); // 如1.2,0.2
|
|
84
|
+
else if (periodArr.length === 0) periodArr.push(`1${unitWording[unit]}内`); // 1 unit内
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return periodArr.join('');
|
|
88
|
+
};
|
|
89
|
+
|
|
10
90
|
/**
|
|
11
91
|
* @function
|
|
12
92
|
* @description 格式化时间
|
|
@@ -15,72 +95,44 @@
|
|
|
15
95
|
*/
|
|
16
96
|
const formatTime = (seconds) => {
|
|
17
97
|
if (typeof seconds !== 'number') return seconds;
|
|
18
|
-
|
|
19
|
-
const PER_MINUTE = 60 * 1;
|
|
20
|
-
const PER_HOUR = 60 * PER_MINUTE;
|
|
21
|
-
const PRE_DAY = 24 * PER_HOUR;
|
|
22
|
-
let cost = '';
|
|
98
|
+
const { day, hour, minute, decimal } = groupTimeDuration(seconds, 'day', 'minute', 2);
|
|
23
99
|
// >24小时的显示 【x天】
|
|
24
|
-
if (
|
|
25
|
-
cost = `${Math.floor(seconds / PRE_DAY)}天`;
|
|
100
|
+
if (day > 0) return `${day}天`;
|
|
26
101
|
// <1小时的显示 【x分钟】 ,x取整数上限,最低为1分钟。
|
|
27
|
-
|
|
28
|
-
cost = `${Math.ceil(seconds / PER_MINUTE)}分钟`;
|
|
102
|
+
if (hour === 0) return `${Math.ceil(minute + decimal)}分钟`;
|
|
29
103
|
// <24小时&>1小时的显示 【x小时y分钟】 ,分钟取整数上限
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (s > 0) {
|
|
34
|
-
cost += `${Math.ceil(s / PER_MINUTE)}分钟`;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
104
|
+
let cost = `${hour}小时`;
|
|
105
|
+
const min = Math.ceil(minute + decimal);
|
|
106
|
+
if (min > 0) cost += `${min}分钟`;
|
|
38
107
|
return cost;
|
|
39
108
|
};
|
|
40
109
|
|
|
41
110
|
/**
|
|
42
111
|
* @function
|
|
43
112
|
* @description 将秒数格式化为x天y小时z分钟
|
|
44
|
-
* @param {Number}
|
|
113
|
+
* @param {Number} seconds 秒数
|
|
45
114
|
* @returns {String} 格式化后的文案
|
|
46
115
|
*/
|
|
47
|
-
const formatTimeWithDetails = (
|
|
48
|
-
let seconds = oriSeconds;
|
|
116
|
+
const formatTimeWithDetails = (seconds) => {
|
|
49
117
|
// 非Number类型,直接返回,不进行处理
|
|
50
118
|
if (typeof seconds !== 'number') return seconds;
|
|
51
119
|
// 参数为NaN类型,直接抛出异常
|
|
52
120
|
if (isNaN(seconds)) throw new Error(`formatTimeWithDetails方法的参数seconds必须时一个非NaN数字,现在的值为${seconds}`);
|
|
53
121
|
|
|
54
|
-
|
|
55
|
-
// 1分钟包含的秒数
|
|
56
|
-
const PER_MINUTE = 60 * 1;
|
|
57
|
-
// 1小时包含的秒数
|
|
58
|
-
const PER_HOUR = 60 * PER_MINUTE;
|
|
59
|
-
// 1天包含的秒数
|
|
60
|
-
const PRE_DAY = 24 * PER_HOUR;
|
|
122
|
+
const { day, hour, minute, decimal } = groupTimeDuration(seconds, 'day', 'minute', 2);
|
|
61
123
|
let cost = '';
|
|
62
|
-
|
|
63
|
-
// 秒数多于1天
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
cost += `${Math.ceil(seconds / PER_MINUTE)}分钟`;
|
|
75
|
-
} else {
|
|
76
|
-
// 秒数介于1天和1分钟之间
|
|
77
|
-
cost += `${Math.floor(seconds / PER_HOUR)}小时`;
|
|
78
|
-
const s = seconds % PER_HOUR;
|
|
79
|
-
if (s > 0) {
|
|
80
|
-
cost += `${Math.ceil(s / PER_MINUTE)}分钟`;
|
|
81
|
-
}
|
|
124
|
+
const min = Math.ceil(minute + decimal);
|
|
125
|
+
if (day > 0) { // 秒数多于1天
|
|
126
|
+
cost = `${day}天${hour}小时`;
|
|
127
|
+
// 这里大概率是有bug的,从测试用例里能看出:formatTime(86400) => 1天0小时0分钟
|
|
128
|
+
// 但为了不改变方法原有表现,先这样吧
|
|
129
|
+
if (min > 0 || hour === 0) cost += `${min}分钟`;
|
|
130
|
+
} else if (hour === 0) { // 秒数小于1小时
|
|
131
|
+
cost = `${min}分钟`;
|
|
132
|
+
} else { // 秒数介于1天和1分钟之间
|
|
133
|
+
cost = `${hour}小时`;
|
|
134
|
+
if (min > 0) cost += `${min}分钟`;
|
|
82
135
|
}
|
|
83
|
-
|
|
84
136
|
return cost;
|
|
85
137
|
};
|
|
86
138
|
|
|
@@ -157,10 +209,49 @@ const dateToString = (date, withTime = false, withSeconds = false, join = '-') =
|
|
|
157
209
|
return formatDateTime(date || new Date(), fmt);
|
|
158
210
|
};
|
|
159
211
|
|
|
212
|
+
/**
|
|
213
|
+
* 将时间字符串转换为Date对象
|
|
214
|
+
* @param {String} str 时间字符串,如:2022-03-24 20:02:05
|
|
215
|
+
* 支持对日期完整但时间部分缺失的情况进行处理,如:
|
|
216
|
+
* 2022-03-24 20:02 -> Date(1648123320000)
|
|
217
|
+
* 2022-03-24 20: -> Date(1648123200000)
|
|
218
|
+
* 2022-03-24 20 -> Date(1648123200000)
|
|
219
|
+
* 2022-03-24 -> Date(1648051200000)
|
|
220
|
+
* 小于10的数字可以省略0:
|
|
221
|
+
* 2022-3-24 20:2 -> Date(1648123320000)
|
|
222
|
+
* 支持任意分割符,如:
|
|
223
|
+
* 2022年03月24日 20时02分 -> Date(1648123320000)
|
|
224
|
+
* 2022/03/24 20:02:05 -> Date(1648123325000)
|
|
225
|
+
* @returns {Date|Null} 时间对象;转换失败时返回null
|
|
226
|
+
*/
|
|
227
|
+
const parseDateTime = (str) => {
|
|
228
|
+
const arr = new RegExp(/(\d{4})[^\d]+(\d{1,2})[^\d]+(\d{1,2})(.*)/, 'g').exec(str); // 分割日期和时间
|
|
229
|
+
if (!(arr && arr.index > -1)) return null; // 日期不合法,转换失败
|
|
230
|
+
const timeArr = new RegExp(/[^\d]?(\d{1,2})([^\d]+(\d{1,2})([^\d]+(\d{1,2}))?)?/, 'g').exec(arr[4]); // 分割时间
|
|
231
|
+
const [, year = 0, month = 0, day = 0] = arr; // 解析日期
|
|
232
|
+
const [, hour = 0, , minute = 0, , second = 0] = timeArr || []; // 解析时间
|
|
233
|
+
|
|
234
|
+
const date = new Date();
|
|
235
|
+
date.setFullYear(year);
|
|
236
|
+
// 解决js setMonth bug,在setMonth前先将day设置为1号,保证setMonth结果正确
|
|
237
|
+
// https://www.google.com/search?q=js+setmonth+bug&oq=js+setMonth&aqs=chrome.1.69i57j0i512j0i8i30j0i5i30j69i61.4323j0j4&sourceid=chrome&ie=UTF-8
|
|
238
|
+
date.setDate(1);
|
|
239
|
+
date.setMonth(month - 1);
|
|
240
|
+
date.setDate(day);
|
|
241
|
+
date.setHours(hour);
|
|
242
|
+
date.setMinutes(minute);
|
|
243
|
+
date.setSeconds(second);
|
|
244
|
+
date.setMilliseconds(0);
|
|
245
|
+
return date;
|
|
246
|
+
};
|
|
247
|
+
|
|
160
248
|
export {
|
|
249
|
+
groupTimeDuration,
|
|
250
|
+
formatTimeDuration,
|
|
161
251
|
formatDateTime,
|
|
162
252
|
formatTime,
|
|
163
253
|
formatTimeStr,
|
|
164
254
|
formatTimeWithDetails,
|
|
165
255
|
dateToString,
|
|
256
|
+
parseDateTime,
|
|
166
257
|
};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { formatTimeDuration, formatTime, formatTimeWithDetails, parseDateTime } from './timeUtils';
|
|
2
|
+
|
|
3
|
+
describe('timeUtils', () => {
|
|
4
|
+
describe('formatTimeDuration test cases', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
[0.001, undefined, undefined, undefined, '1秒内'],
|
|
7
|
+
[0.01, 'year', 'second', 2, '0.01秒'],
|
|
8
|
+
[0.01, 'year', 'minute', 2, '1分钟内'],
|
|
9
|
+
[0.1, 'year', 'second', 2, '0.1秒'],
|
|
10
|
+
[0.1, 'year', 'minute', 2, '1分钟内'],
|
|
11
|
+
[1, 'year', 'second', 2, '1秒'],
|
|
12
|
+
[1, 'year', 'minute', 2, '0.02分钟'],
|
|
13
|
+
[1, 'year', 'hour', 0, '1小时内'],
|
|
14
|
+
[1.1, 'year', 'second', 1, '1.1秒'],
|
|
15
|
+
[60, 'second', 'second', 2, '60秒'],
|
|
16
|
+
[60, 'year', 'second', 2, '1分钟'],
|
|
17
|
+
[60, 'year', 'hour', 2, '0.02小时'],
|
|
18
|
+
[60, 'year', 'hour', 1, '1小时内'],
|
|
19
|
+
[60, 'year', 'month', 0, '1月内'],
|
|
20
|
+
[61, 'year', 'second', 0, '1分钟1秒'],
|
|
21
|
+
[61, 'year', 'minute', 2, '1.02分钟'],
|
|
22
|
+
[3600, 'year', 'second', 0, '1小时'],
|
|
23
|
+
[3600, 'year', 'minute', 0, '1小时'],
|
|
24
|
+
[3600, 'year', 'hour', 0, '1小时'],
|
|
25
|
+
[3600, 'second', 'second', 2, '3600秒'],
|
|
26
|
+
[3600, 'minute', 'second', 2, '60分钟'],
|
|
27
|
+
[3600, 'year', 'day', 2, '0.04天'],
|
|
28
|
+
[3600, 'year', 'month', 2, '1月内'],
|
|
29
|
+
[3601, 'year', 'second', 2, '1小时1秒'],
|
|
30
|
+
[3601, 'year', 'minute', 2, '1小时0.02分钟'],
|
|
31
|
+
[3601, 'year', 'hour', 2, '1小时'],
|
|
32
|
+
[86400, 'year', 'second', 2, '1天'],
|
|
33
|
+
[86400, 'year', 'minute', 2, '1天'],
|
|
34
|
+
[86400, 'year', 'hour', 2, '1天'],
|
|
35
|
+
[86400, 'year', 'day', 2, '1天'],
|
|
36
|
+
[86400, 'hour', 'second', 2, '24小时'],
|
|
37
|
+
[86400, 'minute', 'second', 2, '1440分钟'],
|
|
38
|
+
[86400, 'year', 'month', 2, '0.03月'],
|
|
39
|
+
[86400, 'year', 'year', 2, '1年内'],
|
|
40
|
+
[86401, 'year', 'second', 2, '1天1秒'],
|
|
41
|
+
[86401, 'year', 'minute', 2, '1天0.02分钟'],
|
|
42
|
+
[86401, 'year', 'minute', 0, '1天'],
|
|
43
|
+
[86401, 'year', 'hour', 2, '1天'],
|
|
44
|
+
[31536000, 'year', 'second', 2, '1年'],
|
|
45
|
+
[31536000, 'month', 'second', 2, '12月5天'],
|
|
46
|
+
[31626061, 'year', 'second', 2, '1年1天1小时1分钟1秒'],
|
|
47
|
+
])('formatTimeDuration(%s, %s, %s, %d) => %s', (seconds, maxUnit, minUnit, decimal, expected) => {
|
|
48
|
+
expect(formatTimeDuration(seconds, maxUnit, minUnit, decimal)).toBe(expected);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('formatTime', () => {
|
|
53
|
+
test.each([
|
|
54
|
+
['invalid', 'invalid'],
|
|
55
|
+
[172800, '2天'],
|
|
56
|
+
[172799, '1天'],
|
|
57
|
+
[86401, '1天'],
|
|
58
|
+
[86400, '1天'],
|
|
59
|
+
[86399, '23小时60分钟'],
|
|
60
|
+
[86341, '23小时60分钟'],
|
|
61
|
+
[86340, '23小时59分钟'],
|
|
62
|
+
[7200, '2小时'],
|
|
63
|
+
[3601, '1小时1分钟'],
|
|
64
|
+
[3600, '1小时'],
|
|
65
|
+
[3599, '60分钟'],
|
|
66
|
+
[3541, '60分钟'],
|
|
67
|
+
[3540, '59分钟'],
|
|
68
|
+
[61, '2分钟'],
|
|
69
|
+
[60, '1分钟'],
|
|
70
|
+
[59, '1分钟'],
|
|
71
|
+
[1, '1分钟'],
|
|
72
|
+
])('formatTime(%s) => %s', (seconds, result) => expect(formatTime(seconds)).toBe(result));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('formatTimeWithDetails', () => {
|
|
76
|
+
test('数据非法', () => expect(formatTimeWithDetails('invalid')).toBe('invalid'));
|
|
77
|
+
test('NaN', () => expect(() => formatTimeWithDetails(parseInt('invalid', 10))).toThrow());
|
|
78
|
+
|
|
79
|
+
test.each([
|
|
80
|
+
[172800, '2天0小时0分钟'],
|
|
81
|
+
[172799, '1天23小时60分钟'],
|
|
82
|
+
[172741, '1天23小时60分钟'],
|
|
83
|
+
[172740, '1天23小时59分钟'],
|
|
84
|
+
[90001, '1天1小时1分钟'],
|
|
85
|
+
[90000, '1天1小时'],
|
|
86
|
+
[86401, '1天0小时1分钟'],
|
|
87
|
+
[86400, '1天0小时0分钟'],
|
|
88
|
+
[86399, '23小时60分钟'],
|
|
89
|
+
[86341, '23小时60分钟'],
|
|
90
|
+
[86340, '23小时59分钟'],
|
|
91
|
+
[7200, '2小时'],
|
|
92
|
+
[7199, '1小时60分钟'],
|
|
93
|
+
[7141, '1小时60分钟'],
|
|
94
|
+
[7140, '1小时59分钟'],
|
|
95
|
+
[3601, '1小时1分钟'],
|
|
96
|
+
[3600, '1小时'],
|
|
97
|
+
[3599, '60分钟'],
|
|
98
|
+
[3541, '60分钟'],
|
|
99
|
+
[3540, '59分钟'],
|
|
100
|
+
[61, '2分钟'],
|
|
101
|
+
[60, '1分钟'],
|
|
102
|
+
[59, '1分钟'],
|
|
103
|
+
[1, '1分钟'],
|
|
104
|
+
])('formatTime(%s) => %s', (seconds, result) => expect(formatTimeWithDetails(seconds)).toBe(result));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('parseDateTime', () => {
|
|
108
|
+
// 转换失败的情况
|
|
109
|
+
test('不合法转换', () => {
|
|
110
|
+
const date = parseDateTime('invalid');
|
|
111
|
+
expect(date).toBe(null);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// 转换成功的情况
|
|
115
|
+
test.each([
|
|
116
|
+
['2022-03-24 20:02:05', 1648123325000],
|
|
117
|
+
['2022/03/24 20:02', 1648123320000],
|
|
118
|
+
['2022/03/24 20:', 1648123200000],
|
|
119
|
+
['2022/03/24 20', 1648123200000],
|
|
120
|
+
['2022/03/24', 1648051200000],
|
|
121
|
+
['2022/03/24 20:2', 1648123320000],
|
|
122
|
+
['2022年03月24日20时02分', 1648123320000],
|
|
123
|
+
['2022年4月1日20时02分', 1648814520000],
|
|
124
|
+
['2022年6月1日', 1654012800000],
|
|
125
|
+
])('parseDateTime(%s) => Date(%d)', (str, timeStamp) => {
|
|
126
|
+
const date = parseDateTime(str);
|
|
127
|
+
expect(date).not.toBe(null);
|
|
128
|
+
expect(date.getTime()).toBe(timeStamp);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|