@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
|
@@ -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
|
|
|
@@ -21,8 +21,8 @@ export interface BaseRemoteSelectProps {
|
|
|
21
21
|
* 处理通用的数据加载、防抖搜索、竞态处理、错误提示逻辑。
|
|
22
22
|
* 子类只需覆写 getLoadParams() 和 render()。
|
|
23
23
|
*/
|
|
24
|
-
export class BaseRemoteSelect<P extends BaseRemoteSelectProps = BaseRemoteSelectProps> extends React.Component<P, BaseRemoteSelectState> {
|
|
25
|
-
private fetchIdRef: number = 0;
|
|
24
|
+
export class BaseRemoteSelect<P extends BaseRemoteSelectProps = BaseRemoteSelectProps> extends React.Component<P, BaseRemoteSelectState> {
|
|
25
|
+
private fetchIdRef: number = 0;
|
|
26
26
|
private loadDataDebounce: ReturnType<typeof debounce>;
|
|
27
27
|
|
|
28
28
|
static readonly defaultProps = {
|
|
@@ -117,14 +117,14 @@ export class BaseRemoteSelect<P extends BaseRemoteSelectProps = BaseRemoteSelect
|
|
|
117
117
|
|
|
118
118
|
// ========== Helper 方法 ==========
|
|
119
119
|
|
|
120
|
-
getShowSearch(): { filterOption: false; onSearch: (value: string) => void } {
|
|
120
|
+
getShowSearch(): { filterOption: false; onSearch: (value: string) => void } {
|
|
121
121
|
return {
|
|
122
122
|
filterOption: false,
|
|
123
123
|
onSearch: this.handleSearch,
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
getNotFoundContent(): React.ReactNode {
|
|
127
|
+
getNotFoundContent(): React.ReactNode {
|
|
128
128
|
return this.state.loading ? <Spin size="small" /> : '数据为空';
|
|
129
129
|
}
|
|
130
130
|
}
|
|
@@ -11,7 +11,7 @@ import type {FieldProps} from '../types';
|
|
|
11
11
|
const SP = StringUtils.ISO_SPLITTER;
|
|
12
12
|
|
|
13
13
|
/** 日期范围值,形如 "2024-01-01/2024-01-31"(起止以 / 分隔) */
|
|
14
|
-
export type FieldDateRangeValue = string;
|
|
14
|
+
export type FieldDateRangeValue = string;
|
|
15
15
|
|
|
16
16
|
export interface FieldDateRangeProps extends FieldProps<FieldDateRangeValue> {
|
|
17
17
|
/** 日期类型,如 YYYY-MM-DD、YYYY-MM、HH:mm:ss 等,默认 YYYY-MM-DD */
|
|
@@ -12,7 +12,7 @@ export class FieldDictSelect extends React.Component<FieldDictSelectProps> {
|
|
|
12
12
|
render() {
|
|
13
13
|
const {value, typeCode} = this.props;
|
|
14
14
|
const options = DictUtils.dictOptions(typeCode);
|
|
15
|
-
const strValue = value == null ? null : String(value);
|
|
15
|
+
const strValue = value == null ? null : String(value);
|
|
16
16
|
|
|
17
17
|
return <Select value={strValue}
|
|
18
18
|
onChange={this.props.onChange}
|
|
@@ -6,7 +6,7 @@ import type {FieldProps} from '../types';
|
|
|
6
6
|
const SP = StringUtils.ISO_SPLITTER;
|
|
7
7
|
|
|
8
8
|
/** 数字范围值,形如 "1/100"(起止以 / 分隔) */
|
|
9
|
-
export type FieldNumberRangeValue = string;
|
|
9
|
+
export type FieldNumberRangeValue = string;
|
|
10
10
|
|
|
11
11
|
export type RangePartValue = number | string | null;
|
|
12
12
|
|
|
@@ -91,7 +91,7 @@ function readDims(url: string): Promise<Dims> {
|
|
|
91
91
|
|
|
92
92
|
function compressToFile(source: Blob, options: {maxWidth?: number; maxHeight?: number; width?: number; height?: number; quality?: number}): Promise<File> {
|
|
93
93
|
return new Promise((resolve, reject) => {
|
|
94
|
-
new Compressor(source, {
|
|
94
|
+
new Compressor(source, {
|
|
95
95
|
maxWidth: options.maxWidth,
|
|
96
96
|
maxHeight: options.maxHeight,
|
|
97
97
|
width: options.width,
|
|
@@ -139,9 +139,9 @@ async function compressToTarget(source: Blob, maxWidth: number | undefined, targ
|
|
|
139
139
|
return best;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
export class FieldUploadImage extends React.Component<FieldUploadImageProps, FieldUploadImageState> {
|
|
142
|
+
export class FieldUploadImage extends React.Component<FieldUploadImageProps, FieldUploadImageState> {
|
|
143
143
|
|
|
144
|
-
state: FieldUploadImageState = {
|
|
144
|
+
state: FieldUploadImageState = {
|
|
145
145
|
// 传入的参数
|
|
146
146
|
maxCount: 1,
|
|
147
147
|
thumbWidth: 300,
|
|
@@ -170,7 +170,7 @@ export class FieldUploadImage extends React.Component<FieldUploadImageProps, Fie
|
|
|
170
170
|
if (props.value) this.state.objectNames = props.value.split(',');
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
componentDidUpdate(prevProps: FieldUploadImageProps, prevState: FieldUploadImageState) {
|
|
173
|
+
componentDidUpdate(prevProps: FieldUploadImageProps, prevState: FieldUploadImageState) {
|
|
174
174
|
const next: Partial<FieldUploadImageState> = {};
|
|
175
175
|
if (this.props.maxCount !== prevProps.maxCount) next.maxCount = this.props.maxCount;
|
|
176
176
|
if (this.props.thumbWidth !== prevProps.thumbWidth) next.thumbWidth = this.props.thumbWidth;
|
|
@@ -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
|
}
|
|
@@ -67,6 +67,6 @@ export class UrlUtils {
|
|
|
67
67
|
static contextPath(path: string): string {
|
|
68
68
|
const base = import.meta.env.VITE_SERVER_SERVLET_CONTEXT_PATH;
|
|
69
69
|
if (base === '/' || base === '') return path;
|
|
70
|
-
return base.replace(/\/+$/, '') + path;
|
|
70
|
+
return base.replace(/\/+$/, '') + path;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
@@ -9,6 +9,38 @@ export class AdminTabs extends React.Component {
|
|
|
9
9
|
contextMenuTabKey: null,
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
barRef = React.createRef();
|
|
13
|
+
activeTabRef = React.createRef();
|
|
14
|
+
|
|
15
|
+
componentDidMount() {
|
|
16
|
+
this.scrollActiveIntoView();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
componentDidUpdate(prevProps) {
|
|
20
|
+
if (prevProps.activeKey !== this.props.activeKey) {
|
|
21
|
+
this.scrollActiveIntoView();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 激活的标签被滚动到可视区外时(标签过多),横向带入视野 */
|
|
26
|
+
scrollActiveIntoView = () => {
|
|
27
|
+
const bar = this.barRef.current;
|
|
28
|
+
const tab = this.activeTabRef.current;
|
|
29
|
+
if (!bar || !tab) return;
|
|
30
|
+
const margin = 8;
|
|
31
|
+
const barRect = bar.getBoundingClientRect();
|
|
32
|
+
const tabRect = tab.getBoundingClientRect();
|
|
33
|
+
let delta = 0;
|
|
34
|
+
if (tabRect.left < barRect.left + margin) {
|
|
35
|
+
delta = tabRect.left - barRect.left - margin;
|
|
36
|
+
} else if (tabRect.right > barRect.right - margin) {
|
|
37
|
+
delta = tabRect.right - barRect.right + margin;
|
|
38
|
+
}
|
|
39
|
+
if (delta !== 0) {
|
|
40
|
+
bar.scrollTo({left: bar.scrollLeft + delta, behavior: 'smooth'});
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
12
44
|
handleContextMenu = (e, key) => {
|
|
13
45
|
e.preventDefault();
|
|
14
46
|
this.setState({
|
|
@@ -38,12 +70,14 @@ export class AdminTabs extends React.Component {
|
|
|
38
70
|
|
|
39
71
|
return (
|
|
40
72
|
<>
|
|
41
|
-
<div className="admin-tabs-bar">
|
|
73
|
+
<div className="admin-tabs-bar" ref={this.barRef}>
|
|
42
74
|
{tabs.map(tab => (
|
|
43
75
|
<div
|
|
44
76
|
key={tab.key}
|
|
77
|
+
ref={tab.key === activeKey ? this.activeTabRef : null}
|
|
45
78
|
className={`admin-tab${tab.key === activeKey ? ' active' : ''}`}
|
|
46
79
|
role="tab"
|
|
80
|
+
title={tab.label}
|
|
47
81
|
tabIndex={tab.key === activeKey ? 0 : -1}
|
|
48
82
|
aria-selected={tab.key === activeKey}
|
|
49
83
|
onClick={() => onChange(tab.key)}
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
.admin-tabs-bar {
|
|
2
2
|
display: flex;
|
|
3
3
|
flex-wrap: nowrap;
|
|
4
|
+
align-items: flex-end;
|
|
5
|
+
gap: 2px;
|
|
4
6
|
overflow-x: auto;
|
|
5
7
|
overflow-y: hidden;
|
|
6
8
|
flex-shrink: 0;
|
|
9
|
+
padding: 4px 8px 0;
|
|
7
10
|
border-bottom: 1px solid var(--oa-color-border, #e8e8e8);
|
|
8
|
-
|
|
11
|
+
/* 条带用页面底色,与白色顶栏拉开层次;选中标签再用容器色浮起 */
|
|
12
|
+
background: var(--oa-color-bg-layout, #f5f5f5);
|
|
9
13
|
|
|
10
14
|
&::-webkit-scrollbar {
|
|
11
15
|
height: 0;
|
|
@@ -14,28 +18,37 @@
|
|
|
14
18
|
.admin-tab {
|
|
15
19
|
display: inline-flex;
|
|
16
20
|
align-items: center;
|
|
17
|
-
gap:
|
|
18
|
-
padding: 0 12px;
|
|
21
|
+
gap: 6px;
|
|
22
|
+
padding: 0 8px 0 12px;
|
|
19
23
|
height: var(--oa-control-height, 32px);
|
|
20
24
|
line-height: var(--oa-control-height, 32px);
|
|
21
25
|
font-size: 13px;
|
|
22
26
|
cursor: pointer;
|
|
23
|
-
border-right: 1px solid var(--oa-color-border, #e8e8e8);
|
|
24
27
|
white-space: nowrap;
|
|
25
28
|
color: var(--oa-color-text-secondary, rgba(0, 0, 0, 0.65));
|
|
26
|
-
background:
|
|
29
|
+
background: transparent;
|
|
30
|
+
border-right: none;
|
|
31
|
+
border-radius: var(--oa-border-radius, 6px) var(--oa-border-radius, 6px) 0 0;
|
|
27
32
|
transition: color 0.2s, background 0.2s;
|
|
28
33
|
position: relative;
|
|
29
34
|
flex-shrink: 0;
|
|
30
35
|
|
|
31
36
|
&:hover {
|
|
32
|
-
color: var(--
|
|
37
|
+
color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
|
|
38
|
+
background: color-mix(in srgb, var(--oa-color-text, rgba(0, 0, 0, 0.88)) 6%, transparent);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
&:focus-visible {
|
|
42
|
+
outline: 2px solid var(--primary-color, #1677ff);
|
|
43
|
+
outline-offset: -2px;
|
|
33
44
|
}
|
|
34
45
|
|
|
35
46
|
&.active {
|
|
36
|
-
color: var(--primary-color);
|
|
47
|
+
color: var(--primary-color, #1677ff);
|
|
37
48
|
background: var(--oa-color-bg-container, #fff);
|
|
38
49
|
font-weight: 500;
|
|
50
|
+
/* 顶部主色指示条;inset 保证不撑高标签 */
|
|
51
|
+
box-shadow: inset 0 2px 0 var(--primary-color, #1677ff);
|
|
39
52
|
}
|
|
40
53
|
|
|
41
54
|
.admin-tab-label {
|
|
@@ -53,11 +66,19 @@
|
|
|
53
66
|
font-size: 12px;
|
|
54
67
|
border-radius: 50%;
|
|
55
68
|
color: var(--oa-color-text-tertiary, rgba(0, 0, 0, 0.45));
|
|
69
|
+
/* 未选中的标签不常驻关闭按钮,减少视觉噪音 */
|
|
70
|
+
opacity: 0;
|
|
71
|
+
transition: opacity 0.15s, background 0.2s, color 0.2s;
|
|
56
72
|
|
|
57
73
|
&:hover {
|
|
58
74
|
background: var(--oa-color-fill, rgba(0, 0, 0, 0.08));
|
|
59
75
|
color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
|
|
60
76
|
}
|
|
61
77
|
}
|
|
78
|
+
|
|
79
|
+
&:hover .admin-tab-close,
|
|
80
|
+
&.active .admin-tab-close {
|
|
81
|
+
opacity: 1;
|
|
82
|
+
}
|
|
62
83
|
}
|
|
63
84
|
}
|
|
@@ -112,7 +112,7 @@ export default class AdminLayout extends React.Component {
|
|
|
112
112
|
this.setState({menuLoading: false})
|
|
113
113
|
})
|
|
114
114
|
}
|
|
115
|
-
actionRef = React.createRef()
|
|
115
|
+
actionRef = React.createRef()
|
|
116
116
|
|
|
117
117
|
classifyMenus = (menuTree) => {
|
|
118
118
|
const topMenus = [];
|
|
@@ -95,7 +95,11 @@
|
|
|
95
95
|
display: flex;
|
|
96
96
|
align-items: center;
|
|
97
97
|
gap: 8px;
|
|
98
|
-
|
|
98
|
+
/* 与顶栏同高,保证 logo 基线与一级菜单对齐 */
|
|
99
|
+
height: 48px;
|
|
100
|
+
padding: 0 16px;
|
|
101
|
+
/* 底边线:与右侧顶栏/标签栏分隔线连成一线;色值由侧栏文字变量派生,避免硬编码 */
|
|
102
|
+
border-bottom: 1px solid color-mix(in srgb, var(--oa-sider-text, rgba(255, 255, 255, 0.85)) 14%, transparent);
|
|
99
103
|
|
|
100
104
|
.logo-img {
|
|
101
105
|
height: 36px;
|
|
@@ -115,10 +119,25 @@
|
|
|
115
119
|
margin: 0;
|
|
116
120
|
font-size: 14px;
|
|
117
121
|
line-height: 1;
|
|
122
|
+
/* 允许在 flex 中收缩,长站点名省略号截断而不溢出侧栏 */
|
|
123
|
+
min-width: 0;
|
|
124
|
+
overflow: hidden;
|
|
118
125
|
white-space: nowrap;
|
|
126
|
+
|
|
127
|
+
a {
|
|
128
|
+
display: block;
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
text-overflow: ellipsis;
|
|
131
|
+
}
|
|
119
132
|
}
|
|
120
133
|
}
|
|
121
134
|
|
|
135
|
+
/* 折叠态:logo 居中,去掉左侧内边距 */
|
|
136
|
+
&.ant-layout-sider-collapsed .sider-header {
|
|
137
|
+
justify-content: center;
|
|
138
|
+
padding-inline: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
&.ant-layout-sider-collapsed .sider-header h3 {
|
|
123
142
|
display: none;
|
|
124
143
|
}
|
|
@@ -129,6 +148,11 @@
|
|
|
129
148
|
scrollbar-width: thin;
|
|
130
149
|
scrollbar-color: unset;
|
|
131
150
|
|
|
151
|
+
/* 菜单分隔线:加大上下间距,让不同菜单分组更清晰 */
|
|
152
|
+
.ant-menu-item-divider {
|
|
153
|
+
margin-block: 8px;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
.ant-menu-submenu-selected {
|
|
133
157
|
&>div {
|
|
134
158
|
color: var(--oa-sider-text, rgba(255, 255, 255, 0.85));
|
package/src/layouts/index.tsx
CHANGED
|
@@ -3,8 +3,8 @@ import {App, ConfigProvider, Modal} from "antd";
|
|
|
3
3
|
import zhCN from 'antd/locale/zh_CN';
|
|
4
4
|
import dayjs from 'dayjs';
|
|
5
5
|
import 'dayjs/locale/zh-cn';
|
|
6
|
-
import {ErrorBoundary, GlobalData, HttpClient, PageFrame, PageLoading, PageUtils, history, getThemeConfig, getThemeMode, setThemeColors, setMessageApi, EventBus} from "../framework";
|
|
7
|
-
import type {ThemeColors} from "../framework";
|
|
6
|
+
import {ErrorBoundary, GlobalData, HttpClient, LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN, PageFrame, PageLoading, PageUtils, history, getThemeConfig, getThemeMode, setThemeColors, setMessageApi, EventBus} from "../framework";
|
|
7
|
+
import type {LoginExpiredPayload, ThemeColors} from "../framework";
|
|
8
8
|
|
|
9
9
|
import AdminLayout from "./admin"
|
|
10
10
|
|
|
@@ -42,12 +42,15 @@ interface LayoutsProps {
|
|
|
42
42
|
colors?: Partial<ThemeColors>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
const DEFAULT_LOGIN_EXPIRED_MESSAGE = '登录已过期,请重新登录';
|
|
46
|
+
|
|
45
47
|
export class Layouts extends React.Component<LayoutsProps> {
|
|
46
48
|
state = {
|
|
47
49
|
location: history.location,
|
|
48
50
|
siteInfoLoaded: false,
|
|
49
51
|
loginChecked: false,
|
|
50
52
|
loginExpiredVisible: false,
|
|
53
|
+
loginExpiredMessage: DEFAULT_LOGIN_EXPIRED_MESSAGE,
|
|
51
54
|
themeMode: getThemeMode(),
|
|
52
55
|
themeVersion: 0,
|
|
53
56
|
};
|
|
@@ -56,6 +59,8 @@ export class Layouts extends React.Component<LayoutsProps> {
|
|
|
56
59
|
unsubscribeLoginExpired: (() => void) | null = null;
|
|
57
60
|
unsubscribeLogoutSuccess: (() => void) | null = null;
|
|
58
61
|
unsubscribeTheme: (() => void) | null = null;
|
|
62
|
+
/** 本次登录失效弹窗的原因。同步字段(非 state):同一次失效会并发上报多个 401,setState 异步可能读到旧值 */
|
|
63
|
+
private loginExpiredReason: string = '';
|
|
59
64
|
|
|
60
65
|
constructor(props: LayoutsProps) {
|
|
61
66
|
super(props);
|
|
@@ -66,6 +71,29 @@ export class Layouts extends React.Component<LayoutsProps> {
|
|
|
66
71
|
this.setState({location});
|
|
67
72
|
};
|
|
68
73
|
|
|
74
|
+
/**
|
|
75
|
+
* 处理登录失效事件并从后端载荷解析弹窗文案。
|
|
76
|
+
* 一次登录失效会并发触发多个请求(各自返回 401、各自上报),已经拿到更具体的原因(并发被顶下线)时不再降级,
|
|
77
|
+
* 避免后到的通用 401 覆盖掉「已在别处登录」文案
|
|
78
|
+
*/
|
|
79
|
+
onLoginExpired = (payload?: LoginExpiredPayload) => {
|
|
80
|
+
const concurrent = payload?.reason === LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN;
|
|
81
|
+
if (this.loginExpiredReason === LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN && !concurrent) {
|
|
82
|
+
this.setState({loginExpiredVisible: true});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.loginExpiredReason = concurrent ? LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN : '';
|
|
86
|
+
this.setState({
|
|
87
|
+
loginExpiredVisible: true,
|
|
88
|
+
loginExpiredMessage: this.resolveLoginExpiredMessage(payload),
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
closeLoginExpiredModal = () => {
|
|
93
|
+
this.loginExpiredReason = '';
|
|
94
|
+
this.setState({loginExpiredVisible: false});
|
|
95
|
+
};
|
|
96
|
+
|
|
69
97
|
getPageType(pathname): 'public' | 'standalone' | 'protected' {
|
|
70
98
|
if (pathname === '/' || pathname === '/index') return 'protected';
|
|
71
99
|
if (pathname.startsWith('/public/')) return 'public';
|
|
@@ -116,11 +144,7 @@ export class Layouts extends React.Component<LayoutsProps> {
|
|
|
116
144
|
|
|
117
145
|
componentDidMount() {
|
|
118
146
|
this.unlisten = history.listen(this.onLocationChange);
|
|
119
|
-
this.unsubscribeLoginExpired = EventBus.on('loginExpired',
|
|
120
|
-
if (!this.state.loginExpiredVisible) {
|
|
121
|
-
this.setState({loginExpiredVisible: true});
|
|
122
|
-
}
|
|
123
|
-
});
|
|
147
|
+
this.unsubscribeLoginExpired = EventBus.on('loginExpired', this.onLoginExpired);
|
|
124
148
|
this.unsubscribeLogoutSuccess = EventBus.on('logoutSuccess', () => {
|
|
125
149
|
this.setState({loginChecked: false});
|
|
126
150
|
});
|
|
@@ -154,6 +178,18 @@ export class Layouts extends React.Component<LayoutsProps> {
|
|
|
154
178
|
}
|
|
155
179
|
}
|
|
156
180
|
|
|
181
|
+
/**
|
|
182
|
+
* 解析登录失效弹窗文案:
|
|
183
|
+
* - 并发被顶下线:优先用后端随 401 下发的文案(sys.concurrent-login-message)
|
|
184
|
+
* - 其它(空闲超时/未登录):用站点信息里的文案(sys.login-expired-message)
|
|
185
|
+
*/
|
|
186
|
+
resolveLoginExpiredMessage(payload?: LoginExpiredPayload): string {
|
|
187
|
+
if (payload?.reason === LOGIN_EXPIRED_REASON_CONCURRENT_LOGIN && payload.message) {
|
|
188
|
+
return payload.message;
|
|
189
|
+
}
|
|
190
|
+
return GlobalData.getSiteInfo().loginExpiredMessage || payload?.message || DEFAULT_LOGIN_EXPIRED_MESSAGE;
|
|
191
|
+
}
|
|
192
|
+
|
|
157
193
|
render() {
|
|
158
194
|
const {location, siteInfoLoaded, loginChecked} = this.state;
|
|
159
195
|
const {pathname, search} = location;
|
|
@@ -190,12 +226,13 @@ export class Layouts extends React.Component<LayoutsProps> {
|
|
|
190
226
|
renderLoginExpiredModal() {
|
|
191
227
|
return (
|
|
192
228
|
<Modal open={this.state.loginExpiredVisible} title="确认操作" okText="确定"
|
|
193
|
-
onCancel={
|
|
229
|
+
onCancel={this.closeLoginExpiredModal}
|
|
194
230
|
onOk={() => {
|
|
195
|
-
this.
|
|
231
|
+
this.closeLoginExpiredModal();
|
|
232
|
+
this.setState({loginChecked: false});
|
|
196
233
|
PageUtils.redirectToLogin();
|
|
197
234
|
}}>
|
|
198
|
-
|
|
235
|
+
{this.state.loginExpiredMessage}
|
|
199
236
|
</Modal>
|
|
200
237
|
);
|
|
201
238
|
}
|
|
@@ -26,13 +26,13 @@ export default class ArticlePage extends React.Component {
|
|
|
26
26
|
const {article, loading} = this.state
|
|
27
27
|
|
|
28
28
|
if (loading) {
|
|
29
|
-
return <Page
|
|
29
|
+
return <Page>
|
|
30
30
|
<div>加载中...</div>
|
|
31
31
|
</Page>
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
if (!article) {
|
|
35
|
-
return <Page
|
|
35
|
+
return <Page>
|
|
36
36
|
<div>文章不存在或已禁用</div>
|
|
37
37
|
</Page>
|
|
38
38
|
}
|