@jiangood/open-admin 3.1.8 → 3.1.10
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 +4 -0
- package/src/framework/biz/OrgTree.tsx +4 -0
- package/src/framework/biz/RoleTree.tsx +4 -0
- package/src/framework/components/DownloadModal/index.tsx +7 -0
- package/src/framework/components/LogViewer/index.tsx +249 -0
- package/src/framework/components/Page/index.less +2 -0
- package/src/framework/components/Page/index.tsx +3 -9
- package/src/framework/components/index.ts +1 -0
- package/src/framework/config/themeConfig.ts +30 -6
- package/src/framework/fields/FieldTable/styles.less +3 -3
- package/src/framework/utils/HttpClient.ts +63 -25
- package/src/layouts/admin/AdminTabs.jsx +139 -31
- package/src/layouts/admin/AdminTabs.less +93 -14
- package/src/layouts/admin/index.jsx +4 -1
- package/src/layouts/admin/index.less +2 -2
- package/src/layouts/index.tsx +47 -10
- package/src/pages/system/codegen/index.jsx +83 -44
- package/src/pages/system/job/index.jsx +165 -48
- package/src/pages/userCenter/index.jsx +1 -1
- package/src/style/global.less +2 -2
package/package.json
CHANGED
|
@@ -7,7 +7,11 @@ const SITE_ARTICLES_KEY = "siteArticles";
|
|
|
7
7
|
|
|
8
8
|
export interface SiteInfo {
|
|
9
9
|
title?: string;
|
|
10
|
+
/** 系统简称,用于侧栏等空间受限处;未配置时前端回退到 title */
|
|
11
|
+
shortTitle?: string;
|
|
10
12
|
waterMark?: boolean;
|
|
13
|
+
/** 登录失效(空闲超时/未登录)时全局弹窗的提示文案,对应 sys.login-expired-message */
|
|
14
|
+
loginExpiredMessage?: string;
|
|
11
15
|
[key: string]: unknown;
|
|
12
16
|
}
|
|
13
17
|
|
|
@@ -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
|
|
|
@@ -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,
|
|
@@ -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',
|
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { Space, Typography } from "antd";
|
|
3
3
|
import './index.less'
|
|
4
|
-
import {getToken} from "../../config";
|
|
5
4
|
import { PermActions, type PermAction } from "../../biz/PermActions";
|
|
6
5
|
import { PermUtils } from "../../utils";
|
|
7
6
|
|
|
@@ -17,6 +16,7 @@ function renderActionNodes(nodes: React.ReactNode) {
|
|
|
17
16
|
|
|
18
17
|
interface PageProps {
|
|
19
18
|
padding?: boolean;
|
|
19
|
+
/** @deprecated 页面底色已默认跟随布局底色,该参数不再产生差异;保留仅为兼容旧写法 */
|
|
20
20
|
backgroundGray?: boolean;
|
|
21
21
|
debug?: boolean;
|
|
22
22
|
title?: React.ReactNode;
|
|
@@ -28,23 +28,17 @@ interface PageProps {
|
|
|
28
28
|
|
|
29
29
|
export class Page extends React.Component<PageProps> {
|
|
30
30
|
render() {
|
|
31
|
-
const { padding = true,
|
|
31
|
+
const { padding = true, debug = false, title, description, actions, children } = this.props;
|
|
32
32
|
const hasHeader = title != null || description != null || actions != null;
|
|
33
33
|
const style: React.CSSProperties = {};
|
|
34
34
|
|
|
35
35
|
if (padding) {
|
|
36
36
|
style.padding = 16;
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
if (backgroundGray) {
|
|
40
|
-
style.backgroundColor = token.colorBgLayout;
|
|
41
|
-
}
|
|
38
|
+
// 页面底色由 .oa-page 统一使用 --oa-color-bg-layout,避免内联值脱离变量体系
|
|
42
39
|
if (debug) {
|
|
43
40
|
style.backgroundColor = 'rgba(255, 0, 0, 0.08)';
|
|
44
41
|
}
|
|
45
|
-
if (!style.backgroundColor) {
|
|
46
|
-
style.backgroundColor = token.colorBgLayout;
|
|
47
|
-
}
|
|
48
42
|
|
|
49
43
|
return <div className={'oa-page'} style={style}>
|
|
50
44
|
{hasHeader && (
|
|
@@ -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'
|
|
@@ -12,7 +12,13 @@ export interface ThemeColors {
|
|
|
12
12
|
colorWarning?: string;
|
|
13
13
|
colorError?: string;
|
|
14
14
|
colorInfo?: string;
|
|
15
|
+
/** 页面底色(antd token),仅浅色模式生效;暗色模式请用 darkColorBgLayout */
|
|
15
16
|
colorBgLayout?: string;
|
|
17
|
+
/**
|
|
18
|
+
* 暗色模式页面底色(框架自定义字段,非 antd token)。
|
|
19
|
+
* 不设置时由 antd 暗色算法推导(#000000)。
|
|
20
|
+
*/
|
|
21
|
+
darkColorBgLayout?: string;
|
|
16
22
|
/** 侧栏底色(深色系);不设置时使用 antd 默认值 */
|
|
17
23
|
siderBg?: string;
|
|
18
24
|
/** 侧栏子菜单底色;缺省与 siderBg 相同 */
|
|
@@ -266,8 +272,22 @@ function getMergedColors(): ThemeColors {
|
|
|
266
272
|
};
|
|
267
273
|
}
|
|
268
274
|
|
|
269
|
-
|
|
270
|
-
|
|
275
|
+
/** 页面底色:浅色用 colorBgLayout;暗色只认 darkColorBgLayout,未传则交给 antd 暗色算法推导 */
|
|
276
|
+
function resolveBgLayout(mode: ThemeMode): string | undefined {
|
|
277
|
+
if (mode === 'dark') {
|
|
278
|
+
return businessColors?.darkColorBgLayout ?? getActivePreset()?.colors.darkColorBgLayout;
|
|
279
|
+
}
|
|
280
|
+
return getMergedColors().colorBgLayout;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let warnedBgLayoutIgnored = false;
|
|
284
|
+
|
|
285
|
+
/** 暗色模式下浅色 colorBgLayout 被忽略时提示一次,避免业务配置静默失效 */
|
|
286
|
+
function warnIfBgLayoutIgnored() {
|
|
287
|
+
if (warnedBgLayoutIgnored || currentMode !== 'dark') return;
|
|
288
|
+
if (businessColors?.colorBgLayout == null || businessColors.darkColorBgLayout != null) return;
|
|
289
|
+
warnedBgLayoutIgnored = true;
|
|
290
|
+
console.warn('[open-admin] 暗色模式下 colors.colorBgLayout 不生效,请改用 colors.darkColorBgLayout 指定暗色页面底色。');
|
|
271
291
|
}
|
|
272
292
|
|
|
273
293
|
function invalidate() {
|
|
@@ -295,10 +315,11 @@ function buildConfig(): ThemeConfig {
|
|
|
295
315
|
colorError: merged.colorError,
|
|
296
316
|
colorInfo: merged.colorInfo,
|
|
297
317
|
};
|
|
298
|
-
// colorBgLayout
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
318
|
+
// 页面底色:浅色用 colorBgLayout;暗色只认 darkColorBgLayout,未传则交给算法推导,
|
|
319
|
+
// 避免业务按浅色习惯传入的浅灰把暗色页面染成浅底。
|
|
320
|
+
const bgLayout = resolveBgLayout(currentMode);
|
|
321
|
+
if (bgLayout != null) {
|
|
322
|
+
token.colorBgLayout = bgLayout;
|
|
302
323
|
}
|
|
303
324
|
|
|
304
325
|
const resolved = getDesignToken({ token, algorithm } as ThemeConfig);
|
|
@@ -377,6 +398,7 @@ function syncCssVars(token: Record<string, unknown>) {
|
|
|
377
398
|
setVar(el, '--oa-color-text-secondary', token.colorTextSecondary);
|
|
378
399
|
setVar(el, '--oa-color-text-tertiary', token.colorTextTertiary);
|
|
379
400
|
setVar(el, '--oa-color-fill', token.colorFillTertiary);
|
|
401
|
+
setVar(el, '--oa-color-fill-quaternary', token.colorFillQuaternary);
|
|
380
402
|
setVar(el, '--oa-color-primary-bg', token.colorPrimaryBg);
|
|
381
403
|
setVar(el, '--oa-color-error', token.colorError);
|
|
382
404
|
// 尺寸/圆角变量,供 less 复用,使自定义样式跟随密度与圆角预设
|
|
@@ -397,6 +419,7 @@ export function setThemeColors(colors?: Partial<ThemeColors>) {
|
|
|
397
419
|
businessColors = colors || null;
|
|
398
420
|
invalidate();
|
|
399
421
|
syncCssVars(getToken());
|
|
422
|
+
warnIfBgLayoutIgnored();
|
|
400
423
|
}
|
|
401
424
|
|
|
402
425
|
export function getThemeConfig(): ThemeConfig {
|
|
@@ -487,6 +510,7 @@ export function setThemeMode(mode: ThemeMode) {
|
|
|
487
510
|
}
|
|
488
511
|
invalidate();
|
|
489
512
|
syncCssVars(getToken());
|
|
513
|
+
warnIfBgLayoutIgnored();
|
|
490
514
|
EventBus.emit('themeChange', mode);
|
|
491
515
|
}
|
|
492
516
|
|
|
@@ -15,7 +15,8 @@ import {getMessageApi} from "./MessageApi";
|
|
|
15
15
|
* - 失败默认自动 console.warn + message.error,并 reject(AjaxError);传 {toastError: false} 可完全静默(仅 reject)
|
|
16
16
|
* - 返回值被忽略也安全:内部已对返回的 Promise 挂 no-op catch,不会产生 unhandled rejection
|
|
17
17
|
* - reject 的 AjaxError {code, message}:业务失败 code 为后端业务码,网络异常 code 为 HTTP 状态码,message 始终为人类可读提示
|
|
18
|
-
* - HTTP 401 或业务 code=401 均广播 loginExpired
|
|
18
|
+
* - HTTP 401 或业务 code=401 均广播 loginExpired,载荷为 {message, reason},交由全局登录失效弹窗统一提示(不再单独弹错)
|
|
19
|
+
* - 非 AjaxResult 响应体(如纯文本、HTML 错误页)一律按失败处理,不再当成功 resolve,避免掩盖登录失效
|
|
19
20
|
*
|
|
20
21
|
* 用法:
|
|
21
22
|
* ```ts
|
|
@@ -48,6 +49,8 @@ import {getMessageApi} from "./MessageApi";
|
|
|
48
49
|
export interface AjaxError {
|
|
49
50
|
code?: number | string;
|
|
50
51
|
message: string;
|
|
52
|
+
/** 登录失效原因(仅 code=401 时可能携带),见 LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN */
|
|
53
|
+
loginExpiredReason?: string;
|
|
51
54
|
}
|
|
52
55
|
|
|
53
56
|
/** 后端统一响应结构 AjaxResult;putExtData 附加字段在顶层,data 为业务数据 */
|
|
@@ -57,9 +60,25 @@ export interface AjaxBody<T = unknown> {
|
|
|
57
60
|
data: T;
|
|
58
61
|
message?: string;
|
|
59
62
|
traceId?: string;
|
|
63
|
+
/** 登录失效原因(框架在 401 响应中透传,如并发登录被顶下线) */
|
|
64
|
+
loginExpiredReason?: string;
|
|
60
65
|
[key: string]: unknown;
|
|
61
66
|
}
|
|
62
67
|
|
|
68
|
+
/**
|
|
69
|
+
* 登录失效原因:同一账号并发登录被顶下线(后端扩展字段 loginExpiredReason 的取值之一)。
|
|
70
|
+
* 全局登录失效弹窗据此使用后端下发的定制文案,而非站点级默认文案。
|
|
71
|
+
*/
|
|
72
|
+
export const LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN = 'CONCURRENT_LOGIN';
|
|
73
|
+
|
|
74
|
+
/** loginExpired 事件载荷 */
|
|
75
|
+
export interface LoginExpiredPayload {
|
|
76
|
+
/** 提示文案,来自后端 401 响应体的 message */
|
|
77
|
+
message?: string;
|
|
78
|
+
/** 原因标识,来自后端 401 响应体的扩展字段 loginExpiredReason */
|
|
79
|
+
reason?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
63
82
|
/** 请求选项:headers 自定义请求头;toastError=false 时失败不自动弹错(完全静默,仅 reject);toastSuccess=false 时成功不自动弹后端 message */
|
|
64
83
|
export interface RequestOptions {
|
|
65
84
|
headers?: AxiosRequestConfig['headers'];
|
|
@@ -144,16 +163,13 @@ export class HttpClient {
|
|
|
144
163
|
}).then((response) => {
|
|
145
164
|
const blob: Blob = response.data;
|
|
146
165
|
|
|
147
|
-
if (blob.type
|
|
166
|
+
if (blob.type.toLowerCase().startsWith('application/json')) {
|
|
148
167
|
const reader = new FileReader();
|
|
149
168
|
reader.onerror = () => HttpClient.fail(url, toastError, reject, {message: '读取下载数据失败'}, '下载失败');
|
|
150
169
|
reader.onload = () => {
|
|
151
170
|
try {
|
|
152
171
|
const rs = JSON.parse(reader.result as string);
|
|
153
|
-
|
|
154
|
-
EventBus.emit('loginExpired');
|
|
155
|
-
}
|
|
156
|
-
HttpClient.fail(url, toastError, reject, {code: rs.code, message: rs.message || '下载失败'}, '下载失败');
|
|
172
|
+
HttpClient.fail(url, toastError, reject, rs, '下载失败', rs.code === 401 ? 401 : undefined);
|
|
157
173
|
} catch {
|
|
158
174
|
HttpClient.fail(url, toastError, reject, {message: '解析错误响应失败'}, '下载失败');
|
|
159
175
|
}
|
|
@@ -246,21 +262,29 @@ export class HttpClient {
|
|
|
246
262
|
|
|
247
263
|
const promise = new Promise<AjaxBody<T>>((resolve, reject) => {
|
|
248
264
|
axiosInstance(config).then((response) => {
|
|
249
|
-
const body = response.data as AjaxBody<T
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
265
|
+
const body = response.data as AjaxBody<T> | undefined;
|
|
266
|
+
|
|
267
|
+
// 非 AjaxResult 响应体(如被过滤器/Servlet 容器拦截返回的纯文本、HTML 错误页)
|
|
268
|
+
// 一律按失败处理:曾经被当成成功 resolve,会掩盖登录失效等错误
|
|
269
|
+
// (axios 默认对 4xx/5xx 直接 reject,这里主要覆盖 2xx 却是非 JSON 的响应;401 分支为自定义 validateStatus 等场景兜底)
|
|
270
|
+
if (body == null || typeof body !== 'object' || body.success === undefined) {
|
|
271
|
+
HttpClient.fail(url, toastError, reject, {
|
|
272
|
+
code: response.status,
|
|
273
|
+
message: response.status === 401 ? '登录过期' : `响应格式错误(HTTP ${response.status})`,
|
|
274
|
+
}, '请求失败', response.status);
|
|
253
275
|
return;
|
|
254
276
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
277
|
+
|
|
278
|
+
if (!body.success) {
|
|
279
|
+
HttpClient.fail(url, toastError, reject, {
|
|
280
|
+
code: body.code,
|
|
281
|
+
message: body.message || '操作失败',
|
|
282
|
+
loginExpiredReason: body.loginExpiredReason,
|
|
283
|
+
}, '请求失败', body.code === 401 ? 401 : undefined);
|
|
260
284
|
return;
|
|
261
285
|
}
|
|
262
|
-
if (toastSuccess && message) {
|
|
263
|
-
getMessageApi().success(message);
|
|
286
|
+
if (toastSuccess && body.message) {
|
|
287
|
+
getMessageApi().success(body.message);
|
|
264
288
|
}
|
|
265
289
|
resolve(body);
|
|
266
290
|
}).catch((e: unknown) => {
|
|
@@ -273,14 +297,28 @@ export class HttpClient {
|
|
|
273
297
|
return promise;
|
|
274
298
|
}
|
|
275
299
|
|
|
276
|
-
/**
|
|
277
|
-
private static
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
300
|
+
/** 从 axios 异常 / AjaxResult / AjaxError 中提取登录失效文案与原因 */
|
|
301
|
+
private static loginExpiredInfo(e: unknown): {message: string; loginExpiredReason?: string} {
|
|
302
|
+
const data = (axios.isAxiosError(e) ? e.response?.data : e) as AjaxError | undefined;
|
|
303
|
+
return {
|
|
304
|
+
message: typeof data?.message === 'string' && data.message ? data.message : '登录过期',
|
|
305
|
+
loginExpiredReason: typeof data?.loginExpiredReason === 'string' ? data.loginExpiredReason : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 统一的失败处理:
|
|
311
|
+
* - HTTP 401 / 业务 code=401:广播 loginExpired(载荷含后端文案与原因),由全局登录失效弹窗统一提示,
|
|
312
|
+
* 不再单独弹错,避免与弹窗重复;最终 reject(AjaxError)
|
|
313
|
+
* - 其余:提取可读消息后按 toastError 决定是否弹错,最终 reject(AjaxError)
|
|
314
|
+
*/
|
|
315
|
+
private static fail(url: string, toastError: boolean, reject: (reason?: unknown) => void, e: unknown, label = '请求失败', httpStatus?: number): void {
|
|
316
|
+
const status = httpStatus ?? (axios.isAxiosError(e) ? e.response?.status : undefined);
|
|
317
|
+
if (status === 401) {
|
|
318
|
+
const {message, loginExpiredReason} = HttpClient.loginExpiredInfo(e);
|
|
319
|
+
const payload: LoginExpiredPayload = {message, reason: loginExpiredReason};
|
|
320
|
+
EventBus.emit('loginExpired', payload);
|
|
321
|
+
const err: AjaxError = {code: 401, message, loginExpiredReason};
|
|
284
322
|
reject(err);
|
|
285
323
|
return;
|
|
286
324
|
}
|