@jiangood/open-admin 3.1.6 → 3.1.8

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 (60) hide show
  1. package/package.json +2 -5
  2. package/src/framework/biz/PermActions.tsx +1 -1
  3. package/src/framework/biz/UserPicker.less +17 -0
  4. package/src/framework/biz/UserPicker.tsx +296 -0
  5. package/src/framework/biz/index.tsx +2 -0
  6. package/src/framework/biz/pickerUser.ts +19 -0
  7. package/src/framework/components/ContextMenu/index.tsx +11 -8
  8. package/src/framework/components/DownloadModal/index.tsx +7 -7
  9. package/src/framework/components/ErrorBoundary/index.tsx +1 -1
  10. package/src/framework/components/FormModal/index.tsx +4 -4
  11. package/src/framework/components/NamedIcon/index.tsx +1 -1
  12. package/src/framework/components/OrgSwitcher/index.tsx +1 -1
  13. package/src/framework/components/Page/index.tsx +18 -4
  14. package/src/framework/components/PageLoading/index.tsx +2 -2
  15. package/src/framework/components/ProTable/index.less +70 -8
  16. package/src/framework/components/ProTable/index.tsx +63 -16
  17. package/src/framework/components/ThemeSettings/index.tsx +173 -0
  18. package/src/framework/components/index.ts +1 -0
  19. package/src/framework/config/index.ts +44 -2
  20. package/src/framework/config/layoutConfig.ts +52 -0
  21. package/src/framework/config/themeConfig.ts +449 -14
  22. package/src/framework/fields/BaseRemoteSelect/index.tsx +4 -4
  23. package/src/framework/fields/FieldDateRange/index.tsx +1 -1
  24. package/src/framework/fields/FieldDictSelect/index.tsx +1 -1
  25. package/src/framework/fields/FieldEditor/index.tsx +2 -2
  26. package/src/framework/fields/FieldNumberRange/index.tsx +1 -1
  27. package/src/framework/fields/FieldUploadFile/index.tsx +3 -3
  28. package/src/framework/fields/FieldUploadImage/index.tsx +19 -19
  29. package/src/framework/fields/FieldUserPicker/index.tsx +124 -0
  30. package/src/framework/fields/index.ts +1 -0
  31. package/src/framework/utils/HttpClient.ts +15 -4
  32. package/src/framework/utils/MessageApi.ts +16 -3
  33. package/src/framework/utils/UrlUtils.ts +1 -1
  34. package/src/framework/views/ViewText/index.tsx +1 -1
  35. package/src/layouts/admin/AdminTabs.less +11 -11
  36. package/src/layouts/admin/HeaderRight.jsx +23 -12
  37. package/src/layouts/admin/SinglePageLayout.jsx +36 -0
  38. package/src/layouts/admin/TabLayout.jsx +21 -1
  39. package/src/layouts/admin/index.jsx +46 -26
  40. package/src/layouts/admin/index.less +68 -24
  41. package/src/layouts/index.tsx +29 -4
  42. package/src/pages/article/$code.jsx +2 -2
  43. package/src/pages/article/article.less +13 -13
  44. package/src/pages/index.jsx +1 -1
  45. package/src/pages/public/login.jsx +29 -15
  46. package/src/pages/public/login.less +243 -22
  47. package/src/pages/standalone/forceUpdatePwd.jsx +8 -7
  48. package/src/pages/system/article/index.jsx +13 -15
  49. package/src/pages/system/codegen/index.jsx +54 -51
  50. package/src/pages/system/dict/index.jsx +2 -2
  51. package/src/pages/system/file/index.jsx +3 -3
  52. package/src/pages/system/job/index.jsx +57 -20
  53. package/src/pages/system/log/index.jsx +2 -2
  54. package/src/pages/system/org/index.jsx +5 -5
  55. package/src/pages/system/role/index.jsx +28 -55
  56. package/src/pages/system/role/rolePerm.jsx +1 -1
  57. package/src/pages/system/user/index.jsx +7 -11
  58. package/src/pages/system/user/userPerm.jsx +5 -5
  59. package/src/pages/userCenter/permView.jsx +2 -2
  60. package/src/style/global.less +6 -6
