jettask 0.2.7__py3-none-any.whl → 0.2.8__py3-none-any.whl
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.
- jettask/core/cli.py +152 -0
- jettask/pg_consumer/sql/add_execution_time_field.sql +29 -0
- jettask/pg_consumer/sql/create_new_tables.sql +137 -0
- jettask/pg_consumer/sql/create_tables_v3.sql +175 -0
- jettask/pg_consumer/sql/migrate_to_new_structure.sql +179 -0
- jettask/pg_consumer/sql/modify_time_fields.sql +69 -0
- jettask/webui/frontend/package.json +30 -0
- jettask/webui/frontend/src/App.css +109 -0
- jettask/webui/frontend/src/App.jsx +66 -0
- jettask/webui/frontend/src/components/NamespaceSelector.jsx +166 -0
- jettask/webui/frontend/src/components/QueueBacklogChart.jsx +298 -0
- jettask/webui/frontend/src/components/QueueBacklogTrend.jsx +638 -0
- jettask/webui/frontend/src/components/QueueDetailsTable.css +65 -0
- jettask/webui/frontend/src/components/QueueDetailsTable.jsx +487 -0
- jettask/webui/frontend/src/components/QueueDetailsTableV2.jsx +465 -0
- jettask/webui/frontend/src/components/ScheduledTaskFilter.jsx +423 -0
- jettask/webui/frontend/src/components/TaskFilter.jsx +425 -0
- jettask/webui/frontend/src/components/TimeRangeSelector.css +21 -0
- jettask/webui/frontend/src/components/TimeRangeSelector.jsx +160 -0
- jettask/webui/frontend/src/components/charts/QueueChart.jsx +111 -0
- jettask/webui/frontend/src/components/charts/QueueTrendChart.jsx +115 -0
- jettask/webui/frontend/src/components/charts/WorkerChart.jsx +40 -0
- jettask/webui/frontend/src/components/common/StatsCard.jsx +18 -0
- jettask/webui/frontend/src/components/layout/AppLayout.css +95 -0
- jettask/webui/frontend/src/components/layout/AppLayout.jsx +49 -0
- jettask/webui/frontend/src/components/layout/Header.css +106 -0
- jettask/webui/frontend/src/components/layout/Header.jsx +106 -0
- jettask/webui/frontend/src/components/layout/SideMenu.css +137 -0
- jettask/webui/frontend/src/components/layout/SideMenu.jsx +209 -0
- jettask/webui/frontend/src/components/layout/TabsNav.css +244 -0
- jettask/webui/frontend/src/components/layout/TabsNav.jsx +206 -0
- jettask/webui/frontend/src/components/layout/UserInfo.css +197 -0
- jettask/webui/frontend/src/components/layout/UserInfo.jsx +197 -0
- jettask/webui/frontend/src/contexts/LoadingContext.jsx +27 -0
- jettask/webui/frontend/src/contexts/NamespaceContext.jsx +72 -0
- jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +245 -0
- jettask/webui/frontend/src/index.css +114 -0
- jettask/webui/frontend/src/main.jsx +20 -0
- jettask/webui/frontend/src/pages/Alerts.jsx +684 -0
- jettask/webui/frontend/src/pages/Dashboard/index.css +35 -0
- jettask/webui/frontend/src/pages/Dashboard/index.jsx +281 -0
- jettask/webui/frontend/src/pages/Dashboard.jsx +1330 -0
- jettask/webui/frontend/src/pages/QueueDetail.jsx +1117 -0
- jettask/webui/frontend/src/pages/QueueMonitor.jsx +527 -0
- jettask/webui/frontend/src/pages/Queues.jsx +12 -0
- jettask/webui/frontend/src/pages/ScheduledTasks.jsx +809 -0
- jettask/webui/frontend/src/pages/Settings.jsx +800 -0
- jettask/webui/frontend/src/pages/Workers.jsx +12 -0
- jettask/webui/frontend/src/services/api.js +114 -0
- jettask/webui/frontend/src/services/queueTrend.js +152 -0
- jettask/webui/frontend/src/utils/suppressWarnings.js +22 -0
- jettask/webui/frontend/src/utils/userPreferences.js +154 -0
- jettask/webui/frontend/vite.config.js +26 -0
- {jettask-0.2.7.dist-info → jettask-0.2.8.dist-info}/METADATA +1 -1
- {jettask-0.2.7.dist-info → jettask-0.2.8.dist-info}/RECORD +59 -14
- jettask/webui/static/dist/assets/index-7129cfe1.css +0 -1
- jettask/webui/static/dist/assets/index-8d1935cc.js +0 -774
- jettask/webui/static/dist/index.html +0 -15
- jettask/webui/static/index.html +0 -1734
- jettask/webui/static/queue.html +0 -981
- jettask/webui/static/queues.html +0 -549
- jettask/webui/static/workers.html +0 -734
- {jettask-0.2.7.dist-info → jettask-0.2.8.dist-info}/WHEEL +0 -0
- {jettask-0.2.7.dist-info → jettask-0.2.8.dist-info}/entry_points.txt +0 -0
- {jettask-0.2.7.dist-info → jettask-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {jettask-0.2.7.dist-info → jettask-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,425 @@
|
|
1
|
+
import React, { useState } from 'react';
|
2
|
+
import { Button, Popover, Form, Select, Input, Space, Tag, message } from 'antd';
|
3
|
+
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
4
|
+
|
5
|
+
const { Option } = Select;
|
6
|
+
|
7
|
+
// 可用的字段列表
|
8
|
+
const AVAILABLE_FIELDS = [
|
9
|
+
{ value: 'id', label: '任务ID', type: 'string' },
|
10
|
+
{ value: 'task_name', label: '任务名称', type: 'string' },
|
11
|
+
{ value: 'consumer_group', label: '消费者组', type: 'string' },
|
12
|
+
{ value: 'status', label: '状态', type: 'enum', options: ['pending', 'running', 'success', 'error', 'rejected'] },
|
13
|
+
{ value: 'worker_id', label: 'Worker ID', type: 'string' },
|
14
|
+
{ value: 'task_data', label: '任务参数(JSON)', type: 'json' },
|
15
|
+
{ value: 'result', label: '执行结果(JSON)', type: 'json' },
|
16
|
+
{ value: 'created_at', label: '创建时间', type: 'datetime' },
|
17
|
+
{ value: 'started_at', label: '开始时间', type: 'datetime' },
|
18
|
+
{ value: 'completed_at', label: '完成时间', type: 'datetime' },
|
19
|
+
{ value: 'retry_count', label: '重试次数', type: 'number' },
|
20
|
+
{ value: 'priority', label: '优先级', type: 'number' },
|
21
|
+
{ value: 'max_retry', label: '最大重试次数', type: 'number' },
|
22
|
+
{ value: 'error_message', label: '错误信息', type: 'string' },
|
23
|
+
];
|
24
|
+
|
25
|
+
// 操作符列表
|
26
|
+
const OPERATORS = {
|
27
|
+
string: [
|
28
|
+
{ value: 'eq', label: '等于' },
|
29
|
+
{ value: 'ne', label: '不等于' },
|
30
|
+
{ value: 'contains', label: '包含' },
|
31
|
+
{ value: 'starts_with', label: '开始于' },
|
32
|
+
{ value: 'ends_with', label: '结束于' },
|
33
|
+
{ value: 'is_null', label: '为空' },
|
34
|
+
{ value: 'is_not_null', label: '不为空' },
|
35
|
+
],
|
36
|
+
number: [
|
37
|
+
{ value: 'eq', label: '等于' },
|
38
|
+
{ value: 'ne', label: '不等于' },
|
39
|
+
{ value: 'gt', label: '大于' },
|
40
|
+
{ value: 'lt', label: '小于' },
|
41
|
+
{ value: 'gte', label: '大于等于' },
|
42
|
+
{ value: 'lte', label: '小于等于' },
|
43
|
+
{ value: 'is_null', label: '为空' },
|
44
|
+
{ value: 'is_not_null', label: '不为空' },
|
45
|
+
],
|
46
|
+
datetime: [
|
47
|
+
{ value: 'gt', label: '晚于' },
|
48
|
+
{ value: 'lt', label: '早于' },
|
49
|
+
{ value: 'gte', label: '不早于' },
|
50
|
+
{ value: 'lte', label: '不晚于' },
|
51
|
+
{ value: 'is_null', label: '为空' },
|
52
|
+
{ value: 'is_not_null', label: '不为空' },
|
53
|
+
],
|
54
|
+
enum: [
|
55
|
+
{ value: 'eq', label: '等于' },
|
56
|
+
{ value: 'ne', label: '不等于' },
|
57
|
+
{ value: 'in', label: '在列表中' },
|
58
|
+
{ value: 'not_in', label: '不在列表中' },
|
59
|
+
],
|
60
|
+
json: [
|
61
|
+
{ value: 'contains', label: '包含文本' },
|
62
|
+
{ value: 'json_key_exists', label: '包含键名' },
|
63
|
+
{ value: 'json_path_value', label: 'JSON路径值' },
|
64
|
+
{ value: 'is_null', label: '为空' },
|
65
|
+
{ value: 'is_not_null', label: '不为空' },
|
66
|
+
],
|
67
|
+
};
|
68
|
+
|
69
|
+
function TaskFilter({ filters, onFiltersChange }) {
|
70
|
+
const [visible, setVisible] = useState(false);
|
71
|
+
const [form] = Form.useForm();
|
72
|
+
const [selectedField, setSelectedField] = useState(null);
|
73
|
+
const [selectedOperator, setSelectedOperator] = useState(null);
|
74
|
+
const [disabledFilters, setDisabledFilters] = useState(new Set()); // 记录被禁用的筛选条件索引
|
75
|
+
|
76
|
+
// 获取当前字段的类型
|
77
|
+
const getFieldType = (fieldValue) => {
|
78
|
+
const field = AVAILABLE_FIELDS.find(f => f.value === fieldValue);
|
79
|
+
return field ? field.type : 'string';
|
80
|
+
};
|
81
|
+
|
82
|
+
// 获取当前字段的选项(用于枚举类型)
|
83
|
+
const getFieldOptions = (fieldValue) => {
|
84
|
+
const field = AVAILABLE_FIELDS.find(f => f.value === fieldValue);
|
85
|
+
return field && field.options ? field.options : [];
|
86
|
+
};
|
87
|
+
|
88
|
+
// 添加筛选条件
|
89
|
+
const handleAddFilter = () => {
|
90
|
+
form.validateFields().then(values => {
|
91
|
+
const newFilter = {
|
92
|
+
id: Date.now(), // 添加唯一ID
|
93
|
+
field: values.field,
|
94
|
+
operator: values.operator,
|
95
|
+
value: values.value,
|
96
|
+
enabled: true, // 默认启用
|
97
|
+
};
|
98
|
+
|
99
|
+
// 处理特殊操作符
|
100
|
+
if (values.operator === 'is_null' || values.operator === 'is_not_null') {
|
101
|
+
newFilter.value = null;
|
102
|
+
} else if (values.operator === 'in' || values.operator === 'not_in') {
|
103
|
+
// 如果是枚举类型的多选
|
104
|
+
if (getFieldType(values.field) === 'enum') {
|
105
|
+
newFilter.value = values.value; // 已经是数组
|
106
|
+
} else {
|
107
|
+
// 普通文本,用逗号分隔
|
108
|
+
newFilter.value = values.value.split(',').map(v => v.trim());
|
109
|
+
}
|
110
|
+
}
|
111
|
+
|
112
|
+
const updatedFilters = [...(filters || []), newFilter];
|
113
|
+
onFiltersChange(updatedFilters);
|
114
|
+
|
115
|
+
// 重置表单
|
116
|
+
form.resetFields();
|
117
|
+
setSelectedField(null);
|
118
|
+
setSelectedOperator(null);
|
119
|
+
setVisible(false);
|
120
|
+
message.success('筛选条件已添加');
|
121
|
+
});
|
122
|
+
};
|
123
|
+
|
124
|
+
// 切换筛选条件的启用/禁用状态
|
125
|
+
const handleToggleFilter = (index) => {
|
126
|
+
const updatedFilters = [...filters];
|
127
|
+
updatedFilters[index] = {
|
128
|
+
...updatedFilters[index],
|
129
|
+
enabled: !updatedFilters[index].enabled
|
130
|
+
};
|
131
|
+
onFiltersChange(updatedFilters);
|
132
|
+
};
|
133
|
+
|
134
|
+
// 删除筛选条件
|
135
|
+
const handleRemoveFilter = (index) => {
|
136
|
+
const updatedFilters = filters.filter((_, i) => i !== index);
|
137
|
+
onFiltersChange(updatedFilters);
|
138
|
+
message.success('筛选条件已删除');
|
139
|
+
};
|
140
|
+
|
141
|
+
// 渲染筛选条件标签
|
142
|
+
const renderFilterTag = (filter, index) => {
|
143
|
+
const field = AVAILABLE_FIELDS.find(f => f.value === filter.field);
|
144
|
+
const fieldLabel = field ? field.label : filter.field;
|
145
|
+
|
146
|
+
const operator = OPERATORS[getFieldType(filter.field)]?.find(op => op.value === filter.operator);
|
147
|
+
const operatorLabel = operator ? operator.label : filter.operator;
|
148
|
+
|
149
|
+
let valueLabel = filter.value;
|
150
|
+
if (filter.operator === 'is_null' || filter.operator === 'is_not_null') {
|
151
|
+
valueLabel = '';
|
152
|
+
} else if (Array.isArray(filter.value)) {
|
153
|
+
valueLabel = filter.value.join(', ');
|
154
|
+
} else if (filter.operator === 'json_key_exists') {
|
155
|
+
valueLabel = `键: ${filter.value}`;
|
156
|
+
} else if (filter.operator === 'json_path_value') {
|
157
|
+
// 显示更友好的格式
|
158
|
+
if (filter.value && filter.value.includes('=')) {
|
159
|
+
const [path, val] = filter.value.split('=', 2);
|
160
|
+
valueLabel = `${path} = ${val}`;
|
161
|
+
}
|
162
|
+
}
|
163
|
+
|
164
|
+
const isDisabled = filter.enabled === false;
|
165
|
+
|
166
|
+
return (
|
167
|
+
<Tag
|
168
|
+
key={index}
|
169
|
+
closable
|
170
|
+
onClose={() => handleRemoveFilter(index)}
|
171
|
+
onClick={() => handleToggleFilter(index)}
|
172
|
+
style={{
|
173
|
+
margin: 0,
|
174
|
+
height: '24px',
|
175
|
+
lineHeight: '22px',
|
176
|
+
display: 'inline-flex',
|
177
|
+
alignItems: 'center',
|
178
|
+
cursor: 'pointer',
|
179
|
+
opacity: isDisabled ? 0.5 : 1,
|
180
|
+
textDecoration: isDisabled ? 'line-through' : 'none',
|
181
|
+
backgroundColor: isDisabled ? '#f5f5f5' : undefined,
|
182
|
+
borderStyle: isDisabled ? 'dashed' : 'solid',
|
183
|
+
}}
|
184
|
+
color={isDisabled ? 'default' : undefined}
|
185
|
+
>
|
186
|
+
{fieldLabel} {operatorLabel} {valueLabel}
|
187
|
+
</Tag>
|
188
|
+
);
|
189
|
+
};
|
190
|
+
|
191
|
+
// 渲染值输入组件
|
192
|
+
const renderValueInput = () => {
|
193
|
+
if (!selectedField || !selectedOperator) return null;
|
194
|
+
|
195
|
+
// 如果是空值判断,不需要输入值
|
196
|
+
if (selectedOperator === 'is_null' || selectedOperator === 'is_not_null') {
|
197
|
+
return null;
|
198
|
+
}
|
199
|
+
|
200
|
+
const fieldType = getFieldType(selectedField);
|
201
|
+
const fieldOptions = getFieldOptions(selectedField);
|
202
|
+
|
203
|
+
// 枚举类型的多选
|
204
|
+
if (fieldType === 'enum' && (selectedOperator === 'in' || selectedOperator === 'not_in')) {
|
205
|
+
return (
|
206
|
+
<Form.Item
|
207
|
+
name="value"
|
208
|
+
label="值"
|
209
|
+
rules={[{ required: true, message: '请选择值' }]}
|
210
|
+
>
|
211
|
+
<Select mode="multiple" placeholder="选择值">
|
212
|
+
{fieldOptions.map(opt => (
|
213
|
+
<Option key={opt} value={opt}>{opt}</Option>
|
214
|
+
))}
|
215
|
+
</Select>
|
216
|
+
</Form.Item>
|
217
|
+
);
|
218
|
+
}
|
219
|
+
|
220
|
+
// 枚举类型的单选
|
221
|
+
if (fieldType === 'enum') {
|
222
|
+
return (
|
223
|
+
<Form.Item
|
224
|
+
name="value"
|
225
|
+
label="值"
|
226
|
+
rules={[{ required: true, message: '请选择值' }]}
|
227
|
+
>
|
228
|
+
<Select placeholder="选择值">
|
229
|
+
{fieldOptions.map(opt => (
|
230
|
+
<Option key={opt} value={opt}>{opt}</Option>
|
231
|
+
))}
|
232
|
+
</Select>
|
233
|
+
</Form.Item>
|
234
|
+
);
|
235
|
+
}
|
236
|
+
|
237
|
+
// 日期时间类型
|
238
|
+
if (fieldType === 'datetime') {
|
239
|
+
return (
|
240
|
+
<Form.Item
|
241
|
+
name="value"
|
242
|
+
label="值"
|
243
|
+
rules={[{ required: true, message: '请输入值' }]}
|
244
|
+
>
|
245
|
+
<Input type="datetime-local" />
|
246
|
+
</Form.Item>
|
247
|
+
);
|
248
|
+
}
|
249
|
+
|
250
|
+
// 数字类型
|
251
|
+
if (fieldType === 'number') {
|
252
|
+
return (
|
253
|
+
<Form.Item
|
254
|
+
name="value"
|
255
|
+
label="值"
|
256
|
+
rules={[{ required: true, message: '请输入值' }]}
|
257
|
+
>
|
258
|
+
<Input type="number" placeholder="输入数字" />
|
259
|
+
</Form.Item>
|
260
|
+
);
|
261
|
+
}
|
262
|
+
|
263
|
+
// JSON类型
|
264
|
+
if (fieldType === 'json') {
|
265
|
+
// 根据不同的操作符显示不同的输入提示
|
266
|
+
let label = '搜索内容';
|
267
|
+
let placeholder = '输入搜索内容';
|
268
|
+
let extra = '';
|
269
|
+
|
270
|
+
if (selectedOperator === 'json_key_exists') {
|
271
|
+
label = '键名';
|
272
|
+
placeholder = '输入要查找的键名,如:user_id';
|
273
|
+
extra = '检查JSON中是否存在指定的键';
|
274
|
+
} else if (selectedOperator === 'json_path_value') {
|
275
|
+
label = 'JSON路径和值';
|
276
|
+
placeholder = '路径=值,如:$.user_id=123 或 $.data.status=active';
|
277
|
+
extra = '使用JSON路径语法,格式:路径=值。支持嵌套路径如 $.data.user.name';
|
278
|
+
} else if (selectedOperator === 'contains') {
|
279
|
+
label = '搜索文本';
|
280
|
+
placeholder = '输入要搜索的文本内容';
|
281
|
+
extra = '在JSON数据中搜索包含此文本的任务';
|
282
|
+
}
|
283
|
+
|
284
|
+
return (
|
285
|
+
<Form.Item
|
286
|
+
name="value"
|
287
|
+
label={label}
|
288
|
+
rules={[{ required: true, message: `请输入${label}` }]}
|
289
|
+
extra={extra}
|
290
|
+
>
|
291
|
+
<Input.TextArea
|
292
|
+
placeholder={placeholder}
|
293
|
+
rows={2}
|
294
|
+
spellCheck={false} // 关闭拼写检查,避免JSON输入时的红线
|
295
|
+
/>
|
296
|
+
</Form.Item>
|
297
|
+
);
|
298
|
+
}
|
299
|
+
|
300
|
+
// 默认文本输入
|
301
|
+
return (
|
302
|
+
<Form.Item
|
303
|
+
name="value"
|
304
|
+
label="值"
|
305
|
+
rules={[{ required: true, message: '请输入值' }]}
|
306
|
+
extra={
|
307
|
+
(selectedOperator === 'in' || selectedOperator === 'not_in')
|
308
|
+
? '多个值用逗号分隔' : null
|
309
|
+
}
|
310
|
+
>
|
311
|
+
<Input placeholder="输入值" />
|
312
|
+
</Form.Item>
|
313
|
+
);
|
314
|
+
};
|
315
|
+
|
316
|
+
const filterContent = (
|
317
|
+
<div style={{ width: 350 }}>
|
318
|
+
<Form
|
319
|
+
form={form}
|
320
|
+
layout="vertical"
|
321
|
+
onFinish={handleAddFilter}
|
322
|
+
>
|
323
|
+
<Form.Item
|
324
|
+
name="field"
|
325
|
+
label="字段"
|
326
|
+
rules={[{ required: true, message: '请选择字段' }]}
|
327
|
+
>
|
328
|
+
<Select
|
329
|
+
placeholder="选择字段"
|
330
|
+
onChange={(value) => {
|
331
|
+
setSelectedField(value);
|
332
|
+
setSelectedOperator(null);
|
333
|
+
form.setFieldsValue({ operator: undefined, value: undefined });
|
334
|
+
}}
|
335
|
+
>
|
336
|
+
{AVAILABLE_FIELDS.map(field => (
|
337
|
+
<Option key={field.value} value={field.value}>
|
338
|
+
{field.label}
|
339
|
+
</Option>
|
340
|
+
))}
|
341
|
+
</Select>
|
342
|
+
</Form.Item>
|
343
|
+
|
344
|
+
{selectedField && (
|
345
|
+
<Form.Item
|
346
|
+
name="operator"
|
347
|
+
label="操作符"
|
348
|
+
rules={[{ required: true, message: '请选择操作符' }]}
|
349
|
+
>
|
350
|
+
<Select
|
351
|
+
placeholder="选择操作符"
|
352
|
+
onChange={(value) => {
|
353
|
+
setSelectedOperator(value);
|
354
|
+
form.setFieldsValue({ value: undefined });
|
355
|
+
}}
|
356
|
+
>
|
357
|
+
{OPERATORS[getFieldType(selectedField)]?.map(op => (
|
358
|
+
<Option key={op.value} value={op.value}>
|
359
|
+
{op.label}
|
360
|
+
</Option>
|
361
|
+
))}
|
362
|
+
</Select>
|
363
|
+
</Form.Item>
|
364
|
+
)}
|
365
|
+
|
366
|
+
{renderValueInput()}
|
367
|
+
|
368
|
+
<Form.Item>
|
369
|
+
<Space>
|
370
|
+
<Button type="primary" htmlType="submit">
|
371
|
+
添加筛选条件
|
372
|
+
</Button>
|
373
|
+
<Button onClick={() => {
|
374
|
+
setVisible(false);
|
375
|
+
form.resetFields();
|
376
|
+
setSelectedField(null);
|
377
|
+
setSelectedOperator(null);
|
378
|
+
}}>
|
379
|
+
取消
|
380
|
+
</Button>
|
381
|
+
</Space>
|
382
|
+
</Form.Item>
|
383
|
+
</Form>
|
384
|
+
</div>
|
385
|
+
);
|
386
|
+
|
387
|
+
return (
|
388
|
+
<div style={{
|
389
|
+
display: 'inline-flex',
|
390
|
+
alignItems: 'center',
|
391
|
+
flexWrap: 'wrap',
|
392
|
+
gap: '8px',
|
393
|
+
verticalAlign: 'middle'
|
394
|
+
}}>
|
395
|
+
{filters && filters.map((filter, index) => renderFilterTag(filter, index))}
|
396
|
+
|
397
|
+
<Popover
|
398
|
+
content={filterContent}
|
399
|
+
title="添加筛选条件"
|
400
|
+
trigger="click"
|
401
|
+
open={visible}
|
402
|
+
onOpenChange={setVisible}
|
403
|
+
placement="bottomLeft"
|
404
|
+
>
|
405
|
+
<Button
|
406
|
+
type="dashed"
|
407
|
+
icon={<PlusOutlined />}
|
408
|
+
size="small"
|
409
|
+
style={{
|
410
|
+
height: '24px',
|
411
|
+
padding: '0 8px',
|
412
|
+
display: 'inline-flex',
|
413
|
+
alignItems: 'center',
|
414
|
+
lineHeight: '22px',
|
415
|
+
verticalAlign: 'middle'
|
416
|
+
}}
|
417
|
+
>
|
418
|
+
Add Filter
|
419
|
+
</Button>
|
420
|
+
</Popover>
|
421
|
+
</div>
|
422
|
+
);
|
423
|
+
}
|
424
|
+
|
425
|
+
export default TaskFilter;
|
@@ -0,0 +1,21 @@
|
|
1
|
+
/* 时间范围选择器样式 */
|
2
|
+
.time-range-selector-wrapper {
|
3
|
+
position: relative;
|
4
|
+
display: inline-block;
|
5
|
+
}
|
6
|
+
|
7
|
+
/* 预设标签覆盖层 hover 效果 */
|
8
|
+
.preset-label-overlay:hover {
|
9
|
+
border-color: #4096ff !important;
|
10
|
+
}
|
11
|
+
|
12
|
+
/* 隐藏下面的 RangePicker 输入框文字,但保留交互 */
|
13
|
+
.time-range-selector-wrapper .ant-picker-input {
|
14
|
+
position: relative;
|
15
|
+
}
|
16
|
+
|
17
|
+
/* 确保日历图标在覆盖层之上 */
|
18
|
+
.time-range-selector-wrapper .ant-picker-suffix {
|
19
|
+
position: relative;
|
20
|
+
z-index: 2;
|
21
|
+
}
|
@@ -0,0 +1,160 @@
|
|
1
|
+
import { useState, useEffect } from 'react';
|
2
|
+
import { DatePicker } from 'antd';
|
3
|
+
import dayjs from 'dayjs';
|
4
|
+
import './TimeRangeSelector.css';
|
5
|
+
|
6
|
+
const { RangePicker } = DatePicker;
|
7
|
+
|
8
|
+
// 预设时间范围选项
|
9
|
+
const PRESET_RANGES = {
|
10
|
+
'最近15分钟': () => [dayjs().subtract(15, 'minute'), dayjs()],
|
11
|
+
'最近30分钟': () => [dayjs().subtract(30, 'minute'), dayjs()],
|
12
|
+
'最近1小时': () => [dayjs().subtract(1, 'hour'), dayjs()],
|
13
|
+
'最近3小时': () => [dayjs().subtract(3, 'hour'), dayjs()],
|
14
|
+
'最近6小时': () => [dayjs().subtract(6, 'hour'), dayjs()],
|
15
|
+
'最近12小时': () => [dayjs().subtract(12, 'hour'), dayjs()],
|
16
|
+
'最近24小时': () => [dayjs().subtract(24, 'hour'), dayjs()],
|
17
|
+
'最近7天': () => [dayjs().subtract(7, 'day'), dayjs()],
|
18
|
+
'最近30天': () => [dayjs().subtract(30, 'day'), dayjs()],
|
19
|
+
};
|
20
|
+
|
21
|
+
// 时间范围值映射
|
22
|
+
const TIME_RANGE_MAP = {
|
23
|
+
'最近15分钟': '15m',
|
24
|
+
'最近30分钟': '30m',
|
25
|
+
'最近1小时': '1h',
|
26
|
+
'最近3小时': '3h',
|
27
|
+
'最近6小时': '6h',
|
28
|
+
'最近12小时': '12h',
|
29
|
+
'最近24小时': '24h',
|
30
|
+
'最近7天': '7d',
|
31
|
+
'最近30天': '30d',
|
32
|
+
};
|
33
|
+
|
34
|
+
// 反向映射,从值到标签
|
35
|
+
const VALUE_TO_LABEL_MAP = Object.fromEntries(
|
36
|
+
Object.entries(TIME_RANGE_MAP).map(([label, value]) => [value, label])
|
37
|
+
);
|
38
|
+
|
39
|
+
function TimeRangeSelector({
|
40
|
+
value,
|
41
|
+
onChange,
|
42
|
+
customValue,
|
43
|
+
onCustomChange,
|
44
|
+
style = {},
|
45
|
+
...props
|
46
|
+
}) {
|
47
|
+
const [isOpen, setIsOpen] = useState(false);
|
48
|
+
const [presetLabel, setPresetLabel] = useState(
|
49
|
+
value !== 'custom' && VALUE_TO_LABEL_MAP[value] ? VALUE_TO_LABEL_MAP[value] : null
|
50
|
+
);
|
51
|
+
|
52
|
+
// 当外部value改变时,更新预设标签
|
53
|
+
useEffect(() => {
|
54
|
+
if (value !== 'custom' && VALUE_TO_LABEL_MAP[value]) {
|
55
|
+
setPresetLabel(VALUE_TO_LABEL_MAP[value]);
|
56
|
+
} else if (value === 'custom') {
|
57
|
+
setPresetLabel(null);
|
58
|
+
}
|
59
|
+
}, [value]);
|
60
|
+
|
61
|
+
// 处理RangePicker变化
|
62
|
+
const handleRangeChange = (dates) => {
|
63
|
+
console.log('[TimeRangeSelector] handleRangeChange 被调用, dates:', dates);
|
64
|
+
if (!dates || dates.length !== 2) {
|
65
|
+
console.log('[TimeRangeSelector] dates 无效,返回');
|
66
|
+
return;
|
67
|
+
}
|
68
|
+
|
69
|
+
const [start, end] = dates;
|
70
|
+
|
71
|
+
// 检查是否匹配预设时间范围
|
72
|
+
let matchedPreset = null;
|
73
|
+
for (const [label, getRangeFn] of Object.entries(PRESET_RANGES)) {
|
74
|
+
const [presetStart, presetEnd] = getRangeFn();
|
75
|
+
// 允许2秒的误差
|
76
|
+
if (
|
77
|
+
Math.abs(start.diff(presetStart, 'second')) <= 2 &&
|
78
|
+
Math.abs(end.diff(presetEnd, 'second')) <= 2
|
79
|
+
) {
|
80
|
+
matchedPreset = label;
|
81
|
+
break;
|
82
|
+
}
|
83
|
+
}
|
84
|
+
|
85
|
+
console.log('[TimeRangeSelector] handleRangeChange - matchedPreset:', matchedPreset, 'start:', start.format(), 'end:', end.format());
|
86
|
+
|
87
|
+
if (matchedPreset) {
|
88
|
+
// 匹配到预设
|
89
|
+
setPresetLabel(matchedPreset);
|
90
|
+
const presetValue = TIME_RANGE_MAP[matchedPreset];
|
91
|
+
console.log('[TimeRangeSelector] 设置预设值:', presetValue, '调用 onChange');
|
92
|
+
onChange(presetValue);
|
93
|
+
onCustomChange(null);
|
94
|
+
} else {
|
95
|
+
// 自定义时间范围
|
96
|
+
setPresetLabel(null);
|
97
|
+
console.log('[TimeRangeSelector] 设置自定义时间范围');
|
98
|
+
onChange('custom');
|
99
|
+
onCustomChange(dates);
|
100
|
+
}
|
101
|
+
};
|
102
|
+
|
103
|
+
// 获取RangePicker的值
|
104
|
+
const getRangePickerValue = () => {
|
105
|
+
if (value === 'custom' && customValue) {
|
106
|
+
return customValue;
|
107
|
+
}
|
108
|
+
// 预设时返回空,让RangePicker不显示具体时间
|
109
|
+
return undefined;
|
110
|
+
};
|
111
|
+
|
112
|
+
return (
|
113
|
+
<div className="time-range-selector-wrapper" style={{ position: 'relative', display: 'inline-block', ...style }}>
|
114
|
+
<RangePicker
|
115
|
+
showTime
|
116
|
+
format="YYYY-MM-DD HH:mm:ss"
|
117
|
+
value={getRangePickerValue()}
|
118
|
+
onChange={handleRangeChange}
|
119
|
+
placeholder={['开始时间', '结束时间']}
|
120
|
+
presets={Object.entries(PRESET_RANGES).map(([label, getRangeFn]) => ({
|
121
|
+
label,
|
122
|
+
value: getRangeFn(),
|
123
|
+
}))}
|
124
|
+
style={{ width: 360 }}
|
125
|
+
open={isOpen}
|
126
|
+
onOpenChange={setIsOpen}
|
127
|
+
allowClear={false}
|
128
|
+
{...props}
|
129
|
+
/>
|
130
|
+
|
131
|
+
{/* 预设标签覆盖层 */}
|
132
|
+
{presetLabel && !isOpen && (
|
133
|
+
<div
|
134
|
+
className="preset-label-overlay"
|
135
|
+
onClick={() => setIsOpen(true)}
|
136
|
+
style={{
|
137
|
+
position: 'absolute',
|
138
|
+
top: 0,
|
139
|
+
left: 0,
|
140
|
+
right: 0,
|
141
|
+
bottom: 0,
|
142
|
+
display: 'flex',
|
143
|
+
alignItems: 'center',
|
144
|
+
paddingLeft: 11,
|
145
|
+
paddingRight: 35,
|
146
|
+
backgroundColor: '#fff',
|
147
|
+
border: '1px solid #d9d9d9',
|
148
|
+
borderRadius: 6,
|
149
|
+
cursor: 'pointer',
|
150
|
+
zIndex: 1,
|
151
|
+
}}
|
152
|
+
>
|
153
|
+
<span style={{ color: 'rgba(0, 0, 0, 0.88)' }}>{presetLabel}</span>
|
154
|
+
</div>
|
155
|
+
)}
|
156
|
+
</div>
|
157
|
+
);
|
158
|
+
}
|
159
|
+
|
160
|
+
export default TimeRangeSelector;
|