@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.
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
- import {CloseOutlined} from '@ant-design/icons';
2
+ import {CloseOutlined, DownOutlined} from '@ant-design/icons';
3
+ import {Dropdown} from 'antd';
3
4
  import {ContextMenu} from '../../framework';
4
5
  import './AdminTabs.less';
5
6
 
@@ -7,6 +8,88 @@ export class AdminTabs extends React.Component {
7
8
  state = {
8
9
  contextMenu: null,
9
10
  contextMenuTabKey: null,
11
+ overflowing: false,
12
+ };
13
+
14
+ barRef = React.createRef();
15
+ activeTabRef = React.createRef();
16
+ resizeObserver = null;
17
+
18
+ componentDidMount() {
19
+ this.scrollActiveIntoView();
20
+ this.updateOverflow();
21
+ this.observeOverflow();
22
+ this.barRef.current?.addEventListener('wheel', this.handleWheel, {passive: false});
23
+ }
24
+
25
+ componentDidUpdate(prevProps) {
26
+ if (prevProps.activeKey !== this.props.activeKey) {
27
+ this.scrollActiveIntoView();
28
+ }
29
+ this.updateOverflow();
30
+ }
31
+
32
+ componentWillUnmount() {
33
+ this.resizeObserver?.disconnect();
34
+ this.barRef.current?.removeEventListener('wheel', this.handleWheel);
35
+ }
36
+
37
+ observeOverflow = () => {
38
+ if (typeof ResizeObserver === 'undefined' || !this.barRef.current) return;
39
+ this.resizeObserver = new ResizeObserver(() => {
40
+ this.updateOverflow();
41
+ // 窗口变窄后激活标签可能被挤出可视区,跟随重新定位
42
+ this.scrollActiveIntoView();
43
+ });
44
+ this.resizeObserver.observe(this.barRef.current);
45
+ };
46
+
47
+ /** 标签总宽是否超过条带可用宽度(不含溢出时给按钮预留的 padding) */
48
+ isOverflowing = () => {
49
+ const bar = this.barRef.current;
50
+ if (!bar) return false;
51
+ const tabsWidth = [...bar.querySelectorAll('.admin-tab')]
52
+ .reduce((sum, tab) => sum + tab.getBoundingClientRect().width, 0);
53
+ // 基础左右 padding 各 8px;溢出时额外加的 40px 不参与判断,避免无法恢复
54
+ return tabsWidth > bar.clientWidth - 16 + 1;
55
+ };
56
+
57
+ /** 标签超出可视宽度时显示右侧「全部标签」入口 */
58
+ updateOverflow = () => {
59
+ const overflowing = this.isOverflowing();
60
+ if (overflowing !== this.state.overflowing) {
61
+ this.setState({overflowing});
62
+ }
63
+ };
64
+
65
+ /** 竖向滚轮在标签栏上转为横向滚动,避免标签被隐藏时无操作入口 */
66
+ handleWheel = e => {
67
+ const bar = this.barRef.current;
68
+ if (!bar || !this.isOverflowing()) return;
69
+ if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
70
+ bar.scrollLeft += e.deltaY;
71
+ e.preventDefault();
72
+ };
73
+
74
+ /** 激活的标签被滚动到可视区外时(标签过多),横向带入视野 */
75
+ scrollActiveIntoView = () => {
76
+ const bar = this.barRef.current;
77
+ const tab = this.activeTabRef.current;
78
+ if (!bar || !tab) return;
79
+ const margin = 8;
80
+ // 溢出时右侧有「全部标签」按钮,避免激活标签滚到按钮下面
81
+ const rightMargin = this.isOverflowing() ? 44 : margin;
82
+ const barRect = bar.getBoundingClientRect();
83
+ const tabRect = tab.getBoundingClientRect();
84
+ let delta = 0;
85
+ if (tabRect.left < barRect.left + margin) {
86
+ delta = tabRect.left - barRect.left - margin;
87
+ } else if (tabRect.right > barRect.right - rightMargin) {
88
+ delta = tabRect.right - barRect.right + rightMargin;
89
+ }
90
+ if (delta !== 0) {
91
+ bar.scrollTo({left: bar.scrollLeft + delta, behavior: 'smooth'});
92
+ }
10
93
  };
11
94
 
12
95
  handleContextMenu = (e, key) => {
@@ -34,39 +117,64 @@ export class AdminTabs extends React.Component {
34
117
 
35
118
  render() {
36
119
  const {tabs, activeKey, onChange, onClose} = this.props;
37
- const {contextMenu} = this.state;
120
+ const {contextMenu, overflowing} = this.state;
38
121
 
39
122
  return (
40
123
  <>
41
- <div className="admin-tabs-bar">
42
- {tabs.map(tab => (
43
- <div
44
- key={tab.key}
45
- className={`admin-tab${tab.key === activeKey ? ' active' : ''}`}
46
- role="tab"
47
- tabIndex={tab.key === activeKey ? 0 : -1}
48
- aria-selected={tab.key === activeKey}
49
- onClick={() => onChange(tab.key)}
50
- onKeyDown={e => {
51
- if (e.key === 'Enter' || e.key === ' ') {
52
- e.preventDefault();
53
- onChange(tab.key);
54
- }
55
- }}
56
- onContextMenu={e => this.handleContextMenu(e, tab.key)}
57
- >
58
- <span className="admin-tab-label">{tab.label}</span>
59
- {tabs.length > 1 && (
60
- <CloseOutlined
61
- className="admin-tab-close"
62
- onClick={e => {
63
- e.stopPropagation();
64
- onClose(tab.key);
65
- }}
66
- />
67
- )}
68
- </div>
69
- ))}
124
+ <div className={`admin-tabs-wrap${overflowing ? ' overflowing' : ''}`}>
125
+ <div className="admin-tabs-bar" ref={this.barRef}>
126
+ {tabs.map(tab => (
127
+ <div
128
+ key={tab.key}
129
+ ref={tab.key === activeKey ? this.activeTabRef : null}
130
+ className={`admin-tab${tab.key === activeKey ? ' active' : ''}`}
131
+ role="tab"
132
+ title={tab.label}
133
+ tabIndex={tab.key === activeKey ? 0 : -1}
134
+ aria-selected={tab.key === activeKey}
135
+ onClick={() => onChange(tab.key)}
136
+ onKeyDown={e => {
137
+ if (e.key === 'Enter' || e.key === ' ') {
138
+ e.preventDefault();
139
+ onChange(tab.key);
140
+ }
141
+ }}
142
+ onContextMenu={e => this.handleContextMenu(e, tab.key)}
143
+ >
144
+ <span className="admin-tab-label">{tab.label}</span>
145
+ {tabs.length > 1 && (
146
+ <CloseOutlined
147
+ className="admin-tab-close"
148
+ onClick={e => {
149
+ e.stopPropagation();
150
+ onClose(tab.key);
151
+ }}
152
+ />
153
+ )}
154
+ </div>
155
+ ))}
156
+ </div>
157
+ {overflowing && (
158
+ <>
159
+ <div className="admin-tabs-fade"/>
160
+ <Dropdown
161
+ trigger={['click']}
162
+ placement="bottomRight"
163
+ menu={{
164
+ items: tabs.map(tab => ({key: tab.key, label: tab.label})),
165
+ selectable: true,
166
+ selectedKeys: [activeKey],
167
+ onClick: ({key}) => onChange(key),
168
+ style: {maxHeight: 320, overflowY: 'auto'},
169
+ }}
170
+ >
171
+ <button type="button" className="admin-tabs-more" title="全部标签"
172
+ aria-label="全部标签">
173
+ <DownOutlined/>
174
+ </button>
175
+ </Dropdown>
176
+ </>
177
+ )}
70
178
  </div>
71
179
  {contextMenu && (
72
180
  <ContextMenu
@@ -1,10 +1,23 @@
1
+ .admin-tabs-wrap {
2
+ position: relative;
3
+ display: flex;
4
+ flex-shrink: 0;
5
+ min-width: 0;
6
+ }
7
+
1
8
  .admin-tabs-bar {
2
9
  display: flex;
3
10
  flex-wrap: nowrap;
11
+ align-items: stretch;
4
12
  overflow-x: auto;
5
13
  overflow-y: hidden;
6
- flex-shrink: 0;
7
- border-bottom: 1px solid var(--oa-color-border, #e8e8e8);
14
+ scrollbar-width: none;
15
+ flex: 1 1 auto;
16
+ min-width: 0;
17
+ height: var(--oa-control-height, 32px);
18
+ padding: 0 8px;
19
+ /* 条带与顶栏同色,和灰色页面拉开层次;底线用 inset 阴影绘制,选中标签可盖住 */
20
+ box-shadow: inset 0 -1px 0 var(--oa-color-border, #e8e8e8);
8
21
  background: var(--oa-color-bg-container, #fff);
9
22
 
10
23
  &::-webkit-scrollbar {
@@ -14,28 +27,38 @@
14
27
  .admin-tab {
15
28
  display: inline-flex;
16
29
  align-items: center;
17
- gap: 4px;
18
- padding: 0 12px;
19
- height: var(--oa-control-height, 32px);
20
- line-height: var(--oa-control-height, 32px);
30
+ gap: 6px;
31
+ padding: 0 8px 0 10px;
21
32
  font-size: 13px;
33
+ line-height: 1.4;
22
34
  cursor: pointer;
23
- border-right: 1px solid var(--oa-color-border, #e8e8e8);
24
35
  white-space: nowrap;
25
36
  color: var(--oa-color-text-secondary, rgba(0, 0, 0, 0.65));
26
- background: var(--oa-color-fill, #fafafa);
27
- transition: color 0.2s, background 0.2s;
28
- position: relative;
37
+ background: transparent;
38
+ /* 方形标签,标签之间仅用 1px 竖线分隔,贴近系统 / IDEA 原生观感 */
39
+ border-radius: 0;
40
+ transition: color 0.15s, background 0.15s;
29
41
  flex-shrink: 0;
30
42
 
43
+ /* 分隔线画在相邻标签之间,条带首尾不留多余竖线 */
44
+ & + .admin-tab {
45
+ border-left: 1px solid var(--oa-color-border, #e8e8e8);
46
+ }
47
+
31
48
  &:hover {
32
- color: var(--primary-color);
49
+ color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
50
+ background: var(--oa-color-fill, rgba(0, 0, 0, 0.06));
51
+ }
52
+
53
+ &:focus-visible {
54
+ outline: 2px solid var(--primary-color, #1677ff);
55
+ outline-offset: -2px;
33
56
  }
34
57
 
35
58
  &.active {
36
- color: var(--primary-color);
37
- background: var(--oa-color-bg-container, #fff);
38
- font-weight: 500;
59
+ /* IDEA 式:选中标签用页面底色盖住条带底线,文字用主色点明选中项 */
60
+ color: var(--primary-color, #1677ff);
61
+ background: var(--oa-color-bg-layout, #f5f5f5);
39
62
  }
40
63
 
41
64
  .admin-tab-label {
@@ -53,11 +76,67 @@
53
76
  font-size: 12px;
54
77
  border-radius: 50%;
55
78
  color: var(--oa-color-text-tertiary, rgba(0, 0, 0, 0.45));
79
+ /* 未选中的标签不常驻关闭按钮,减少视觉噪音 */
80
+ opacity: 0;
81
+ transition: opacity 0.15s, background 0.2s, color 0.2s;
56
82
 
57
83
  &:hover {
58
84
  background: var(--oa-color-fill, rgba(0, 0, 0, 0.08));
59
85
  color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
60
86
  }
61
87
  }
88
+
89
+ &:hover .admin-tab-close,
90
+ &.active .admin-tab-close {
91
+ opacity: 1;
92
+ }
93
+ }
94
+ }
95
+
96
+ /* 标签溢出:右侧渐隐 + 全部标签入口,改由按钮触发下拉 */
97
+ .admin-tabs-wrap.overflowing {
98
+ .admin-tabs-bar {
99
+ /* 给右侧按钮让位,滚到最右时最后一个标签不会被遮住 */
100
+ padding-right: 40px;
101
+ }
102
+
103
+ .admin-tabs-fade {
104
+ position: absolute;
105
+ top: 0;
106
+ right: 0;
107
+ bottom: 1px;
108
+ width: 44px;
109
+ pointer-events: none;
110
+ background: linear-gradient(to right, transparent, var(--oa-color-bg-container, #fff) 68%);
111
+ }
112
+
113
+ .admin-tabs-more {
114
+ position: absolute;
115
+ top: 50%;
116
+ right: 6px;
117
+ transform: translateY(-50%);
118
+ display: inline-flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ width: 24px;
122
+ height: 24px;
123
+ padding: 0;
124
+ border: none;
125
+ border-radius: var(--oa-border-radius, 6px);
126
+ background: var(--oa-color-bg-container, #fff);
127
+ color: var(--oa-color-text-secondary, rgba(0, 0, 0, 0.65));
128
+ font-size: 12px;
129
+ cursor: pointer;
130
+ transition: background 0.15s, color 0.15s;
131
+
132
+ &:hover {
133
+ background: var(--oa-color-fill, rgba(0, 0, 0, 0.06));
134
+ color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
135
+ }
136
+
137
+ &:focus-visible {
138
+ outline: 2px solid var(--primary-color, #1677ff);
139
+ outline-offset: -2px;
140
+ }
62
141
  }
63
142
  }
@@ -202,6 +202,8 @@ export default class AdminLayout extends React.Component {
202
202
 
203
203
  render() {
204
204
  const {siteInfo, loginInfo} = this.state
205
+ // 侧栏空间有限,优先显示系统简称;未配置时回退完整标题
206
+ const siderTitle = (siteInfo.shortTitle || '').trim() || siteInfo.title || '首页';
205
207
 
206
208
  return <Layout className='main-layout'>
207
209
  {/* 侧栏单独套暗色算法,使分割线/边框在深色底上可见,并按预设覆盖侧栏底色 */}
@@ -217,7 +219,8 @@ return <Layout className='main-layout'>
217
219
  <img src="./logo.png" alt={siteInfo.title || '首页'}/>
218
220
  </button>
219
221
  <h3 className='hide-on-mobile'>
220
- <Link to="/" style={{color: 'var(--oa-sider-text, rgba(255,255,255,0.85))'}}>{siteInfo.title}</Link>
222
+ <Link to="/" title={siteInfo.title || siderTitle}
223
+ style={{color: 'var(--oa-sider-text, rgba(255,255,255,0.85))'}}>{siderTitle}</Link>
221
224
  </h3>
222
225
  </div>
223
226
  {this.renderLeftMenu()}
@@ -61,13 +61,13 @@
61
61
  font: inherit;
62
62
 
63
63
  &:hover {
64
- color: var(--primary-color, #1677ff);
64
+ /* hover 只给底色,主色保留给当前项,避免悬停项比 active 更抢眼 */
65
+ color: var(--oa-color-text, rgba(0, 0, 0, 0.88));
65
66
  background: var(--oa-color-fill, rgba(0, 0, 0, 0.06));
66
67
  }
67
68
 
68
69
  &.active {
69
70
  color: var(--primary-color, #1677ff);
70
- box-shadow: inset 0 -2px 0 var(--primary-color, #1677ff);
71
71
  }
72
72
  }
73
73
  }
@@ -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={() => this.setState({loginExpiredVisible: false})}
229
+ onCancel={this.closeLoginExpiredModal}
194
230
  onOk={() => {
195
- this.setState({loginExpiredVisible: false, loginChecked: false});
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
  }