@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,309 @@
1
+ import React from 'react'
2
+ import {
3
+ Button,
4
+ Card,
5
+ Col,
6
+ Divider,
7
+ Form,
8
+ Input,
9
+ InputNumber,
10
+ Popconfirm,
11
+ Row,
12
+ Select,
13
+ Space,
14
+ Switch,
15
+ Tag,
16
+ Tooltip,
17
+ Typography,
18
+ message
19
+ } from 'antd'
20
+ import {QuestionCircleOutlined, UndoOutlined} from '@ant-design/icons'
21
+ import {EventBus, GlobalData, HttpClient, Page, Perm} from '../../../framework'
22
+
23
+ /**
24
+ * 系统配置页。
25
+ *
26
+ * 配置项由后端 ConfigProvider 声明(分组 / 类型 / 默认值 / 范围 / 说明),
27
+ * 本页面只负责按元数据渲染、收集修改项并提交差异。
28
+ */
29
+ export default class SysConfigPage extends React.Component {
30
+
31
+ state = {
32
+ groups: [],
33
+ loading: true,
34
+ saving: false,
35
+ dirty: false,
36
+ }
37
+
38
+ formRef = React.createRef()
39
+
40
+ componentDidMount() {
41
+ this.load()
42
+ }
43
+
44
+ /** 多 Tab 下页面保持 mounted,切回时重新拉取;有未保存修改时不覆盖用户输入 */
45
+ onShow = () => {
46
+ if (this.state.dirty || this.state.loading) {
47
+ return
48
+ }
49
+ this.load()
50
+ }
51
+
52
+ load = async () => {
53
+ this.setState({loading: true})
54
+ try {
55
+ const rs = await HttpClient.get('admin/sys-config/items')
56
+ this.applyGroups(rs.data?.groups || [])
57
+ } catch {
58
+ this.setState({loading: false})
59
+ }
60
+ }
61
+
62
+ /**
63
+ * 用后端返回值更新页面,不触发整页 loading。
64
+ * syncKeys 为空时同步全部字段;否则只同步指定项,保留其他未保存的修改。
65
+ */
66
+ applyGroups = (groups, syncKeys = null) => {
67
+ this.setState({groups, loading: false}, () => {
68
+ const form = this.formRef.current
69
+ if (!form) {
70
+ this.setState({dirty: false})
71
+ return
72
+ }
73
+ const target = syncKeys ? new Set(syncKeys) : null
74
+ groups.forEach(group => {
75
+ group.items.forEach(item => {
76
+ if (!target || target.has(item.key)) {
77
+ form.setFieldValue([item.key], item.secret ? undefined : item.value)
78
+ }
79
+ })
80
+ })
81
+ this.setState({dirty: Object.keys(this.buildPatch(form.getFieldsValue())).length > 0})
82
+ })
83
+ }
84
+
85
+ /** 只收集与当前生效值不同的项 */
86
+ buildPatch = (values) => {
87
+ const items = new Map()
88
+ this.state.groups.forEach(group => {
89
+ group.items.forEach(item => items.set(item.key, item))
90
+ })
91
+
92
+ const patch = {}
93
+ Object.entries(values || {}).forEach(([key, value]) => {
94
+ const item = items.get(key)
95
+ if (!item || value === undefined) {
96
+ return
97
+ }
98
+ // 敏感值留空表示不修改
99
+ if (item.secret && (value === null || value === '')) {
100
+ return
101
+ }
102
+ const before = item.value
103
+ const same = Array.isArray(value) || Array.isArray(before)
104
+ ? JSON.stringify(value ?? []) === JSON.stringify(before ?? [])
105
+ : value === before
106
+ if (!same) {
107
+ patch[key] = value
108
+ }
109
+ })
110
+ return patch
111
+ }
112
+
113
+ onValuesChange = (_, allValues) => {
114
+ const dirty = Object.keys(this.buildPatch(allValues)).length > 0
115
+ if (dirty !== this.state.dirty) {
116
+ this.setState({dirty})
117
+ }
118
+ }
119
+
120
+ save = async () => {
121
+ const form = this.formRef.current
122
+ if (!form) {
123
+ return
124
+ }
125
+ const patch = this.buildPatch(form.getFieldsValue())
126
+ const values = Object.entries(patch).map(([key, value]) => ({key, value}))
127
+ if (values.length === 0) {
128
+ message.info('没有需要保存的修改')
129
+ return
130
+ }
131
+
132
+ this.setState({saving: true})
133
+ try {
134
+ const rs = await HttpClient.post('admin/sys-config/update', {values})
135
+ this.applyGroups(rs.data?.groups || [], Object.keys(patch))
136
+ await this.refreshSiteInfo()
137
+ } catch {
138
+ // 错误提示由 HttpClient 统一处理
139
+ } finally {
140
+ this.setState({saving: false})
141
+ }
142
+ }
143
+
144
+ resetKeys = async (keys) => {
145
+ try {
146
+ const rs = await HttpClient.post('admin/sys-config/reset', {keys})
147
+ this.applyGroups(rs.data?.groups || [], keys && keys.length ? keys : null)
148
+ await this.refreshSiteInfo()
149
+ } catch {
150
+ // 错误提示由 HttpClient 统一处理
151
+ }
152
+ }
153
+
154
+ /** 站点标题、水印等配置经 site-info 下发,保存后刷新本地缓存并通知布局 */
155
+ refreshSiteInfo = async () => {
156
+ try {
157
+ const rs = await HttpClient.get('/admin/public/site-info', null, {toastError: false})
158
+ const next = rs.data || {}
159
+ const changed = JSON.stringify(GlobalData.getSiteInfo()) !== JSON.stringify(next)
160
+ GlobalData.setSiteInfo(next)
161
+ if (changed) {
162
+ EventBus.emit('siteInfoChange')
163
+ }
164
+ } catch {
165
+ // site-info 刷新失败不影响配置保存结果
166
+ }
167
+ }
168
+
169
+ controlOf = (item) => {
170
+ switch (item.type) {
171
+ case 'bool':
172
+ return <Switch/>
173
+ case 'int':
174
+ return <InputNumber style={{width: '100%'}} precision={0} step={1}
175
+ min={item.min ?? undefined} max={item.max ?? undefined}/>
176
+ case 'float':
177
+ return <InputNumber style={{width: '100%'}} step={0.01}
178
+ min={item.min ?? undefined} max={item.max ?? undefined}/>
179
+ case 'time':
180
+ return <Input placeholder="HH:mm" style={{width: '100%'}}/>
181
+ case 'list':
182
+ return <Select mode="tags" tokenSeparators={[',']} style={{width: '100%'}}
183
+ placeholder="输入后回车,或粘贴逗号分隔的内容"/>
184
+ case 'select':
185
+ return <Select style={{width: '100%'}}
186
+ options={(item.options || []).map(option => ({
187
+ value: option.value,
188
+ label: option.label,
189
+ }))}/>
190
+ case 'text':
191
+ return <Input.TextArea rows={3}/>
192
+ case 'string':
193
+ return item.secret
194
+ ? <Input.Password autoComplete="new-password"
195
+ placeholder={item.hasValue ? '已设置,留空表示不修改' : '未设置'}/>
196
+ : <Input/>
197
+ default:
198
+ return <Input/>
199
+ }
200
+ }
201
+
202
+ renderItem = (item) => {
203
+ const hasRange = (item.min !== null && item.min !== undefined)
204
+ || (item.max !== null && item.max !== undefined)
205
+ const range = hasRange
206
+ ? `范围 ${item.min ?? '-∞'} ~ ${item.max ?? '+∞'}`
207
+ : null
208
+ const sourceLabel = item.source === 'db'
209
+ ? '数据库覆盖'
210
+ : item.source === 'env' ? 'application.yml' : '代码默认'
211
+ const defaultValue = item.defaultValue === null
212
+ || item.defaultValue === undefined
213
+ || item.defaultValue === ''
214
+ ? '空'
215
+ : JSON.stringify(item.defaultValue)
216
+
217
+ return <Col xs={24} md={12} xl={8} key={item.key}>
218
+ <Form.Item
219
+ name={[item.key]}
220
+ required={item.required}
221
+ label={<Space size={4}>
222
+ <span>{item.label}</span>
223
+ <Tooltip title={<>
224
+ {item.hint && <div>{item.hint}</div>}
225
+ {!item.secret && <div>默认值:{defaultValue}</div>}
226
+ {range && <div>{range}</div>}
227
+ <div>当前来源:{sourceLabel}</div>
228
+ <div>配置键:{item.key}</div>
229
+ </>}>
230
+ <QuestionCircleOutlined style={{color: 'var(--oa-color-text-tertiary)'}}/>
231
+ </Tooltip>
232
+ {item.overridden && (
233
+ <Space size={2}>
234
+ <Tag color="blue" style={{marginInlineEnd: 0}}>已覆盖</Tag>
235
+ <Perm code='sys-config:update'>
236
+ <Popconfirm title="恢复为默认值?" onConfirm={() => this.resetKeys([item.key])}>
237
+ <Button type="text" size="small" title="恢复默认"
238
+ icon={<UndoOutlined/>}
239
+ style={{padding: 0, width: 20, minWidth: 20, height: 20}}/>
240
+ </Popconfirm>
241
+ </Perm>
242
+ </Space>
243
+ )}
244
+ </Space>}
245
+ >
246
+ {this.controlOf(item)}
247
+ </Form.Item>
248
+ <div style={{marginTop: -18, marginBottom: 12}}>
249
+ <Typography.Text type="secondary" style={{fontSize: 12}}>{item.hint}</Typography.Text>
250
+ {item.secret && (
251
+ <Typography.Text type="secondary" style={{fontSize: 12, marginInlineStart: 8}}>
252
+ {item.hasValue ? '已设置' : '未设置'}
253
+ </Typography.Text>
254
+ )}
255
+ </div>
256
+ </Col>
257
+ }
258
+
259
+ render() {
260
+ const {groups, loading, saving, dirty} = this.state
261
+ const hasOverridden = groups.some(group => group.items.some(item => item.overridden))
262
+
263
+ return <Page>
264
+ <Form ref={this.formRef} layout="vertical" onValuesChange={this.onValuesChange}>
265
+ <Card loading={loading}>
266
+ {groups.map((group, index) => (
267
+ <div key={group.code}>
268
+ <div style={{marginBottom: 12}}>
269
+ <Typography.Title level={5} style={{margin: 0}}>{group.label}</Typography.Title>
270
+ {group.description && (
271
+ <Typography.Text type="secondary" style={{fontSize: 12}}>
272
+ {group.description}
273
+ </Typography.Text>
274
+ )}
275
+ </div>
276
+ <Row gutter={16}>
277
+ {group.items.map(this.renderItem)}
278
+ </Row>
279
+ {index < groups.length - 1 && <Divider style={{margin: '8px 0 20px'}}/>}
280
+ </div>
281
+ ))}
282
+
283
+ {groups.length > 0 && <Divider style={{margin: '4px 0 16px'}}/>}
284
+
285
+ <Space wrap>
286
+ <Perm code='sys-config:update'>
287
+ <Button type="primary" loading={saving} disabled={!dirty} onClick={this.save}>
288
+ 保存修改
289
+ </Button>
290
+ </Perm>
291
+ <Perm code='sys-config:update'>
292
+ <Popconfirm
293
+ title="恢复全部默认值?"
294
+ description="所有已修改的配置项都会回到默认值。"
295
+ disabled={!hasOverridden}
296
+ onConfirm={() => this.resetKeys([])}
297
+ >
298
+ <Button danger disabled={!hasOverridden}>全部恢复默认</Button>
299
+ </Popconfirm>
300
+ </Perm>
301
+ <Typography.Text type="secondary" style={{fontSize: 12}}>
302
+ 配置默认值来自 application.yml 或代码声明
303
+ </Typography.Text>
304
+ </Space>
305
+ </Card>
306
+ </Form>
307
+ </Page>
308
+ }
309
+ }
@@ -1,7 +1,6 @@
1
1
  import React from 'react'
