hyper-scheduler 1.0.0
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/.editorconfig +21 -0
- package/.eslintrc.cjs +26 -0
- package/GEMINI.md +1 -0
- package/README.md +38 -0
- package/docs/.vitepress/config.ts +52 -0
- package/docs/README.md +120 -0
- package/docs/api/devtools.md +232 -0
- package/docs/api/index.md +178 -0
- package/docs/api/scheduler.md +322 -0
- package/docs/api/task.md +439 -0
- package/docs/api/types.md +365 -0
- package/docs/examples/index.md +295 -0
- package/docs/guide/best-practices.md +436 -0
- package/docs/guide/core-concepts.md +363 -0
- package/docs/guide/getting-started.md +138 -0
- package/docs/index.md +33 -0
- package/docs/public/logo.svg +54 -0
- package/examples/browser/index.html +354 -0
- package/examples/node/simple.js +36 -0
- package/examples/react-demo/index.html +12 -0
- package/examples/react-demo/package.json +23 -0
- package/examples/react-demo/src/App.css +212 -0
- package/examples/react-demo/src/App.jsx +160 -0
- package/examples/react-demo/src/main.jsx +9 -0
- package/examples/react-demo/vite.config.ts +12 -0
- package/examples/react-demo/yarn.lock +752 -0
- package/examples/vue-demo/index.html +12 -0
- package/examples/vue-demo/package.json +21 -0
- package/examples/vue-demo/src/App.vue +373 -0
- package/examples/vue-demo/src/main.ts +4 -0
- package/examples/vue-demo/vite.config.ts +13 -0
- package/examples/vue-demo/yarn.lock +375 -0
- package/package.json +51 -0
- package/src/constants.ts +18 -0
- package/src/core/retry-strategy.ts +28 -0
- package/src/core/scheduler.ts +601 -0
- package/src/core/task-registry.ts +58 -0
- package/src/index.ts +74 -0
- package/src/platform/browser/browser-timer.ts +66 -0
- package/src/platform/browser/main-thread-timer.ts +16 -0
- package/src/platform/browser/worker.ts +31 -0
- package/src/platform/node/debug-cli.ts +19 -0
- package/src/platform/node/node-timer.ts +15 -0
- package/src/platform/timer-strategy.ts +19 -0
- package/src/plugins/dev-tools.ts +101 -0
- package/src/types.ts +115 -0
- package/src/ui/components/devtools.ts +525 -0
- package/src/ui/components/floating-trigger.ts +102 -0
- package/src/ui/components/icons.ts +16 -0
- package/src/ui/components/resizer.ts +129 -0
- package/src/ui/components/task-detail.ts +228 -0
- package/src/ui/components/task-header.ts +319 -0
- package/src/ui/components/task-list.ts +416 -0
- package/src/ui/components/timeline.ts +364 -0
- package/src/ui/debug-panel.ts +56 -0
- package/src/ui/i18n/en.ts +76 -0
- package/src/ui/i18n/index.ts +42 -0
- package/src/ui/i18n/zh.ts +76 -0
- package/src/ui/store/dev-tools-store.ts +191 -0
- package/src/ui/styles/theme.css.ts +56 -0
- package/src/ui/styles.ts +43 -0
- package/src/utils/cron-lite.ts +221 -0
- package/src/utils/cron.ts +20 -0
- package/src/utils/id.ts +10 -0
- package/src/utils/schedule.ts +93 -0
- package/src/vite-env.d.ts +1 -0
- package/stats.html +4949 -0
- package/tests/integration/Debug.test.ts +58 -0
- package/tests/unit/Plugin.test.ts +16 -0
- package/tests/unit/RetryStrategy.test.ts +21 -0
- package/tests/unit/Scheduler.test.ts +38 -0
- package/tests/unit/schedule.test.ts +70 -0
- package/tests/unit/ui/DevToolsStore.test.ts +67 -0
- package/tsconfig.json +28 -0
- package/vite.config.ts +51 -0
- package/vitest.config.ts +24 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { TaskSnapshot, TaskControlAPI } from '../../types';
|
|
2
|
+
import { setLanguage } from '../i18n';
|
|
3
|
+
|
|
4
|
+
export interface DevToolsState {
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
activeTab: 'tasks' | 'timeline';
|
|
7
|
+
theme: 'light' | 'dark' | 'auto';
|
|
8
|
+
dockPosition: 'right' | 'bottom';
|
|
9
|
+
panelSize: { width: number; height: number };
|
|
10
|
+
language: 'en' | 'zh';
|
|
11
|
+
filterText: string;
|
|
12
|
+
selectedTaskId: string | null;
|
|
13
|
+
tasks: Map<string, TaskSnapshot>;
|
|
14
|
+
history: Map<string, any[]>;
|
|
15
|
+
fps: number;
|
|
16
|
+
schedulerRunning: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Listener<T> = (value: T) => void;
|
|
20
|
+
|
|
21
|
+
export class DevToolsStore {
|
|
22
|
+
private state: DevToolsState;
|
|
23
|
+
private listeners: Map<keyof DevToolsState, Set<Listener<any>>>;
|
|
24
|
+
private scheduler?: TaskControlAPI;
|
|
25
|
+
|
|
26
|
+
constructor() {
|
|
27
|
+
this.state = {
|
|
28
|
+
isOpen: false,
|
|
29
|
+
activeTab: 'tasks',
|
|
30
|
+
theme: 'auto',
|
|
31
|
+
dockPosition: 'right',
|
|
32
|
+
panelSize: { width: 500, height: 500 },
|
|
33
|
+
language: 'en',
|
|
34
|
+
filterText: '',
|
|
35
|
+
selectedTaskId: null,
|
|
36
|
+
tasks: new Map(),
|
|
37
|
+
history: new Map(),
|
|
38
|
+
fps: 0,
|
|
39
|
+
schedulerRunning: false,
|
|
40
|
+
};
|
|
41
|
+
this.listeners = new Map();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
setScheduler(scheduler: TaskControlAPI) {
|
|
45
|
+
this.scheduler = scheduler;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
getState(): Readonly<DevToolsState> {
|
|
49
|
+
return this.state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
subscribe<K extends keyof DevToolsState>(
|
|
53
|
+
key: K,
|
|
54
|
+
callback: Listener<DevToolsState[K]>
|
|
55
|
+
): () => void {
|
|
56
|
+
if (!this.listeners.has(key)) {
|
|
57
|
+
this.listeners.set(key, new Set());
|
|
58
|
+
}
|
|
59
|
+
this.listeners.get(key)!.add(callback);
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
this.listeners.get(key)?.delete(callback);
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private notify<K extends keyof DevToolsState>(key: K, value: DevToolsState[K]) {
|
|
67
|
+
this.listeners.get(key)?.forEach((cb) => cb(value));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
toggle() {
|
|
71
|
+
this.state.isOpen = !this.state.isOpen;
|
|
72
|
+
this.notify('isOpen', this.state.isOpen);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setTheme(theme: 'light' | 'dark' | 'auto') {
|
|
76
|
+
this.state.theme = theme;
|
|
77
|
+
this.notify('theme', this.state.theme);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set language synchronously without notifying listeners.
|
|
82
|
+
* Used during initialization before child components render.
|
|
83
|
+
*/
|
|
84
|
+
setLanguageSync(lang: 'en' | 'zh') {
|
|
85
|
+
setLanguage(lang);
|
|
86
|
+
this.state.language = lang;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
setLanguage(lang: 'en' | 'zh') {
|
|
90
|
+
setLanguage(lang);
|
|
91
|
+
this.state.language = lang;
|
|
92
|
+
this.notify('language', this.state.language);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
setPanelSize(size: { width?: number; height?: number }) {
|
|
96
|
+
this.state.panelSize = { ...this.state.panelSize, ...size };
|
|
97
|
+
// Persist to localStorage?
|
|
98
|
+
try {
|
|
99
|
+
localStorage.setItem('hs-panel-size', JSON.stringify(this.state.panelSize));
|
|
100
|
+
} catch (e) { /* ignore */ }
|
|
101
|
+
|
|
102
|
+
this.notify('panelSize', this.state.panelSize);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
setTab(tab: 'tasks' | 'timeline') {
|
|
106
|
+
this.state.activeTab = tab;
|
|
107
|
+
this.notify('activeTab', this.state.activeTab);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setDockPosition(pos: 'right' | 'bottom') {
|
|
111
|
+
this.state.dockPosition = pos;
|
|
112
|
+
this.notify('dockPosition', this.state.dockPosition);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setFilterText(text: string) {
|
|
116
|
+
this.state.filterText = text;
|
|
117
|
+
this.notify('filterText', this.state.filterText);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
updateTask(task: TaskSnapshot) {
|
|
121
|
+
// Create a new Map to ensure reference change for listeners
|
|
122
|
+
const newTasks = new Map(this.state.tasks);
|
|
123
|
+
newTasks.set(task.id, task);
|
|
124
|
+
this.state.tasks = newTasks;
|
|
125
|
+
this.notify('tasks', this.state.tasks);
|
|
126
|
+
|
|
127
|
+
// If task object contains history (Task interface), update it too
|
|
128
|
+
if ('history' in task && Array.isArray((task as any).history)) {
|
|
129
|
+
const newHistory = new Map(this.state.history);
|
|
130
|
+
newHistory.set(task.id, (task as any).history);
|
|
131
|
+
this.state.history = newHistory;
|
|
132
|
+
this.notify('history', this.state.history);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
selectTask(id: string | null) {
|
|
137
|
+
this.state.selectedTaskId = id;
|
|
138
|
+
this.notify('selectedTaskId', id);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
addHistory(taskId: string, record: any) {
|
|
142
|
+
const list = [...(this.state.history.get(taskId) || [])];
|
|
143
|
+
list.push(record);
|
|
144
|
+
// Limit history size (e.g. 50)
|
|
145
|
+
if (list.length > 50) {
|
|
146
|
+
list.splice(0, list.length - 50);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const newHistory = new Map(this.state.history);
|
|
150
|
+
newHistory.set(taskId, list);
|
|
151
|
+
this.state.history = newHistory;
|
|
152
|
+
this.notify('history', newHistory);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async triggerTask(id: string) {
|
|
156
|
+
if (this.scheduler) {
|
|
157
|
+
await this.scheduler.trigger(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stopTask(id: string) {
|
|
162
|
+
console.log('[DevToolsStore] stopTask:', id);
|
|
163
|
+
if (this.scheduler) {
|
|
164
|
+
this.scheduler.pause(id); // pause maps to stopTask in scheduler
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
startTask(id: string) {
|
|
169
|
+
console.log('[DevToolsStore] startTask:', id);
|
|
170
|
+
if (this.scheduler) {
|
|
171
|
+
this.scheduler.resume(id); // resume maps to startTask in scheduler
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
removeTask(id: string) {
|
|
176
|
+
if (this.scheduler) {
|
|
177
|
+
this.scheduler.remove(id);
|
|
178
|
+
// Optimistic update or wait for event?
|
|
179
|
+
// Wait for 'task_removed' event usually best, but we can remove from state too.
|
|
180
|
+
const newTasks = new Map(this.state.tasks);
|
|
181
|
+
newTasks.delete(id);
|
|
182
|
+
this.state.tasks = newTasks;
|
|
183
|
+
this.notify('tasks', this.state.tasks);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
setSchedulerRunning(running: boolean) {
|
|
188
|
+
this.state.schedulerRunning = running;
|
|
189
|
+
this.notify('schedulerRunning', running);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export const themeStyles = `
|
|
2
|
+
:host {
|
|
3
|
+
--hs-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
4
|
+
--hs-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
5
|
+
--hs-font-size: 12px;
|
|
6
|
+
--hs-line-height: 1.5;
|
|
7
|
+
--hs-panel-width: 400px;
|
|
8
|
+
--hs-panel-height: 300px;
|
|
9
|
+
|
|
10
|
+
/* Light Theme (Default) */
|
|
11
|
+
--hs-bg: #ffffff;
|
|
12
|
+
--hs-bg-secondary: #f3f4f6;
|
|
13
|
+
--hs-text: #1f2937;
|
|
14
|
+
--hs-text-secondary: #6b7280;
|
|
15
|
+
--hs-border: #e5e7eb;
|
|
16
|
+
--hs-primary: #3b82f6;
|
|
17
|
+
--hs-primary-hover: #2563eb;
|
|
18
|
+
--hs-danger: #ef4444;
|
|
19
|
+
--hs-danger-hover: #dc2626;
|
|
20
|
+
--hs-success: #10b981;
|
|
21
|
+
--hs-warning: #f59e0b;
|
|
22
|
+
|
|
23
|
+
--hs-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
|
24
|
+
--hs-radius: 6px;
|
|
25
|
+
--hs-header-height: 40px;
|
|
26
|
+
--hs-z-index: 9999;
|
|
27
|
+
|
|
28
|
+
/* Default display styles for the host itself */
|
|
29
|
+
background: var(--hs-bg);
|
|
30
|
+
color: var(--hs-text);
|
|
31
|
+
font-family: var(--hs-font-family);
|
|
32
|
+
font-size: var(--hs-font-size);
|
|
33
|
+
line-height: var(--hs-line-height);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
:host([theme="dark"]) {
|
|
37
|
+
--hs-bg: #111827;
|
|
38
|
+
--hs-bg-secondary: #1f2937;
|
|
39
|
+
--hs-text: #f9fafb;
|
|
40
|
+
--hs-text-secondary: #9ca3af;
|
|
41
|
+
--hs-border: #374151;
|
|
42
|
+
--hs-primary: #60a5fa;
|
|
43
|
+
--hs-primary-hover: #3b82f6;
|
|
44
|
+
--hs-danger: #f87171;
|
|
45
|
+
--hs-danger-hover: #ef4444;
|
|
46
|
+
--hs-success: #34d399;
|
|
47
|
+
--hs-warning: #fbbf24;
|
|
48
|
+
|
|
49
|
+
--hs-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -1px rgba(0, 0, 0, 0.3);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
:host {
|
|
53
|
+
background: var(--hs-bg);
|
|
54
|
+
color: var(--hs-text);
|
|
55
|
+
}
|
|
56
|
+
`;
|
package/src/ui/styles.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export const styles = `
|
|
2
|
+
#hyper-scheduler-debug-panel {
|
|
3
|
+
position: fixed;
|
|
4
|
+
bottom: 10px;
|
|
5
|
+
right: 10px;
|
|
6
|
+
width: 300px;
|
|
7
|
+
background: rgba(0, 0, 0, 0.8);
|
|
8
|
+
color: white;
|
|
9
|
+
font-family: monospace;
|
|
10
|
+
font-size: 12px;
|
|
11
|
+
z-index: 9999;
|
|
12
|
+
border-radius: 5px;
|
|
13
|
+
padding: 10px;
|
|
14
|
+
max-height: 300px;
|
|
15
|
+
overflow-y: auto;
|
|
16
|
+
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
|
17
|
+
}
|
|
18
|
+
.hs-header {
|
|
19
|
+
display: flex;
|
|
20
|
+
justify-content: space-between;
|
|
21
|
+
border-bottom: 1px solid #555;
|
|
22
|
+
padding-bottom: 5px;
|
|
23
|
+
margin-bottom: 5px;
|
|
24
|
+
font-weight: bold;
|
|
25
|
+
}
|
|
26
|
+
.hs-task-row {
|
|
27
|
+
display: flex;
|
|
28
|
+
justify-content: space-between;
|
|
29
|
+
margin-bottom: 3px;
|
|
30
|
+
}
|
|
31
|
+
.hs-status-running { color: #4caf50; }
|
|
32
|
+
.hs-status-stopped { color: #f44336; }
|
|
33
|
+
.hs-status-idle { color: #ffeb3b; }
|
|
34
|
+
.hs-status-error { color: #ff5722; }
|
|
35
|
+
.hs-toggle-btn {
|
|
36
|
+
background: none;
|
|
37
|
+
border: 1px solid #fff;
|
|
38
|
+
color: #fff;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
font-size: 10px;
|
|
41
|
+
margin-left: 5px;
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 轻量级 Cron 解析器
|
|
3
|
+
* 支持标准 5 位和 6 位 cron 表达式
|
|
4
|
+
* 格式: [秒] 分 时 日 月 周
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface CronFields {
|
|
8
|
+
second: number[];
|
|
9
|
+
minute: number[];
|
|
10
|
+
hour: number[];
|
|
11
|
+
dayOfMonth: number[];
|
|
12
|
+
month: number[];
|
|
13
|
+
dayOfWeek: number[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 解析单个 cron 字段
|
|
18
|
+
*/
|
|
19
|
+
function parseField(field: string, min: number, max: number): number[] {
|
|
20
|
+
const values: Set<number> = new Set();
|
|
21
|
+
|
|
22
|
+
// 处理 * (所有值)
|
|
23
|
+
if (field === '*') {
|
|
24
|
+
for (let i = min; i <= max; i++) {
|
|
25
|
+
values.add(i);
|
|
26
|
+
}
|
|
27
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 处理逗号分隔的多个值: 1,3,5
|
|
31
|
+
const parts = field.split(',');
|
|
32
|
+
for (const part of parts) {
|
|
33
|
+
// 处理步长: */5 或 1-10/2
|
|
34
|
+
if (part.includes('/')) {
|
|
35
|
+
const [range, step] = part.split('/');
|
|
36
|
+
const stepNum = parseInt(step, 10);
|
|
37
|
+
|
|
38
|
+
if (range === '*') {
|
|
39
|
+
for (let i = min; i <= max; i += stepNum) {
|
|
40
|
+
values.add(i);
|
|
41
|
+
}
|
|
42
|
+
} else if (range.includes('-')) {
|
|
43
|
+
const [start, end] = range.split('-').map(Number);
|
|
44
|
+
if (start < min || end > max) {
|
|
45
|
+
throw new Error(`Value out of range: ${start}-${end} (expected ${min}-${max})`);
|
|
46
|
+
}
|
|
47
|
+
for (let i = start; i <= end; i += stepNum) {
|
|
48
|
+
values.add(i);
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
const start = parseInt(range, 10);
|
|
52
|
+
if (start < min || start > max) {
|
|
53
|
+
throw new Error(`Value out of range: ${start} (expected ${min}-${max})`);
|
|
54
|
+
}
|
|
55
|
+
for (let i = start; i <= max; i += stepNum) {
|
|
56
|
+
values.add(i);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// 处理范围: 1-5
|
|
61
|
+
else if (part.includes('-')) {
|
|
62
|
+
const [start, end] = part.split('-').map(Number);
|
|
63
|
+
if (start < min || end > max) {
|
|
64
|
+
throw new Error(`Value out of range: ${start}-${end} (expected ${min}-${max})`);
|
|
65
|
+
}
|
|
66
|
+
for (let i = start; i <= end; i++) {
|
|
67
|
+
values.add(i);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// 处理单个值: 5
|
|
71
|
+
else {
|
|
72
|
+
const val = parseInt(part, 10);
|
|
73
|
+
if (val < min || val > max) {
|
|
74
|
+
throw new Error(`Value out of range: ${val} (expected ${min}-${max})`);
|
|
75
|
+
}
|
|
76
|
+
values.add(val);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Array.from(values).sort((a, b) => a - b);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 解析 cron 表达式
|
|
85
|
+
*/
|
|
86
|
+
function parseCronExpression(expression: string): CronFields {
|
|
87
|
+
const parts = expression.trim().split(/\s+/);
|
|
88
|
+
|
|
89
|
+
// 支持 5 位 (分 时 日 月 周) 和 6 位 (秒 分 时 日 月 周)
|
|
90
|
+
let second: number[], minute: number[], hour: number[], dayOfMonth: number[], month: number[], dayOfWeek: number[];
|
|
91
|
+
|
|
92
|
+
if (parts.length === 5) {
|
|
93
|
+
// 5 位格式: 分 时 日 月 周
|
|
94
|
+
second = [0]; // 默认在 0 秒执行
|
|
95
|
+
[minute, hour, dayOfMonth, month, dayOfWeek] = [
|
|
96
|
+
parseField(parts[0], 0, 59),
|
|
97
|
+
parseField(parts[1], 0, 23),
|
|
98
|
+
parseField(parts[2], 1, 31),
|
|
99
|
+
parseField(parts[3], 1, 12),
|
|
100
|
+
parseField(parts[4], 0, 6),
|
|
101
|
+
];
|
|
102
|
+
} else if (parts.length === 6) {
|
|
103
|
+
// 6 位格式: 秒 分 时 日 月 周
|
|
104
|
+
[second, minute, hour, dayOfMonth, month, dayOfWeek] = [
|
|
105
|
+
parseField(parts[0], 0, 59),
|
|
106
|
+
parseField(parts[1], 0, 59),
|
|
107
|
+
parseField(parts[2], 0, 23),
|
|
108
|
+
parseField(parts[3], 1, 31),
|
|
109
|
+
parseField(parts[4], 1, 12),
|
|
110
|
+
parseField(parts[5], 0, 6),
|
|
111
|
+
];
|
|
112
|
+
} else {
|
|
113
|
+
throw new Error(`Invalid cron expression: expected 5 or 6 fields, got ${parts.length}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { second, minute, hour, dayOfMonth, month, dayOfWeek };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 验证 cron 表达式
|
|
121
|
+
*/
|
|
122
|
+
export function validateCron(expression: string): void {
|
|
123
|
+
try {
|
|
124
|
+
parseCronExpression(expression);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw new Error(`Invalid cron expression: ${expression}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 检查日期是否匹配 cron 字段
|
|
132
|
+
*/
|
|
133
|
+
function matchesCron(date: Date, fields: CronFields): boolean {
|
|
134
|
+
const second = date.getSeconds();
|
|
135
|
+
const minute = date.getMinutes();
|
|
136
|
+
const hour = date.getHours();
|
|
137
|
+
const dayOfMonth = date.getDate();
|
|
138
|
+
const month = date.getMonth() + 1; // JS 月份从 0 开始
|
|
139
|
+
const dayOfWeek = date.getDay(); // 0 = 周日
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
fields.second.includes(second) &&
|
|
143
|
+
fields.minute.includes(minute) &&
|
|
144
|
+
fields.hour.includes(hour) &&
|
|
145
|
+
fields.month.includes(month) &&
|
|
146
|
+
(fields.dayOfMonth.includes(dayOfMonth) || fields.dayOfWeek.includes(dayOfWeek))
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 计算下一次运行时间
|
|
152
|
+
*/
|
|
153
|
+
export function getNextRun(expression: string, _timezone?: string): Date {
|
|
154
|
+
const fields = parseCronExpression(expression);
|
|
155
|
+
const now = new Date();
|
|
156
|
+
|
|
157
|
+
// 从下一秒开始查找
|
|
158
|
+
let candidate = new Date(now.getTime() + 1000);
|
|
159
|
+
candidate.setMilliseconds(0);
|
|
160
|
+
|
|
161
|
+
// 最多向前查找 4 年(避免无限循环)
|
|
162
|
+
const maxIterations = 4 * 365 * 24 * 60 * 60; // 4 年的秒数
|
|
163
|
+
let iterations = 0;
|
|
164
|
+
|
|
165
|
+
while (iterations < maxIterations) {
|
|
166
|
+
if (matchesCron(candidate, fields)) {
|
|
167
|
+
return candidate;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 优化:跳到下一个可能的时间点
|
|
171
|
+
const second = candidate.getSeconds();
|
|
172
|
+
const minute = candidate.getMinutes();
|
|
173
|
+
const hour = candidate.getHours();
|
|
174
|
+
|
|
175
|
+
// 如果秒不匹配,跳到下一个匹配的秒
|
|
176
|
+
if (!fields.second.includes(second)) {
|
|
177
|
+
const nextSecond = fields.second.find(s => s > second) ?? fields.second[0];
|
|
178
|
+
if (nextSecond > second) {
|
|
179
|
+
candidate.setSeconds(nextSecond);
|
|
180
|
+
} else {
|
|
181
|
+
candidate.setMinutes(minute + 1, nextSecond);
|
|
182
|
+
}
|
|
183
|
+
candidate.setMilliseconds(0);
|
|
184
|
+
iterations++;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 如果分钟不匹配,跳到下一个匹配的分钟
|
|
189
|
+
if (!fields.minute.includes(minute)) {
|
|
190
|
+
const nextMinute = fields.minute.find(m => m > minute) ?? fields.minute[0];
|
|
191
|
+
if (nextMinute > minute) {
|
|
192
|
+
candidate.setMinutes(nextMinute, fields.second[0]);
|
|
193
|
+
} else {
|
|
194
|
+
candidate.setHours(hour + 1, nextMinute, fields.second[0]);
|
|
195
|
+
}
|
|
196
|
+
candidate.setMilliseconds(0);
|
|
197
|
+
iterations++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 如果小时不匹配,跳到下一个匹配的小时
|
|
202
|
+
if (!fields.hour.includes(hour)) {
|
|
203
|
+
const nextHour = fields.hour.find(h => h > hour) ?? fields.hour[0];
|
|
204
|
+
if (nextHour > hour) {
|
|
205
|
+
candidate.setHours(nextHour, fields.minute[0], fields.second[0]);
|
|
206
|
+
} else {
|
|
207
|
+
candidate.setDate(candidate.getDate() + 1);
|
|
208
|
+
candidate.setHours(nextHour, fields.minute[0], fields.second[0]);
|
|
209
|
+
}
|
|
210
|
+
candidate.setMilliseconds(0);
|
|
211
|
+
iterations++;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// 如果都不匹配,跳到下一秒
|
|
216
|
+
candidate = new Date(candidate.getTime() + 1000);
|
|
217
|
+
iterations++;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
throw new Error(`Could not find next run time for cron expression: ${expression}`);
|
|
221
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { validateCron as validate, getNextRun as getNext } from './cron-lite';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 验证 Cron 表达式是否有效。
|
|
5
|
+
* @param cronExpression Cron 表达式
|
|
6
|
+
* @throws Error 如果表达式无效
|
|
7
|
+
*/
|
|
8
|
+
export function validateCron(cronExpression: string): void {
|
|
9
|
+
validate(cronExpression);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 根据 Cron 表达式计算下一次运行时间。
|
|
14
|
+
* @param cronExpression Cron 表达式
|
|
15
|
+
* @param timezone 可选的时区(暂不支持,保留接口兼容性)
|
|
16
|
+
* @returns 下一次运行的 Date 对象
|
|
17
|
+
*/
|
|
18
|
+
export function getNextRun(cronExpression: string, timezone?: string): Date {
|
|
19
|
+
return getNext(cronExpression, timezone);
|
|
20
|
+
}
|
package/src/utils/id.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { validateCron, getNextRun as getCronNextRun } from './cron-lite';
|
|
2
|
+
|
|
3
|
+
export type ScheduleType = 'cron' | 'interval';
|
|
4
|
+
|
|
5
|
+
export interface ParsedSchedule {
|
|
6
|
+
type: ScheduleType;
|
|
7
|
+
value: any | number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 解析调度规则字符串,将其转换为 Cron 表达式对象或毫秒级间隔。
|
|
12
|
+
* 支持的间隔格式: "10s", "5m", "2h", "1d"。
|
|
13
|
+
* @param schedule 调度规则字符串
|
|
14
|
+
* @returns 解析后的调度对象
|
|
15
|
+
* @throws Error 如果字符串格式无效
|
|
16
|
+
*/
|
|
17
|
+
export function parseSchedule(schedule: string): ParsedSchedule {
|
|
18
|
+
// 1. 尝试作为间隔字符串解析
|
|
19
|
+
const intervalRegex = /^(\d+)(s|m|h|d)$/;
|
|
20
|
+
const match = schedule.match(intervalRegex);
|
|
21
|
+
|
|
22
|
+
if (match) {
|
|
23
|
+
const value = parseInt(match[1], 10);
|
|
24
|
+
const unit = match[2];
|
|
25
|
+
let multiplier = 1000;
|
|
26
|
+
|
|
27
|
+
switch (unit) {
|
|
28
|
+
case 's':
|
|
29
|
+
multiplier = 1000;
|
|
30
|
+
break;
|
|
31
|
+
case 'm':
|
|
32
|
+
multiplier = 60 * 1000;
|
|
33
|
+
break;
|
|
34
|
+
case 'h':
|
|
35
|
+
multiplier = 60 * 60 * 1000;
|
|
36
|
+
break;
|
|
37
|
+
case 'd':
|
|
38
|
+
multiplier = 24 * 60 * 60 * 1000;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
type: 'interval',
|
|
44
|
+
value: value * multiplier,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. 尝试作为 Cron 表达式解析
|
|
49
|
+
try {
|
|
50
|
+
validateCron(schedule);
|
|
51
|
+
// 对于 Cron,我们返回原始字符串,以便后续根据上下文(如时区)重新解析
|
|
52
|
+
return {
|
|
53
|
+
type: 'cron',
|
|
54
|
+
value: schedule,
|
|
55
|
+
};
|
|
56
|
+
} catch (err) {
|
|
57
|
+
// 解析失败
|
|
58
|
+
throw new Error(`无效的调度格式: "${schedule}". 必须是有效的 Cron 表达式或间隔字符串 (例如 "10s")。`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 计算下一次运行时间。
|
|
64
|
+
* @param schedule 调度规则字符串或已解析的对象
|
|
65
|
+
* @param options 可选配置 (cron 的时区,interval 的上次运行时间)
|
|
66
|
+
* @returns 下一次运行的 Date 对象
|
|
67
|
+
*/
|
|
68
|
+
export function getNextRun(
|
|
69
|
+
schedule: string | ParsedSchedule,
|
|
70
|
+
options?: { timezone?: string; lastRun?: number }
|
|
71
|
+
): Date {
|
|
72
|
+
let parsed: ParsedSchedule;
|
|
73
|
+
|
|
74
|
+
if (typeof schedule === 'string') {
|
|
75
|
+
parsed = parseSchedule(schedule);
|
|
76
|
+
} else {
|
|
77
|
+
parsed = schedule;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (parsed.type === 'interval') {
|
|
81
|
+
const intervalMs = parsed.value as number;
|
|
82
|
+
const lastRun = options?.lastRun || Date.now();
|
|
83
|
+
return new Date(lastRun + intervalMs);
|
|
84
|
+
} else {
|
|
85
|
+
// Cron
|
|
86
|
+
const cronExpression = parsed.value as string;
|
|
87
|
+
try {
|
|
88
|
+
return getCronNextRun(cronExpression, options?.timezone);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
throw new Error(`无法计算下一次 Cron 运行时间: ${cronExpression}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|