@jiangood/open-admin 3.1.10 → 3.1.12

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.
Files changed (59) hide show
  1. package/package.json +3 -3
  2. package/src/framework/GlobalData.ts +4 -0
  3. package/src/framework/biz/OrgTree.tsx +1 -1
  4. package/src/framework/biz/RoleTree.tsx +1 -1
  5. package/src/framework/biz/UserPicker.tsx +4 -4
  6. package/src/framework/biz/index.tsx +4 -4
  7. package/src/framework/components/CronEditor/index.tsx +318 -0
  8. package/src/framework/components/LinkButton/index.tsx +4 -0
  9. package/src/framework/components/OrgSwitcher/index.tsx +2 -2
  10. package/src/framework/components/Page/index.tsx +6 -14
  11. package/src/framework/components/ProTable/index.tsx +3 -1
  12. package/src/framework/components/ThemeSettings/index.tsx +5 -38
  13. package/src/framework/components/index.ts +1 -0
  14. package/src/framework/config/index.ts +12 -0
  15. package/src/framework/config/layoutConfig.ts +47 -8
  16. package/src/framework/config/themeConfig.ts +93 -25
  17. package/src/framework/config/uiDefaults.ts +26 -0
  18. package/src/framework/fields/FieldEditor/index.tsx +1 -1
  19. package/src/framework/fields/FieldUploadFile/index.tsx +1 -1
  20. package/src/framework/fields/FieldUploadImage/index.tsx +42 -10
  21. package/src/framework/fields/FieldUserPicker/index.tsx +3 -3
  22. package/src/framework/fields/orgTree.ts +1 -1
  23. package/src/framework/router/index.ts +1 -0
  24. package/src/framework/router/matcher.ts +19 -4
  25. package/src/framework/utils/AvatarUtils.ts +64 -0
  26. package/src/framework/utils/CronUtils.ts +562 -0
  27. package/src/framework/utils/HttpClient.ts +5 -5
  28. package/src/framework/utils/PageUtils.ts +38 -7
  29. package/src/framework/utils/TitleUtils.ts +38 -0
  30. package/src/framework/utils/UrlUtils.ts +3 -3
  31. package/src/framework/utils/index.ts +3 -0
  32. package/src/layouts/admin/HeaderRight.jsx +62 -16
  33. package/src/layouts/admin/SinglePageLayout.jsx +13 -2
  34. package/src/layouts/admin/TabLayout.jsx +38 -19
  35. package/src/layouts/admin/index.jsx +12 -3
  36. package/src/layouts/index.tsx +52 -15
  37. package/src/pages/404.jsx +4 -0
  38. package/src/pages/article/$code.jsx +5 -1
  39. package/src/pages/login.jsx +157 -0
  40. package/src/pages/{public/login.less → login.less} +16 -1
  41. package/src/pages/public/login.jsx +11 -145
  42. package/src/pages/standalone/{forceUpdatePwd.jsx → force-update-pwd.jsx} +10 -4
  43. package/src/pages/system/article/index.jsx +1 -1
  44. package/src/pages/system/config/index.jsx +335 -0
  45. package/src/pages/system/file/index.jsx +3 -3
  46. package/src/pages/system/job/index.jsx +62 -19
  47. package/src/pages/system/log/index.jsx +1 -1
  48. package/src/pages/system/online/index.jsx +95 -0
  49. package/src/pages/system/org/index.jsx +7 -7
  50. package/src/pages/system/role/index.jsx +8 -8
  51. package/src/pages/system/role/{rolePerm.jsx → role-perm.jsx} +9 -4
  52. package/src/pages/system/user/{userPerm.jsx → UserPermModal.jsx} +3 -3
  53. package/src/pages/system/user/index.jsx +6 -6
  54. package/src/pages/user-center/AvatarPicker.jsx +76 -0
  55. package/src/pages/{userCenter/permView.jsx → user-center/PermView.jsx} +48 -25
  56. package/src/pages/user-center/RecentOps.jsx +36 -0
  57. package/src/pages/user-center/index.jsx +393 -0
  58. package/vite-plugin/index.js +64 -6
  59. package/src/pages/userCenter/index.jsx +0 -93
