@jiangood/open-admin 3.1.10 → 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
  {
@@ -332,17 +369,19 @@ export default class JobPage extends React.Component {
332
369
  <Input/>
333
370
  </Form.Item>
334
371
 
335
- <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}
336
375
  rules={[{required: true}]}>
337
- <AutoComplete placeholder='如 0 */5 * * * ?' options={cronOptions}/>
376
+ <CronEditor presets={cronPresets} placeholder='如 0 */5 * * * ?'/>
338
377
  </Form.Item>
339
378
 
340
379
  <Form.Item label='启用' name='enabled' valuePropName='checked' rules={[{required: true}]}>
341
380
  <Switch/>
342
381
  </Form.Item>
343
382
 
344
- <Form.Item label='日志聚合' name='logAggregation' initialValue='PER_RUN'
345
- help='高频任务建议按天聚合:一天只保留一条执行记录,日志文件按天合并'>
383
+ <Form.Item label='执行记录方式' name='logAggregation' initialValue='PER_RUN'
384
+ help='控制执行记录与日志文件的粒度。高频任务建议选择「每天一份记录」:一天只保留一条执行记录,日志文件按天合并'>
346
385
  <FieldDictSelect typeCode='jobLogAggregation'/>
347
386
  </Form.Item>
348
387
 
@@ -460,6 +499,10 @@ export default class JobPage extends React.Component {
460
499
  }
461
500
 
462
501
  onValuesChange = (changed, values) => {
502
+ if (changed.cron !== undefined) {
503
+ this.setState({cronDesc: CronUtils.describe(values.cron)})
504
+ }
505
+
463
506
  if (changed.jobClass) {
464
507
  this.loadJobParamFields(values.jobClass)
465
508
  const option = this.state.jobClassOptions.find(o => o.value === changed.jobClass)