@jiangood/open-admin 3.1.9 → 3.1.11

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.
@@ -0,0 +1,562 @@
1
+ /**
2
+ * Quartz cron 表达式工具:生成、识别与中文说明。
3
+ *
4
+ * Quartz 格式为 6 或 7 段:秒 分 时 日 月 周 [年],第一位是秒。
5
+ */
6
+
7
+ export type CronIntervalUnit = 'second' | 'minute' | 'hour' | 'day';
8
+ export type CronPeriod = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
9
+
10
+ export interface CronInterval {
11
+ mode: 'interval';
12
+ every: number;
13
+ unit: CronIntervalUnit;
14
+ }
15
+
16
+ export interface CronSchedule {
17
+ mode: 'schedule';
18
+ period: CronPeriod;
19
+ hour: number;
20
+ minute: number;
21
+ second: number;
22
+ weekday?: string;
23
+ dayOfMonth?: number | 'L';
24
+ month?: number;
25
+ }
26
+
27
+ export type CronParsed = CronInterval | CronSchedule;
28
+
29
+ export interface CronScheduleOptions {
30
+ period: CronPeriod;
31
+ hour: number;
32
+ minute: number;
33
+ second?: number;
34
+ weekday?: string;
35
+ dayOfMonth?: number | 'L';
36
+ month?: number;
37
+ }
38
+
39
+ export const CRON_INTERVAL_UNITS: { value: CronIntervalUnit; label: string }[] = [
40
+ {value: 'second', label: '秒'},
41
+ {value: 'minute', label: '分钟'},
42
+ {value: 'hour', label: '小时'},
43
+ {value: 'day', label: '天'},
44
+ ];
45
+
46
+ export const CRON_WEEKDAYS: { value: string; label: string }[] = [
47
+ {value: 'MON', label: '周一'},
48
+ {value: 'TUE', label: '周二'},
49
+ {value: 'WED', label: '周三'},
50
+ {value: 'THU', label: '周四'},
51
+ {value: 'FRI', label: '周五'},
52
+ {value: 'SAT', label: '周六'},
53
+ {value: 'SUN', label: '周日'},
54
+ ];
55
+
56
+ const WEEKDAY_CN: Record<string, string> = {
57
+ SUN: '周日',
58
+ MON: '周一',
59
+ TUE: '周二',
60
+ WED: '周三',
61
+ THU: '周四',
62
+ FRI: '周五',
63
+ SAT: '周六',
64
+ };
65
+
66
+ /** Quartz 数字星期:1=周日 ... 7=周六 */
67
+ const WEEKDAY_BY_NUMBER = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'];
68
+
69
+ const MONTH_BY_NAME: Record<string, number> = {
70
+ JAN: 1,
71
+ FEB: 2,
72
+ MAR: 3,
73
+ APR: 4,
74
+ MAY: 5,
75
+ JUN: 6,
76
+ JUL: 7,
77
+ AUG: 8,
78
+ SEP: 9,
79
+ OCT: 10,
80
+ NOV: 11,
81
+ DEC: 12,
82
+ };
83
+
84
+ type SimpleField =
85
+ | { kind: 'any' }
86
+ | { kind: 'step'; step: number }
87
+ | { kind: 'number'; value: number }
88
+ | { kind: 'list'; values: number[] }
89
+ | { kind: 'range'; from: number; to: number };
90
+
91
+ type DomField =
92
+ | { kind: 'any' }
93
+ | { kind: 'day'; value: number }
94
+ | { kind: 'days'; values: number[] }
95
+ | { kind: 'range'; from: number; to: number }
96
+ | { kind: 'step'; step: number }
97
+ | { kind: 'last' }
98
+ | { kind: 'lastOffset'; offset: number }
99
+ | { kind: 'lastWeekday' }
100
+ | { kind: 'nearestWeekday'; day: number };
101
+
102
+ type MonthField =
103
+ | { kind: 'any' }
104
+ | { kind: 'month'; value: number }
105
+ | { kind: 'months'; values: number[] }
106
+ | { kind: 'range'; from: number; to: number };
107
+
108
+ type DowField =
109
+ | { kind: 'any' }
110
+ | { kind: 'day'; name: string }
111
+ | { kind: 'days'; names: string[] }
112
+ | { kind: 'range'; from: string; to: string }
113
+ | { kind: 'last'; name: string }
114
+ | { kind: 'nth'; name: string; nth: number };
115
+
116
+ function toInt(token: string): number | null {
117
+ return /^\d+$/.test(token) ? Number(token) : null;
118
+ }
119
+
120
+ function splitFields(expr: string | null | undefined): string[] | null {
121
+ if (!expr) return null;
122
+ const parts = expr.trim().split(/\s+/);
123
+ if (parts.length === 7 && parts[6] === '*') return parts.slice(0, 6);
124
+ if (parts.length !== 6) return null;
125
+ return parts;
126
+ }
127
+
128
+ function weekdayKey(token: string): string | null {
129
+ const upper = token.toUpperCase();
130
+ if (WEEKDAY_CN[upper]) return upper;
131
+ const n = toInt(token);
132
+ return n != null && n >= 1 && n <= 7 ? WEEKDAY_BY_NUMBER[n - 1] : null;
133
+ }
134
+
135
+ function monthNumber(token: string): number | null {
136
+ const upper = token.toUpperCase();
137
+ if (MONTH_BY_NAME[upper] != null) return MONTH_BY_NAME[upper];
138
+ const n = toInt(token);
139
+ return n != null && n >= 1 && n <= 12 ? n : null;
140
+ }
141
+
142
+ function parseSimpleField(field: string, min: number, max: number): SimpleField | null {
143
+ if (field === '*') return {kind: 'any'};
144
+
145
+ // */n 与 0/n 都表示从 0 开始每隔 n
146
+ const step = field.match(/^(?:\*|0)\/(\d+)$/);
147
+ if (step) {
148
+ const n = Number(step[1]);
149
+ return n >= 1 && n <= max ? {kind: 'step', step: n} : null;
150
+ }
151
+
152
+ const range = field.match(/^(\d+)-(\d+)$/);
153
+ if (range) {
154
+ const from = Number(range[1]);
155
+ const to = Number(range[2]);
156
+ return from >= min && to <= max && from <= to ? {kind: 'range', from, to} : null;
157
+ }
158
+
159
+ if (field.includes(',')) {
160
+ const values = field.split(',').map(toInt);
161
+ if (values.every(v => v != null && v >= min && v <= max)) {
162
+ return {kind: 'list', values: values as number[]};
163
+ }
164
+ return null;
165
+ }
166
+
167
+ const n = toInt(field);
168
+ return n != null && n >= min && n <= max ? {kind: 'number', value: n} : null;
169
+ }
170
+
171
+ function parseDom(field: string): DomField | null {
172
+ if (field === '*' || field === '?') return {kind: 'any'};
173
+ if (field === 'L') return {kind: 'last'};
174
+ if (field === 'LW') return {kind: 'lastWeekday'};
175
+
176
+ const lastOffset = field.match(/^L-(\d+)$/);
177
+ if (lastOffset) {
178
+ const offset = Number(lastOffset[1]);
179
+ return offset >= 1 && offset <= 30 ? {kind: 'lastOffset', offset} : null;
180
+ }
181
+
182
+ const nearest = field.match(/^(\d+)W$/);
183
+ if (nearest) {
184
+ const day = Number(nearest[1]);
185
+ return day >= 1 && day <= 31 ? {kind: 'nearestWeekday', day} : null;
186
+ }
187
+
188
+ const step = field.match(/^(?:\*|0)\/(\d+)$/);
189
+ if (step) {
190
+ const n = Number(step[1]);
191
+ return n >= 1 && n <= 31 ? {kind: 'step', step: n} : null;
192
+ }
193
+
194
+ if (field.includes(',')) {
195
+ const values = field.split(',').map(toInt);
196
+ if (values.every(v => v != null && v >= 1 && v <= 31)) {
197
+ return {kind: 'days', values: values as number[]};
198
+ }
199
+ return null;
200
+ }
201
+
202
+ const range = field.match(/^(\d+)-(\d+)$/);
203
+ if (range) {
204
+ const from = Number(range[1]);
205
+ const to = Number(range[2]);
206
+ return from >= 1 && to <= 31 && from <= to ? {kind: 'range', from, to} : null;
207
+ }
208
+
209
+ const day = toInt(field);
210
+ return day != null && day >= 1 && day <= 31 ? {kind: 'day', value: day} : null;
211
+ }
212
+
213
+ function parseMonth(field: string): MonthField | null {
214
+ if (field === '*') return {kind: 'any'};
215
+
216
+ const range = field.match(/^([A-Za-z0-9]+)-([A-Za-z0-9]+)$/);
217
+ if (range) {
218
+ const from = monthNumber(range[1]);
219
+ const to = monthNumber(range[2]);
220
+ return from != null && to != null && from <= to ? {kind: 'range', from, to} : null;
221
+ }
222
+
223
+ if (field.includes(',')) {
224
+ const values = field.split(',').map(monthNumber);
225
+ if (values.every(v => v != null)) {
226
+ return {kind: 'months', values: values as number[]};
227
+ }
228
+ return null;
229
+ }
230
+
231
+ const month = monthNumber(field);
232
+ return month != null ? {kind: 'month', value: month} : null;
233
+ }
234
+
235
+ function parseDow(field: string): DowField | null {
236
+ if (field === '*' || field === '?') return {kind: 'any'};
237
+
238
+ const nth = field.match(/^([A-Za-z0-9]+)#(\d+)$/);
239
+ if (nth) {
240
+ const name = weekdayKey(nth[1]);
241
+ const n = Number(nth[2]);
242
+ return name && n >= 1 && n <= 5 ? {kind: 'nth', name, nth: n} : null;
243
+ }
244
+
245
+ const last = field.match(/^([A-Za-z0-9]+)L$/);
246
+ if (last) {
247
+ const name = weekdayKey(last[1]);
248
+ return name ? {kind: 'last', name} : null;
249
+ }
250
+
251
+ const range = field.match(/^([A-Za-z0-9]+)-([A-Za-z0-9]+)$/);
252
+ if (range) {
253
+ const from = weekdayKey(range[1]);
254
+ const to = weekdayKey(range[2]);
255
+ return from && to ? {kind: 'range', from, to} : null;
256
+ }
257
+
258
+ if (field.includes(',')) {
259
+ const names = field.split(',').map(weekdayKey);
260
+ if (names.every(Boolean)) {
261
+ return {kind: 'days', names: names as string[]};
262
+ }
263
+ return null;
264
+ }
265
+
266
+ const name = weekdayKey(field);
267
+ return name ? {kind: 'day', name} : null;
268
+ }
269
+
270
+ function pad2(n: number): string {
271
+ return String(n).padStart(2, '0');
272
+ }
273
+
274
+ function formatTime(hour: number, minute: number, second: number): string {
275
+ return `${pad2(hour)}:${pad2(minute)}:${pad2(second)}`;
276
+ }
277
+
278
+ function weekdayCn(name: string): string {
279
+ return WEEKDAY_CN[name] ?? name;
280
+ }
281
+
282
+ function joinPrefix(prefix: string, text: string): string {
283
+ return /^\d/.test(text) ? `${prefix} ${text}` : `${prefix}${text}`;
284
+ }
285
+
286
+ function describeTimePoint(hour: SimpleField, minute: SimpleField, second: SimpleField): string | null {
287
+ if (minute.kind !== 'number' || second.kind !== 'number') return null;
288
+ if (hour.kind === 'number') return formatTime(hour.value, minute.value, second.value);
289
+ if (hour.kind === 'list') {
290
+ return hour.values.map(h => formatTime(h, minute.value, second.value)).join('、');
291
+ }
292
+ return null;
293
+ }
294
+
295
+ function describeFrequency(hour: SimpleField, minute: SimpleField, second: SimpleField): string | null {
296
+ if (hour.kind === 'any') {
297
+ if (minute.kind === 'any') {
298
+ if (second.kind === 'any') return '每秒';
299
+ if (second.kind === 'step') return second.step === 1 ? '每秒' : `每隔 ${second.step} 秒`;
300
+ if (second.kind === 'number') {
301
+ return second.value === 0 ? '每分钟' : `每分钟的第 ${second.value} 秒`;
302
+ }
303
+ if (second.kind === 'list') return `每分钟的第 ${second.values.join('、')} 秒`;
304
+ if (second.kind === 'range') return `每分钟的第 ${second.from}-${second.to} 秒`;
305
+ return null;
306
+ }
307
+
308
+ if (minute.kind === 'step') {
309
+ if (second.kind !== 'number') return null;
310
+ return second.value === 0
311
+ ? `每隔 ${minute.step} 分钟`
312
+ : `每隔 ${minute.step} 分钟的第 ${second.value} 秒`;
313
+ }
314
+
315
+ if (minute.kind === 'number') {
316
+ if (second.kind === 'number') {
317
+ if (minute.value === 0 && second.value === 0) return '每小时整点';
318
+ if (second.value === 0) return `每小时的第 ${minute.value} 分`;
319
+ return `每小时的第 ${minute.value} 分 ${second.value} 秒`;
320
+ }
321
+ if (second.kind === 'list') {
322
+ return `每小时的第 ${minute.value} 分的第 ${second.values.join('、')} 秒`;
323
+ }
324
+ return null;
325
+ }
326
+
327
+ if (minute.kind === 'list' && second.kind === 'number') {
328
+ const minuteText = `每小时的第 ${minute.values.join('、')} 分`;
329
+ return second.value === 0 ? minuteText : `${minuteText}的第 ${second.value} 秒`;
330
+ }
331
+ return null;
332
+ }
333
+
334
+ if (hour.kind === 'step') {
335
+ if (minute.kind === 'number' && second.kind === 'number') {
336
+ return `每隔 ${hour.step} 小时的第 ${minute.value} 分 ${second.value} 秒`;
337
+ }
338
+ return null;
339
+ }
340
+
341
+ return null;
342
+ }
343
+
344
+ function describeDayOfMonth(dom: Exclude<DomField, { kind: 'any' }>): string | null {
345
+ switch (dom.kind) {
346
+ case 'day':
347
+ return `${dom.value} 号`;
348
+ case 'days':
349
+ return `${dom.values.join('、')} 号`;
350
+ case 'range':
351
+ return `${dom.from}-${dom.to} 号`;
352
+ case 'step':
353
+ return `从 1 号起每隔 ${dom.step} 天`;
354
+ case 'last':
355
+ return '最后一天';
356
+ case 'lastOffset':
357
+ return `倒数第 ${dom.offset} 天`;
358
+ case 'lastWeekday':
359
+ return '最后一个工作日';
360
+ case 'nearestWeekday':
361
+ return `${dom.day} 号最近的工作日`;
362
+ default:
363
+ return null;
364
+ }
365
+ }
366
+
367
+ function describeMonth(month: MonthField): string | null {
368
+ switch (month.kind) {
369
+ case 'month':
370
+ return `${month.value} 月`;
371
+ case 'months':
372
+ return `${month.values.join('、')} 月`;
373
+ case 'range':
374
+ return `${month.from}-${month.to} 月`;
375
+ default:
376
+ return null;
377
+ }
378
+ }
379
+
380
+ function describeDate(dom: DomField, dow: DowField, month: MonthField): string | null {
381
+ const monthAny = month.kind === 'any';
382
+ const monthText = monthAny ? '' : describeMonth(month);
383
+ if (!monthAny && !monthText) return null;
384
+
385
+ if (dow.kind !== 'any') {
386
+ // 同时指定日期和星期在 Quartz 中不支持,日期必须是 ?
387
+ if (!monthAny || dom.kind !== 'any') return null;
388
+ switch (dow.kind) {
389
+ case 'day':
390
+ return `每${weekdayCn(dow.name)}`;
391
+ case 'days':
392
+ return `每${dow.names.map(weekdayCn).join('、')}`;
393
+ case 'range':
394
+ return `每${weekdayCn(dow.from)}至${weekdayCn(dow.to)}`;
395
+ case 'last':
396
+ return `每月最后一个${weekdayCn(dow.name)}`;
397
+ case 'nth':
398
+ return `每月第 ${dow.nth} 个${weekdayCn(dow.name)}`;
399
+ default:
400
+ return null;
401
+ }
402
+ }
403
+
404
+ if (dom.kind === 'any') {
405
+ return monthAny ? '每天' : `每年 ${monthText}的每天`;
406
+ }
407
+
408
+ const dayText = describeDayOfMonth(dom);
409
+ if (!dayText) return null;
410
+ return monthAny ? joinPrefix('每月', dayText) : joinPrefix(`每年 ${monthText}`, dayText);
411
+ }
412
+
413
+ export class CronUtils {
414
+
415
+ /**
416
+ * 生成中文说明;表达式非法或暂不支持时返回空字符串。
417
+ */
418
+ static describe(expr: string | null | undefined): string {
419
+ const fields = splitFields(expr);
420
+ if (!fields) return '';
421
+
422
+ const [secText, minText, hourText, domText, monthText, dowText] = fields;
423
+ const second = parseSimpleField(secText, 0, 59);
424
+ const minute = parseSimpleField(minText, 0, 59);
425
+ const hour = parseSimpleField(hourText, 0, 23);
426
+ const dom = parseDom(domText);
427
+ const month = parseMonth(monthText);
428
+ const dow = parseDow(dowText);
429
+ if (!second || !minute || !hour || !dom || !month || !dow) return '';
430
+
431
+ const dateAny = dom.kind === 'any' && dow.kind === 'any' && month.kind === 'any';
432
+ const timePoint = describeTimePoint(hour, minute, second);
433
+
434
+ if (timePoint) {
435
+ if (dateAny) return `每天 ${timePoint}`;
436
+ const dateText = describeDate(dom, dow, month);
437
+ return dateText ? `${dateText} ${timePoint}` : '';
438
+ }
439
+
440
+ if (!dateAny) return '';
441
+ return describeFrequency(hour, minute, second) ?? '';
442
+ }
443
+
444
+ /** 生成按间隔执行的表达式 */
445
+ static buildInterval(every: number, unit: CronIntervalUnit): string {
446
+ const n = Math.max(1, Math.floor(every || 1));
447
+ switch (unit) {
448
+ case 'second':
449
+ return n === 1 ? '* * * * * ?' : `*/${n} * * * * ?`;
450
+ case 'minute':
451
+ return n === 1 ? '0 * * * * ?' : `0 */${n} * * * ?`;
452
+ case 'hour':
453
+ return n === 1 ? '0 0 * * * ?' : `0 0 */${n} * * ?`;
454
+ case 'day':
455
+ return n === 1 ? '0 0 0 * * ?' : `0 0 0 */${n} * ?`;
456
+ default:
457
+ return '';
458
+ }
459
+ }
460
+
461
+ /** 生成按固定时间点执行的表达式 */
462
+ static buildSchedule(options: CronScheduleOptions): string {
463
+ const second = clamp(options.second ?? 0, 0, 59);
464
+ const minute = clamp(options.minute, 0, 59);
465
+ const hour = clamp(options.hour, 0, 23);
466
+
467
+ switch (options.period) {
468
+ case 'DAILY':
469
+ return `${second} ${minute} ${hour} * * ?`;
470
+ case 'WEEKLY':
471
+ return `${second} ${minute} ${hour} ? * ${options.weekday || 'MON'}`;
472
+ case 'MONTHLY': {
473
+ const day = options.dayOfMonth === 'L' ? 'L' : clamp(options.dayOfMonth ?? 1, 1, 31);
474
+ return `${second} ${minute} ${hour} ${day} * ?`;
475
+ }
476
+ case 'YEARLY': {
477
+ const day = clamp(options.dayOfMonth === 'L' ? 1 : (options.dayOfMonth ?? 1), 1, 31);
478
+ const month = clamp(options.month ?? 1, 1, 12);
479
+ return `${second} ${minute} ${hour} ${day} ${month} ?`;
480
+ }
481
+ default:
482
+ return '';
483
+ }
484
+ }
485
+
486
+ /** 识别编辑器生成的常见表达式,用于打开弹窗时回填 */
487
+ static parse(expr: string | null | undefined): CronParsed | null {
488
+ const fields = splitFields(expr);
489
+ if (!fields) return null;
490
+ const text = fields.join(' ');
491
+
492
+ let match = text.match(/^(\*|\*\/(\d+)) \* \* \* \* \?$/);
493
+ if (match) {
494
+ return {mode: 'interval', every: match[1] === '*' ? 1 : Number(match[2]), unit: 'second'};
495
+ }
496
+ match = text.match(/^0 (\*|\*\/(\d+)) \* \* \* \?$/);
497
+ if (match) {
498
+ return {mode: 'interval', every: match[1] === '*' ? 1 : Number(match[2]), unit: 'minute'};
499
+ }
500
+ match = text.match(/^0 0 (\*|\*\/(\d+)) \* \* \?$/);
501
+ if (match) {
502
+ return {mode: 'interval', every: match[1] === '*' ? 1 : Number(match[2]), unit: 'hour'};
503
+ }
504
+ match = text.match(/^0 0 0 (\*|\*\/(\d+)) \* \?$/);
505
+ if (match) {
506
+ return {mode: 'interval', every: match[1] === '*' ? 1 : Number(match[2]), unit: 'day'};
507
+ }
508
+
509
+ match = text.match(/^(\d+) (\d+) (\d+) \* \* \?$/);
510
+ if (match) {
511
+ return {
512
+ mode: 'schedule',
513
+ period: 'DAILY',
514
+ second: Number(match[1]),
515
+ minute: Number(match[2]),
516
+ hour: Number(match[3]),
517
+ };
518
+ }
519
+ match = text.match(/^(\d+) (\d+) (\d+) \? \* ([A-Za-z0-9]+)$/);
520
+ if (match) {
521
+ const weekday = weekdayKey(match[4]);
522
+ if (weekday) {
523
+ return {
524
+ mode: 'schedule',
525
+ period: 'WEEKLY',
526
+ second: Number(match[1]),
527
+ minute: Number(match[2]),
528
+ hour: Number(match[3]),
529
+ weekday,
530
+ };
531
+ }
532
+ }
533
+ match = text.match(/^(\d+) (\d+) (\d+) (L|\d+) \* \?$/);
534
+ if (match) {
535
+ return {
536
+ mode: 'schedule',
537
+ period: 'MONTHLY',
538
+ second: Number(match[1]),
539
+ minute: Number(match[2]),
540
+ hour: Number(match[3]),
541
+ dayOfMonth: match[4] === 'L' ? 'L' : Number(match[4]),
542
+ };
543
+ }
544
+ match = text.match(/^(\d+) (\d+) (\d+) (\d+) (\d+) \?$/);
545
+ if (match) {
546
+ return {
547
+ mode: 'schedule',
548
+ period: 'YEARLY',
549
+ second: Number(match[1]),
550
+ minute: Number(match[2]),
551
+ hour: Number(match[3]),
552
+ dayOfMonth: Number(match[4]),
553
+ month: Number(match[5]),
554
+ };
555
+ }
556
+ return null;
557
+ }
558
+ }
559
+
560
+ function clamp(value: number, min: number, max: number): number {
561
+ return Math.min(max, Math.max(min, Math.floor(value)));
562
+ }
@@ -1,4 +1,5 @@
1
1
  export * from "./DateUtils";
2
+ export * from "./CronUtils";
2
3
  export * from "./ArrayUtils";
3
4
  export * from "./UrlUtils"
4
5
  export * from "./StringUtils"