@@ -1,13 +1,14 @@
1
1
  .oa-pro-table {
2
2
  .filter-bar {
3
3
  display: grid;
4
- grid-template-columns: repeat(4, 1fr);
4
+ /* 列数由 searchFormCols 通过 --oa-filter-cols 注入,窄窗口下按断点收敛 */
5
+ grid-template-columns: repeat(var(--oa-filter-cols, 4), 1fr);
5
6
  gap: 12px 20px;
6
7
  align-items: center;
7
8
  padding: 16px 20px;
8
- background: #fff;
9
- border: 1px solid #e8e8e8;
10
- border-radius: 6px;
9
+ background: var(--oa-color-bg-container, #fff);
10
+ border: 1px solid var(--oa-color-border, #e8e8e8);
11
+ border-radius: var(--oa-border-radius, 6px);
11
12
  margin-bottom: 16px;
12
13
 
13
14
  .ant-form-item {
@@ -28,7 +29,7 @@
28
29
 
29
30
  label {
30
31
  font-size: 13px;
31
- color: #666;
32
+ color: var(--oa-color-text-secondary, #666);
32
33
  }
33
34
  }
34
35
 
@@ -50,9 +51,9 @@
50
51
  }
51
52
 
52
53
  .pro-table-wrapper {
53
- background: #fff;
54
- border: 1px solid #e8e8e8;
55
- border-radius: 6px;
54
+ background: var(--oa-color-bg-container, #fff);
55
+ border: 1px solid var(--oa-color-border, #e8e8e8);
56
+ border-radius: var(--oa-border-radius, 6px);
56
57
  }
57
58
 
58
59
  .pro-table-toolbar {
@@ -68,6 +69,11 @@
68
69
  gap: 8px;
69
70
  flex: 1;
70
71
  min-width: 0;
72
+
73
+ /* 仅内联搜索时允许换行,存量工具栏布局与之前完全一致 */
74
+ &.has-inline-search {
75
+ flex-wrap: wrap;
76
+ }
71
77
  }
72
78
 
73
79
  .pro-table-toolbar-right {
@@ -79,5 +85,61 @@
79
85
  margin-left: auto;
80
86
  }
81
87
  }
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
+
128
+ /* 桌面窄窗口下收敛搜索栏列数,避免标签+输入挤压/溢出 */
129
+ @media (max-width: 1400px) {
130
+ .filter-bar {
131
+ --oa-filter-cols: 3 !important;
132
+ }
133
+ }
134
+
135
+ @media (max-width: 1100px) {
136
+ .filter-bar {
137
+ --oa-filter-cols: 2 !important;
138
+
139
+ .filter-actions {
140
+ grid-column: 1 / -1;
141
+ }
142
+ }
143
+ }
82
144
  }
83
145
 
@@ -1,5 +1,5 @@
1
1
  import React from "react";
2
- import {Button, Form, Table} from 'antd';
2
+ import {Button, Empty, Form, Table} from 'antd';
3
3
  import type {FormInstance, TableProps} from 'antd';
4
4
 
5
5
  import {StringUtils, type AjaxBody} from "../../utils";
@@ -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;
@@ -198,14 +204,18 @@ this.setState({loading: true})
198
204
  rowSelection,
199
205
  rowKey = "id",
200
206
  } = this.props
207
+ // 有筛选条件时区分「无匹配结果」与「暂无数据」,避免业务人员误以为数据丢失
208
+ const hasFilter = Object.keys(this.state.params || {}).length > 0
201
209
 
210
+ const inlineSearch = this.renderInlineSearch()
202
211
 
203
212
  return <div className={'oa-pro-table '} id={this.id}>
204
213
  {this.renderForm()}
205
214
  <div className="pro-table-wrapper">
206
- {(toolBarRender || toolBarRightRender) && <div className="pro-table-toolbar">
207
- {toolBarRender && <div className="pro-table-toolbar-left">
208
- {this.getToolBarRenderNode(toolBarRender)}
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)}
209
219
  </div>}
210
220
  {toolBarRightRender && <div className="pro-table-toolbar-right">
211
221
  {this.getToolBarRenderNode(toolBarRightRender)}
@@ -226,7 +236,7 @@ this.setState({loading: true})
226
236
  total: this.state.total,
227
237
  pageSize: this.state.pageSize,
228
238
  current: this.state.current,
229
- pageSizeOptions: [10, 20, 50, 100, 500, 1000, 5000],
239
+ pageSizeOptions: [10, 20, 50, 100],
230
240
  showTotal: (total) => `共 ${total} 条`
231
241
  }}
232
242
 
@@ -244,6 +254,10 @@ this.setState({loading: true})
244
254
 
245
255
  footer={this.state.extData.summary ? () => this.state.extData.summary : null}
246
256
  bordered={this.props.bordered ?? false}
257
+ locale={{
258
+ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE}
259
+ description={hasFilter ? '没有符合条件的数据' : '暂无数据'}/>,
260
+ }}
247
261
  />
