yingzios-platform-frontend-devtools 1.0.1
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/README.md +77 -0
- package/dist/base-path.d.ts +2 -0
- package/dist/base-path.d.ts.map +1 -0
- package/dist/base-path.js +2 -0
- package/dist/components/DevUserMenu/JwtGeneratorModal.d.ts +13 -0
- package/dist/components/DevUserMenu/JwtGeneratorModal.d.ts.map +1 -0
- package/dist/components/DevUserMenu/JwtGeneratorModal.js +137 -0
- package/dist/components/DevUserMenu/index.d.ts +31 -0
- package/dist/components/DevUserMenu/index.d.ts.map +1 -0
- package/dist/components/DevUserMenu/index.js +219 -0
- package/dist/components/DevUserMenu/jwt-utils.d.ts +34 -0
- package/dist/components/DevUserMenu/jwt-utils.d.ts.map +1 -0
- package/dist/components/DevUserMenu/jwt-utils.js +85 -0
- package/dist/components/DevUserMenu/styles.d.ts +5 -0
- package/dist/components/DevUserMenu/styles.d.ts.map +1 -0
- package/dist/components/DevUserMenu/styles.js +225 -0
- package/dist/components/StandaloneAppLayout/index.d.ts +63 -0
- package/dist/components/StandaloneAppLayout/index.d.ts.map +1 -0
- package/dist/components/StandaloneAppLayout/index.js +161 -0
- package/dist/components/StandaloneAppLayout/styles.d.ts +5 -0
- package/dist/components/StandaloneAppLayout/styles.d.ts.map +1 -0
- package/dist/components/StandaloneAppLayout/styles.js +230 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/manifest-loader.d.ts +50 -0
- package/dist/manifest-loader.d.ts.map +1 -0
- package/dist/manifest-loader.js +90 -0
- package/package.json +50 -0
- package/src/base-path.ts +2 -0
- package/src/components/DevUserMenu/JwtGeneratorModal.tsx +219 -0
- package/src/components/DevUserMenu/index.tsx +372 -0
- package/src/components/DevUserMenu/jwt-utils.ts +121 -0
- package/src/components/DevUserMenu/styles.ts +229 -0
- package/src/components/StandaloneAppLayout/index.tsx +332 -0
- package/src/components/StandaloneAppLayout/styles.ts +234 -0
- package/src/index.ts +4 -0
- package/src/manifest-loader.ts +146 -0
- package/src/types/scss.d.ts +19 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import React, { memo, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { Link, useLocation } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
import type { FrontendNavIconKey } from 'yz-frontend-core';
|
|
5
|
+
import {
|
|
6
|
+
loadAppManifest,
|
|
7
|
+
buildMenuGroupsFromManifest,
|
|
8
|
+
type StandaloneMenuGroup,
|
|
9
|
+
type StandaloneMenuItem,
|
|
10
|
+
} from '../../manifest-loader';
|
|
11
|
+
import { DevUserMenu } from '../DevUserMenu';
|
|
12
|
+
|
|
13
|
+
import { layoutStyles, leftNavStyles, groupNavStyles } from './styles';
|
|
14
|
+
|
|
15
|
+
export type IconRenderer = (iconKey: FrontendNavIconKey) => React.ReactNode;
|
|
16
|
+
|
|
17
|
+
type PrimaryNavItem = {
|
|
18
|
+
key: string;
|
|
19
|
+
label: string;
|
|
20
|
+
path: string;
|
|
21
|
+
iconKey?: FrontendNavIconKey;
|
|
22
|
+
items: StandaloneMenuItem[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isPathActive(currentPath: string, candidatePath: string, end?: boolean) {
|
|
26
|
+
if (candidatePath === currentPath) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return end === false && currentPath.startsWith(candidatePath + '/');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isStandaloneMenuItemActive(
|
|
34
|
+
currentPath: string,
|
|
35
|
+
item: StandaloneMenuItem,
|
|
36
|
+
) {
|
|
37
|
+
if (isPathActive(currentPath, item.path, item.end)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return item.matchPaths.some((matchPath) =>
|
|
42
|
+
isPathActive(currentPath, matchPath, item.end),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface StandaloneAppLayoutProps {
|
|
47
|
+
/**
|
|
48
|
+
* manifest 文件路径,默认 'app-manifest.json'(相对路径,受 <base> 标签影响)
|
|
49
|
+
*/
|
|
50
|
+
manifestUrl?: string;
|
|
51
|
+
/**
|
|
52
|
+
* 子内容
|
|
53
|
+
*/
|
|
54
|
+
children: React.ReactNode;
|
|
55
|
+
/**
|
|
56
|
+
* 自定义 Logo 组件
|
|
57
|
+
*/
|
|
58
|
+
logo?: React.ReactNode;
|
|
59
|
+
/**
|
|
60
|
+
* 自定义顶部右侧内容
|
|
61
|
+
* 如果为 false,则不显示默认的登录组件
|
|
62
|
+
*/
|
|
63
|
+
headerRight?: React.ReactNode | false;
|
|
64
|
+
/**
|
|
65
|
+
* 自定义图标渲染函数
|
|
66
|
+
* 传入 iconKey,返回 React 节点
|
|
67
|
+
*/
|
|
68
|
+
renderIcon?: IconRenderer;
|
|
69
|
+
/**
|
|
70
|
+
* API 基础路径,用于登录接口
|
|
71
|
+
* 默认 '/portal/api'
|
|
72
|
+
*/
|
|
73
|
+
apiBasePath?: string;
|
|
74
|
+
/**
|
|
75
|
+
* 组织切换页面路径
|
|
76
|
+
* 默认 '/org-select'
|
|
77
|
+
*/
|
|
78
|
+
orgSwitchPath?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 独立运行模式下的应用布局
|
|
83
|
+
*
|
|
84
|
+
* 自动从 app-manifest.json 加载配置并生成导航菜单。
|
|
85
|
+
* 用于微应用本地独立开发时提供统一的导航体验。
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```tsx
|
|
89
|
+
* import { StandaloneAppLayout } from 'yz-frontend-devtools';
|
|
90
|
+
*
|
|
91
|
+
* function StandaloneApp() {
|
|
92
|
+
* return (
|
|
93
|
+
* <StandaloneAppLayout>
|
|
94
|
+
* <AppRoutes />
|
|
95
|
+
* </StandaloneAppLayout>
|
|
96
|
+
* );
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export function StandaloneAppLayout({
|
|
101
|
+
manifestUrl = 'app-manifest.json',
|
|
102
|
+
children,
|
|
103
|
+
logo,
|
|
104
|
+
headerRight,
|
|
105
|
+
renderIcon,
|
|
106
|
+
apiBasePath = '/portal/api',
|
|
107
|
+
orgSwitchPath = '/org-select',
|
|
108
|
+
}: StandaloneAppLayoutProps) {
|
|
109
|
+
const [menuGroups, setMenuGroups] = useState<StandaloneMenuGroup[]>([]);
|
|
110
|
+
const [appName, setAppName] = useState<string>('');
|
|
111
|
+
const [loading, setLoading] = useState(true);
|
|
112
|
+
const [error, setError] = useState<string | null>(null);
|
|
113
|
+
const location = useLocation();
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
loadAppManifest(manifestUrl)
|
|
117
|
+
.then((manifest) => {
|
|
118
|
+
const groups = buildMenuGroupsFromManifest(manifest);
|
|
119
|
+
setMenuGroups(groups);
|
|
120
|
+
setAppName(manifest.displayName);
|
|
121
|
+
setLoading(false);
|
|
122
|
+
})
|
|
123
|
+
.catch((err) => {
|
|
124
|
+
console.error('[StandaloneAppLayout] Failed to load manifest:', err);
|
|
125
|
+
setError(err.message);
|
|
126
|
+
setLoading(false);
|
|
127
|
+
});
|
|
128
|
+
}, [manifestUrl]);
|
|
129
|
+
|
|
130
|
+
const isMenuItemActive = (item: StandaloneMenuItem) =>
|
|
131
|
+
isStandaloneMenuItemActive(location.pathname, item);
|
|
132
|
+
|
|
133
|
+
const primaryNavItems = useMemo<PrimaryNavItem[]>(() => menuGroups.flatMap((group) => {
|
|
134
|
+
const firstItem = group.items[0];
|
|
135
|
+
|
|
136
|
+
if (!firstItem) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [{
|
|
141
|
+
key: `group-${group.groupLabel}`,
|
|
142
|
+
label: group.groupLabel,
|
|
143
|
+
path: firstItem.path,
|
|
144
|
+
iconKey: firstItem.iconKey,
|
|
145
|
+
items: group.items,
|
|
146
|
+
}];
|
|
147
|
+
}), [menuGroups]);
|
|
148
|
+
|
|
149
|
+
const activePrimaryNav = useMemo(
|
|
150
|
+
() => primaryNavItems.find((item) => item.items.some((child) => isMenuItemActive(child))) || primaryNavItems[0],
|
|
151
|
+
[location.pathname, primaryNavItems],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const activeSidebarItems = activePrimaryNav?.items ?? [];
|
|
155
|
+
|
|
156
|
+
// 渲染右上角内容
|
|
157
|
+
const renderHeaderRight = () => {
|
|
158
|
+
// 如果显式传入 false,则不渲染
|
|
159
|
+
if (headerRight === false) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 如果传入了自定义内容,则渲染自定义内容
|
|
164
|
+
if (headerRight) {
|
|
165
|
+
return headerRight;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 默认渲染登录组件
|
|
169
|
+
return <DevUserMenu apiBasePath={apiBasePath} orgSwitchPath={orgSwitchPath} />;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (loading) {
|
|
173
|
+
return (
|
|
174
|
+
<div style={layoutStyles.layout}>
|
|
175
|
+
<div style={layoutStyles.loading}>加载中...</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (error) {
|
|
181
|
+
return (
|
|
182
|
+
<div style={layoutStyles.layout}>
|
|
183
|
+
<div style={layoutStyles.error}>
|
|
184
|
+
<span>加载失败: {error}</span>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div style={layoutStyles.layout}>
|
|
192
|
+
<header style={layoutStyles.header}>
|
|
193
|
+
<div style={layoutStyles.headerLeft}>
|
|
194
|
+
{logo ?? (
|
|
195
|
+
<span style={layoutStyles.logo}>{appName || 'App'}</span>
|
|
196
|
+
)}
|
|
197
|
+
<span style={layoutStyles.devBadge as React.CSSProperties}>DEV</span>
|
|
198
|
+
</div>
|
|
199
|
+
<div style={layoutStyles.headerRight}>{renderHeaderRight()}</div>
|
|
200
|
+
</header>
|
|
201
|
+
<div style={layoutStyles.shell}>
|
|
202
|
+
<aside style={layoutStyles.sidebar}>
|
|
203
|
+
<LeftNav
|
|
204
|
+
items={activeSidebarItems}
|
|
205
|
+
currentPath={location.pathname}
|
|
206
|
+
renderIcon={renderIcon}
|
|
207
|
+
/>
|
|
208
|
+
</aside>
|
|
209
|
+
<main style={layoutStyles.main}>
|
|
210
|
+
<GroupNav items={primaryNavItems} currentPath={location.pathname} />
|
|
211
|
+
<div style={layoutStyles.content}>{children}</div>
|
|
212
|
+
</main>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
interface LeftNavProps {
|
|
219
|
+
items: StandaloneMenuItem[];
|
|
220
|
+
currentPath: string;
|
|
221
|
+
renderIcon?: IconRenderer;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function LeftNav({ items, currentPath, renderIcon }: LeftNavProps) {
|
|
225
|
+
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
|
226
|
+
|
|
227
|
+
const isActive = (item: StandaloneMenuItem) =>
|
|
228
|
+
isStandaloneMenuItemActive(currentPath, item);
|
|
229
|
+
|
|
230
|
+
const getIcon = (iconKey?: FrontendNavIconKey, active?: boolean) => {
|
|
231
|
+
if (!iconKey) {
|
|
232
|
+
return <span style={leftNavStyles.itemIconPlaceholder} />;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (renderIcon) {
|
|
236
|
+
return renderIcon(iconKey);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 默认:显示一个简单的圆点
|
|
240
|
+
return <span style={leftNavStyles.itemIconDot} />;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const getItemStyle = (item: StandaloneMenuItem): React.CSSProperties => {
|
|
244
|
+
const active = isActive(item);
|
|
245
|
+
const hovered = hoveredKey === item.key;
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
...leftNavStyles.item,
|
|
249
|
+
...(hovered && !active ? leftNavStyles.itemHover : {}),
|
|
250
|
+
...(active ? leftNavStyles.itemActive : {}),
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const getIconStyle = (item: StandaloneMenuItem): React.CSSProperties => {
|
|
255
|
+
const active = isActive(item);
|
|
256
|
+
return {
|
|
257
|
+
...leftNavStyles.itemIcon,
|
|
258
|
+
...(active ? leftNavStyles.itemIconActive : {}),
|
|
259
|
+
};
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<nav aria-label="开发模式主导航" style={leftNavStyles.nav}>
|
|
264
|
+
<div style={leftNavStyles.scrollArea}>
|
|
265
|
+
<section style={leftNavStyles.group}>
|
|
266
|
+
{items.map((item) => (
|
|
267
|
+
<Link
|
|
268
|
+
aria-current={isActive(item) ? 'page' : undefined}
|
|
269
|
+
key={item.key}
|
|
270
|
+
to={item.path}
|
|
271
|
+
title={item.label}
|
|
272
|
+
style={getItemStyle(item)}
|
|
273
|
+
onMouseEnter={() => setHoveredKey(item.key)}
|
|
274
|
+
onMouseLeave={() => setHoveredKey(null)}
|
|
275
|
+
>
|
|
276
|
+
<span style={getIconStyle(item)}>
|
|
277
|
+
{getIcon(item.iconKey, isActive(item))}
|
|
278
|
+
</span>
|
|
279
|
+
<span style={leftNavStyles.itemLabel}>{item.label}</span>
|
|
280
|
+
</Link>
|
|
281
|
+
))}
|
|
282
|
+
</section>
|
|
283
|
+
</div>
|
|
284
|
+
</nav>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
interface GroupNavProps {
|
|
289
|
+
items: PrimaryNavItem[];
|
|
290
|
+
currentPath: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function GroupNav({ items, currentPath }: GroupNavProps) {
|
|
294
|
+
const [hoveredKey, setHoveredKey] = useState<string | null>(null);
|
|
295
|
+
|
|
296
|
+
const isActive = (item: PrimaryNavItem) =>
|
|
297
|
+
item.items.some((child) => isStandaloneMenuItemActive(currentPath, child));
|
|
298
|
+
|
|
299
|
+
const getItemStyle = (item: PrimaryNavItem): React.CSSProperties => {
|
|
300
|
+
const active = isActive(item);
|
|
301
|
+
const hovered = hoveredKey === item.key;
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
...groupNavStyles.item,
|
|
305
|
+
...(hovered && !active ? groupNavStyles.itemHover : {}),
|
|
306
|
+
...(active ? groupNavStyles.itemActive : {}),
|
|
307
|
+
};
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return (
|
|
311
|
+
<div style={groupNavStyles.nav}>
|
|
312
|
+
<div style={groupNavStyles.scroll}>
|
|
313
|
+
{items.map((item) => (
|
|
314
|
+
<Link
|
|
315
|
+
aria-current={isActive(item) ? 'page' : undefined}
|
|
316
|
+
key={item.key}
|
|
317
|
+
to={item.path}
|
|
318
|
+
style={getItemStyle(item)}
|
|
319
|
+
onMouseEnter={() => setHoveredKey(item.key)}
|
|
320
|
+
onMouseLeave={() => setHoveredKey(null)}
|
|
321
|
+
>
|
|
322
|
+
{item.label}
|
|
323
|
+
</Link>
|
|
324
|
+
))}
|
|
325
|
+
</div>
|
|
326
|
+
</div>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
StandaloneAppLayout.displayName = 'StandaloneAppLayout';
|
|
331
|
+
|
|
332
|
+
export default memo(StandaloneAppLayout);
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { CSSProperties } from 'react';
|
|
2
|
+
|
|
3
|
+
// CSS-in-JS 样式定义,避免 scss 构建问题
|
|
4
|
+
export const layoutStyles: Record<string, CSSProperties> = {
|
|
5
|
+
layout: {
|
|
6
|
+
display: 'flex',
|
|
7
|
+
flexDirection: 'column',
|
|
8
|
+
height: '100vh',
|
|
9
|
+
minHeight: 0,
|
|
10
|
+
width: '100%',
|
|
11
|
+
overflow: 'hidden',
|
|
12
|
+
background: '#ffffff',
|
|
13
|
+
},
|
|
14
|
+
loading: {
|
|
15
|
+
display: 'flex',
|
|
16
|
+
alignItems: 'center',
|
|
17
|
+
justifyContent: 'center',
|
|
18
|
+
minHeight: '100vh',
|
|
19
|
+
color: '#666',
|
|
20
|
+
fontSize: 14,
|
|
21
|
+
},
|
|
22
|
+
error: {
|
|
23
|
+
display: 'flex',
|
|
24
|
+
alignItems: 'center',
|
|
25
|
+
justifyContent: 'center',
|
|
26
|
+
minHeight: '100vh',
|
|
27
|
+
color: '#ff4d4f',
|
|
28
|
+
fontSize: 14,
|
|
29
|
+
},
|
|
30
|
+
header: {
|
|
31
|
+
position: 'sticky',
|
|
32
|
+
top: 0,
|
|
33
|
+
zIndex: 20,
|
|
34
|
+
display: 'flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'space-between',
|
|
37
|
+
height: 56,
|
|
38
|
+
padding: '0 16px',
|
|
39
|
+
background: '#ffffff',
|
|
40
|
+
borderBottom: '1px solid rgba(226, 232, 240, 0.9)',
|
|
41
|
+
flexShrink: 0,
|
|
42
|
+
},
|
|
43
|
+
headerLeft: {
|
|
44
|
+
display: 'flex',
|
|
45
|
+
alignItems: 'center',
|
|
46
|
+
gap: 12,
|
|
47
|
+
},
|
|
48
|
+
headerRight: {
|
|
49
|
+
display: 'flex',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
gap: 8,
|
|
52
|
+
},
|
|
53
|
+
logo: {
|
|
54
|
+
fontSize: 18,
|
|
55
|
+
fontWeight: 600,
|
|
56
|
+
color: '#0f172a',
|
|
57
|
+
},
|
|
58
|
+
devBadge: {
|
|
59
|
+
display: 'inline-flex',
|
|
60
|
+
alignItems: 'center',
|
|
61
|
+
padding: '2px 8px',
|
|
62
|
+
fontSize: 11,
|
|
63
|
+
fontWeight: 600,
|
|
64
|
+
color: '#0f172a',
|
|
65
|
+
background: '#f1f5f9',
|
|
66
|
+
border: '1px solid #e2e8f0',
|
|
67
|
+
borderRadius: 999,
|
|
68
|
+
textTransform: 'uppercase',
|
|
69
|
+
letterSpacing: 0.5,
|
|
70
|
+
},
|
|
71
|
+
shell: {
|
|
72
|
+
display: 'flex',
|
|
73
|
+
flex: 1,
|
|
74
|
+
overflow: 'hidden',
|
|
75
|
+
minHeight: 0,
|
|
76
|
+
background: 'linear-gradient(180deg, #f8fafc 0%, #f3f6fb 100%)',
|
|
77
|
+
},
|
|
78
|
+
sidebar: {
|
|
79
|
+
width: 256,
|
|
80
|
+
display: 'flex',
|
|
81
|
+
flexShrink: 0,
|
|
82
|
+
minHeight: 0,
|
|
83
|
+
overflow: 'hidden',
|
|
84
|
+
},
|
|
85
|
+
main: {
|
|
86
|
+
flex: 1,
|
|
87
|
+
display: 'flex',
|
|
88
|
+
flexDirection: 'column',
|
|
89
|
+
minWidth: 0,
|
|
90
|
+
minHeight: 0,
|
|
91
|
+
},
|
|
92
|
+
content: {
|
|
93
|
+
flex: 1,
|
|
94
|
+
overflowY: 'auto',
|
|
95
|
+
minHeight: 0,
|
|
96
|
+
position: 'relative',
|
|
97
|
+
padding: '24px 28px 28px',
|
|
98
|
+
background: 'transparent',
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const leftNavStyles: Record<string, CSSProperties> = {
|
|
103
|
+
nav: {
|
|
104
|
+
width: 256,
|
|
105
|
+
flex: 1,
|
|
106
|
+
flexShrink: 0,
|
|
107
|
+
display: 'flex',
|
|
108
|
+
flexDirection: 'column',
|
|
109
|
+
minHeight: 0,
|
|
110
|
+
height: '100%',
|
|
111
|
+
borderRight: '1px solid rgba(226, 232, 240, 0.9)',
|
|
112
|
+
background: 'linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(248, 250, 252, 0.98) 100%)',
|
|
113
|
+
padding: 0,
|
|
114
|
+
overflow: 'hidden',
|
|
115
|
+
},
|
|
116
|
+
scrollArea: {
|
|
117
|
+
flex: 1,
|
|
118
|
+
overflowY: 'auto',
|
|
119
|
+
padding: '18px 16px 12px',
|
|
120
|
+
},
|
|
121
|
+
group: {
|
|
122
|
+
display: 'flex',
|
|
123
|
+
flexDirection: 'column',
|
|
124
|
+
gap: 6,
|
|
125
|
+
},
|
|
126
|
+
item: {
|
|
127
|
+
display: 'flex',
|
|
128
|
+
alignItems: 'center',
|
|
129
|
+
justifyContent: 'flex-start',
|
|
130
|
+
gap: 12,
|
|
131
|
+
minHeight: 44,
|
|
132
|
+
padding: '10px 12px',
|
|
133
|
+
color: '#475569',
|
|
134
|
+
textDecoration: 'none',
|
|
135
|
+
border: '1px solid transparent',
|
|
136
|
+
borderRadius: 8,
|
|
137
|
+
background: 'transparent',
|
|
138
|
+
transition: 'background 0.15s, color 0.15s',
|
|
139
|
+
cursor: 'pointer',
|
|
140
|
+
},
|
|
141
|
+
itemHover: {
|
|
142
|
+
background: '#f8fafc',
|
|
143
|
+
color: '#0f172a',
|
|
144
|
+
},
|
|
145
|
+
itemActive: {
|
|
146
|
+
background: '#030213',
|
|
147
|
+
color: '#ffffff',
|
|
148
|
+
fontWeight: 500,
|
|
149
|
+
boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.1)',
|
|
150
|
+
},
|
|
151
|
+
itemIcon: {
|
|
152
|
+
display: 'flex',
|
|
153
|
+
alignItems: 'center',
|
|
154
|
+
justifyContent: 'center',
|
|
155
|
+
width: 32,
|
|
156
|
+
minWidth: 32,
|
|
157
|
+
height: 32,
|
|
158
|
+
borderRadius: 8,
|
|
159
|
+
background: 'linear-gradient(180deg, #f8fafc 0%, #edf2f7 100%)',
|
|
160
|
+
color: '#64748b',
|
|
161
|
+
boxShadow: 'inset 0 0 0 1px rgba(226, 232, 240, 0.85)',
|
|
162
|
+
},
|
|
163
|
+
itemIconActive: {
|
|
164
|
+
background: 'rgba(255, 255, 255, 0.15)',
|
|
165
|
+
color: '#ffffff',
|
|
166
|
+
boxShadow: 'none',
|
|
167
|
+
},
|
|
168
|
+
itemIconPlaceholder: {
|
|
169
|
+
width: 6,
|
|
170
|
+
height: 6,
|
|
171
|
+
borderRadius: '50%',
|
|
172
|
+
background: '#94a3b8',
|
|
173
|
+
},
|
|
174
|
+
itemIconDot: {
|
|
175
|
+
width: 6,
|
|
176
|
+
height: 6,
|
|
177
|
+
borderRadius: '50%',
|
|
178
|
+
background: 'currentColor',
|
|
179
|
+
opacity: 0.5,
|
|
180
|
+
},
|
|
181
|
+
itemLabel: {
|
|
182
|
+
fontSize: 14,
|
|
183
|
+
fontWeight: 500,
|
|
184
|
+
whiteSpace: 'nowrap',
|
|
185
|
+
overflow: 'hidden',
|
|
186
|
+
textOverflow: 'ellipsis',
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
export const groupNavStyles: Record<string, CSSProperties> = {
|
|
191
|
+
nav: {
|
|
192
|
+
display: 'flex',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
height: 56,
|
|
195
|
+
padding: '0 28px',
|
|
196
|
+
borderBottom: '1px solid rgba(226, 232, 240, 0.9)',
|
|
197
|
+
background: 'rgba(255, 255, 255, 0.7)',
|
|
198
|
+
backdropFilter: 'blur(8px)',
|
|
199
|
+
flexShrink: 0,
|
|
200
|
+
},
|
|
201
|
+
scroll: {
|
|
202
|
+
display: 'flex',
|
|
203
|
+
alignItems: 'center',
|
|
204
|
+
gap: 8,
|
|
205
|
+
overflowX: 'auto',
|
|
206
|
+
},
|
|
207
|
+
item: {
|
|
208
|
+
display: 'inline-flex',
|
|
209
|
+
alignItems: 'center',
|
|
210
|
+
justifyContent: 'center',
|
|
211
|
+
height: 32,
|
|
212
|
+
padding: '0 14px',
|
|
213
|
+
fontSize: 13,
|
|
214
|
+
fontWeight: 500,
|
|
215
|
+
color: '#64748b',
|
|
216
|
+
textDecoration: 'none',
|
|
217
|
+
border: '1px solid transparent',
|
|
218
|
+
borderRadius: 8,
|
|
219
|
+
background: 'transparent',
|
|
220
|
+
whiteSpace: 'nowrap',
|
|
221
|
+
cursor: 'pointer',
|
|
222
|
+
transition: 'all 0.15s',
|
|
223
|
+
},
|
|
224
|
+
itemHover: {
|
|
225
|
+
color: '#0f172a',
|
|
226
|
+
background: '#f1f5f9',
|
|
227
|
+
},
|
|
228
|
+
itemActive: {
|
|
229
|
+
color: '#0f172a',
|
|
230
|
+
background: '#ffffff',
|
|
231
|
+
borderColor: '#e2e8f0',
|
|
232
|
+
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
|
|
233
|
+
},
|
|
234
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FrontendAppManifest,
|
|
3
|
+
FrontendAppSectionDefinition,
|
|
4
|
+
FrontendNavIconKey,
|
|
5
|
+
} from 'yz-frontend-core';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 加载 app-manifest.json 文件
|
|
9
|
+
* @param url manifest 文件路径,默认 'app-manifest.json'(相对路径,受 <base> 标签影响)
|
|
10
|
+
*/
|
|
11
|
+
export async function loadAppManifest(url = 'app-manifest.json'): Promise<FrontendAppManifest> {
|
|
12
|
+
const response = await fetch(url);
|
|
13
|
+
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error(`Failed to load manifest: ${response.status} ${response.statusText}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return response.json();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 从 manifest 的 menus 构建 sections(用于导航菜单)
|
|
23
|
+
*
|
|
24
|
+
* 自动推导规则:
|
|
25
|
+
* - path: 默认 `${routeBase}/${key}`(useRelativePaths=true 时为 `/${key}`)
|
|
26
|
+
* - matchPaths: 默认 `[path]`
|
|
27
|
+
* - group: 仅在 manifest 中显式声明时保留;未声明由导航层使用 displayName 作为默认组
|
|
28
|
+
* - order: 默认按数组顺序
|
|
29
|
+
*
|
|
30
|
+
* @param manifest 应用 manifest
|
|
31
|
+
* @param useRelativePaths 是否使用相对路径(Standalone 模式下建议为 true)
|
|
32
|
+
*/
|
|
33
|
+
export function buildSectionsFromManifest(
|
|
34
|
+
manifest: FrontendAppManifest,
|
|
35
|
+
useRelativePaths = false,
|
|
36
|
+
): FrontendAppSectionDefinition[] {
|
|
37
|
+
const { routeBase, menus } = manifest;
|
|
38
|
+
|
|
39
|
+
if (!menus || menus.length === 0) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return menus.map((menu, index) => {
|
|
44
|
+
// useRelativePaths 为 true 时,使用相对路径 /${key},否则用完整路径 ${routeBase}/${key}
|
|
45
|
+
const path = menu.path ?? (useRelativePaths ? `/${menu.key}` : `${routeBase}/${menu.key}`);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
key: menu.key,
|
|
49
|
+
label: menu.label,
|
|
50
|
+
path,
|
|
51
|
+
group: menu.group,
|
|
52
|
+
iconKey: menu.iconKey as FrontendNavIconKey | undefined,
|
|
53
|
+
description: menu.description,
|
|
54
|
+
order: menu.order ?? index,
|
|
55
|
+
end: menu.end,
|
|
56
|
+
matchPaths: menu.matchPaths ?? [path],
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface StandaloneMenuItem {
|
|
62
|
+
key: string;
|
|
63
|
+
label: string;
|
|
64
|
+
path: string;
|
|
65
|
+
iconKey?: FrontendNavIconKey;
|
|
66
|
+
matchPaths: string[];
|
|
67
|
+
end?: boolean;
|
|
68
|
+
order: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface StandaloneMenuGroup {
|
|
72
|
+
groupLabel: string;
|
|
73
|
+
items: StandaloneMenuItem[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
type GroupAccumulator = {
|
|
77
|
+
firstIndex: number;
|
|
78
|
+
items: StandaloneMenuItem[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export interface BuildMenuGroupsOptions {
|
|
82
|
+
/**
|
|
83
|
+
* 是否使用相对路径(Standalone 本地开发模式下建议为 true)
|
|
84
|
+
* - true: 菜单路径为 `/${key}`,如 `/profile`
|
|
85
|
+
* - false: 菜单路径为 `${routeBase}/${key}`,如 `/apps/personal/profile`
|
|
86
|
+
* @default true
|
|
87
|
+
*/
|
|
88
|
+
useRelativePaths?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 从 manifest 直接构建菜单组。
|
|
93
|
+
* 按 group 分组;未声明 group 的菜单统一归入 manifest.displayName 默认组。
|
|
94
|
+
*
|
|
95
|
+
* @param manifest 应用 manifest
|
|
96
|
+
* @param options 构建选项
|
|
97
|
+
*/
|
|
98
|
+
export function buildMenuGroupsFromManifest(
|
|
99
|
+
manifest: FrontendAppManifest,
|
|
100
|
+
options: BuildMenuGroupsOptions = {},
|
|
101
|
+
): StandaloneMenuGroup[] {
|
|
102
|
+
const { useRelativePaths = true } = options;
|
|
103
|
+
const sections = buildSectionsFromManifest(manifest, useRelativePaths);
|
|
104
|
+
|
|
105
|
+
if (sections.length === 0) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const defaultGroupName = manifest.displayName;
|
|
110
|
+
const groupedItems = new Map<string, GroupAccumulator>();
|
|
111
|
+
|
|
112
|
+
sections.forEach((section, index) => {
|
|
113
|
+
const groupName = section.group || defaultGroupName;
|
|
114
|
+
|
|
115
|
+
const existing = groupedItems.get(groupName) || {
|
|
116
|
+
items: [],
|
|
117
|
+
firstIndex: index,
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
existing.items.push({
|
|
121
|
+
key: section.key,
|
|
122
|
+
label: section.label,
|
|
123
|
+
path: section.path,
|
|
124
|
+
iconKey: section.iconKey,
|
|
125
|
+
matchPaths: section.matchPaths ?? [section.path],
|
|
126
|
+
end: section.end,
|
|
127
|
+
order: section.order ?? 0,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
groupedItems.set(groupName, existing);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
return Array.from(groupedItems.entries())
|
|
134
|
+
.map(([groupLabel, { items, firstIndex }]) => ({
|
|
135
|
+
firstIndex,
|
|
136
|
+
groupLabel,
|
|
137
|
+
items: items.sort((a, b) => {
|
|
138
|
+
if (a.order !== b.order) {
|
|
139
|
+
return a.order - b.order;
|
|
140
|
+
}
|
|
141
|
+
return a.label.localeCompare(b.label, 'zh-CN');
|
|
142
|
+
}),
|
|
143
|
+
}))
|
|
144
|
+
.sort((a, b) => a.firstIndex - b.firstIndex)
|
|
145
|
+
.map(({ groupLabel, items }) => ({ groupLabel, items }));
|
|
146
|
+
}
|