@jiangood/open-admin 3.1.7 → 3.1.9
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.
- package/package.json +1 -1
- package/src/framework/GlobalData.ts +2 -0
- package/src/framework/biz/OrgTree.tsx +4 -0
- package/src/framework/biz/PermActions.tsx +1 -1
- package/src/framework/biz/RoleTree.tsx +4 -0
- package/src/framework/components/DownloadModal/index.tsx +10 -3
- package/src/framework/components/FormModal/index.tsx +4 -4
- package/src/framework/components/LogViewer/index.tsx +249 -0
- package/src/framework/components/NamedIcon/index.tsx +1 -1
- package/src/framework/components/Page/index.less +2 -0
- package/src/framework/components/Page/index.tsx +21 -13
- package/src/framework/components/PageLoading/index.tsx +2 -2
- package/src/framework/components/ProTable/index.less +44 -0
- package/src/framework/components/ProTable/index.tsx +54 -13
- package/src/framework/components/index.ts +1 -0
- package/src/framework/config/themeConfig.ts +30 -6
- package/src/framework/fields/BaseRemoteSelect/index.tsx +4 -4
- package/src/framework/fields/FieldDateRange/index.tsx +1 -1
- package/src/framework/fields/FieldDictSelect/index.tsx +1 -1
- package/src/framework/fields/FieldNumberRange/index.tsx +1 -1
- package/src/framework/fields/FieldTable/styles.less +3 -3
- package/src/framework/fields/FieldUploadImage/index.tsx +4 -4
- package/src/framework/utils/HttpClient.ts +63 -25
- package/src/framework/utils/UrlUtils.ts +1 -1
- package/src/layouts/admin/AdminTabs.jsx +35 -1
- package/src/layouts/admin/AdminTabs.less +28 -7
- package/src/layouts/admin/index.jsx +1 -1
- package/src/layouts/admin/index.less +25 -1
- package/src/layouts/index.tsx +47 -10
- package/src/pages/article/$code.jsx +2 -2
- package/src/pages/index.jsx +1 -1
- package/src/pages/system/article/index.jsx +13 -15
- package/src/pages/system/codegen/index.jsx +1 -3
- package/src/pages/system/dict/index.jsx +2 -2
- package/src/pages/system/file/index.jsx +3 -3
- package/src/pages/system/job/index.jsx +151 -40
- package/src/pages/system/log/index.jsx +2 -2
- package/src/pages/system/org/index.jsx +5 -5
- package/src/pages/system/role/index.jsx +12 -13
- package/src/pages/system/role/rolePerm.jsx +1 -1
- package/src/pages/system/user/index.jsx +7 -7
- package/src/pages/system/user/userPerm.jsx +2 -2
- package/src/pages/userCenter/index.jsx +1 -1
- package/src/pages/userCenter/permView.jsx +2 -2
- package/src/style/global.less +2 -2
package/package.json
CHANGED
|
@@ -24,6 +24,10 @@ export class OrgTree extends React.Component<OrgTreeProps, OrgTreeState> {
|
|
|
24
24
|
componentDidMount() {
|
|
25
25
|
HttpClient.get('admin/sysOrg/tree', null).then(tree => {
|
|
26
26
|
this.setState({treeData: tree.data, treeDataLoading: false})
|
|
27
|
+
}).catch(() => {
|
|
28
|
+
// 失败时结束 loading 并置空(组件会显示空状态提示),避免一直停留在骨架屏;
|
|
29
|
+
// 401 由 HttpClient 广播 loginExpired(全局弹窗提示),其余错误仍按 toastError 弹错
|
|
30
|
+
this.setState({treeData: [], treeDataLoading: false})
|
|
27
31
|
})
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -101,7 +101,7 @@ export class PermActions extends React.Component<PermActionsProps> {
|
|
|
101
101
|
|
|
102
102
|
if (!more || visible.length <= 1) {
|
|
103
103
|
const buttons = visible.map((action, index) => (
|
|
104
|
-
<React.Fragment key={index}>{this.renderButton(action, size)}</React.Fragment>
|
|
104
|
+
<React.Fragment key={index}>{this.renderButton(action, size)}</React.Fragment>
|
|
105
105
|
));
|
|
106
106
|
return <Space>{buttons}</Space>;
|
|
107
107
|
}
|
|
@@ -24,6 +24,10 @@ export class RoleTree extends React.Component<RoleTreeProps, RoleTreeState> {
|
|
|
24
24
|
componentDidMount() {
|
|
25
25
|
HttpClient.get('admin/sysRole/biz-tree', null).then(tree => {
|
|
26
26
|
this.setState({treeData: tree.data, treeDataLoading: false})
|
|
27
|
+
}).catch(() => {
|
|
28
|
+
// 失败时结束 loading 并置空(组件会显示空状态提示),避免一直停留在骨架屏;
|
|
29
|
+
// 401 由 HttpClient 广播 loginExpired(全局弹窗提示),其余错误仍按 toastError 弹错
|
|
30
|
+
this.setState({treeData: [], treeDataLoading: false})
|
|
27
31
|
})
|
|
28
32
|
}
|
|
29
33
|
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import axios from "axios";
|
|
9
9
|
import qs from 'qs';
|
|
10
10
|
import type {AxiosProgressEvent, AxiosRequestConfig, AxiosResponse} from "axios";
|
|
11
|
+
import {EventBus} from "../../utils/EventBus";
|
|
11
12
|
|
|
12
13
|
const axiosInstance = axios.create({
|
|
13
14
|
baseURL: import.meta.env.VITE_SERVER_SERVLET_CONTEXT_PATH,
|
|
@@ -40,8 +41,8 @@ export interface DownloadModalProps {
|
|
|
40
41
|
onFinish?: () => void;
|
|
41
42
|
}
|
|
42
43
|
|
|
43
|
-
export class DownloadModal extends React.Component<DownloadModalProps, ModalState> {
|
|
44
|
-
private abortController: AbortController | null = null;
|
|
44
|
+
export class DownloadModal extends React.Component<DownloadModalProps, ModalState> {
|
|
45
|
+
private abortController: AbortController | null = null;
|
|
45
46
|
private lastOptions: DownloadOptions | null = null;
|
|
46
47
|
private lastTime: number = 0;
|
|
47
48
|
private lastLoaded: number = 0;
|
|
@@ -61,7 +62,7 @@ export class DownloadModal extends React.Component<DownloadModalProps, ModalStat
|
|
|
61
62
|
};
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
download = (options: DownloadOptions) => {
|
|
65
|
+
download = (options: DownloadOptions) => {
|
|
65
66
|
this.startDownload(options);
|
|
66
67
|
};
|
|
67
68
|
|
|
@@ -264,6 +265,12 @@ export class DownloadModal extends React.Component<DownloadModalProps, ModalStat
|
|
|
264
265
|
if (error.message) {
|
|
265
266
|
msg = error.message;
|
|
266
267
|
}
|
|
268
|
+
// 登录失效:交由全局登录失效弹窗提示,避免在此显示「Request failed with status code 401」
|
|
269
|
+
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
|
270
|
+
EventBus.emit('loginExpired');
|
|
271
|
+
this.setState({open: false, status: ''});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
267
274
|
console.error('[DownloadModal] 下载失败:', msg);
|
|
268
275
|
this.setState({
|
|
269
276
|
status: 'failed',
|
|
@@ -12,9 +12,9 @@ export interface FormModalProps {
|
|
|
12
12
|
children?: React.ReactNode
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export default class FormModal extends React.Component<FormModalProps> {
|
|
15
|
+
export default class FormModal extends React.Component<FormModalProps> {
|
|
16
16
|
|
|
17
|
-
readonly state = {
|
|
17
|
+
readonly state = {
|
|
18
18
|
visible: false,
|
|
19
19
|
confirmLoading: false,
|
|
20
20
|
}
|
|
@@ -22,11 +22,11 @@ export default class FormModal extends React.Component<FormModalProps> { // NOSO
|
|
|
22
22
|
private pendingValues: Record<string, unknown> | null = null
|
|
23
23
|
private formRef = React.createRef<FormInstance>()
|
|
24
24
|
|
|
25
|
-
get formInstance() {
|
|
25
|
+
get formInstance() {
|
|
26
26
|
return this.formRef.current
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
open = (values?: Record<string, unknown>) => {
|
|
29
|
+
open = (values?: Record<string, unknown>) => {
|
|
30
30
|
this.pendingValues = values || {}
|
|
31
31
|
this.setState({visible: true})
|
|
32
32
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Alert, Spin, Typography } from "antd";
|
|
3
|
+
import { HttpClient } from "../../utils";
|
|
4
|
+
import { getToken } from "../../config";
|
|
5
|
+
|
|
6
|
+
/** 增量拉取时每次最多读取的字节数(与后端默认值一致) */
|
|
7
|
+
const DEFAULT_LENGTH = 256 * 1024;
|
|
8
|
+
|
|
9
|
+
/** 运行中日志的默认刷新间隔(毫秒) */
|
|
10
|
+
const DEFAULT_POLL_INTERVAL = 1500;
|
|
11
|
+
|
|
12
|
+
export interface LogViewerProps {
|
|
13
|
+
/** 日志 key,如 `job/{logId}`;与后端 `admin/sys/log/{*key}` 对应 */
|
|
14
|
+
logKey?: string;
|
|
15
|
+
/**
|
|
16
|
+
* 自定义读取地址,覆盖默认的 `admin/sys/log/{logKey}`。
|
|
17
|
+
* <p>
|
|
18
|
+
* 用于「带行级/数据权限、由业务模块自己提供读取接口」的场景:
|
|
19
|
+
* 只要接口返回同样的 `{content, nextOffset, eof}` 结构,就能复用本组件的增量跟随。
|
|
20
|
+
*/
|
|
21
|
+
url?: string;
|
|
22
|
+
/** 任务是否仍在运行:为 true 时按 `pollInterval` 增量轮询,false 时只读一次 */
|
|
23
|
+
running?: boolean;
|
|
24
|
+
/** 运行中的轮询间隔(毫秒),默认 1500 */
|
|
25
|
+
pollInterval?: number;
|
|
26
|
+
/** 可视高度(像素),默认 500 */
|
|
27
|
+
height?: number;
|
|
28
|
+
/** 是否自动滚动到底部,默认 true;用户上滑查看历史后不再强制跟随 */
|
|
29
|
+
autoFollow?: boolean;
|
|
30
|
+
/** 内容为空时的提示 */
|
|
31
|
+
emptyText?: React.ReactNode;
|
|
32
|
+
className?: string;
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface LogViewerState {
|
|
37
|
+
content: string;
|
|
38
|
+
nextOffset: number;
|
|
39
|
+
loading: boolean;
|
|
40
|
+
error: string | null;
|
|
41
|
+
eof: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 文件日志查看器:按字节偏移增量读取日志文件。
|
|
46
|
+
* <p>
|
|
47
|
+
* 运行中的任务(`running`)按 `pollInterval` 反复拉取新增内容并追加;任务结束后停止轮询,
|
|
48
|
+
* 且会补拉一次剩余内容。日志很长时也不会每次整文件重读。
|
|
49
|
+
* <p>
|
|
50
|
+
* 使用方式(定时任务日志):
|
|
51
|
+
* ```tsx
|
|
52
|
+
* <LogViewer logKey={`job/${logId}`} running={isRunning} />
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export class LogViewer extends React.Component<LogViewerProps, LogViewerState> {
|
|
56
|
+
|
|
57
|
+
state: LogViewerState = {
|
|
58
|
+
content: "",
|
|
59
|
+
nextOffset: 0,
|
|
60
|
+
loading: false,
|
|
61
|
+
error: null,
|
|
62
|
+
eof: false,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
private containerRef = React.createRef<HTMLPreElement>();
|
|
66
|
+
private timer: ReturnType<typeof setTimeout> | null = null;
|
|
67
|
+
private mounted = false;
|
|
68
|
+
/** 用户是否仍处于底部(跟随最新日志);上滑后置为 false */
|
|
69
|
+
private follow = true;
|
|
70
|
+
/** 请求进行中标记,避免慢请求叠加 */
|
|
71
|
+
private fetching = false;
|
|
72
|
+
/** 请求进行中被跳过的拉取请求,结束后立即补一次 */
|
|
73
|
+
private pendingFetch = false;
|
|
74
|
+
/** 请求代号:切换 logKey / 重新加载后,丢弃旧请求的迟到响应 */
|
|
75
|
+
private generation = 0;
|
|
76
|
+
|
|
77
|
+
componentDidMount() {
|
|
78
|
+
this.mounted = true;
|
|
79
|
+
this.reload();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
componentDidUpdate(prevProps: LogViewerProps) {
|
|
83
|
+
if (prevProps.logKey !== this.props.logKey || prevProps.url !== this.props.url) {
|
|
84
|
+
this.reload();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// 运行状态变化(尤其运行中 -> 结束)时补拉一次,拿到最后一段日志
|
|
88
|
+
if (prevProps.running !== this.props.running) {
|
|
89
|
+
this.schedule(0);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
componentWillUnmount() {
|
|
94
|
+
this.mounted = false;
|
|
95
|
+
this.clearTimer();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 从头重新加载 */
|
|
99
|
+
reload = () => {
|
|
100
|
+
this.clearTimer();
|
|
101
|
+
this.generation++;
|
|
102
|
+
this.pendingFetch = false;
|
|
103
|
+
this.follow = true;
|
|
104
|
+
this.setState({ content: "", nextOffset: 0, loading: true, error: null, eof: false },
|
|
105
|
+
() => this.fetchChunk(true));
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
private clearTimer() {
|
|
109
|
+
if (this.timer) {
|
|
110
|
+
clearTimeout(this.timer);
|
|
111
|
+
this.timer = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private schedule(delay?: number) {
|
|
116
|
+
if (!this.mounted) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
this.clearTimer();
|
|
120
|
+
const running = !!this.props.running;
|
|
121
|
+
// 未运行时只在需要时补拉(delay 显式传入),不做周期性轮询
|
|
122
|
+
if (!running && delay == null) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const interval = this.props.pollInterval ?? DEFAULT_POLL_INTERVAL;
|
|
126
|
+
this.timer = setTimeout(() => this.fetchChunk(false), delay ?? interval);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private fetchChunk(initial: boolean) {
|
|
130
|
+
const { logKey, url } = this.props;
|
|
131
|
+
// 业务模块自建接口(带行级权限)可通过 url 覆盖默认的通用日志接口
|
|
132
|
+
const requestUrl = url || (logKey ? `admin/sys/log/${logKey}` : null);
|
|
133
|
+
if (!requestUrl) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (this.fetching) {
|
|
137
|
+
// 已有请求在途:打完标记,等它结束后立即补一次
|
|
138
|
+
this.pendingFetch = true;
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
this.fetching = true;
|
|
142
|
+
const generation = this.generation;
|
|
143
|
+
const offset = initial ? 0 : this.state.nextOffset;
|
|
144
|
+
|
|
145
|
+
HttpClient.get<{ content: string; nextOffset: number; eof: boolean }>(
|
|
146
|
+
requestUrl,
|
|
147
|
+
{ offset, length: DEFAULT_LENGTH },
|
|
148
|
+
{ toastError: false }
|
|
149
|
+
).then(rs => {
|
|
150
|
+
if (!this.mounted || generation !== this.generation) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const chunk = rs.data;
|
|
154
|
+
if (!chunk) {
|
|
155
|
+
this.setState({ loading: false, eof: true });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.setState(prev => ({
|
|
159
|
+
content: initial ? (chunk.content || "") : prev.content + (chunk.content || ""),
|
|
160
|
+
nextOffset: chunk.nextOffset,
|
|
161
|
+
eof: chunk.eof,
|
|
162
|
+
loading: false,
|
|
163
|
+
error: null,
|
|
164
|
+
}), () => {
|
|
165
|
+
this.scrollToBottom();
|
|
166
|
+
if (!chunk.eof) {
|
|
167
|
+
// 还有剩余内容(超过单次读取上限):立即续读
|
|
168
|
+
this.schedule(0);
|
|
169
|
+
} else {
|
|
170
|
+
// 已读到末尾:运行中按间隔等新内容,已结束则停止
|
|
171
|
+
this.schedule();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}).catch(e => {
|
|
175
|
+
if (!this.mounted || generation !== this.generation) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.setState({ loading: false, error: e?.message || "日志加载失败" });
|
|
179
|
+
// 运行中遇到临时错误(如文件尚未创建)继续重试
|
|
180
|
+
this.schedule();
|
|
181
|
+
}).finally(() => {
|
|
182
|
+
this.fetching = false;
|
|
183
|
+
if (this.pendingFetch) {
|
|
184
|
+
this.pendingFetch = false;
|
|
185
|
+
this.fetchChunk(false);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private scrollToBottom() {
|
|
191
|
+
const el = this.containerRef.current;
|
|
192
|
+
if (!el || !(this.props.autoFollow ?? true)) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!this.follow) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
el.scrollTop = el.scrollHeight;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private handleScroll = () => {
|
|
202
|
+
const el = this.containerRef.current;
|
|
203
|
+
if (!el) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// 距底部 24px 内视为跟随
|
|
207
|
+
this.follow = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
render() {
|
|
211
|
+
const { height = 500, emptyText = "暂无日志", className, style } = this.props;
|
|
212
|
+
const { content, loading, error } = this.state;
|
|
213
|
+
const token = getToken();
|
|
214
|
+
|
|
215
|
+
return <div className={className} style={{ height, position: "relative", ...style }}>
|
|
216
|
+
{error && content.length === 0
|
|
217
|
+
? <Alert type="error" showIcon message={error}/>
|
|
218
|
+
: <>
|
|
219
|
+
{loading && content.length === 0 && (
|
|
220
|
+
<div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
|
221
|
+
<Spin/>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
{!loading && content.length === 0 && (
|
|
225
|
+
<Typography.Text type="secondary">{emptyText}</Typography.Text>
|
|
226
|
+
)}
|
|
227
|
+
<pre
|
|
228
|
+
ref={this.containerRef}
|
|
229
|
+
onScroll={this.handleScroll}
|
|
230
|
+
style={{
|
|
231
|
+
margin: 0,
|
|
232
|
+
height: "100%",
|
|
233
|
+
overflow: "auto",
|
|
234
|
+
whiteSpace: "pre-wrap",
|
|
235
|
+
wordBreak: "break-all",
|
|
236
|
+
fontSize: 12,
|
|
237
|
+
lineHeight: 1.6,
|
|
238
|
+
padding: 12,
|
|
239
|
+
boxSizing: "border-box",
|
|
240
|
+
backgroundColor: token.colorBgContainer,
|
|
241
|
+
color: token.colorText,
|
|
242
|
+
border: `1px solid ${token.colorBorderSecondary}`,
|
|
243
|
+
borderRadius: token.borderRadius,
|
|
244
|
+
}}
|
|
245
|
+
>{content}</pre>
|
|
246
|
+
</>}
|
|
247
|
+
</div>;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -12,7 +12,7 @@ const iconLoaders: Record<string, () => Promise<{ default: IconComponent }>> = {
|
|
|
12
12
|
for (const [path, loader] of Object.entries(
|
|
13
13
|
import.meta.glob('/node_modules/@ant-design/icons/es/icons/*.js')
|
|
14
14
|
)) {
|
|
15
|
-
const name = /([^/]+)\.js$/.exec(path)?.[1];
|
|
15
|
+
const name = /([^/]+)\.js$/.exec(path)?.[1];
|
|
16
16
|
if (name) {
|
|
17
17
|
iconLoaders[name] = loader as () => Promise<{ default: IconComponent }>;
|
|
18
18
|
}
|
|
@@ -1,38 +1,44 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Typography } from "antd";
|
|
2
|
+
import { Space, Typography } from "antd";
|
|
3
3
|
import './index.less'
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { PermActions, type PermAction } from "../../biz/PermActions";
|
|
5
|
+
import { PermUtils } from "../../utils";
|
|
6
|
+
|
|
7
|
+
/** 兼容旧写法:按子节点的 perm 属性过滤(新代码请用 PermAction[]) */
|
|
8
|
+
function renderActionNodes(nodes: React.ReactNode) {
|
|
9
|
+
const visible = React.Children.toArray(nodes).filter((node) => {
|
|
10
|
+
const perm = (node as React.ReactElement<{ perm?: string }>)?.props?.perm;
|
|
11
|
+
return !perm || PermUtils.hasPermission(perm);
|
|
12
|
+
});
|
|
13
|
+
if (visible.length === 0) return null;
|
|
14
|
+
return <Space>{visible}</Space>;
|
|
15
|
+
}
|
|
6
16
|
|
|
7
17
|
interface PageProps {
|
|
8
18
|
padding?: boolean;
|
|
19
|
+
/** @deprecated 页面底色已默认跟随布局底色,该参数不再产生差异;保留仅为兼容旧写法 */
|
|
9
20
|
backgroundGray?: boolean;
|
|
10
21
|
debug?: boolean;
|
|
11
22
|
title?: React.ReactNode;
|
|
12
23
|
description?: React.ReactNode;
|
|
13
|
-
|
|
24
|
+
/** 右侧操作区:推荐传 PermAction[](数据驱动),兼容带 perm 属性的节点 */
|
|
25
|
+
actions?: PermAction[] | React.ReactNode;
|
|
14
26
|
children?: React.ReactNode;
|
|
15
27
|
}
|
|
16
28
|
|
|
17
29
|
export class Page extends React.Component<PageProps> {
|
|
18
30
|
render() {
|
|
19
|
-
const { padding = true,
|
|
31
|
+
const { padding = true, debug = false, title, description, actions, children } = this.props;
|
|
20
32
|
const hasHeader = title != null || description != null || actions != null;
|
|
21
33
|
const style: React.CSSProperties = {};
|
|
22
34
|
|
|
23
35
|
if (padding) {
|
|
24
36
|
style.padding = 16;
|
|
25
37
|
}
|
|
26
|
-
|
|
27
|
-
if (backgroundGray) {
|
|
28
|
-
style.backgroundColor = token.colorBgLayout;
|
|
29
|
-
}
|
|
38
|
+
// 页面底色由 .oa-page 统一使用 --oa-color-bg-layout,避免内联值脱离变量体系
|
|
30
39
|
if (debug) {
|
|
31
40
|
style.backgroundColor = 'rgba(255, 0, 0, 0.08)';
|
|
32
41
|
}
|
|
33
|
-
if (!style.backgroundColor) {
|
|
34
|
-
style.backgroundColor = token.colorBgLayout;
|
|
35
|
-
}
|
|
36
42
|
|
|
37
43
|
return <div className={'oa-page'} style={style}>
|
|
38
44
|
{hasHeader && (
|
|
@@ -44,7 +50,9 @@ export class Page extends React.Component<PageProps> {
|
|
|
44
50
|
{description && <div><Typography.Text type="secondary">{description}</Typography.Text></div>}
|
|
45
51
|
</div>
|
|
46
52
|
</div>
|
|
47
|
-
{actions &&
|
|
53
|
+
{actions != null && (Array.isArray(actions)
|
|
54
|
+
? <PermActions actions={actions}/>
|
|
55
|
+
: renderActionNodes(actions))}
|
|
48
56
|
</div>
|
|
49
57
|
</div>
|
|
50
58
|
)}
|
|
@@ -9,7 +9,7 @@ export interface PageLoadingProps {
|
|
|
9
9
|
messages?: string[];
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function PageLoading(props: PageLoadingProps) {
|
|
12
|
+
export function PageLoading(props: PageLoadingProps) {
|
|
13
13
|
const titles = props.messages || (props.message ? [props.message] : ['页面加载中...']);
|
|
14
14
|
const primaryColor = getToken().colorPrimary ?? "";
|
|
15
15
|
|
|
@@ -22,7 +22,7 @@ export function PageLoading(props: PageLoadingProps) { // NOSONAR: 仅读取 pro
|
|
|
22
22
|
<div className="oa-page-loading-messages">
|
|
23
23
|
{titles.map((msg, i) => (
|
|
24
24
|
<Alert
|
|
25
|
-
key={i}
|
|
25
|
+
key={i}
|
|
26
26
|
title={<span style={{color: primaryColor}}>{msg}</span>}
|
|
27
27
|
type="info"
|
|
28
28
|
showIcon
|
|
@@ -69,6 +69,11 @@
|
|
|
69
69
|
gap: 8px;
|
|
70
70
|
flex: 1;
|
|
71
71
|
min-width: 0;
|
|
72
|
+
|
|
73
|
+
/* 仅内联搜索时允许换行,存量工具栏布局与之前完全一致 */
|
|
74
|
+
&.has-inline-search {
|
|
75
|
+
flex-wrap: wrap;
|
|
76
|
+
}
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
.pro-table-toolbar-right {
|
|
@@ -81,6 +86,45 @@
|
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
89
|
+
/* searchInline:搜索控件内联在工具栏左侧,与操作按钮同一行 */
|
|
90
|
+
.pro-table-search-inline {
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 8px;
|
|
94
|
+
flex-wrap: wrap;
|
|
95
|
+
margin: 0;
|
|
96
|
+
|
|
97
|
+
/* 定宽由 --oa-search-inline-width 控制,业务侧可覆盖;noStyle 的输入控件同样收敛 */
|
|
98
|
+
> .ant-form-item,
|
|
99
|
+
> .ant-input,
|
|
100
|
+
> .ant-input-affix-wrapper,
|
|
101
|
+
> .ant-picker,
|
|
102
|
+
> .ant-select {
|
|
103
|
+
width: var(--oa-search-inline-width, 260px);
|
|
104
|
+
max-width: 100%;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.ant-form-item {
|
|
108
|
+
margin: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* 视觉隐藏的提交按钮:保留回车提交能力,且不占位(不用 display:none,避免浏览器忽略默认提交按钮) */
|
|
112
|
+
.pro-table-search-submit {
|
|
113
|
+
position: absolute;
|
|
114
|
+
width: 1px;
|
|
115
|
+
height: 1px;
|
|
116
|
+
min-width: 0;
|
|
117
|
+
padding: 0;
|
|
118
|
+
margin: -1px;
|
|
119
|
+
border: 0;
|
|
120
|
+
overflow: hidden;
|
|
121
|
+
white-space: nowrap;
|
|
122
|
+
clip-path: inset(50%);
|
|
123
|
+
opacity: 0;
|
|
124
|
+
pointer-events: none;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
84
128
|
/* 桌面窄窗口下收敛搜索栏列数,避免标签+输入挤压/溢出 */
|
|
85
129
|
@media (max-width: 1400px) {
|
|
86
130
|
.filter-bar {
|
|
@@ -57,8 +57,14 @@ export interface ProTableProps<T = unknown> {
|
|
|
57
57
|
onChange?: (selectedRowKeys: React.Key[], selectedRows: T[]) => void;
|
|
58
58
|
};
|
|
59
59
|
defaultPageSize?: number;
|
|
60
|
-
/** 搜索栏每行列数,默认 4 */
|
|
60
|
+
/** 搜索栏每行列数,默认 4(searchInline 时忽略) */
|
|
61
61
|
searchFormCols?: number;
|
|
62
|
+
/**
|
|
63
|
+
* 搜索表单内联渲染到工具栏左侧,与操作按钮同一行(单条件页面省一行空间)。
|
|
64
|
+
* 内联模式不渲染「查询/重置」按钮:回车提交,条件清空后自动回到无筛选状态。
|
|
65
|
+
* 默认 false = 现有独立筛选卡
|
|
66
|
+
*/
|
|
67
|
+
searchInline?: boolean;
|
|
62
68
|
/** 搜索表单渲染函数,返回 Form.Item 列表 */
|
|
63
69
|
searchFormRender?: () => React.ReactNode;
|
|
64
70
|
scrollY?: number | string;
|
|
@@ -201,13 +207,15 @@ this.setState({loading: true})
|
|
|
201
207
|
// 有筛选条件时区分「无匹配结果」与「暂无数据」,避免业务人员误以为数据丢失
|
|
202
208
|
const hasFilter = Object.keys(this.state.params || {}).length > 0
|
|
203
209
|
|
|
210
|
+
const inlineSearch = this.renderInlineSearch()
|
|
204
211
|
|
|
205
212
|
return <div className={'oa-pro-table '} id={this.id}>
|
|
206
213
|
{this.renderForm()}
|
|
207
214
|
<div className="pro-table-wrapper">
|
|
208
|
-
{(toolBarRender || toolBarRightRender) && <div className="pro-table-toolbar">
|
|
209
|
-
{toolBarRender && <div className=
|
|
210
|
-
{
|
|
215
|
+
{(inlineSearch || toolBarRender || toolBarRightRender) && <div className="pro-table-toolbar">
|
|
216
|
+
{(inlineSearch || toolBarRender) && <div className={'pro-table-toolbar-left' + (inlineSearch ? ' has-inline-search' : '')}>
|
|
217
|
+
{inlineSearch}
|
|
218
|
+
{toolBarRender && this.getToolBarRenderNode(toolBarRender)}
|
|
211
219
|
</div>}
|
|
212
220
|
{toolBarRightRender && <div className="pro-table-toolbar-right">
|
|
213
221
|
{this.getToolBarRenderNode(toolBarRightRender)}
|
|
@@ -258,10 +266,9 @@ this.setState({loading: true})
|
|
|
258
266
|
|
|
259
267
|
|
|
260
268
|
renderForm = () => {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const searchContent = this.props.searchFormRender?.()
|
|
269
|
+
// 内联模式:搜索表单改由工具栏渲染(见 renderInlineSearch)
|
|
270
|
+
if (this.props.searchInline) return
|
|
271
|
+
const searchContent = this.getSearchContent()
|
|
265
272
|
if (!searchContent) return
|
|
266
273
|
|
|
267
274
|
return (
|
|
@@ -269,10 +276,7 @@ this.setState({loading: true})
|
|
|
269
276
|
className="filter-bar"
|
|
270
277
|
style={{'--oa-filter-cols': this.props.searchFormCols ?? 4} as React.CSSProperties}
|
|
271
278
|
onFinish={(values) => this.onSearch(values)}
|
|
272
|
-
ref={
|
|
273
|
-
this.formRef.current = instance;
|
|
274
|
-
if (this.props.formRef) this.props.formRef.current = instance;
|
|
275
|
-
}}
|
|
279
|
+
ref={this.bindFormRef}
|
|
276
280
|
>
|
|
277
281
|
{searchContent}
|
|
278
282
|
<div className="filter-actions">
|
|
@@ -286,6 +290,43 @@ this.setState({loading: true})
|
|
|
286
290
|
)
|
|
287
291
|
};
|
|
288
292
|
|
|
293
|
+
/** 内联搜索:单条件页面把搜索控件放到工具栏左侧,回车即查询,复用 onSearch 的取数逻辑 */
|
|
294
|
+
renderInlineSearch = () => {
|
|
295
|
+
if (!this.props.searchInline) return
|
|
296
|
+
const searchContent = this.getSearchContent()
|
|
297
|
+
if (!searchContent) return
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<Form
|
|
301
|
+
className="pro-table-search-inline"
|
|
302
|
+
onFinish={(values) => this.onSearch(values)}
|
|
303
|
+
onValuesChange={(changedValues) => {
|
|
304
|
+
// 条件被清空(allowClear 等)后自动回到无筛选状态,否则用户还要再按一次回车
|
|
305
|
+
const cleared = Object.values(changedValues).some(v => v == null || v === '' || (Array.isArray(v) && v.length === 0))
|
|
306
|
+
if (cleared) this.formRef.current?.submit()
|
|
307
|
+
}}
|
|
308
|
+
ref={this.bindFormRef}
|
|
309
|
+
>
|
|
310
|
+
{searchContent}
|
|
311
|
+
{/* 视觉隐藏的提交按钮:多字段内联时也能回车提交 */}
|
|
312
|
+
<Button className="pro-table-search-submit" htmlType="submit" tabIndex={-1} aria-hidden/>
|
|
313
|
+
</Form>
|
|
314
|
+
)
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
/** 校验已废弃的 children 用法,返回搜索表单内容(无搜索表单时返回空) */
|
|
318
|
+
getSearchContent = () => {
|
|
319
|
+
if (this.props.children) {
|
|
320
|
+
throw new Error('[ProTable] children 已废弃,请使用 searchFormRender prop 替代')
|
|
321
|
+
}
|
|
322
|
+
return this.props.searchFormRender?.()
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
bindFormRef = (instance: FormInstance | null) => {
|
|
326
|
+
this.formRef.current = instance;
|
|
327
|
+
if (this.props.formRef) this.props.formRef.current = instance;
|
|
328
|
+
}
|
|
329
|
+
|
|
289
330
|
getToolBarRenderNode(toolBarRender: ProTableToolBarRender<T>) {
|
|
290
331
|
if (!toolBarRender) {
|
|
291
332
|
return
|
|
@@ -323,7 +364,7 @@ this.setState({loading: true})
|
|
|
323
364
|
this.setState({params: values, current: 1, sorter: {}}, this.loadData)
|
|
324
365
|
}
|
|
325
366
|
|
|
326
|
-
changeFormValues = (values: Record<string, unknown>) => {
|
|
367
|
+
changeFormValues = (values: Record<string, unknown>) => {
|
|
327
368
|
if (this.formRef.current) {
|
|
328
369
|
this.formRef.current.resetFields()
|
|
329
370
|
this.formRef.current.setFieldsValue(values)
|
|
@@ -9,6 +9,7 @@ export * from './ContextMenu'
|
|
|
9
9
|
export * from './DownloadModal'
|
|
10
10
|
export * from './OrgSwitcher'
|
|
11
11
|
export * from './ThemeSettings'
|
|
12
|
+
export * from './LogViewer'
|
|
12
13
|
export { default as ErrorBoundary } from './ErrorBoundary'
|
|
13
14
|
export { default as FormModal } from './FormModal'
|
|
14
15
|
export { default as FormDrawer } from './DrawerForm'
|