248
262
  </div>
249
263
  </div>
@@ -252,21 +266,17 @@ this.setState({loading: true})
252
266
 
253
267
 
254
268
  renderForm = () => {
255
- if (this.props.children) {
256
- throw new Error('[ProTable] children 已废弃,请使用 searchFormRender prop 替代')
257
- }
258
- const searchContent = this.props.searchFormRender?.()
269
+ // 内联模式:搜索表单改由工具栏渲染(见 renderInlineSearch)
270
+ if (this.props.searchInline) return
271
+ const searchContent = this.getSearchContent()
259
272
  if (!searchContent) return
260
273
 
261
274
  return (
262
275
  <Form
263
276
  className="filter-bar"
264
- style={{gridTemplateColumns: `repeat(${this.props.searchFormCols ?? 4}, 1fr)`}}
277
+ style={{'--oa-filter-cols': this.props.searchFormCols ?? 4} as React.CSSProperties}
265
278
  onFinish={(values) => this.onSearch(values)}
266
- ref={(instance) => {
267
- this.formRef.current = instance;
268
- if (this.props.formRef) this.props.formRef.current = instance;
269
- }}
279
+ ref={this.bindFormRef}
270
280
  >
271
281
  {searchContent}
272
282
  <div className="filter-actions">
@@ -280,6 +290,43 @@ this.setState({loading: true})
280
290
  )
281
291
  };
