@jiangood/open-admin 2.0.1 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/config/config.js +9 -14
  2. package/package.json +5 -1
  3. package/src/.umi-production/appData.json +75 -90
  4. package/src/.umi-production/umi.ts +2 -2
  5. package/src/framework/components/Page/index.less +1 -1
  6. package/src/framework/components/Page/index.tsx +19 -22
  7. package/src/framework/fields/FieldEditor/index.tsx +3 -2
  8. package/src/framework/fields/FieldUploadFile/index.tsx +8 -4
  9. package/src/framework/utils/MessageUtils.tsx +21 -99
  10. package/src/framework/utils/system/HttpUtils.ts +2 -1
  11. package/src/framework/utils/system/SysUtils.ts +8 -0
  12. package/src/layouts/PageRender.d.ts +2 -17
  13. package/src/layouts/PageRender.jsx +14 -76
  14. package/src/layouts/admin/TabPageRender.jsx +138 -158
  15. package/src/layouts/admin/index.jsx +120 -17
  16. package/src/layouts/admin/index.less +123 -5
  17. package/src/layouts/index.jsx +78 -161
  18. package/src/pages/404.jsx +7 -5
  19. package/src/pages/about.jsx +3 -3
  20. package/src/pages/index.jsx +4 -5
  21. package/src/pages/login.jsx +16 -9
  22. package/src/pages/login.less +0 -1
  23. package/src/pages/system/dict/index.jsx +7 -10
  24. package/src/pages/system/file/index.jsx +5 -5
  25. package/src/pages/system/job/index.jsx +9 -7
  26. package/src/pages/system/log/index.jsx +4 -5
  27. package/src/pages/system/manual/index.jsx +6 -4
  28. package/src/pages/system/org/index.jsx +4 -2
  29. package/src/pages/system/role/index.jsx +9 -7
  30. package/src/pages/system/role/perm.jsx +3 -4
  31. package/src/pages/system/user/index.jsx +8 -6
  32. package/src/pages/userCenter/index.jsx +1 -1
  33. package/src/pages/userCenter/message.jsx +4 -2
@@ -1,173 +1,153 @@
1
- import React from "react";
2
- import {withRouter} from "umi";
3
- import {Tabs} from "antd";
1
+ import React, {useState, useEffect, useRef} from "react";
2
+ import {history} from "umi";
4
3
  import {PageRender} from "../PageRender";
5
- import {PageUtils} from "../../framework";
4
+ import {UrlUtils} from "../../framework";
6
5
 
7
6
  const MAX_TABS = 20;
8
7
 