@@ -0,0 +1,335 @@
1
+ import React from 'react'
2
+ import {
3
+ Button,
4
+ Card,
5
+ Col,
6
+ Form,
7
+ Input,
8
+ InputNumber,
9
+ Popconfirm,
10
+ Row,
11
+ Select,
12
+ Space,
13
+ Switch,
14
+ Tag,
15
+ Tooltip,
16
+ Typography,
17
+ message
18
+ } from 'antd'
19
+ import {QuestionCircleOutlined, UndoOutlined} from '@ant-design/icons'
20
+ import {EventBus, GlobalData, HttpClient, Page, Perm} from '../../../framework'
21
+
22
+ /**
23
+ * 参数设置页。
24
+ *
25
+ * 配置项由后端 ConfigProvider 声明(分组 / 类型 / 默认值 / 范围 / 说明),
26
+ * 本页面只负责按元数据渲染、收集修改项并提交差异。
27
+ */
28
+ export default class SysConfigPage extends React.Component {
29
+
30
+ state = {
31
+ groups: [],
32
+ loading: true,
33
+ saving: false,
34
+ testingMail: 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
+ /** 用已保存的配置发一封测试邮件到发件邮箱自身 */
145
+ sendTestMail = async () => {
146
+ this.setState({testingMail: true})
147
+ try {
148
+ // 成功提示由后端 message 自动弹出
149
+ await HttpClient.post('admin/mail/test')
150
+ } catch {
151
+ // 错误提示由 HttpClient 统一处理
152
+ } finally {
153
+ this.setState({testingMail: false})
154
+ }
155
+ }
156
+
157
+ resetKeys = async (keys) => {
158
+ try {
159
+ const rs = await HttpClient.post('admin/sys-config/reset', {keys})
160
+ this.applyGroups(rs.data?.groups || [], keys && keys.length ? keys : null)
161
+ await this.refreshSiteInfo()
162
+ } catch {
163
+ // 错误提示由 HttpClient 统一处理
164
+ }
165
+ }
166
+
167
+ /** 站点标题、水印等配置经 site-info 下发,保存后刷新本地缓存并通知布局 */
168
+ refreshSiteInfo = async () => {
169
+ try {
170
+ const rs = await HttpClient.get('/admin/public/site-info', null, {toastError: false})
171
+ const next = rs.data || {}
172
+ const changed = JSON.stringify(GlobalData.getSiteInfo()) !== JSON.stringify(next)
173
+ GlobalData.setSiteInfo(next)
174
+ if (changed) {
175
+ EventBus.emit('siteInfoChange')
176
+ }
177
+ } catch {
178
+ // site-info 刷新失败不影响配置保存结果
179
+ }
180
+ }
181
+
182
+ controlOf = (item) => {
183
+ switch (item.type) {
184
+ case 'bool':
185
+ return <Switch/>
186
+ case 'int':
187
+ return <InputNumber style={{width: '100%'}} precision={0} step={1}
188
+ min={item.min ?? undefined} max={item.max ?? undefined}/>
189
+ case 'float':
190
+ return <InputNumber style={{width: '100%'}} step={0.01}
191
+ min={item.min ?? undefined} max={item.max ?? undefined}/>
192
+ case 'time':
193
+ return <Input placeholder="HH:mm" style={{width: '100%'}}/>
194
+ case 'list':
195
+ return <Select mode="tags" tokenSeparators={[',']} style={{width: '100%'}}
196
+ placeholder="输入后回车,或粘贴逗号分隔的内容"/>
197
+ case 'select':
198
+ return <Select style={{width: '100%'}}
199
+ options={(item.options || []).map(option => ({
200
+ value: option.value,
201
+ label: option.label,
202
+ }))}/>
203
+ case 'text':
204
+ return <Input.TextArea rows={3}/>
205
+ case 'proxy':
206
+ return <Input placeholder="http://user:pass@192.168.1.10:8080" autoComplete="off"/>
207
+ case 'string':
208
+ return item.secret
209
+ ? <Input.Password autoComplete="new-password"
210
+ placeholder={item.hasValue ? '已设置,留空表示不修改' : '未设置'}/>
211
+ : <Input/>
212
+ default:
213
+ return <Input/>
214
+ }
215
+ }
216
+
217
+ renderItem = (item) => {
218
+ const hasRange = (item.min !== null && item.min !== undefined)
219
+ || (item.max !== null && item.max !== undefined)
220
+ const range = hasRange
221
+ ? `范围 ${item.min ?? '-∞'} ~ ${item.max ?? '+∞'}`
222
+ : null
223
+ const sourceLabel = item.source === 'db'
224
+ ? '数据库覆盖'
225
+ : item.source === 'env' ? 'application.yml' : '代码默认'
226
+ const defaultValue = item.defaultValue === null
227
+ || item.defaultValue === undefined
228
+ || item.defaultValue === ''
229
+ ? '空'
230
+ : JSON.stringify(item.defaultValue)
231
+
232
+ return <Col xs={24} md={12} xl={8} key={item.key}>
233
+ <Form.Item
234
+ name={[item.key]}
235
+ required={item.required}
236
+ label={<Space size={4}>
237
+ <span>{item.label}</span>
238
+ <Tooltip title={<>
239
+ {item.hint && <div>{item.hint}</div>}
240
+ {!item.secret && <div>默认值:{defaultValue}</div>}
241
+ {range && <div>{range}</div>}
242
+ <div>当前来源:{sourceLabel}</div>
243
+ <div>配置键:{item.key}</div>
244
+ </>}>
245
+ <QuestionCircleOutlined style={{color: 'var(--oa-color-text-tertiary)'}}/>
246
+ </Tooltip>
247
+ {item.overridden && (
248
+ <Space size={2}>
249
+ <Tag color="blue" style={{marginInlineEnd: 0}}>已覆盖</Tag>
250
+ <Perm code='sys-config:update'>
251
+ <Popconfirm title="恢复为默认值?" onConfirm={() => this.resetKeys([item.key])}>
252
+ <Button type="text" size="small" title="恢复默认"
253
+ icon={<UndoOutlined/>}
254
+ style={{padding: 0, width: 20, minWidth: 20, height: 20}}/>
255
+ </Popconfirm>
256
+ </Perm>
257
+ </Space>
258
+ )}
259
+ </Space>}
260
+ >
261
+ {this.controlOf(item)}
262
+ </Form.Item>
263
+ <div style={{marginTop: -18, marginBottom: 12}}>
264
+ <Typography.Text type="secondary" style={{fontSize: 12}}>{item.hint}</Typography.Text>
265
+ {item.secret && (
266
+ <Typography.Text type="secondary" style={{fontSize: 12, marginInlineStart: 8}}>
267
+ {item.hasValue ? '已设置' : '未设置'}
268
+ </Typography.Text>
269
+ )}
270
+ </div>
271
+ </Col>
272
+ }
273
+
274
+ render() {
275
+ const {groups, loading, saving, testingMail, dirty} = this.state
276
+ const hasOverridden = groups.some(group => group.items.some(item => item.overridden))
277
+ // 首次加载显示骨架屏;切回 Tab 等刷新时保留现有内容,避免闪白
278
+ const initialLoading = loading && groups.length === 0
279
+
280
+ return <Page>
281
+ <Form ref={this.formRef} layout="vertical" onValuesChange={this.onValuesChange}>
282
+ {initialLoading && <Card loading style={{marginBottom: 16}}/>}
283
+ <div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
284
+ {groups.map(group => (
285
+ <Card key={group.code}
286
+ title={group.label}
287
+ extra={group.code === 'mail' && (
288
+ <Perm code='sys-config:update'>
289
+ <Button loading={testingMail}
290
+ disabled={dirty}
291
+ title={dirty ? '请先保存配置修改' : '发送一封测试邮件到发件邮箱'}
292
+ onClick={this.sendTestMail}>
293
+ 发送测试邮件
294
+ </Button>
295
+ </Perm>
296
+ )}>
297
+ {group.description && (
298
+ <Typography.Text type="secondary"
299
+ style={{display: 'block', fontSize: 12, marginBottom: 16}}>
300
+ {group.description}
301
+ </Typography.Text>
302
+ )}
303
+ <Row gutter={16}>
304
+ {group.items.map(this.renderItem)}
305
+ </Row>
306
+ </Card>
307
+ ))}
308
+ </div>
309
+
310
+ {groups.length > 0 && (
311
+ <Space wrap style={{marginTop: 16}}>
312
+ <Perm code='sys-config:update'>
313
+ <Button type="primary" loading={saving} disabled={!dirty} onClick={this.save}>
314
+ 保存修改
315
+ </Button>
316
+ </Perm>
317
+ <Perm code='sys-config:update'>
318
+ <Popconfirm
319
+ title="恢复全部默认值?"
320
+ description="所有已修改的配置项都会回到默认值。"
321
+ disabled={!hasOverridden}
322
+ onConfirm={() => this.resetKeys([])}
323
+ >
324
+ <Button danger disabled={!hasOverridden}>全部恢复默认</Button>
325
+ </Popconfirm>
326
+ </Perm>
327
+ <Typography.Text type="secondary" style={{fontSize: 12}}>
328
+ 配置默认值来自 application.yml 或代码声明
329
+ </Typography.Text>
330
+ </Space>
331
+ )}
332
+ </Form>
333
+ </Page>
334
+ }
335
+ }
@@ -15,13 +15,13 @@ export default class FilePage extends React.Component {
15
15
  formRef = React.createRef()
16
16
 
17
17
  handleDelete = row => {
18
- HttpClient.post('admin/sysFile/delete', {value: row.objectName}, null).then(() => {
18
+ HttpClient.post('admin/sys-file/delete', {value: row.objectName}, null).then(() => {
19
19
  this.tableRef.current.reload()
20
20
  })
21
21
  }
22
22
 
23
23
  handleBatchDelete = ids => {
24
- HttpClient.post('admin/sysFile/deleteBatch', ids, null).then(() => {
24
+ HttpClient.post('admin/sys-file/delete-batch', ids, null).then(() => {
25
25
  this.tableRef.current.reload()
26
26
  this.tableRef.current.clearSelection()
27
27
  })
@@ -167,7 +167,7 @@ export default class FilePage extends React.Component {
167
167
  </Space>
168
168
  )}
169
169
  request={(params) => {
170
- return HttpClient.get('admin/sysFile/page', params);
170
+ return HttpClient.get('admin/sys-file/page', params);
171
171
  }}
172
172
 
173
173
  columns={this.columns}
@@ -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
  {
@@ -325,24 +362,26 @@ export default class JobPage extends React.Component {
325
362
  onFinish={this.onFinish}
326
363
  onValuesChange={this.onValuesChange}>
327
364
  <Form.Item label='执行类' name='jobClass' rules={[{required: true}]}
328
- tooltip='继承 BaseJob,参考 HelloWorldJob'>
365
+ tooltip='继承 BaseJob'>
329
366
  <Select options={this.state.jobClassOptions}/>
330
367
  </Form.Item>
331
368
  <Form.Item label='名称' name='name' rules={[{required: true}]}>
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)
@@ -55,7 +55,7 @@ export default class LogPage extends React.Component {
55
55
  render() {
56
56
  return <Page>
57
57
  <ProTable
58
- request={(params) => HttpClient.get('admin/sysLog/page', params)}
58
+ request={(params) => HttpClient.get('admin/sys-log/page', params)}
59
59
  columns={this.columns}
60
60
  searchFormRender={() => (
61
61
  <>
@@ -0,0 +1,95 @@
1
+ import React from 'react';
2
+ import {Form, Input, Tag} from 'antd';
3
+ import {HttpClient, Page, PermActions, ProTable} from "../../../framework";
4
+
5
+ /**
6
+ * 在线用户:一个登录会话一行,支持强制下线。
7
+ * 数据来自内存中的 HttpSession 元数据,仅单实例部署准确。
8
+ */
9
+ export default class OnlineUserPage extends React.Component {
10
+
11
+ tableRef = React.createRef();
12
+
13
+ handleKick = row => {
14
+ HttpClient.post('admin/sys-online-user/kick', {id: row.id}).then(() => {
15
+ this.tableRef.current.reload();
16
+ });
17
+ };
18
+
19
+ columns = [
20
+ {
21
+ title: '登录账号',
22
+ dataIndex: 'username',
23
+ width: 140,
24
+ },
25
+ {
26
+ title: '姓名',
27
+ dataIndex: 'name',
28
+ width: 120,
29
+ },
30
+ {
31
+ title: '所属机构',
32
+ dataIndex: 'unitName',
33
+ width: 180,
34
+ render: (_, record) => {
35
+ // 单位与机构可能指向同一节点(如 admin 挂在根单位),去重后避免「默认单位 / 默认单位」
36
+ const names = [...new Set([record.unitName, record.orgName].filter(Boolean))];
37
+ return names.length ? names.join(' / ') : '-';
38
+ },
39
+ },
40
+ {
41
+ title: 'IP',
42
+ dataIndex: 'ip',
43
+ width: 140,
44
+ },
45
+ {
46
+ title: '登录时间',
47
+ dataIndex: 'loginTime',
48
+ width: 170,
49
+ render: v => v || '-',
50
+ },
51
+ {
52
+ title: '最后活跃',
53
+ dataIndex: 'lastActiveTime',
54
+ width: 170,
55
+ render: v => v || '-',
56
+ },
57
+ {
58
+ title: '操作',
59
+ dataIndex: 'option',
60
+ width: 130,
61
+ fixed: 'right',
62
+ render: (_, record) => {
63
+ if (record.current) {
64
+ return <Tag color="green">当前会话</Tag>;
65
+ }
66
+ return <PermActions
67
+ size="small"
68
+ actions={[{
69
+ label: '强制下线',
70
+ perm: 'sys-online:kick',
71
+ danger: true,
72
+ confirm: `确定强制下线「${record.name || record.username}」吗?`,
73
+ onClick: () => this.handleKick(record),
74
+ }]}
75
+ />;
76
+ },
77
+ },
78
+ ];
79
+
80
+ render() {
81
+ return <Page>
82
+ <ProTable
83
+ actionRef={this.tableRef}
84
+ request={params => HttpClient.get('admin/sys-online-user/page', params)}
85
+ columns={this.columns}
86
+ searchFormRender={() => (
87
+ <Form.Item label='账号/姓名/IP' name='keyword'>
88
+ <Input placeholder='支持账号、姓名、IP 模糊搜索' allowClear/>
89
+ </Form.Item>
90
+ )}
91
+ />
92
+ </Page>;
93
+ }
94
+
95
+ }