jettask 0.2.1__py3-none-any.whl → 0.2.4__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/constants.py +213 -0
- jettask/core/app.py +525 -205
- jettask/core/cli.py +193 -185
- jettask/core/consumer_manager.py +126 -34
- jettask/core/context.py +3 -0
- jettask/core/enums.py +137 -0
- jettask/core/event_pool.py +501 -168
- jettask/core/message.py +147 -0
- jettask/core/offline_worker_recovery.py +181 -114
- jettask/core/task.py +10 -174
- jettask/core/task_batch.py +153 -0
- jettask/core/unified_manager_base.py +243 -0
- jettask/core/worker_scanner.py +54 -54
- jettask/executors/asyncio.py +184 -64
- jettask/webui/backend/config.py +51 -0
- jettask/webui/backend/data_access.py +2083 -92
- jettask/webui/backend/data_api.py +3294 -0
- jettask/webui/backend/dependencies.py +261 -0
- jettask/webui/backend/init_meta_db.py +158 -0
- jettask/webui/backend/main.py +1358 -69
- jettask/webui/backend/main_unified.py +78 -0
- jettask/webui/backend/main_v2.py +394 -0
- jettask/webui/backend/namespace_api.py +295 -0
- jettask/webui/backend/namespace_api_old.py +294 -0
- jettask/webui/backend/namespace_data_access.py +611 -0
- jettask/webui/backend/queue_backlog_api.py +727 -0
- jettask/webui/backend/queue_stats_v2.py +521 -0
- jettask/webui/backend/redis_monitor_api.py +476 -0
- jettask/webui/backend/unified_api_router.py +1601 -0
- jettask/webui/db_init.py +204 -32
- jettask/webui/frontend/package-lock.json +492 -1
- jettask/webui/frontend/package.json +4 -1
- jettask/webui/frontend/src/App.css +105 -7
- jettask/webui/frontend/src/App.jsx +49 -20
- 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/layout/AppLayout.css +95 -0
- jettask/webui/frontend/src/components/layout/AppLayout.jsx +49 -0
- jettask/webui/frontend/src/components/layout/Header.css +34 -10
- jettask/webui/frontend/src/components/layout/Header.jsx +31 -23
- 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/NamespaceContext.jsx +72 -0
- jettask/webui/frontend/src/contexts/TabsContext.backup.jsx +245 -0
- jettask/webui/frontend/src/main.jsx +1 -0
- jettask/webui/frontend/src/pages/Alerts.jsx +684 -0
- jettask/webui/frontend/src/pages/Dashboard.jsx +1330 -0
- jettask/webui/frontend/src/pages/QueueDetail.jsx +1109 -10
- jettask/webui/frontend/src/pages/QueueMonitor.jsx +236 -115
- jettask/webui/frontend/src/pages/Queues.jsx +5 -1
- jettask/webui/frontend/src/pages/ScheduledTasks.jsx +809 -0
- jettask/webui/frontend/src/pages/Settings.jsx +800 -0
- jettask/webui/frontend/src/services/api.js +7 -5
- jettask/webui/frontend/src/utils/suppressWarnings.js +22 -0
- jettask/webui/frontend/src/utils/userPreferences.js +154 -0
- jettask/webui/multi_namespace_consumer.py +543 -0
- jettask/webui/pg_consumer.py +983 -246
- jettask/webui/static/dist/assets/index-7129cfe1.css +1 -0
- jettask/webui/static/dist/assets/index-8d1935cc.js +774 -0
- jettask/webui/static/dist/index.html +2 -2
- jettask/webui/task_center.py +216 -0
- jettask/webui/task_center_client.py +150 -0
- jettask/webui/unified_consumer_manager.py +193 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/METADATA +1 -1
- jettask-0.2.4.dist-info/RECORD +134 -0
- jettask/webui/pg_consumer_slow.py +0 -1099
- jettask/webui/pg_consumer_test.py +0 -678
- jettask/webui/static/dist/assets/index-823408e8.css +0 -1
- jettask/webui/static/dist/assets/index-9968b0b8.js +0 -543
- jettask/webui/test_pg_consumer_recovery.py +0 -547
- jettask/webui/test_recovery_simple.py +0 -492
- jettask/webui/test_self_recovery.py +0 -467
- jettask-0.2.1.dist-info/RECORD +0 -91
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/WHEEL +0 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/entry_points.txt +0 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.dist-info}/licenses/LICENSE +0 -0
- {jettask-0.2.1.dist-info → jettask-0.2.4.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;
|
@@ -0,0 +1,95 @@
|
|
1
|
+
/* 整体布局 */
|
2
|
+
.app-layout {
|
3
|
+
height: 100vh;
|
4
|
+
overflow: hidden;
|
5
|
+
}
|
6
|
+
|
7
|
+
/* 主布局区域 */
|
8
|
+
.main-layout {
|
9
|
+
transition: margin-left 0.2s;
|
10
|
+
display: flex;
|
11
|
+
flex-direction: column;
|
12
|
+
height: 100vh;
|
13
|
+
}
|
14
|
+
|
15
|
+
/* 顶部导航栏 */
|
16
|
+
.app-header {
|
17
|
+
background: #2a2d31;
|
18
|
+
padding: 0;
|
19
|
+
height: 48px;
|
20
|
+
line-height: 48px;
|
21
|
+
display: flex;
|
22
|
+
justify-content: space-between;
|
23
|
+
align-items: center;
|
24
|
+
border-bottom: 1px solid #f0f2f5;
|
25
|
+
z-index: 10;
|
26
|
+
position: sticky;
|
27
|
+
top: 0;
|
28
|
+
}
|
29
|
+
|
30
|
+
.header-left {
|
31
|
+
flex: 1;
|
32
|
+
display: flex;
|
33
|
+
align-items: center;
|
34
|
+
height: 100%;
|
35
|
+
overflow: hidden;
|
36
|
+
}
|
37
|
+
|
38
|
+
.header-right {
|
39
|
+
display: flex;
|
40
|
+
align-items: center;
|
41
|
+
height: 100%;
|
42
|
+
gap: 16px;
|
43
|
+
}
|
44
|
+
|
45
|
+
/* 内容区域 */
|
46
|
+
.app-content {
|
47
|
+
flex: 1;
|
48
|
+
overflow: auto;
|
49
|
+
background: #f0f2f5;
|
50
|
+
padding: 0;
|
51
|
+
}
|
52
|
+
|
53
|
+
/* 页面内容容器 */
|
54
|
+
.page-container {
|
55
|
+
height: 100%;
|
56
|
+
background: #fff;
|
57
|
+
}
|
58
|
+
|
59
|
+
/* 滚动条样式 */
|
60
|
+
.app-content::-webkit-scrollbar {
|
61
|
+
width: 8px;
|
62
|
+
height: 8px;
|
63
|
+
}
|
64
|
+
|
65
|
+
.app-content::-webkit-scrollbar-track {
|
66
|
+
background: #f0f0f0;
|
67
|
+
}
|
68
|
+
|
69
|
+
.app-content::-webkit-scrollbar-thumb {
|
70
|
+
background: #bfbfbf;
|
71
|
+
border-radius: 4px;
|
72
|
+
}
|
73
|
+
|
74
|
+
.app-content::-webkit-scrollbar-thumb:hover {
|
75
|
+
background: #8c8c8c;
|
76
|
+
}
|
77
|
+
|
78
|
+
/* 响应式调整 */
|
79
|
+
@media (max-width: 768px) {
|
80
|
+
.main-layout {
|
81
|
+
margin-left: 0 !important;
|
82
|
+
}
|
83
|
+
|
84
|
+
.app-sider {
|
85
|
+
position: fixed;
|
86
|
+
z-index: 1000;
|
87
|
+
height: 100vh;
|
88
|
+
left: -200px;
|
89
|
+
transition: left 0.2s;
|
90
|
+
}
|
91
|
+
|
92
|
+
.app-sider.ant-layout-sider-collapsed {
|
93
|
+
left: 0;
|
94
|
+
}
|
95
|
+
}
|