9
- class TabPageRenderComponent extends React.Component {
10
-
11
- state = {
12
- active: null,
13
- urlLabelMap: {},
14
- tabs: [],
15
- }
16
-
17
- accessOrder = [];
18
-
19
- componentDidMount() {
20
- const url = this.getUrl(this.props)
21
- this.onUrlChange(url);
22
-
23
- document.addEventListener('close-page-event', (e)=>{
24
- const url = e.detail.url
25
- this.onRemove(url)
26
- })
27
- }
28
-
29
- componentDidUpdate(prevProps, prevState, snapshot) {
30
- const url = this.getUrl(this.props)
31
- const pUrl = this.getUrl(prevProps)
32
-
33
- if (url !== pUrl) {
34
- this.onUrlChange(url);
35
- }
36
- }
37
-
38
- recordAccess(url) {
39
- this.accessOrder = this.accessOrder.filter(k => k !== url);
40
- this.accessOrder.push(url);
41
- }
8
+ function getLabel(pathname, search, pathMenuMap) {
9
+ if (pathname === '/') return '首页';
10
+ const params = UrlUtils.getParams(search);
11
+ if (params._label) return params._label;
12
+ const menu = pathMenuMap[pathname];
13
+ if (menu) return menu.name;
14
+ return '临时';
15
+ }
42
16
 
43
- onUrlChange = url => {
44
- const {location} = this.props
45
- const {pathname, search} = location
46
- const {tabs} = this.state
17
+ export function TabPageRender({pathMenuMap}) {
18
+ const [tabs, setTabs] = useState([]);
19
+ const [activeKey, setActiveKey] = useState(null);
20
+ const accessOrderRef = useRef([]);
21
+ const lastClickTimeRef = useRef({});
22
+ const activeKeyRef = useRef(activeKey);
23
+ activeKeyRef.current = activeKey;
24
+ const pathMenuMapRef = useRef(pathMenuMap);
25
+ pathMenuMapRef.current = pathMenuMap;
26
+
27
+ const recordAccess = (key) => {
28
+ accessOrderRef.current = accessOrderRef.current.filter(k => k !== key);
29
+ accessOrderRef.current.push(key);
30
+ };
47
31
 
48
- const old = tabs.find(t => t.key === url)
49
- if (old == null) {
50
- const cmp = <PageRender pathname={pathname} search={search}/>
51
- const label = this.getLabel(pathname);
52
- tabs.push({
53
- key: url,
54
- label: label,
55
- children: cmp
32
+ // Use history.listen to monitor route changes
33
+ useEffect(() => {
34
+ const unlisten = history.listen(({location}) => {
35
+ const {pathname, search} = location;
36
+ const key = pathname + (search || '');
37
+ recordAccess(key);
38
+
39
+ setTabs(prev => {
40
+ if (prev.find(t => t.key === key)) return prev;
41
+ const content = <PageRender pathname={pathname}/>;
42
+ const label = getLabel(pathname, search, pathMenuMapRef.current);
43
+ const next = [...prev, {key, label, closable: true, content}];
44
+ if (next.length > MAX_TABS) {
45
+ const keep = new Set([key, ...accessOrderRef.current.slice(-3)]);
46
+ const removeIdx = next.findIndex(t => !keep.has(t.key));
47
+ if (removeIdx > -1) next.splice(removeIdx, 1);
48
+ }
49
+ return next;
56
50
  });
57
51
 
58
- if (tabs.length > MAX_TABS) {
59
- const keep = new Set([url, ...this.accessOrder.slice(-3)]);
60
- const removeIdx = tabs.findIndex(t => !keep.has(t.key));
61
- if (removeIdx > -1) {
62
- tabs.splice(removeIdx, 1);
52
+ setActiveKey(key);
53
+ });
54
+
55
+ // Trigger for initial route
56
+ const {pathname, search} = history.location;
57
+ const key = pathname + (search || '');
58
+ recordAccess(key);
59
+ setTabs([{key, label: getLabel(pathname, search, pathMenuMap), closable: false, content: <PageRender pathname={pathname}/>}]);
60
+ setActiveKey(key);
61
+
62
+ return unlisten;
63
+ }, []);
64
+
65
+ // Listen for close-page-event (stable listener, no re-registration)
66
+ useEffect(() => {
67
+ const handler = (e) => {
68
+ const url = e.detail.url;
69
+ setTabs(prev => {
70
+ const next = prev.filter(t => t.key !== url);
71
+ accessOrderRef.current = accessOrderRef.current.filter(k => k !== url);
72
+ if (url === activeKeyRef.current && next.length > 0) {
73
+ history.push(next[next.length - 1].key);
63
74
  }
64
- }
65
-
66
- this.setState({tabs: [...tabs]})
67
- }else {
68
- const menu = this.props.pathMenuMap[pathname]
69
- if (menu && menu.refreshOnTabClick) {
70
- this.refresh(pathname)
71
- }
72
- }
73
-
74
- this.recordAccess(url);
75
- this.setState({active: url})
76
- };
77
-
78
- getLabel(path) {
79
- if (path === '/') {
80
- return '首页'
81
- }
82
- const label = PageUtils.currentLabel();
83
-
84
- if (!label) {
85
- const menu = this.props.pathMenuMap[path]
86
- if (menu) {
87
- return menu.name
88
- }
89
- }
90
-
91
- return label;
92
- }
93
-
94
-
95
- render() {
96
- const {tabs} = this.state
97
- if (tabs.length === 0) {
98
- return null
99
- }
100
-
101
- return <>
102
- <Tabs
103
- items={tabs}
104
- activeKey={this.state.active}
105
- onChange={this.onChange}
106
- onEdit={this.onRemove}
107
-
108
- hideAdd
109
- size='small'
110
- type='editable-card'
111
- style={{background: 'white'}}
112
- rootClassName='tmgg-layout-tabs'
113
-
114
- onTabClick={this.onTabClick}
115
-
116
- >
117
- </Tabs>
118
- </>
119
- }
120
-
121
- lastTabClickTime = 0
122
- onTabClick = (key, event) => {
123
- const now = new Date().getTime();
124
- const doubleClick = now - this.lastTabClickTime < 300;
125
- if (doubleClick) {
126
- this.refresh(key);
75
+ return next;
76
+ });
77
+ };
78
+ document.addEventListener('close-page-event', handler);
79
+ return () => document.removeEventListener('close-page-event', handler);
80
+ }, []);
81
+
82
+ const handleTabClick = (key) => {
83
+ const now = Date.now();
84
+ const last = lastClickTimeRef.current[key] || 0;
85
+ lastClickTimeRef.current[key] = now;
86
+ if (now - last < 300) {
87
+ // Double click → refresh
88
+ setTabs(prev => {
89
+ const idx = prev.findIndex(t => t.key === key);
90
+ if (idx === -1) return prev;
91
+ const originalContent = prev[idx].content;
92
+ const next = [...prev];
93
+ next[idx] = {...next[idx], content: '刷新中...'};
94
+ requestAnimationFrame(() => {
95
+ setTabs(prev2 => {
96
+ const idx2 = prev2.findIndex(t => t.key === key);
97
+ if (idx2 === -1) return prev2;
98
+ const next2 = [...prev2];
99
+ next2[idx2] = {...next2[idx2], content: originalContent};
100
+ return next2;
101
+ });
102
+ });
103
+ return next;
104
+ });
105
+ } else if (key !== activeKey) {
106
+ history.push(key);
127
107
  }
128
-
129
- this.lastTabClickTime = now
130
108
  };
131
109
 
132
- refresh = key => {
133
- const tabs = this.state.tabs;
134
- const tab = tabs.find(t => t.key === key)
135
- if(tab != null){
136
- const old = tab.children
137
- if(old != null){
138
- tab.children = '刷新中...'
139
- this.setState({tabs}, () => {
140
- tab.children = old
141
- this.setState({tabs})
142
- })
110
+ const handleClose = (key, e) => {
111
+ e.stopPropagation();
112
+ setTabs(prev => {
113
+ const next = prev.filter(t => t.key !== key);
114
+ accessOrderRef.current = accessOrderRef.current.filter(k => k !== key);
115
+ if (key === activeKeyRef.current && next.length > 0) {
116
+ history.push(next[next.length - 1].key);
143
117
  }
144
- }
118
+ return next;
119
+ });
145
120
  };
146
121
 
147
- onChange = url => {
148
- if (url !== this.state.active) {
149
- this.props.history.push(url)
150
- }
151
- };
152
-
153
- onRemove = url => {
154
- let {tabs} = this.state
155
- tabs = tabs.filter(t => t.key !== url)
156
- this.accessOrder = this.accessOrder.filter(k => k !== url);
157
-
158
- this.setState({tabs})
159
- if (tabs.length > 0) {
160
- this.setState({active: tabs[tabs.length - 1].key})
161
- }
162
- };
163
-
164
- getUrl = props => {
165
- const {location} = props
166
- const {pathname, search} = location
167
- return pathname + search;
168
- }
169
-
170
- }
171
-
172
-
173
- export const TabPageRender = withRouter(TabPageRenderComponent)
122
+ if (tabs.length === 0) return null;
123
+
124
+ return (
125
+ <div className="oa-tabs">
126
+ <div className="oa-tab-bar">
127
+ {tabs.map(tab => (
128
+ <div
129
+ key={tab.key}
130
+ className={'oa-tab' + (tab.key === activeKey ? ' active' : '')}
131
+ onClick={() => handleTabClick(tab.key)}
132
+ >
133
+ <span className="oa-tab-label">{tab.label}</span>
134
+ {tab.closable && tabs.length > 1 && (
135
+ <span className="oa-tab-close" onClick={(e) => handleClose(tab.key, e)}>×</span>
136
+ )}
137
+ </div>
138
+ ))}
139
+ </div>
140
+ <div className="oa-tabs-content">
141
+ {tabs.map(tab => (
142
+ <div
143
+ key={tab.key}
144
+ style={{display: tab.key === activeKey ? undefined : 'none'}}
145
+ className="oa-tab-pane"
146
+ >
147
+ {tab.content}
148
+ </div>
149
+ ))}
150
+ </div>
151
+ </div>
152
+ );
153
+ }
@@ -25,6 +25,9 @@ export default class extends React.Component {
25
25
 
26
26
  currentMenuKey: null,
27
27
 
28
+ topMenus: [],
29
+ sideMenus: [],
30
+ activeTopMenuKey: null,
28
31
 
29
32
  siteInfo: {},
30
33
 
@@ -55,24 +58,130 @@ export default class extends React.Component {
55
58
  item.icon = <NamedIcon name={item.icon || 'AppstoreOutlined'} style={{fontSize: 12}}/>
56
59
  })
57
60
 
61
+ const {topMenus} = this.classifyMenus(menuTree);
62
+
63
+ // 确定当前活跃的顶部菜单和侧边菜单
64
+ let activeTopMenuKey = null;
65
+ let sideMenus = [];
66
+ let currentMenuKey = null;
67
+
58
68
  if (pathname !== "" && pathname !== "/") {
59
- const menu = pathMenuMap[pathname]
60
- if (menu) {
61
- this.setState({currentMenuKey: menu.key})
69
+ const menuDef = pathMenuMap[pathname];
70
+ if (menuDef) {
71
+ currentMenuKey = menuDef.id;
72
+ const matched = this.findActiveTopMenu(topMenus, menuDef, menuMap);
73
+ if (matched) {
74
+ activeTopMenuKey = matched.key;
75
+ sideMenus = matched.children || [];
76
+ }
62
77
  }
63
78
  }
64
79
 
65
- this.setState({menuTree, pathMenuMap})
80
+ // 默认选中第一个顶部菜单
81
+ if (!activeTopMenuKey && topMenus.length > 0) {
82
+ activeTopMenuKey = topMenus[0].key;
83
+ sideMenus = topMenus[0].children || [];
84
+ if (!currentMenuKey && sideMenus.length > 0) {
85
+ currentMenuKey = sideMenus[0].key;
86
+ if (sideMenus[0].path) {
87
+ history.push(sideMenus[0].path);
88
+ }
89
+ }
90
+ }
91
+
92
+ this.setState({menuTree, pathMenuMap, topMenus, sideMenus, activeTopMenuKey, currentMenuKey})
66
93
 
67
94
  this.loadBadge(menuMap)
95
+ }).catch(err => {
96
+ console.error('加载菜单失败:', err)
97
+ this.setState({menuTree: [], topMenus: [], sideMenus: []})
68
98
  }).finally(()=>{
69
99
  this.setState({menuLoading: false})
70
100
  })
101
+ }
102
+ actionRef = React.createRef()
103
+
104
+ classifyMenus = (menuTree) => {
105
+ const topMenus = [];
106
+ const leafRootNodes = [];
71
107
 
108
+ (menuTree || []).forEach(node => {
109
+ const isDir = node.type === 'directory' || (node.children && node.children.length > 0);
110
+ if (isDir) {
111
+ topMenus.push(node);
112
+ } else {
113
+ leafRootNodes.push(node);
114
+ }
115
+ });
116
+
117
+ if (leafRootNodes.length > 0) {
118
+ const defaultGroup = {
119
+ key: '_default_group',
120
+ label: '默认',
121
+ title: '默',
122
+ icon: <NamedIcon name="AppstoreOutlined" style={{fontSize: 12}}/>,
123
+ children: leafRootNodes,
124
+ type: 'directory',
125
+ };
126
+ topMenus.unshift(defaultGroup);
127
+ }
72
128
 
129
+ return {topMenus};
73
130
  }
74
- actionRef = React.createRef()
75
131
 
132
+ findActiveTopMenu = (topMenus, menuDef, menuMap) => {
133
+ if (!menuDef) return null;
134
+
135
+ // 从 menuDef 的 pid 链向上查找匹配的 topMenu
136
+ let pid = menuDef.pid || menuDef.parentKey;
137
+ while (pid) {
138
+ const match = topMenus.find(t => t.key === pid);
139
+ if (match) return match;
140
+ const parent = menuMap[pid];
141
+ pid = parent ? (parent.pid || parent.parentKey) : null;
142
+ }
143
+ return null;
144
+ }
145
+
146
+ onTopMenuClick = ({key}) => {
147
+ const topMenu = this.state.topMenus.find(t => t.key === key);
148
+ if (!topMenu) return;
149
+
150
+ const sideMenus = topMenu.children || [];
151
+ this.setState({activeTopMenuKey: key, sideMenus});
152
+
153
+ if (sideMenus.length > 0 && sideMenus[0].path) {
154
+ this.setState({currentMenuKey: sideMenus[0].key});
155
+ history.push(sideMenus[0].path);
156
+ }
157
+ }
158
+
159
+ onLeftMenuClick = ({key}) => {
160
+ const menu = this.state.menuMap[key];
161
+ if (!menu) return;
162
+ const {path} = menu;
163
+ this.setState({currentMenuKey: key});
164
+ history.push(path);
165
+ }
166
+
167
+ renderTopMenu = () => {
168
+ if (this.state.menuLoading) return null;
169
+ const {topMenus, activeTopMenuKey} = this.state;
170
+ if (!topMenus || topMenus.length === 0) return null;
171
+ return (
172
+ <nav className="top-nav">
173
+ {topMenus.map(item => (
174
+ <span
175
+ key={item.key}
176
+ className={'top-nav-item' + (item.key === activeTopMenuKey ? ' active' : '')}
177
+ onClick={() => this.onTopMenuClick({key: item.key})}
178
+ >
179
+ {item.label}
180
+ </span>
181
+ ))}
182
+ </nav>
183
+ );
184
+ }
76
185
 
77
186
  loadBadge = menuMap => {
78
187
  for (let id in menuMap) {
@@ -107,10 +216,11 @@ export default class extends React.Component {
107
216
  </h3>
108
217
 
109
218
  </div>
219
+ {this.renderTopMenu()}
110
220
  <HeaderRight/>
111
221
  </Header>
112
222
 
113
- <Layout style={{height: '100%'}}>
223
+ <Layout style={{flex: 1, overflow: 'hidden'}}>
114
224
  <Sider id='left-sider'
115
225
  collapsible
116
226
  breakpoint={'md'}
@@ -137,17 +247,12 @@ export default class extends React.Component {
137
247
  if(this.state.menuLoading){
138
248
  return <div style={{padding: 16}}><Skeleton active title={false} paragraph={{rows: 8}} /></div>;
139
249
  }
140
- return <Menu items={this.state.menuTree}
250
+ return <Menu items={this.state.sideMenus}
141
251
  theme='dark'
142
252
  mode="inline"
143
253
  className='left-menu'
144
- onClick={({key}) => {
145
- const menu = this.state.menuMap[key]
146
- const {path} = menu;
147
- this.setState({currentMenuKey: key})
148
- history.push(path)
149
- }}
150
- selectedKeys={[this.state.currentMenuKey]}
254
+ onClick={this.onLeftMenuClick}
255
+ selectedKeys={this.state.currentMenuKey ? [this.state.currentMenuKey] : []}
151
256
  inlineIndent={16}
152
257
  >
153
258
  </Menu>;
@@ -155,9 +260,7 @@ export default class extends React.Component {
155
260
 
156
261
  renderCenterContent = () => {
157
262
  const {siteInfo, loginInfo} = this.state
158
- if (this.state.menuTree.length === 0) { // 加载菜单中
159
- return <Skeleton active paragraph={{rows: 10}} style={{padding: 24}} />
160
- }
263
+ // 即使菜单为空也直接显示页面内容,不要一直显示 Skeleton
161
264
  const tabPageRenderNode = <TabPageRender pathMenuMap={this.state.pathMenuMap}/>;
162
265
  if (siteInfo.waterMark === true) {
163
266
  return <Watermark content={[loginInfo.name, loginInfo.account]}>
@@ -1,10 +1,10 @@
1
1
  .main-layout {
2
- min-height: 100vh;
2
+ height: 100vh;
3
+ overflow: hidden;
3
4
  .header {
4
5
  padding: 0 20px;
5
6
  height: 48px;
6
7
  display: flex;
7
- justify-content: space-between;
8
8
  align-items: center;
9
9
  border-bottom: 1px solid rgb(233, 237, 240);
10
10
  background-color: rgb(246, 248, 250);
@@ -14,6 +14,7 @@
14
14
  display: flex;
15
15
  align-items: center;
16
16
  gap: 8px;
17
+ flex-shrink: 0;
17
18
 
18
19
  .logo-img {
19
20
  height: 38px;
@@ -21,10 +22,41 @@
21
22
  }
22
23
  }
23
24
 
25
+ .top-nav {
26
+ flex: 1;
27
+ display: flex;
28
+ align-items: center;
29
+ margin: 0 16px;
30
+ height: 100%;
31
+ overflow: hidden;
32
+
33
+ .top-nav-item {
34
+ display: inline-flex;
35
+ align-items: center;
36
+ gap: 4px;
37
+ padding: 0 16px;
38
+ height: 100%;
39
+ cursor: pointer;
40
+ color: rgba(0, 0, 0, 0.65);
41
+ transition: color 0.2s;
42
+ white-space: nowrap;
43
+
44
+ &:hover {
45
+ color: #1961AC;
46
+ }
47
+
48
+ &.active {
49
+ color: #1961AC;
50
+ box-shadow: inset 0 -2px 0 #1961AC;
51
+ }
52
+ }
53
+ }
54
+
24
55
  .header-right {
25
56
  line-height: 36px;
26
57
  display: flex;
27
58
  align-items: center;
59
+ flex-shrink: 0;
28
60
 
29
61
  .item {
30
62
  padding: 0 0.5rem;
@@ -59,10 +91,96 @@
59
91
  }
60
92
 
61
93
  #admin-layout-content {
62
- .tmgg-layout-tabs {
63
- &>.ant-tabs-nav {
64
- margin-bottom: 8px;
94
+ display: flex;
95
+ flex-direction: column;
96
+ overflow: hidden;
97
+
98
+ .oa-tabs {
99
+ flex: 1;
100
+ display: flex;
101
+ flex-direction: column;
102
+ overflow: hidden;
103
+
104
+ .oa-tab-bar {
105
+ display: flex;
106
+ flex-wrap: nowrap;
107
+ overflow-x: auto;
108
+ overflow-y: hidden;
109
+ flex-shrink: 0;
110
+ margin-bottom: 0;
111
+ border-bottom: 1px solid #e8e8e8;
112
+ background: #fff;
113
+
114
+ &::-webkit-scrollbar {
115
+ height: 0;
116
+ }
117
+ }
118
+
119
+ .oa-tab {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ gap: 4px;
123
+ padding: 0 12px;
124
+ height: 32px;
125
+ line-height: 32px;
126
+ font-size: 13px;
127
+ cursor: pointer;
128
+ border-right: 1px solid #e8e8e8;
129
+ white-space: nowrap;
130
+ color: rgba(0, 0, 0, 0.65);
131
+ background: #fafafa;
132
+ transition: color 0.2s, background 0.2s;
133
+ position: relative;
134
+ flex-shrink: 0;
135
+
136
+ &:hover {
137
+ color: #1961AC;
138
+ }
139
+
140
+ &.active {
141
+ color: #1961AC;
142
+ background: #fff;
143
+ font-weight: 500;
144
+ }
145
+
146
+ .oa-tab-label {
147
+ max-width: 120px;
148
+ overflow: hidden;
149
+ text-overflow: ellipsis;
150
+ }
151
+
152
+ .oa-tab-close {
153
+ display: inline-flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ width: 16px;
157
+ height: 16px;
158
+ font-size: 12px;
159
+ line-height: 1;
160
+ border-radius: 50%;
161
+ color: rgba(0, 0, 0, 0.45);
162
+
163
+ &:hover {
164
+ background: rgba(0, 0, 0, 0.08);
165
+ color: rgba(0, 0, 0, 0.88);
166
+ }
167
+ }
168
+ }
169
+
170
+ .oa-tabs-content {
171
+ flex: 1;
172
+ overflow: hidden;
173
+
174
+ .oa-tab-pane {
175
+ height: 100%;
176
+ }
65
177
  }
66
178
  }
67
179
  }
68
180
  }
181
+
182
+ @media (max-width: 768px) {
183
+ .top-nav {
184
+ display: none !important;
185
+ }
186
+ }