node-resource-utils 0.0.0-stage → 1.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.
- package/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
4
|
+
import { APP_NAME, APP_SHORT, APP_SUBTITLE } from '@/lib/appInfo';
|
|
5
|
+
import { brandMark } from '@/lib/brand-mark';
|
|
6
|
+
import Link from 'next/link';
|
|
7
|
+
import { usePathname, useRouter } from 'next/navigation';
|
|
8
|
+
import { signOut } from 'next-auth/react';
|
|
9
|
+
import { App, Avatar, Breadcrumb, Button, Dropdown, Layout, Menu, Space, Tooltip, type MenuProps } from 'antd';
|
|
10
|
+
import {
|
|
11
|
+
BgColorsOutlined,
|
|
12
|
+
CloseOutlined,
|
|
13
|
+
HomeOutlined,
|
|
14
|
+
LogoutOutlined,
|
|
15
|
+
MenuFoldOutlined,
|
|
16
|
+
MenuUnfoldOutlined,
|
|
17
|
+
QuestionCircleOutlined,
|
|
18
|
+
StarFilled,
|
|
19
|
+
StarOutlined,
|
|
20
|
+
UserOutlined,
|
|
21
|
+
} from '@ant-design/icons';
|
|
22
|
+
import type { SessionUser } from '@/auth';
|
|
23
|
+
import type { MenuTreeNode } from '@/lib/menu-service';
|
|
24
|
+
import { buildSearchEntries } from '@/lib/menu-service';
|
|
25
|
+
import { MAX_FAVORITES, buildFavoriteIndex, resolveFavorites } from '@/lib/favorite-path';
|
|
26
|
+
import { sumBadges } from '@/lib/badges';
|
|
27
|
+
import { iconOf } from './menu-icons';
|
|
28
|
+
import CountBadge from './CountBadge';
|
|
29
|
+
import GlobalSearch from './GlobalSearch';
|
|
30
|
+
import InboxBell from './InboxBell';
|
|
31
|
+
import VisitStats from './VisitStats';
|
|
32
|
+
import { MILITARY, fs } from '@/lib/theme';
|
|
33
|
+
import { useAppearance } from '@/components/appearance/AppearanceProvider';
|
|
34
|
+
import { startRouteProgress } from '@/components/feedback/RouteProgress';
|
|
35
|
+
import { clearTasks } from '@/lib/pending';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 后台外壳:侧边菜单 + 顶栏 + 内容区。
|
|
39
|
+
*
|
|
40
|
+
* 菜单数据由服务端传入(layout.tsx),而不是前端登录后再请求一次:
|
|
41
|
+
* 这样首屏 HTML 里就带着完整侧边栏,不会出现「先进去再闪出菜单」的观感。
|
|
42
|
+
* 收藏路径同理,服务端一起直出 —— 收藏区固定在侧边栏顶部,闪一下整块菜单都会跳。
|
|
43
|
+
*
|
|
44
|
+
* 徽标(未读数 / 待办数)也走同一条路:服务端算好随首屏直出,
|
|
45
|
+
* 之后由前端轮询增量刷新。挂在菜单上而不是页面里,
|
|
46
|
+
* 于是"哪个模块亮红点"是一条数据配置,新模块不用改这个文件。
|
|
47
|
+
*
|
|
48
|
+
* 图标用「字符串 → 组件」的显式映射表(menu-icons.tsx),
|
|
49
|
+
* 侧边栏、收藏区与全局搜索共用一份。
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** 菜单 path → 待处理/未读条数 */
|
|
53
|
+
type BadgeMap = Record<string, number>;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 侧边栏深/浅底上的文字色阶 —— 全部取 CSS 变量。
|
|
57
|
+
* 三套皮肤的侧栏材质完全不同(深军绿面料 / 纸白 / 舱壁网格),
|
|
58
|
+
* 所以这里不能写死任何 rgba,否则换皮肤会出现"浅底白字"。
|
|
59
|
+
*/
|
|
60
|
+
const SIDER = {
|
|
61
|
+
text: 'var(--wb-sider-text)',
|
|
62
|
+
textStrong: 'var(--wb-sider-text-strong)',
|
|
63
|
+
textMuted: 'var(--wb-sider-text-muted)',
|
|
64
|
+
hoverBg: 'var(--wb-sider-hover)',
|
|
65
|
+
activeBg: 'var(--wb-sider-active)',
|
|
66
|
+
line: 'var(--wb-sider-line)',
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 一个菜单节点要显示的徽标数。
|
|
71
|
+
*
|
|
72
|
+
* 目录取「自身 + 所有可见子项」之和:分组收起、或整栏折叠时,
|
|
73
|
+
* 子项的徽标根本看不见,父级不兜一个总数就等于这条提示消失了。
|
|
74
|
+
* 隐藏项不计入 —— 用户点不进去,算了只会让他对不上账。
|
|
75
|
+
*/
|
|
76
|
+
function badgeCountOf(node: MenuTreeNode, badges: BadgeMap): number {
|
|
77
|
+
if (node.type === 'button' || node.hidden === 1) return 0;
|
|
78
|
+
const own = node.path ? badges[node.path] ?? 0 : 0;
|
|
79
|
+
if (node.type !== 'dir') return own;
|
|
80
|
+
return sumBadges([own, ...node.children.map((c) => badgeCountOf(c, badges))]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 菜单文字 + 行尾徽标;文字自己吃省略号,徽标永远完整显示。
|
|
85
|
+
*
|
|
86
|
+
* `flex: 1 1 auto` 不是随手加的(2026-10-23 修):分组标题(dir)里
|
|
87
|
+
* antd 把展开箭头绝对定位在右侧,并给标题留了 `padding-inline-end: 34px`
|
|
88
|
+
* 当它的地盘。若这一行只写 `width: 100%`,百分比是按**内容盒**算的、
|
|
89
|
+
* 且作为 flex 子项默认不收缩,于是整行会顶进那 34px 内边距,
|
|
90
|
+
* 行尾徽标正好压在箭头上(实测右边缘 203 vs 箭头 187~197,重叠 16px)。
|
|
91
|
+
* 允许收缩后整行缩回内容盒内,徽标与箭头之间就能留出 8px 间距。
|
|
92
|
+
*/
|
|
93
|
+
function menuLabel(name: string, count: number): ReactNode {
|
|
94
|
+
return (
|
|
95
|
+
<span
|
|
96
|
+
style={{
|
|
97
|
+
display: 'flex',
|
|
98
|
+
alignItems: 'center',
|
|
99
|
+
gap: 8,
|
|
100
|
+
width: '100%',
|
|
101
|
+
flex: '1 1 auto',
|
|
102
|
+
minWidth: 0,
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<span
|
|
106
|
+
style={{ flex: '1 1 auto', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
|
|
107
|
+
>
|
|
108
|
+
{name}
|
|
109
|
+
</span>
|
|
110
|
+
<CountBadge value={count} label={name} />
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 图标(+ 折叠态叠加的徽标)。
|
|
117
|
+
*
|
|
118
|
+
* 折叠后 antd 只渲染 icon,label 里的徽标随之消失。这时把数字压到图标右上角,
|
|
119
|
+
* 否则"折叠一下就再也看不到有没有待办"——很多人习惯一直折叠着用侧边栏。
|
|
120
|
+
*/
|
|
121
|
+
function menuIcon(icon: string | null, count: number, name: string, collapsed: boolean): ReactNode {
|
|
122
|
+
const node = iconOf(icon);
|
|
123
|
+
if (!collapsed || count <= 0) return node;
|
|
124
|
+
return (
|
|
125
|
+
<span style={{ position: 'relative', display: 'inline-flex', alignItems: 'center' }}>
|
|
126
|
+
{node}
|
|
127
|
+
<CountBadge value={count} floating label={name} />
|
|
128
|
+
</span>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 菜单树 → antd Menu items;按钮与隐藏项在这里被过滤掉 */
|
|
133
|
+
function toMenuItems(
|
|
134
|
+
nodes: MenuTreeNode[],
|
|
135
|
+
badges: BadgeMap,
|
|
136
|
+
collapsed: boolean
|
|
137
|
+
): NonNullable<MenuProps['items']> {
|
|
138
|
+
const out: NonNullable<MenuProps['items']> = [];
|
|
139
|
+
for (const n of nodes) {
|
|
140
|
+
if (n.type === 'button' || n.hidden === 1) continue;
|
|
141
|
+
const children = toMenuItems(n.children, badges, collapsed);
|
|
142
|
+
const key = n.path || n.id;
|
|
143
|
+
const count = badgeCountOf(n, badges);
|
|
144
|
+
if (n.type === 'dir') {
|
|
145
|
+
if (!children.length) continue; // 目录下的菜单全被过滤掉时,目录自身也不该显示
|
|
146
|
+
out.push({
|
|
147
|
+
key,
|
|
148
|
+
icon: menuIcon(n.icon, count, n.name, collapsed),
|
|
149
|
+
label: menuLabel(n.name, count),
|
|
150
|
+
children,
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
out.push({
|
|
154
|
+
key,
|
|
155
|
+
icon: menuIcon(n.icon, count, n.name, collapsed),
|
|
156
|
+
label: menuLabel(n.name, count),
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 在菜单树里找出 pathname 对应的链路,用于面包屑与默认展开 */
|
|
164
|
+
function findTrail(nodes: MenuTreeNode[], pathname: string, trail: MenuTreeNode[] = []): MenuTreeNode[] {
|
|
165
|
+
for (const n of nodes) {
|
|
166
|
+
const next = [...trail, n];
|
|
167
|
+
if (n.path && (pathname === n.path || pathname.startsWith(`${n.path}/`))) return next;
|
|
168
|
+
const deep = findTrail(n.children, pathname, next);
|
|
169
|
+
if (deep.length && deep[deep.length - 1].path) return deep;
|
|
170
|
+
}
|
|
171
|
+
return [];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 调收藏接口,统一处理 { code, data, message } 约定。
|
|
176
|
+
* 失败抛错由调用方回滚乐观更新。
|
|
177
|
+
*/
|
|
178
|
+
async function callFavorites(method: 'POST' | 'DELETE', path: string): Promise<string[]> {
|
|
179
|
+
const url =
|
|
180
|
+
method === 'DELETE' ? `/api/favorites?path=${encodeURIComponent(path)}` : '/api/favorites';
|
|
181
|
+
const res = await fetch(url, {
|
|
182
|
+
method,
|
|
183
|
+
headers: method === 'POST' ? { 'Content-Type': 'application/json' } : undefined,
|
|
184
|
+
body: method === 'POST' ? JSON.stringify({ path }) : undefined,
|
|
185
|
+
});
|
|
186
|
+
const json = await res.json();
|
|
187
|
+
if (json?.code !== 0) throw new Error(json?.message || '收藏操作失败');
|
|
188
|
+
return (json.data ?? []) as string[];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export default function AdminShell({
|
|
192
|
+
user,
|
|
193
|
+
menus,
|
|
194
|
+
favorites,
|
|
195
|
+
badges: initialBadges,
|
|
196
|
+
inboxCount,
|
|
197
|
+
children,
|
|
198
|
+
}: {
|
|
199
|
+
user: SessionUser;
|
|
200
|
+
menus: MenuTreeNode[];
|
|
201
|
+
/** 当前用户收藏的路由(服务端直出,只需 path,文案与图标在下面反查) */
|
|
202
|
+
favorites: string[];
|
|
203
|
+
/** 首屏徽标(服务端直出):`{ '/feedback': 3 }` */
|
|
204
|
+
badges: BadgeMap;
|
|
205
|
+
/** 顶栏铃铛首屏数字:待办 + 未读消息 */
|
|
206
|
+
inboxCount: number;
|
|
207
|
+
children: ReactNode;
|
|
208
|
+
}) {
|
|
209
|
+
const router = useRouter();
|
|
210
|
+
const pathname = usePathname();
|
|
211
|
+
const { modal, message } = App.useApp();
|
|
212
|
+
/** 当前皮肤:决定侧栏深/浅、菜单 theme、以及顶栏那道签名光 */
|
|
213
|
+
const { skin, openPanel } = useAppearance();
|
|
214
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
215
|
+
|
|
216
|
+
/* ------------------------------ 侧边栏徽标 ------------------------------ */
|
|
217
|
+
|
|
218
|
+
const [badges, setBadges] = useState<BadgeMap>(initialBadges);
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 拉一次最新计数;传 ackPath 时顺带把该页的"未读"清零。
|
|
222
|
+
*
|
|
223
|
+
* 刻意用原生 fetch 而不是 trackedFetch:徽标是后台静默刷新,
|
|
224
|
+
* 绝不能因为一次轮询进了全局等待浮层,那会把"后台在数数"说成"你在等"。
|
|
225
|
+
* 失败也一律吞掉 —— 徽标拉不到就不显示,不该弹任何错误。
|
|
226
|
+
*/
|
|
227
|
+
const syncBadges = useCallback(async (ackPath?: string) => {
|
|
228
|
+
try {
|
|
229
|
+
const res = await fetch(
|
|
230
|
+
'/api/badges',
|
|
231
|
+
ackPath
|
|
232
|
+
? {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'Content-Type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ path: ackPath }),
|
|
236
|
+
}
|
|
237
|
+
: { cache: 'no-store' }
|
|
238
|
+
);
|
|
239
|
+
const json = (await res.json()) as { code: number; data?: BadgeMap };
|
|
240
|
+
if (json?.code === 0 && json.data) setBadges(json.data);
|
|
241
|
+
} catch {
|
|
242
|
+
/* 静默:徽标是提示,不是业务数据 */
|
|
243
|
+
}
|
|
244
|
+
}, []);
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 站内跳转统一走这里。
|
|
248
|
+
*
|
|
249
|
+
* 不直接 router.push 的原因:客户端跳转要等下一屏的 JS chunk,
|
|
250
|
+
* 重页面(图谱、工作台)会有明显的"点了没反应"窗口。
|
|
251
|
+
* 先点亮顶部进度条,用户至少在第一时间看到"它动了";
|
|
252
|
+
* pathname 一变,RouteProgress 自己收尾,不需要在这里善后。
|
|
253
|
+
*/
|
|
254
|
+
const goto = useCallback(
|
|
255
|
+
(path: string) => {
|
|
256
|
+
startRouteProgress();
|
|
257
|
+
router.push(path);
|
|
258
|
+
},
|
|
259
|
+
[router]
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const menuItems = useMemo(
|
|
263
|
+
() => toMenuItems(menus, badges, collapsed),
|
|
264
|
+
[menus, badges, collapsed]
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// 全局搜索索引:和侧边栏同一份菜单树,权限可见范围完全一致
|
|
268
|
+
const searchEntries = useMemo(() => buildSearchEntries(menus), [menus]);
|
|
269
|
+
|
|
270
|
+
const trail = useMemo(() => findTrail(menus, pathname), [menus, pathname]);
|
|
271
|
+
const selectedKey = trail.length ? trail[trail.length - 1].path || pathname : pathname;
|
|
272
|
+
/** 当前页对应的真实菜单路由;不在菜单里(详情页等)时为 null,星标不出现 */
|
|
273
|
+
const currentPath = trail.length ? trail[trail.length - 1].path : null;
|
|
274
|
+
/** 当前页挂的计数源;null = 这页没有徽标,进页面也不用 ack */
|
|
275
|
+
const currentBadgeKey = trail.length ? trail[trail.length - 1].badgeKey : null;
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 进入某个模块 → 让它的"未读"计数归零。
|
|
279
|
+
*
|
|
280
|
+
* 三个细节:
|
|
281
|
+
* - 只认**挂在菜单上**的计数源:没配 badgeKey 的页面不产生任何请求
|
|
282
|
+
* - 用 ref 记住上一次处理过的 path:轮询、折叠侧边栏都会让 trail 重算,
|
|
283
|
+
* 没有这层去重就会一直重复 ack(清零本身幂等,但白打请求)
|
|
284
|
+
* - ack 的返回就是最新全量计数,一次往返同时完成"清零"和"刷新"
|
|
285
|
+
*/
|
|
286
|
+
const ackedPathRef = useRef<string | null>(null);
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!currentPath || ackedPathRef.current === currentPath) return;
|
|
289
|
+
ackedPathRef.current = currentPath;
|
|
290
|
+
void syncBadges(currentBadgeKey ? currentPath : undefined);
|
|
291
|
+
}, [currentPath, currentBadgeKey, syncBadges]);
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* 后台轮询:待办数字是按分钟变的,60 秒一次足够;
|
|
295
|
+
* 再补"切回这个标签页/窗口重新获得焦点"两个时机 —— 用户从别的系统处理完回来,
|
|
296
|
+
* 不该再盯着一个已经过期的红点等下一轮。
|
|
297
|
+
*/
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
const refresh = () => {
|
|
300
|
+
if (document.visibilityState === 'visible') void syncBadges();
|
|
301
|
+
};
|
|
302
|
+
const timer = window.setInterval(refresh, 60_000);
|
|
303
|
+
document.addEventListener('visibilitychange', refresh);
|
|
304
|
+
window.addEventListener('focus', refresh);
|
|
305
|
+
return () => {
|
|
306
|
+
window.clearInterval(timer);
|
|
307
|
+
document.removeEventListener('visibilitychange', refresh);
|
|
308
|
+
window.removeEventListener('focus', refresh);
|
|
309
|
+
};
|
|
310
|
+
}, [syncBadges]);
|
|
311
|
+
|
|
312
|
+
/* ------------------------------ 收藏(书签) ------------------------------ */
|
|
313
|
+
|
|
314
|
+
const [favPaths, setFavPaths] = useState<string[]>(favorites);
|
|
315
|
+
const [saving, setSaving] = useState(false);
|
|
316
|
+
const [hoverPath, setHoverPath] = useState<string | null>(null);
|
|
317
|
+
|
|
318
|
+
// path → 菜单条目。用同一份 searchEntries 反查,于是菜单改名、
|
|
319
|
+
// 权限收回、功能开关关闭这三种情况都不用额外写代码就自动跟随。
|
|
320
|
+
const favoriteIndex = useMemo(() => buildFavoriteIndex(searchEntries), [searchEntries]);
|
|
321
|
+
const favItems = useMemo(() => resolveFavorites(favPaths, favoriteIndex), [favPaths, favoriteIndex]);
|
|
322
|
+
const starred = currentPath ? favPaths.includes(currentPath) : false;
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 收藏 / 取消收藏。
|
|
326
|
+
* 先改本地再发请求(点了立刻有反馈),失败回滚并用后端给的文案提示 ——
|
|
327
|
+
* 超上限这类错误的原因只有后端说得准,前端不要自己编。
|
|
328
|
+
*/
|
|
329
|
+
const toggleFavorite = async (path: string) => {
|
|
330
|
+
if (saving) return;
|
|
331
|
+
const has = favPaths.includes(path);
|
|
332
|
+
if (!has && favItems.length >= MAX_FAVORITES) {
|
|
333
|
+
message.warning(`最多收藏 ${MAX_FAVORITES} 个,先移除一个再添加`);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const prev = favPaths;
|
|
338
|
+
setFavPaths(has ? prev.filter((p) => p !== path) : [...prev, path]);
|
|
339
|
+
setSaving(true);
|
|
340
|
+
try {
|
|
341
|
+
setFavPaths(await callFavorites(has ? 'DELETE' : 'POST', path));
|
|
342
|
+
} catch (e) {
|
|
343
|
+
setFavPaths(prev);
|
|
344
|
+
message.error((e as Error).message);
|
|
345
|
+
} finally {
|
|
346
|
+
setSaving(false);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
// 路由变化时自动展开当前所在的分组
|
|
353
|
+
setOpenKeys(trail.map((n) => n.path || n.id));
|
|
354
|
+
}, [trail]);
|
|
355
|
+
|
|
356
|
+
const breadcrumbItems = useMemo(() => {
|
|
357
|
+
const items: { title: ReactNode }[] = [
|
|
358
|
+
{ title: <Link href="/dashboard"><HomeOutlined /></Link> },
|
|
359
|
+
];
|
|
360
|
+
for (const n of trail) {
|
|
361
|
+
items.push({ title: n.name });
|
|
362
|
+
}
|
|
363
|
+
if (trail.length === 0) items.push({ title: '工作台' });
|
|
364
|
+
return items;
|
|
365
|
+
}, [trail]);
|
|
366
|
+
|
|
367
|
+
const userMenuItems: MenuProps['items'] = [
|
|
368
|
+
{ key: 'profile', icon: <UserOutlined />, label: '个人信息' },
|
|
369
|
+
{ key: 'appearance', icon: <BgColorsOutlined />, label: '外观设置' },
|
|
370
|
+
{ type: 'divider' },
|
|
371
|
+
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录' },
|
|
372
|
+
];
|
|
373
|
+
|
|
374
|
+
const onUserMenuClick: MenuProps['onClick'] = ({ key }) => {
|
|
375
|
+
if (key === 'profile') {
|
|
376
|
+
modal.info({
|
|
377
|
+
title: '个人信息',
|
|
378
|
+
content: (
|
|
379
|
+
<div style={{ lineHeight: 2 }}>
|
|
380
|
+
<div>用户名:{user.username}</div>
|
|
381
|
+
<div>姓名:{user.name}</div>
|
|
382
|
+
<div>角色:{user.roleCodes?.length ? user.roleCodes.join('、') : '—'}</div>
|
|
383
|
+
<div>账号类型:{user.isSuper ? '超级管理员' : '普通用户'}</div>
|
|
384
|
+
</div>
|
|
385
|
+
),
|
|
386
|
+
});
|
|
387
|
+
} else if (key === 'appearance') {
|
|
388
|
+
openPanel();
|
|
389
|
+
} else if (key === 'logout') {
|
|
390
|
+
modal.confirm({
|
|
391
|
+
title: '确认退出登录?',
|
|
392
|
+
okText: '退出',
|
|
393
|
+
cancelText: '取消',
|
|
394
|
+
onOk: async () => {
|
|
395
|
+
// 登出前清掉全局等待任务:否则上一个会话没结束的请求
|
|
396
|
+
// 会让浮层在新页面上继续报"已等待",看起来像卡住了
|
|
397
|
+
clearTasks();
|
|
398
|
+
await signOut({ redirectTo: '/login' });
|
|
399
|
+
},
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
return (
|
|
405
|
+
<Layout style={{ minHeight: '100vh' }}>
|
|
406
|
+
<Layout.Sider
|
|
407
|
+
// 侧栏深浅由皮肤决定:原版/炫酷是深色,简洁是纸白
|
|
408
|
+
theme={skin.sider.dark ? 'dark' : 'light'}
|
|
409
|
+
width={222}
|
|
410
|
+
collapsible
|
|
411
|
+
collapsed={collapsed}
|
|
412
|
+
trigger={null}
|
|
413
|
+
style={{
|
|
414
|
+
position: 'sticky',
|
|
415
|
+
top: 0,
|
|
416
|
+
height: '100vh',
|
|
417
|
+
overflow: 'auto',
|
|
418
|
+
// 材质(斜纹面料 / 纸面 / 舱壁网格)由皮肤给,页面不写死
|
|
419
|
+
backgroundImage: 'var(--wb-sider-bg)',
|
|
420
|
+
borderRight: '1px solid var(--wb-sider-border)',
|
|
421
|
+
}}
|
|
422
|
+
>
|
|
423
|
+
{/*
|
|
424
|
+
品牌行:min-height 而不是固定高度。
|
|
425
|
+
系统名超过 9 个字时标题会折成 2 行(见 globals.css 的 .wb-brand-title),
|
|
426
|
+
固定 56px 会把 3 行内容压成一团;这里让它按内容长高,
|
|
427
|
+
竖向内边距固定 9px,于是 1 行标题时正好 56px、与原来的观感一致。
|
|
428
|
+
*/}
|
|
429
|
+
<div
|
|
430
|
+
style={{
|
|
431
|
+
minHeight: 56,
|
|
432
|
+
display: 'flex',
|
|
433
|
+
alignItems: 'center',
|
|
434
|
+
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
435
|
+
gap: 10,
|
|
436
|
+
padding: collapsed ? 0 : '9px 16px',
|
|
437
|
+
color: SIDER.textStrong,
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{/*
|
|
441
|
+
品牌方块:展开态 26px(只显示全名首字),收起态 30px(显示短名)。
|
|
442
|
+
收起态放大 4px 的原因:80px 的收起侧栏里它是唯一的品牌锚点,
|
|
443
|
+
而两个 13px 的中文要 26px,26px 的方块只剩 24px 内容区,装不下。
|
|
444
|
+
尺寸也吃 fs():用户把字号调到特大时方块一起放大,字不会被裁。
|
|
445
|
+
*/}
|
|
446
|
+
<span
|
|
447
|
+
title={collapsed ? APP_NAME : undefined}
|
|
448
|
+
aria-label={collapsed ? APP_NAME : undefined}
|
|
449
|
+
style={{
|
|
450
|
+
width: fs(collapsed ? 30 : 26),
|
|
451
|
+
height: fs(collapsed ? 30 : 26),
|
|
452
|
+
flex: `0 0 ${fs(collapsed ? 30 : 26)}`,
|
|
453
|
+
borderRadius: 7,
|
|
454
|
+
display: 'grid',
|
|
455
|
+
placeItems: 'center',
|
|
456
|
+
// 品牌小标记:底色=侧栏选中态,描边=点缀色
|
|
457
|
+
background: SIDER.activeBg,
|
|
458
|
+
border: '1px solid var(--wb-sider-mark)',
|
|
459
|
+
color: SIDER.textStrong,
|
|
460
|
+
fontSize: fs(13),
|
|
461
|
+
fontWeight: 700,
|
|
462
|
+
letterSpacing: 0,
|
|
463
|
+
// 兜底:短名再离谱也不许漏出方块(旧代码 overflow 默认 visible,'YJXT' 实测漏 6.3px)
|
|
464
|
+
whiteSpace: 'nowrap',
|
|
465
|
+
overflow: 'hidden',
|
|
466
|
+
}}
|
|
467
|
+
>
|
|
468
|
+
{/* 收起时侧栏只剩这个方块:显示 APP_SHORT(不是全名首字 ——
|
|
469
|
+
首字常常是「业」「车」这种没有信息的字),能放几个字由 brandMark 按宽度算 */}
|
|
470
|
+
{brandMark(collapsed ? APP_SHORT || APP_NAME : APP_NAME, collapsed) || APP_NAME.slice(0, 1)}
|
|
471
|
+
</span>
|
|
472
|
+
{!collapsed && (
|
|
473
|
+
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0, flex: '1 1 auto' }}>
|
|
474
|
+
{/*
|
|
475
|
+
系统名的宽度约束交给 .wb-brand-title(最多 2 行 + 省略号)。
|
|
476
|
+
flex 必须允许收缩:否则中文长名会把标识块挤扁 —— 但收缩的下限是
|
|
477
|
+
minWidth:0 的那个容器,字号本身不动,视线重心才稳。
|
|
478
|
+
短名(≤9 字)仍是单行,和以前完全一样。
|
|
479
|
+
*/}
|
|
480
|
+
<span className="wb-brand-title" title={APP_NAME} style={{ fontSize: fs(15) }}>
|
|
481
|
+
{APP_NAME}
|
|
482
|
+
</span>
|
|
483
|
+
{/* 副标题是 appInfo 里的可配置项,不渲染就等于"改了没反应" */}
|
|
484
|
+
{APP_SUBTITLE ? (
|
|
485
|
+
<span className="wb-brand-sub" title={APP_SUBTITLE} style={{ fontSize: fs(10.5), color: SIDER.textMuted }}>
|
|
486
|
+
{APP_SUBTITLE}
|
|
487
|
+
</span>
|
|
488
|
+
) : null}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
|
|
493
|
+
{/* ---------------------------- 我的常用 ---------------------------- */}
|
|
494
|
+
{/*
|
|
495
|
+
刻意不塞进下面的 antd Menu:Menu 的 key 用的是 path,
|
|
496
|
+
同一条路由出现两次会 key 冲突、双高亮,展开/选中状态也会被带乱。
|
|
497
|
+
独立区块还带来一个好处 —— 视觉上明显"轻"于正式菜单(字小半号、
|
|
498
|
+
行高更矮),一眼能看出它是快捷方式而不是新的分类。
|
|
499
|
+
*/}
|
|
500
|
+
<div style={{ borderTop: `1px solid ${SIDER.line}`, paddingTop: collapsed ? 6 : 8 }}>
|
|
501
|
+
{!collapsed && (
|
|
502
|
+
<div
|
|
503
|
+
style={{
|
|
504
|
+
display: 'flex',
|
|
505
|
+
alignItems: 'center',
|
|
506
|
+
justifyContent: 'space-between',
|
|
507
|
+
padding: '0 18px 4px',
|
|
508
|
+
fontSize: fs(11),
|
|
509
|
+
letterSpacing: 0.8,
|
|
510
|
+
color: SIDER.textMuted,
|
|
511
|
+
}}
|
|
512
|
+
>
|
|
513
|
+
<span>我的常用</span>
|
|
514
|
+
<span style={{ fontVariantNumeric: 'tabular-nums' }}>
|
|
515
|
+
{favItems.length}/{MAX_FAVORITES}
|
|
516
|
+
</span>
|
|
517
|
+
</div>
|
|
518
|
+
)}
|
|
519
|
+
|
|
520
|
+
{favItems.length === 0
|
|
521
|
+
? !collapsed && (
|
|
522
|
+
<div
|
|
523
|
+
style={{
|
|
524
|
+
padding: '2px 18px 10px',
|
|
525
|
+
fontSize: fs(12),
|
|
526
|
+
lineHeight: 1.7,
|
|
527
|
+
color: SIDER.textMuted,
|
|
528
|
+
}}
|
|
529
|
+
>
|
|
530
|
+
点页面右上角的 ☆ 收藏常用功能
|
|
531
|
+
</div>
|
|
532
|
+
)
|
|
533
|
+
: favItems.map((item) => {
|
|
534
|
+
const active = item.path === currentPath;
|
|
535
|
+
const row = (
|
|
536
|
+
<div
|
|
537
|
+
key={item.path}
|
|
538
|
+
role="button"
|
|
539
|
+
tabIndex={0}
|
|
540
|
+
onClick={() => goto(item.path)}
|
|
541
|
+
onKeyDown={(e) => {
|
|
542
|
+
if (e.key === 'Enter' || e.key === ' ') goto(item.path);
|
|
543
|
+
}}
|
|
544
|
+
onMouseEnter={() => setHoverPath(item.path)}
|
|
545
|
+
onMouseLeave={() => setHoverPath((p) => (p === item.path ? null : p))}
|
|
546
|
+
style={{
|
|
547
|
+
display: 'flex',
|
|
548
|
+
alignItems: 'center',
|
|
549
|
+
gap: 8,
|
|
550
|
+
height: 34,
|
|
551
|
+
margin: '0 8px 2px',
|
|
552
|
+
padding: collapsed ? 0 : '0 10px',
|
|
553
|
+
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
554
|
+
borderRadius: 8,
|
|
555
|
+
cursor: 'pointer',
|
|
556
|
+
fontSize: fs(13),
|
|
557
|
+
color: active ? SIDER.textStrong : SIDER.text,
|
|
558
|
+
background: active ? SIDER.activeBg : hoverPath === item.path ? SIDER.hoverBg : 'transparent',
|
|
559
|
+
transition: 'background .15s, color .15s',
|
|
560
|
+
}}
|
|
561
|
+
>
|
|
562
|
+
<span
|
|
563
|
+
style={{
|
|
564
|
+
fontSize: fs(13),
|
|
565
|
+
flex: '0 0 auto',
|
|
566
|
+
opacity: 0.9,
|
|
567
|
+
position: 'relative',
|
|
568
|
+
display: 'inline-flex',
|
|
569
|
+
alignItems: 'center',
|
|
570
|
+
}}
|
|
571
|
+
>
|
|
572
|
+
{iconOf(item.icon)}
|
|
573
|
+
{/* 折叠态只剩图标,徽标压到图标上;展开态的徽标在文字右侧 */}
|
|
574
|
+
{collapsed && (
|
|
575
|
+
<CountBadge value={badges[item.path] ?? 0} floating label={item.name} />
|
|
576
|
+
)}
|
|
577
|
+
</span>
|
|
578
|
+
{!collapsed && (
|
|
579
|
+
<span
|
|
580
|
+
style={{
|
|
581
|
+
flex: 1,
|
|
582
|
+
overflow: 'hidden',
|
|
583
|
+
textOverflow: 'ellipsis',
|
|
584
|
+
whiteSpace: 'nowrap',
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
{item.name}
|
|
588
|
+
</span>
|
|
589
|
+
)}
|
|
590
|
+
{/* 收藏区也是模块入口,有待办就得同样亮红点,否则从快捷方式进去看不到提示 */}
|
|
591
|
+
{!collapsed && <CountBadge value={badges[item.path] ?? 0} label={item.name} />}
|
|
592
|
+
{!collapsed && (
|
|
593
|
+
<CloseOutlined
|
|
594
|
+
aria-label={`取消收藏 ${item.name}`}
|
|
595
|
+
onClick={(e) => {
|
|
596
|
+
e.stopPropagation();
|
|
597
|
+
void toggleFavorite(item.path);
|
|
598
|
+
}}
|
|
599
|
+
style={{
|
|
600
|
+
fontSize: fs(10),
|
|
601
|
+
flex: '0 0 auto',
|
|
602
|
+
opacity: hoverPath === item.path ? 0.55 : 0,
|
|
603
|
+
transition: 'opacity .15s',
|
|
604
|
+
}}
|
|
605
|
+
/>
|
|
606
|
+
)}
|
|
607
|
+
</div>
|
|
608
|
+
);
|
|
609
|
+
// 折叠态只剩图标,靠 title 补语义,否则不知道点的是哪个
|
|
610
|
+
return collapsed ? (
|
|
611
|
+
<Tooltip key={item.path} title={item.name} placement="right">
|
|
612
|
+
{row}
|
|
613
|
+
</Tooltip>
|
|
614
|
+
) : (
|
|
615
|
+
row
|
|
616
|
+
);
|
|
617
|
+
})}
|
|
618
|
+
</div>
|
|
619
|
+
|
|
620
|
+
<div style={{ height: 1, margin: '6px 8px 4px', background: SIDER.line }} />
|
|
621
|
+
|
|
622
|
+
<Menu
|
|
623
|
+
theme={skin.sider.dark ? 'dark' : 'light'}
|
|
624
|
+
mode="inline"
|
|
625
|
+
items={menuItems}
|
|
626
|
+
selectedKeys={[selectedKey]}
|
|
627
|
+
// 折叠状态下不能受控 openKeys,否则 antd 会告警且弹出层错位
|
|
628
|
+
{...(collapsed ? {} : { openKeys, onOpenChange: setOpenKeys })}
|
|
629
|
+
onClick={({ key }) => {
|
|
630
|
+
const k = String(key);
|
|
631
|
+
if (k.startsWith('/')) goto(k);
|
|
632
|
+
}}
|
|
633
|
+
/>
|
|
634
|
+
</Layout.Sider>
|
|
635
|
+
|
|
636
|
+
<Layout>
|
|
637
|
+
<Layout.Header
|
|
638
|
+
// .wb-header 是皮肤签名元素的挂点:炫酷底下一道冷光,简洁版去掉投影
|
|
639
|
+
className="wb-header"
|
|
640
|
+
style={{
|
|
641
|
+
background: MILITARY.card,
|
|
642
|
+
padding: '0 16px',
|
|
643
|
+
display: 'flex',
|
|
644
|
+
alignItems: 'center',
|
|
645
|
+
justifyContent: 'space-between',
|
|
646
|
+
borderBottom: `1px solid ${MILITARY.border}`,
|
|
647
|
+
position: 'sticky',
|
|
648
|
+
top: 0,
|
|
649
|
+
zIndex: 10,
|
|
650
|
+
}}
|
|
651
|
+
>
|
|
652
|
+
<Space size={12} style={{ whiteSpace: 'nowrap' }}>
|
|
653
|
+
<Button
|
|
654
|
+
type="text"
|
|
655
|
+
aria-label={collapsed ? '展开菜单' : '收起菜单'}
|
|
656
|
+
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
657
|
+
onClick={() => setCollapsed((v) => !v)}
|
|
658
|
+
/>
|
|
659
|
+
<Breadcrumb items={breadcrumbItems} />
|
|
660
|
+
{/*
|
|
661
|
+
星标放在面包屑右侧而不是每个页面各写一遍:
|
|
662
|
+
外壳已经用 findTrail 知道"当前在哪一页",一处实现就覆盖了所有页面。
|
|
663
|
+
新加的业务页面只要登记了菜单,自动就有收藏入口,不用改页面代码。
|
|
664
|
+
*/}
|
|
665
|
+
{currentPath && (
|
|
666
|
+
<Tooltip title={starred ? '已收藏,点击取消' : '收藏到「我的常用」'}>
|
|
667
|
+
<Button
|
|
668
|
+
type="text"
|
|
669
|
+
size="small"
|
|
670
|
+
aria-label={starred ? '取消收藏当前页' : '收藏当前页'}
|
|
671
|
+
icon={
|
|
672
|
+
starred ? (
|
|
673
|
+
<StarFilled style={{ color: MILITARY.warning, fontSize: fs(16) }} />
|
|
674
|
+
) : (
|
|
675
|
+
<StarOutlined style={{ color: MILITARY.textMuted, fontSize: fs(16) }} />
|
|
676
|
+
)
|
|
677
|
+
}
|
|
678
|
+
onClick={() => void toggleFavorite(currentPath)}
|
|
679
|
+
/>
|
|
680
|
+
</Tooltip>
|
|
681
|
+
)}
|
|
682
|
+
</Space>
|
|
683
|
+
|
|
684
|
+
<Space size={12}>
|
|
685
|
+
<GlobalSearch entries={searchEntries} />
|
|
686
|
+
<InboxBell initialCount={inboxCount} />
|
|
687
|
+
{/*
|
|
688
|
+
外观入口(皮肤 / 字号):
|
|
689
|
+
放在顶栏而不是塞进某个设置页 —— 它是"随时想换"的偏好,
|
|
690
|
+
不该让用户先记住藏在哪个二级菜单里。
|
|
691
|
+
权限上不分角色:换配色不影响任何数据可见性。
|
|
692
|
+
*/}
|
|
693
|
+
<Tooltip title="外观设置(皮肤 / 字号)">
|
|
694
|
+
<Button
|
|
695
|
+
type="text"
|
|
696
|
+
aria-label="外观设置"
|
|
697
|
+
data-testid="appearance-entry"
|
|
698
|
+
icon={<BgColorsOutlined style={{ fontSize: fs(16) }} />}
|
|
699
|
+
onClick={openPanel}
|
|
700
|
+
/>
|
|
701
|
+
</Tooltip>
|
|
702
|
+
{/*
|
|
703
|
+
帮助入口固定在顶栏,而不是只依赖「帮助文档」菜单:
|
|
704
|
+
文档属于全站基础设施,不应因为角色没勾选菜单就看不到入口。
|
|
705
|
+
新标签页打开,阅读文档时不会把当前业务页面顶掉。
|
|
706
|
+
*/}
|
|
707
|
+
<Tooltip title="帮助文档(新标签页打开)">
|
|
708
|
+
<Button
|
|
709
|
+
type="text"
|
|
710
|
+
aria-label="打开帮助文档"
|
|
711
|
+
data-testid="help-docs-entry"
|
|
712
|
+
icon={<QuestionCircleOutlined style={{ fontSize: fs(16) }} />}
|
|
713
|
+
href="/docs"
|
|
714
|
+
target="_blank"
|
|
715
|
+
rel="noreferrer"
|
|
716
|
+
/>
|
|
717
|
+
</Tooltip>
|
|
718
|
+
{/*
|
|
719
|
+
在线 / 今日 / 总访问:放在头像左侧,属于"全站态势"而不是业务数据,
|
|
720
|
+
所以不做权限过滤;组件内部自带轮询与失败静默。
|
|
721
|
+
*/}
|
|
722
|
+
<VisitStats />
|
|
723
|
+
<Dropdown menu={{ items: userMenuItems, onClick: onUserMenuClick }} trigger={['click']}>
|
|
724
|
+
<Space style={{ cursor: 'pointer' }}>
|
|
725
|
+
<Avatar icon={<UserOutlined />} size="small" />
|
|
726
|
+
<span className="header-user-name">{user.name}</span>
|
|
727
|
+
</Space>
|
|
728
|
+
</Dropdown>
|
|
729
|
+
</Space>
|
|
730
|
+
</Layout.Header>
|
|
731
|
+
|
|
732
|
+
<Layout.Content style={{ padding: 16 }}>
|
|
733
|
+
<div
|
|
734
|
+
className="wb-content"
|
|
735
|
+
style={{
|
|
736
|
+
background: MILITARY.paper,
|
|
737
|
+
borderRadius: 12,
|
|
738
|
+
padding: 16,
|
|
739
|
+
minHeight: '100%',
|
|
740
|
+
border: `1px solid ${MILITARY.borderSoft}`,
|
|
741
|
+
}}
|
|
742
|
+
>
|
|
743
|
+
{children}
|
|
744
|
+
</div>
|
|
745
|
+
</Layout.Content>
|
|
746
|
+
</Layout>
|
|
747
|
+
</Layout>
|
|
748
|
+
);
|
|
749
|
+
}
|