create-ssnode-cli 0.2.6 → 0.2.7
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/package.json +1 -1
- package/templates/backend/scripts/server.config.example.cjs +9 -0
- package/templates/web/package.json +4 -1
- package/templates/web/scripts/compress-src-images.cjs +332 -0
- package/templates/web/scripts/publish.cjs +280 -241
- package/templates/web/src/layouts/Background.module.less +0 -82
- package/templates/web/src/layouts/Background.tsx +26 -113
- package/templates/web/src/layouts/components/PageTabs/SortableTab.tsx +133 -0
- package/templates/web/src/layouts/components/PageTabs/index.module.less +105 -0
- package/templates/web/src/layouts/components/PageTabs/index.tsx +110 -0
- package/templates/web/src/layouts/components/PageTabs/usePageTabs.ts +224 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Author Murphy.xie
|
|
3
|
+
* Date 2026-10-29 18:24:37
|
|
4
|
+
* LastEditors Murphy.xie
|
|
5
|
+
* LastEditTime 2026-11-04 09:55:15
|
|
6
|
+
* Description
|
|
7
|
+
*/
|
|
8
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
9
|
+
import { useLocation, useNavigate } from 'react-router-dom';
|
|
10
|
+
import type { RefObject } from 'react';
|
|
11
|
+
import type { KeepAliveRef } from 'keepalive-for-react';
|
|
12
|
+
import RouterConfig, { getRouteByPathName } from '../../../../config/router.config';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 多页签数据模型(《后台多页签KeepAlive方案》§3.4)
|
|
16
|
+
*/
|
|
17
|
+
export interface PageTabItem {
|
|
18
|
+
/** 页签唯一键 = pathname,与 KeepAlive 缓存键同源 */
|
|
19
|
+
key: string;
|
|
20
|
+
/** 页签标题(路由配置 name) */
|
|
21
|
+
title: string;
|
|
22
|
+
/** 切回该页签时要还原的完整地址(search 系页面状态真源,必须带回) */
|
|
23
|
+
location: { pathname: string; search: string };
|
|
24
|
+
/**
|
|
25
|
+
* 最近一次激活时间戳。2026-10-29 排序权下放用户(拖拽调序 + 新签不再置顶)后,
|
|
26
|
+
* 视觉顺序不再表达访问新旧,超限淘汰改为按本字段取 LRU 最旧,与视觉顺序解耦
|
|
27
|
+
*/
|
|
28
|
+
lastActiveAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 页签/缓存上限:超出按 lastActiveAt 淘汰 LRU 最旧(KeepAlive max 同值) */
|
|
32
|
+
export const MAX_TABS = 20;
|
|
33
|
+
/**
|
|
34
|
+
* 不建页签的路径:与 Background 布局 KeepAlive exclude 同源。`/background` 是布局基座路径,
|
|
35
|
+
* 经其进入会先以 pathname='/background' 渲染一帧 <Navigate to='/background/page-data'>
|
|
36
|
+
* (菜单树首项 { path:'/', redirect:'/page-data' } 的 fullPath 落点即 /background)。该帧非真实页面,
|
|
37
|
+
* 不应建页签(否则多出一个「后台管理系统」幽灵签),与旧 TabBar 按菜单树过滤(redirect 子项无 name)
|
|
38
|
+
* 不建签的口径等价。改 KeepAlive exclude 时此处同步。
|
|
39
|
+
*/
|
|
40
|
+
const NO_TAB_PATHS = ['/background'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 取路由名做页签标题:路由链末节点 name,兜底「未命名」。
|
|
44
|
+
* hideInMenu 页(智能助手/消息中心)也有 name,天然覆盖。
|
|
45
|
+
* getRouteByPathName 只做字面量全等,参数路由(./:id 等)须按段通配回退匹配,
|
|
46
|
+
* 否则详情类深链(/background/assessment/3)全落「未命名」。
|
|
47
|
+
*/
|
|
48
|
+
const resolveTabTitle = (pathname: string): string => {
|
|
49
|
+
const { routes } = getRouteByPathName(pathname);
|
|
50
|
+
if (routes.length > 0) return routes[routes.length - 1]?.name || '未命名';
|
|
51
|
+
// 段数一致 + :param 段当通配符,命中末节点即取其 name
|
|
52
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
53
|
+
const matchParamRoute = (routeList: typeof RouterConfig): string | null =>
|
|
54
|
+
routeList.reduce((found, route) => {
|
|
55
|
+
if (found) return found;
|
|
56
|
+
const routeSegments = (route.fullPath || '').split('/').filter(Boolean);
|
|
57
|
+
const hit =
|
|
58
|
+
routeSegments.length === segments.length &&
|
|
59
|
+
routeSegments.every(
|
|
60
|
+
(seg, i) => (seg.startsWith(':') && segments[i]) || seg === segments[i]
|
|
61
|
+
);
|
|
62
|
+
if (hit && route.name) return route.name;
|
|
63
|
+
return route.children ? matchParamRoute(route.children) : null;
|
|
64
|
+
}, null);
|
|
65
|
+
return matchParamRoute(RouterConfig) || '未命名';
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const makeTab = (pathname: string, search: string): PageTabItem => ({
|
|
69
|
+
key: pathname,
|
|
70
|
+
title: resolveTabTitle(pathname),
|
|
71
|
+
location: { pathname, search },
|
|
72
|
+
lastActiveAt: Date.now()
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* LRU 淘汰:剔除 lastActiveAt 最小且非当前激活的页签(当前签恒为最新,不会被误伤)。
|
|
77
|
+
* 只动页签数组(UI 数据),缓存仍交 KeepAlive max 自管 —— 与原「删最右」等价,
|
|
78
|
+
* 但不再要求「最右 = 最久未访问」这一已退役的前置假设。
|
|
79
|
+
*/
|
|
80
|
+
const evictLeastRecent = (list: PageTabItem[], activeKey: string): PageTabItem[] => {
|
|
81
|
+
let victimIdx = -1;
|
|
82
|
+
let minAt = Infinity;
|
|
83
|
+
list.forEach((t, i) => {
|
|
84
|
+
if (t.key !== activeKey && t.lastActiveAt < minAt) {
|
|
85
|
+
minAt = t.lastActiveAt;
|
|
86
|
+
victimIdx = i;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
if (victimIdx === -1) return list;
|
|
90
|
+
const copy = [...list];
|
|
91
|
+
copy.splice(victimIdx, 1);
|
|
92
|
+
return copy;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 多页签状态与操作(《后台多页签KeepAlive方案》)
|
|
97
|
+
*
|
|
98
|
+
* 关键口径:
|
|
99
|
+
* 1. **activeKey 不落 state,恒由 location.pathname 派生** —— 页签关闭(destroy 缓存)与
|
|
100
|
+
* navigate(激活邻页)若分两个 commit,KeepAlive 会对已销毁的 pathname 新建幽灵缓存节点
|
|
101
|
+
* (挂一份邻页组件实例泄内存);闭包内同步 navigate + destroy 由 URL 派生键原子兜死。
|
|
102
|
+
* 2. **页签数组只是 UI 数据**,允许滞后一个 commit,不参与缓存键计算。
|
|
103
|
+
* 2026-10-29 起**顺序由用户掌控**:新签 append 最右、激活不挪位(原 activeToFront 置顶退役),
|
|
104
|
+
* 顺序只在拖拽调序(moveTab)与超限淘汰时变化;溢出视口的新签可见性由 PageTabs 滚入兜底。
|
|
105
|
+
* 3. **关闭/刷新等缓存操作全部走 aliveRef**(destroy / refresh),不自研缓存管理。
|
|
106
|
+
*
|
|
107
|
+
* @param aliveRef KeepAlive 的命令通道(useKeepAliveRef() 产物,由调用方持有)
|
|
108
|
+
* @param homePath 关闭最后一个页签 / 全部关闭时的回落主页,默认 /background/page-data
|
|
109
|
+
* (本模板后台首个菜单叶「页面管理」,即 Background 索引路由 redirect:'/page-data' 的落点)。
|
|
110
|
+
* 回落式兜底,不预置常驻首页签;首个签 = 落地页,全关完回 homePath 由上方 effect 重建其签。
|
|
111
|
+
*/
|
|
112
|
+
export default function usePageTabs(
|
|
113
|
+
aliveRef: RefObject<KeepAliveRef>,
|
|
114
|
+
homePath = '/background/page-data'
|
|
115
|
+
) {
|
|
116
|
+
const location = useLocation();
|
|
117
|
+
const navigate = useNavigate();
|
|
118
|
+
|
|
119
|
+
// 页签条初始:仅当前页(不预置常驻首页签);/background 基座帧不建签(NO_TAB_PATHS)
|
|
120
|
+
const [tabs, setTabs] = useState<PageTabItem[]>(() =>
|
|
121
|
+
NO_TAB_PATHS.includes(location.pathname) ? [] : [makeTab(location.pathname, location.search)]
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// 激活键:URL 唯一真源派生,不用独立 state(见头部口径 1)
|
|
125
|
+
const activeKey = location.pathname;
|
|
126
|
+
|
|
127
|
+
// 路由变化 → 开新签(append 最右)/ 激活已开签并同步最新 search(search 变化不另开签);
|
|
128
|
+
// 激活只刷新 lastActiveAt(超限 LRU 淘汰依据),不挪位 —— 点页签/菜单/深链三入口口径一致
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
const { pathname, search } = location;
|
|
131
|
+
if (NO_TAB_PATHS.includes(pathname)) return;
|
|
132
|
+
setTabs((prev) => {
|
|
133
|
+
const idx = prev.findIndex((t) => t.key === pathname);
|
|
134
|
+
if (idx === -1) {
|
|
135
|
+
const next = [...prev, makeTab(pathname, search)];
|
|
136
|
+
// 超上限淘汰:lastActiveAt 最旧者(新签即当前签,恒不被淘汰)
|
|
137
|
+
return next.length > MAX_TABS ? evictLeastRecent(next, pathname) : next;
|
|
138
|
+
}
|
|
139
|
+
const hit = prev[idx];
|
|
140
|
+
const copy = [...prev];
|
|
141
|
+
copy[idx] = { ...hit, location: { pathname, search }, lastActiveAt: Date.now() };
|
|
142
|
+
return copy;
|
|
143
|
+
});
|
|
144
|
+
}, [location.pathname, location.search]);
|
|
145
|
+
|
|
146
|
+
/** 点击页签:还原完整地址(pathname+search) */
|
|
147
|
+
const activeTab = useCallback(
|
|
148
|
+
(tab: PageTabItem) => {
|
|
149
|
+
if (tab.key === activeKey && tab.location.search === location.search) return;
|
|
150
|
+
navigate({ pathname: tab.location.pathname, search: tab.location.search });
|
|
151
|
+
},
|
|
152
|
+
[navigate, activeKey, location.search]
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 关闭页签:删签 + 销毁缓存;关的是当前页签则跳相邻(顺序由用户掌控后,
|
|
157
|
+
* 数组相邻位即视觉相邻,不再按「次新访问」挑选);无相邻(关最后一个)回落 homePath。
|
|
158
|
+
* 页签条允许瞬时空(回落导航触发上方 effect 重建 homePath 签),与旧 TabBar 行为一致。
|
|
159
|
+
*/
|
|
160
|
+
const closeTab = useCallback(
|
|
161
|
+
(tab: PageTabItem) => {
|
|
162
|
+
const idx = tabs.findIndex((t) => t.key === tab.key);
|
|
163
|
+
if (idx === -1) return;
|
|
164
|
+
const neighbor = tabs[idx - 1] || tabs[idx + 1];
|
|
165
|
+
setTabs((prev) => prev.filter((t) => t.key !== tab.key));
|
|
166
|
+
aliveRef.current?.destroy(tab.key);
|
|
167
|
+
if (tab.key === activeKey) {
|
|
168
|
+
navigate(
|
|
169
|
+
neighbor
|
|
170
|
+
? { pathname: neighbor.location.pathname, search: neighbor.location.search }
|
|
171
|
+
: homePath
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
[tabs, activeKey, navigate, aliveRef, homePath]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
/** 关闭其他:只留当前签,其余连缓存一起销毁 */
|
|
179
|
+
const closeOthers = useCallback(() => {
|
|
180
|
+
const victims = tabs.filter((t) => t.key !== activeKey).map((t) => t.key);
|
|
181
|
+
aliveRef.current?.destroy(victims);
|
|
182
|
+
setTabs((prev) => prev.filter((t) => t.key === activeKey));
|
|
183
|
+
}, [tabs, activeKey, aliveRef]);
|
|
184
|
+
|
|
185
|
+
/** 全部关闭:清光其他签连缓存,回 homePath 兜底主页(homePath 签/缓存保留,未开则由 effect 重建) */
|
|
186
|
+
const closeAll = useCallback(() => {
|
|
187
|
+
const victims = tabs.filter((t) => t.key !== homePath).map((t) => t.key);
|
|
188
|
+
aliveRef.current?.destroy(victims);
|
|
189
|
+
setTabs([makeTab(homePath, '')]);
|
|
190
|
+
if (activeKey !== homePath) navigate(homePath);
|
|
191
|
+
}, [tabs, activeKey, navigate, aliveRef, homePath]);
|
|
192
|
+
|
|
193
|
+
/** 刷新当前页签:renderCount+1 强制重建组件(仅当前签可刷) */
|
|
194
|
+
const refreshTab = useCallback(() => {
|
|
195
|
+
aliveRef.current?.refresh(activeKey);
|
|
196
|
+
}, [activeKey, aliveRef]);
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 拖拽调序(PageTabs onTabReorder 载荷):把 fromKey 页签挪到 overKey 页签当前所在位。
|
|
200
|
+
* 顺序是纯 UI 数据(口径 2),不触发任何缓存操作;lastActiveAt 不动,淘汰依据不受拖拽影响
|
|
201
|
+
*/
|
|
202
|
+
const moveTab = useCallback((fromKey: string, overKey: string) => {
|
|
203
|
+
setTabs((prev) => {
|
|
204
|
+
const fromIdx = prev.findIndex((t) => t.key === fromKey);
|
|
205
|
+
const overIdx = prev.findIndex((t) => t.key === overKey);
|
|
206
|
+
if (fromIdx === -1 || overIdx === -1 || fromIdx === overIdx) return prev;
|
|
207
|
+
const copy = [...prev];
|
|
208
|
+
const [moved] = copy.splice(fromIdx, 1);
|
|
209
|
+
copy.splice(overIdx, 0, moved);
|
|
210
|
+
return copy;
|
|
211
|
+
});
|
|
212
|
+
}, []);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
tabs,
|
|
216
|
+
activeKey,
|
|
217
|
+
activeTab,
|
|
218
|
+
closeTab,
|
|
219
|
+
closeOthers,
|
|
220
|
+
closeAll,
|
|
221
|
+
refreshTab,
|
|
222
|
+
moveTab
|
|
223
|
+
};
|
|
224
|
+
}
|