@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmsfe/tms-core",
3
- "version": "0.0.61",
3
+ "version": "0.0.64",
4
4
  "description": "tms运行时框架",
5
5
  "repository": {
6
6
  "type": "git",
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
- componentOptions.lifetimes = componentOptions.lifetimes || {};
16
- let original = helper.emptyFunc;
17
- const proxyMethod = function (...args: any[]): any {
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
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 自动埋点辅助类
2
+ * 全埋点辅助类
3
3
  */
4
4
 
5
5
  // / <reference path='./types.ts'/>
@@ -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}`, options, extra);
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('wx_navigate_before', api, path, params);
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
- removeItem(`${key}_v${i}`);
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.push({ key, version });
68
- if (cleanTimerId === 0) {
69
- cleanTimerId = setTimeout(cleanTask, 2000);
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}`;
@@ -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) return '';
132
+ if (isNaN(xNum) || isNaN(nNum) || nNum < 0) {
133
+ return '';
134
+ }
133
135
 
134
136
  // 仅保留整数的情况
135
- if (nNum === 0) return Math.round(xNum);
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
- if (rexExp.test(xStr)) { // 情况2,处理方式2.1:如果小数部分第n+1位是5,增加2*10^(-n-1)
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) return str;
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]{1})(0*)$/, (_m, s1) => s1);
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 (seconds >= PRE_DAY) {
25
- cost = `${Math.floor(seconds / PRE_DAY)}天`;
100
+ if (day > 0) return `${day}天`;
26
101
  // <1小时的显示 【x分钟】 ,x取整数上限,最低为1分钟。
27
- } else if (seconds < PER_HOUR) {
28
- cost = `${Math.ceil(seconds / PER_MINUTE)}分钟`;
102
+ if (hour === 0) return `${Math.ceil(minute + decimal)}分钟`;
29
103
  // <24小时&>1小时的显示 【x小时y分钟】 ,分钟取整数上限
30
- } else {
31
- cost = `${Math.floor(seconds / PER_HOUR)}小时`;
32
- const s = seconds % PER_HOUR;
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} oriSeconds 秒数
113
+ * @param {Number} seconds 秒数
45
114
  * @returns {String} 格式化后的文案
46
115
  */
47
- const formatTimeWithDetails = (oriSeconds) => {
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
- if (seconds >= PRE_DAY) {
65
- cost = `${Math.floor(seconds / PRE_DAY)}天`;
66
- seconds %= PRE_DAY;
67
- }
68
-
69
- // 秒数小于1小时
70
- if (seconds < PER_HOUR) {
71
- if (cost) {
72
- cost += '0小时';
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
+ });