2
2
  import {
3
3
  Alert,
4
- AutoComplete,
5
4
  Divider,
6
5
  Form,
7
6
  Input,
@@ -13,29 +12,47 @@ import {
13
12
  Tooltip
14
13
  } from 'antd'
15
14
  import {PlusOutlined, SearchOutlined} from "@ant-design/icons";
16
- import {FormModal, DictUtils, FieldDictSelect, getMessageApi, HttpClient, LogViewer, Page, PermActions, ProTable, UrlUtils, ValueType} from "../../../framework";
17
-
18
-
19
- const cronOptions = [
15
+ import {
16
+ CronEditor,
17
+ CronUtils,
18
+ FormModal,
19
+ DictUtils,
20
+ FieldDictSelect,
21
+ getMessageApi,
22
+ HttpClient,
23
+ LogViewer,
24
+ Page,
25
+ PermActions,
26
+ ProTable,
27
+ UrlUtils,
28
+ ValueType
29
+ } from "../../../framework";
30
+
31
+
32
+ const cronPresets = [
20
33
  {
21
- label: '*/5 * * * * ? 每隔5秒',
34
+ label: '每隔 5 秒',
22
35
  value: '*/5 * * * * ?'
23
36
  },
24
37
  {
25
- label: '0 */5 * * * ? 每隔5分钟',
38
+ label: '每隔 5 分钟',
26
39
  value: '0 */5 * * * ?'
27
40
  },
28
41
  {
29
- label: '0 0 22 * * ? 每天22点',
42
+ label: '每天 22:00',
30
43
  value: '0 0 22 * * ?'
31
44
  },
32
45
  {
33
- label: '0 0 1 * * ? 每天1点',
46
+ label: '每天 01:00',
34
47
  value: '0 0 1 * * ?'
35
48
  },
36
49
  {
37
- label: '0 0 1 1 * ? 每月1号凌晨1点',
50
+ label: '每月 1 号 01:00',
38
51
  value: '0 0 1 1 * ?'
52
+ },
53
+ {
54
+ label: '工作日 09:00',
55
+ value: '0 0 9 ? * MON-FRI'
39
56
  }
40
57
  ]
41
58
 
@@ -57,6 +74,8 @@ export default class JobPage extends React.Component {
57
74
  logOpen: false,
58
75
 
59
76
  progressMap: {},
77
+
78
+ cronDesc: '',
60
79
  }
61
80
  tableRef = React.createRef()
62
81
  modalRef = React.createRef()
@@ -104,11 +123,12 @@ export default class JobPage extends React.Component {
104
123
  }
105
124
 
106
125
  handleAdd = () => {
107
- this.setState({paramList: []})
126
+ this.setState({paramList: [], cronDesc: ''})
108
127
  this.modalRef.current.open({})
109
128
  }
110
129
 
111
130
  handleEdit = (record) => {
131
+ this.setState({cronDesc: CronUtils.describe(record.cron)})
112
132
  this.loadJobParamFields(record.jobClass, record.jobData)
113
133
  this.modalRef.current.open(record)
114
134
  }
@@ -160,8 +180,25 @@ export default class JobPage extends React.Component {
160
180
  },
161
181
 
162
182
  {
163
- title: 'cron',
183
+ title: 'cron 表达式',
164
184
  dataIndex: 'cron',
185
+ width: 220,
186
+ render: (v) => {
187
+ if (!v) {
188
+ return '-'
189
+ }
190
+ const desc = CronUtils.describe(v)
191
+ return <div style={{lineHeight: 1.45}}>
192
+ <div style={{
193
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
194
+ wordBreak: 'break-all'
195
+ }}>{v}</div>
196
+ {desc && <div style={{
197
+ fontSize: 12,
198
+ color: 'var(--oa-color-text-tertiary, rgba(0, 0, 0, 0.45))'
199
+ }}>{desc}</div>}
200
+ </div>
201
+ }
165
202
  },
166
203
 
167
204
 
@@ -184,9 +221,9 @@ export default class JobPage extends React.Component {
184
221
  },
185
222
 
186
223
  {
187
- title: '日志聚合',
224
+ title: '执行记录方式',
188
225
  dataIndex: 'logAggregation',
189
- render: v => DictUtils.dictTag('jobLogAggregation', v) || '每次执行',
226
+ render: v => DictUtils.dictTag('jobLogAggregation', v) || '每次一份记录',
190
227
  },
191
228
 
192
229
  {
@@ -225,7 +262,7 @@ export default class JobPage extends React.Component {
225
262
  more
226
263
  size="small"
227
264
  actions={[
228
- {label: '执行记录', onClick: () => this.showExecuteRecord(record)},
265
+ {label: '执行情况', perm: 'job:read', onClick: () => this.showExecuteRecord(record)},
229
266
  {label: '执行一次', perm: 'job:trigger', onClick: () => this.handleTriggerJob(record)},
230
267
  {label: '编辑', perm: 'job:update', onClick: () => this.handleEdit(record)},
231
268
  {label: '删除', perm: 'job:delete', confirm: '是否确定删除?', onClick: () => this.handleDelete(record)},
@@ -286,6 +323,14 @@ export default class JobPage extends React.Component {
286
323
  p.logId === selectedLogRecord.id && p.state === 'RUNNING')
287
324
  }
288
325
 
326
+ /** 结束时间与开始时间为同一天时只显示时分秒,避免重复日期 */
327
+ formatRecordEndTime = (beginTime, endTime) => {
328
+ if (!beginTime || !endTime) {
329
+ return endTime || '-'
330
+ }
331
+ return endTime.slice(0, 10) === beginTime.slice(0, 10) ? endTime.slice(11) : endTime
332
+ }
333
+
289
334
 
290
335
  render() {
291
336
  return <Page>
@@ -324,17 +369,19 @@ export default class JobPage extends React.Component {
324
369
  <Input/>
325
370
  </Form.Item>
326
371
 
327
- <Form.Item label='cron表达式' name='cron' help='格式:秒分时日月周,留空表示手动执行'
372
+ <Form.Item label='cron表达式' name='cron'
373
+ help='必填,格式:秒 分 时 日 月 周(可选 年)'
374
+ extra={this.state.cronDesc ? `解析:${this.state.cronDesc}` : null}
328
375
  rules={[{required: true}]}>
329
- <AutoComplete placeholder='如 0 */5 * * * ?' options={cronOptions}/>
376
+ <CronEditor presets={cronPresets} placeholder='如 0 */5 * * * ?'/>
330
377
  </Form.Item>
331
378
 
332
379
  <Form.Item label='启用' name='enabled' valuePropName='checked' rules={[{required: true}]}>
333
380
  <Switch/>
334
381
  </Form.Item>
335
382
 
336
- <Form.Item label='日志聚合' name='logAggregation' initialValue='PER_RUN'
337
- help='高频任务建议按天聚合:一天只保留一条执行记录,日志文件按天合并'>
383
+ <Form.Item label='执行记录方式' name='logAggregation' initialValue='PER_RUN'
384
+ help='控制执行记录与日志文件的粒度。高频任务建议选择「每天一份记录」:一天只保留一条执行记录,日志文件按天合并'>
338
385
  <FieldDictSelect typeCode='jobLogAggregation'/>
339
386
  </Form.Item>
340
387
 
@@ -374,56 +421,54 @@ export default class JobPage extends React.Component {
374
421
  destroyOnHidden
375
422
  >
376
423
  {this.renderRunningProgress()}
377
- <ProTable columns={[{
378
- title: '开始时间',
379
- dataIndex: 'beginTime',
380
- },
424
+ <ProTable columns={[
381
425
  {
382
- title: '结束时间',
383
- dataIndex: 'endTime',
426
+ title: '开始 / 结束时间',
427
+ dataIndex: 'beginTime',
428
+ width: 170,
429
+ render: (v, record) => <div style={{lineHeight: 1.45}}>
430
+ <div>{v || '-'}</div>
431
+ <div style={{
432
+ fontSize: 12,
433
+ color: 'var(--oa-color-text-tertiary, rgba(0, 0, 0, 0.45))',
434
+ whiteSpace: 'nowrap'
435
+ }}>{record.endTime
436
+ ? '至 ' + this.formatRecordEndTime(v, record.endTime)
437
+ : '运行中…'}</div>
438
+ </div>
384
439
  },
385
440
  {
386
- title: '耗时',
387
- dataIndex: 'jobRunTimeLabel',
388
-
389
- },
390
- {
391
- title: '执行次数',
441
+ title: '成功/失败',
392
442
  dataIndex: 'executeCount',
393
- width: 90,
394
- render: v => v == null ? 1 : v,
395
- },
396
- {
397
- title: '失败次数',
398
- dataIndex: 'failCount',
399
- width: 90,
400
- render: v => (v == null || v === 0) ? '-' : <span style={{color: 'var(--oa-color-error, #ff4d4f)'}}>{v}</span>,
401
- },
402
- {
403
- title: '累计耗时',
404
- dataIndex: 'totalRunTimeLabel',
405
- width: 100,
406
- render: v => v || '-',
407
- },
408
- {
409
- title: '是否成功',
410
- dataIndex: 'success',
411
- width: 200,
412
- render: v => {
413
- if (v != null) {
414
- return v ? '成功' : '异常'
443
+ width: 150,
444
+ render: (v, record) => {
445
+ const count = v == null ? 1 : v
446
+ // 旧版本升级上来的记录没有 failCount,用 success 兜底
447
+ const failCount = record.failCount == null
448
+ ? (record.success === false ? 1 : 0)
449
+ : record.failCount
450
+ const successCount = Math.max(count - failCount, 0)
451
+ if (successCount === 0 && failCount === 0) {
452
+ return '-'
415
453
  }
416
- }
454
+ return <span>
455
+ {successCount > 0 &&
456
+ <Tag color='success' style={{marginRight: 4}}>成功 {successCount}</Tag>}
457
+ {failCount > 0 &&
458
+ <Tag color='error' style={{marginRight: 0}}>失败 {failCount}</Tag>}
459
+ </span>
460
+ },
417
461
  },
418
462
  {
419
463
  title: '返回结果',
420
464
  dataIndex: 'result',
421
- width: 300
465
+ render: v => (v == null || v === '') ? '-' : v,
422
466
  },
423
-
424
467
  {
425
468
  title: '操作',
426
469
  dataIndex: 'option',
470
+ width: 64,
471
+ fixed: 'right',
427
472
  render: (_, record) => <a onClick={() => this.showLog(record)}>日志</a>,
428
473
  }
429
474
  ]} request={(params) => {
@@ -454,6 +499,10 @@ export default class JobPage extends React.Component {
454
499
  }
455
500
 
456
501
  onValuesChange = (changed, values) => {
502
+ if (changed.cron !== undefined) {
503
+ this.setState({cronDesc: CronUtils.describe(values.cron)})
504
+ }
505
+
457
506
  if (changed.jobClass) {
458
507
  this.loadJobParamFields(values.jobClass)
459
508
  const option = this.state.jobClassOptions.find(o => o.value === changed.jobClass)