282
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
+
283
330
  getToolBarRenderNode(toolBarRender: ProTableToolBarRender<T>) {
284
331
  if (!toolBarRender) {
285
332
  return
@@ -317,7 +364,7 @@ this.setState({loading: true})
317
364
  this.setState({params: values, current: 1, sorter: {}}, this.loadData)
318
365
  }
319
366
 
320
- changeFormValues = (values: Record<string, unknown>) => { // NOSONAR: ref 暴露给父组件/业务项目调用的公共 API
367
+ changeFormValues = (values: Record<string, unknown>) => {
321
368
  if (this.formRef.current) {
322
369
  this.formRef.current.resetFields()
323
370
  this.formRef.current.setFieldsValue(values)
@@ -0,0 +1,173 @@
1
+ import React from 'react';
2
+ import { Button, Drawer, Segmented } from 'antd';
3
+ import { CheckOutlined } from '@ant-design/icons';
4
+ import {
5
+ THEME_PRESETS,
6
+ DEFAULT_COLORS,
7
+ DEFAULT_SIDER_BG,
8
+ applyThemePreset,
9
+ getActivePresetKey,
10
+ getThemeMode,
11
+ resetAllSettings,
12
+ setThemeMode,
13
+ DENSITY_PRESETS,
14
+ RADIUS_PRESETS,
15
+ applyDensityPreset,
16
+ getActiveDensityKey,
17
+ applyRadiusPreset,
18
+ getActiveRadiusKey,
19
+ getLayoutMode,
20
+ setLayoutMode,
21
+ } from '../../config';
22
+ import type { ThemeMode, ThemeDensity, ThemeRadius, LayoutMode } from '../../config';
23
+ import { EventBus } from '../../utils/EventBus';
24
+
25
+ export interface ThemeSettingsProps {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ }
29
+
30
+ function SectionTitle({ children }: { children: React.ReactNode }) {
31
+ return <div style={{ margin: '20px 0 12px', fontWeight: 600 }}>{children}</div>;
32
+ }
33
+
34
+ /**
35
+ * 界面设置面板:外观模式、主题色、界面密度、圆角、界面布局。
36
+ * 所有变更即时生效并持久化到 localStorage。
37
+ */
38
+ export function ThemeSettings({ open, onClose }: ThemeSettingsProps) {
39
+ const [activeKey, setActiveKey] = React.useState(getActivePresetKey());
40
+ const [mode, setMode] = React.useState<ThemeMode>(getThemeMode());
41
+ const [density, setDensity] = React.useState<ThemeDensity>(getActiveDensityKey());
42
+ const [radius, setRadius] = React.useState<ThemeRadius>(getActiveRadiusKey());
43
+ const [layout, setLayout] = React.useState<LayoutMode>(getLayoutMode());
44
+
45
+ React.useEffect(() => {
46
+ const offTheme = EventBus.on('themeChange', () => {
47
+ setActiveKey(getActivePresetKey());
48
+ setMode(getThemeMode());
49
+ setDensity(getActiveDensityKey());
50
+ setRadius(getActiveRadiusKey());
51
+ });
52
+ const offLayout = EventBus.on('layoutChange', () => {
53
+ setLayout(getLayoutMode());
54
+ });
55
+ return () => {
56
+ offTheme();
57
+ offLayout();
58
+ };
59
+ }, []);
60
+
61
+ const radiusValue = (key: ThemeRadius) =>
62
+ (RADIUS_PRESETS.find(p => p.key === key)?.token.borderRadius as number) ?? 6;
63
+
64
+ return (
65
+ <Drawer title="界面设置" open={open} onClose={onClose} size={340}>
66
+ <div style={{ fontWeight: 600 }}>外观模式</div>
67
+ <Segmented
68
+ block
69
+ style={{ marginTop: 12 }}
70
+ value={mode}
71
+ onChange={value => setThemeMode(value as ThemeMode)}
72
+ options={[
73
+ { label: '浅色', value: 'light' },
74
+ { label: '深色', value: 'dark' },
75
+ ]}
76
+ />
77
+
78
+ <SectionTitle>主题色</SectionTitle>
79
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
80
+ {THEME_PRESETS.map(preset => {
81
+ const primary = preset.colors.colorPrimary || DEFAULT_COLORS.colorPrimary || '#1677ff';
82
+ const sider = preset.colors.siderBg || DEFAULT_SIDER_BG;
83
+ const active = preset.key === activeKey;
84
+ return (
85
+ <button
86
+ key={preset.key}
87
+ type="button"
88
+ aria-pressed={active}
89
+ onClick={() => applyThemePreset(preset.key)}
90
+ style={{
91
+ cursor: 'pointer',
92
+ padding: 0,
93
+ overflow: 'hidden',
94
+ borderRadius: 8,
95
+ textAlign: 'left',
96
+ background: 'transparent',
97
+ border: active
98
+ ? `2px solid ${primary}`
99
+ : '1px solid var(--oa-color-border, #e8e8e8)',
100
+ }}
101
+ >
102
+ <div style={{ display: 'flex', height: 44 }}>
103
+ <div
104
+ style={{
105
+ width: '50%',
106
+ background: primary,
107
+ display: 'flex',
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ color: '#fff',
111
+ }}
112
+ >
113
+ {active && <CheckOutlined />}
114
+ </div>
115
+ <div style={{ width: '50%', background: sider }} />
116
+ </div>
117
+ <div style={{ padding: '6px 8px', fontSize: 12 }}>{preset.name}</div>
118
+ </button>
119
+ );
120
+ })}
121
+ </div>
122
+
123
+ <SectionTitle>界面密度</SectionTitle>
124
+ <Segmented
125
+ block
126
+ value={density}
127
+ onChange={value => applyDensityPreset(value as ThemeDensity)}
128
+ options={DENSITY_PRESETS.map(p => ({ label: p.name, value: p.key }))}
129
+ />
130
+
131
+ <SectionTitle>圆角</SectionTitle>
132
+ <Segmented
133
+ block
134
+ value={radius}
135
+ onChange={value => applyRadiusPreset(value as ThemeRadius)}
136
+ options={RADIUS_PRESETS.map(p => ({
137
+ value: p.key,
138
+ label: (
139
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
140
+ <span
141
+ style={{
142
+ display: 'inline-block',
143
+ width: 14,
144
+ height: 14,
145
+ border: '1.5px solid currentColor',
146
+ borderRadius: radiusValue(p.key),
147
+ }}
148
+ />
149
+ {p.name}
150
+ </span>
151
+ ),
152
+ }))}
153
+ />
154
+
155
+ <SectionTitle>界面布局</SectionTitle>
156
+ <Segmented
157
+ block
158
+ value={layout}
159
+ onChange={value => setLayoutMode(value as LayoutMode)}
160
+ options={[
161
+ { label: '多标签页', value: 'tabs' },
162
+ { label: '单页', value: 'single' },
163
+ ]}
164
+ />
165
+
166
+ <Button block style={{ marginTop: 20 }} onClick={() => resetAllSettings()}>
167
+ 恢复默认
168
+ </Button>
169
+ </Drawer>
170
+ );
171
+ }
172
+
173
+ export default ThemeSettings;
@@ -8,6 +8,7 @@ export * from './ValueType'
8
8
  export * from './ContextMenu'
9
9
  export * from './DownloadModal'
10
10
  export * from './OrgSwitcher'
11
+ export * from './ThemeSettings'
11
12
  export { default as ErrorBoundary } from './ErrorBoundary'
12
13
  export { default as FormModal } from './FormModal'
13
14
  export { default as FormDrawer } from './DrawerForm'
@@ -1,2 +1,44 @@
1
- export { getThemeConfig, getToken, setThemeColors } from './themeConfig'
2
- export type { ThemeColors } from './themeConfig'
1
+ export {
2
+ getThemeConfig,
3
+ getSiderThemeConfig,
4
+ getToken,
5
+ setThemeColors,
6
+ getThemeMode,
7
+ setThemeMode,
8
+ toggleThemeMode,
9
+ applyThemePreset,
10
+ getActivePresetKey,
11
+ applyDensityPreset,
12
+ getActiveDensityKey,
13
+ applyRadiusPreset,
14
+ getActiveRadiusKey,
15
+ resetUserTheme,
16
+ DEFAULT_COLORS,
17
+ DEFAULT_PRESET_KEY,
18
+ DEFAULT_SIDER_BG,
19
+ DEFAULT_SIDER_TRIGGER_BG,
20
+ THEME_PRESETS,
21
+ DEFAULT_DENSITY_KEY,
22
+ DENSITY_PRESETS,
23
+ DEFAULT_RADIUS_KEY,
24
+ RADIUS_PRESETS,
25
+ } from './themeConfig'
26
+ export type { ThemeColors, ThemeMode, ThemePreset, ThemeDensity, ThemeRadius, ThemeDensityPreset, ThemeRadiusPreset } from './themeConfig'
27
+
28
+ export {
29
+ getLayoutMode,
30
+ setLayoutMode,
31
+ toggleLayoutMode,
32
+ resetLayoutMode,
33
+ DEFAULT_LAYOUT_MODE,
34
+ } from './layoutConfig'
35
+ export type { LayoutMode } from './layoutConfig'
36
+
37
+ import { resetUserTheme } from './themeConfig'
38
+ import { resetLayoutMode } from './layoutConfig'
39
+
40
+ /** 一键恢复全部界面设置:主题(颜色/明暗/密度/圆角)+ 布局 */
41
+ export function resetAllSettings() {
42
+ resetUserTheme()
43
+ resetLayoutMode()
44
+ }
@@ -0,0 +1,52 @@
1
+ import { EventBus } from '../utils/EventBus';
2
+
3
+ export type LayoutMode = 'tabs' | 'single';
4
+
5
+ const LAYOUT_MODE_KEY = 'oa-layout-tabs';
6
+
7
+ export const DEFAULT_LAYOUT_MODE: LayoutMode = 'tabs';
8
+
9
+ const LAYOUT_MODES: LayoutMode[] = ['tabs', 'single'];
10
+
11
+ function readStoredLayoutMode(): LayoutMode {
12
+ if (typeof window === 'undefined') return DEFAULT_LAYOUT_MODE;
13
+ try {
14
+ const mode = localStorage.getItem(LAYOUT_MODE_KEY);
15
+ if (mode && LAYOUT_MODES.includes(mode as LayoutMode)) return mode as LayoutMode;
16
+ } catch {
17
+ // 隐私模式下 localStorage 不可用,忽略
18
+ }
19
+ return DEFAULT_LAYOUT_MODE;
20
+ }
21
+
22
+ let currentLayoutMode: LayoutMode = readStoredLayoutMode();
23
+
24
+ /** 当前布局模式:'tabs' 多标签页 / 'single' 单页 */
25
+ export function getLayoutMode(): LayoutMode {
26
+ return currentLayoutMode;
27
+ }
28
+
29
+ /** 设置布局模式,持久化并广播 layoutChange 供管理布局重渲染 */
30
+ export function setLayoutMode(mode: LayoutMode) {
31
+ if (!LAYOUT_MODES.includes(mode) || currentLayoutMode === mode) return;
32
+ currentLayoutMode = mode;
33
+ if (typeof window !== 'undefined') {
34
+ try {
35
+ localStorage.setItem(LAYOUT_MODE_KEY, mode);
36
+ } catch {
37
+ // 忽略
38
+ }
39
+ }
40
+ EventBus.emit('layoutChange', mode);
41
+ }
42
+
43
+ export function toggleLayoutMode(): LayoutMode {
44
+ const next: LayoutMode = currentLayoutMode === 'tabs' ? 'single' : 'tabs';
45
+ setLayoutMode(next);
46
+ return next;
47
+ }
48
+
49
+ /** 恢复默认布局(多标签页) */
50
+ export function resetLayoutMode() {
51
+ setLayoutMode(DEFAULT_LAYOUT_MODE);
52
+ }