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.
@@ -191,88 +191,6 @@
191
191
  flex-direction: row;
192
192
  align-items: center;
193
193
  height: 44px;
194
-
195
- // 页签条:胶囊页签横向排列,超出滚动
196
- .tabbar {
197
- display: flex;
198
- flex-direction: row;
199
- align-items: center;
200
- gap: 8px;
201
- width: 100%;
202
- overflow-x: auto;
203
- overflow-y: hidden;
204
-
205
- // 隐藏滚动条美化
206
- &::-webkit-scrollbar {
207
- height: 4px;
208
- }
209
-
210
- &::-webkit-scrollbar-thumb {
211
- background: #d0d7e2;
212
- border-radius: 2px;
213
- }
214
- }
215
-
216
- // 胶囊页签
217
- .tab {
218
- display: inline-flex;
219
- align-items: center;
220
- gap: 6px;
221
- padding: 4px 12px;
222
- border: 1px solid #d0d7e2;
223
- border-radius: 999px;
224
- color: #333840;
225
- font-size: 13px;
226
- line-height: 1.4;
227
- white-space: nowrap;
228
- cursor: pointer;
229
- user-select: none;
230
- background-color: transparent;
231
- transition: color 0.2s, border-color 0.2s, background-color 0.2s;
232
-
233
- &:hover {
234
- border-color: #1667ff;
235
- color: #1667ff;
236
- }
237
-
238
- // 激活态:主题蓝实底
239
- &.tabActive {
240
- background-color: #1667ff;
241
- border-color: #1667ff;
242
- color: #ffffff;
243
-
244
- .tabClose {
245
- color: rgba(255, 255, 255, 0.75);
246
-
247
- &:hover {
248
- color: #ffffff;
249
- background-color: rgba(255, 255, 255, 0.2);
250
- }
251
- }
252
- }
253
-
254
- .tabTitle {
255
- // Airtable: interactive 常规字号
256
- font-size: 13px;
257
- }
258
-
259
- .tabClose {
260
- display: inline-flex;
261
- align-items: center;
262
- justify-content: center;
263
- width: 16px;
264
- height: 16px;
265
- border-radius: 50%;
266
- font-size: 12px;
267
- line-height: 1;
268
- transition: color 0.2s, background-color 0.2s;
269
-
270
- &:hover {
271
- color: #1667ff;
272
- background-color: rgba(22, 103, 255, 0.1);
273
- }
274
- }
275
- }
276
194
  }
277
195
 
278
196
  .contentview {
@@ -1,4 +1,4 @@
1
- import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
2
  import { useLocation, useNavigate, useOutlet } from 'react-router-dom';
3
3
  import { Menu, Avatar, ConfigProvider, Dropdown } from 'antd';
4
4
  import { useKeepAliveRef, KeepAlive } from 'keepalive-for-react';
@@ -19,21 +19,11 @@ import ProjectConfig from '../../config/project.config';
19
19
  import api from '@/services/api';
20
20
  import { RouteConfigItem } from '@/types';
21
21
  import { airtableTheme } from '@/styles/theme';
22
+ import PageTabs from './components/PageTabs';
23
+ import usePageTabs from './components/PageTabs/usePageTabs';
22
24
 
23
25
  dayjs.locale(zhCn);
24
26
 
25
- /**
26
- * 页签项:一个已打开的后台页面
27
- */
28
- interface TabItem {
29
- // 缓存/去重 key,取路由 fullPath
30
- key: string;
31
- // 页签标题(取菜单节点 name)
32
- title: string;
33
- // 跳转路径
34
- path: string;
35
- }
36
-
37
27
  export default function BackgroundLayout() {
38
28
  //
39
29
  const location = useLocation();
@@ -53,15 +43,24 @@ export default function BackgroundLayout() {
53
43
  openKeys: []
54
44
  });
55
45
 
56
- // 已打开页签
57
- const [tabs, setTabs] = useState<TabItem[]>([]);
58
-
59
46
  // KeepAlive 缓存句柄:关闭页签时销毁对应缓存
60
47
  const aliveRef = useKeepAliveRef();
61
48
 
62
49
  // 当前页面内容(包进 KeepAlive 前的 Outlet)
63
50
  const outlet = useOutlet();
64
51
 
52
+ // 多页签状态与操作(tabs 数据 + 关闭/刷新/拖拽等),缓存销毁全走 aliveRef —— 见 usePageTabs
53
+ const {
54
+ tabs,
55
+ activeKey,
56
+ activeTab,
57
+ closeTab,
58
+ closeOthers,
59
+ closeAll,
60
+ refreshTab,
61
+ moveTab
62
+ } = usePageTabs(aliveRef);
63
+
65
64
  /**
66
65
  * 渲染菜单图标
67
66
  * route.icon / route.selectIcon 在配置中以字符串路径形式声明(指向 public/files 下 svg)。
@@ -106,65 +105,6 @@ export default function BackgroundLayout() {
106
105
  setMenus(bgLayoutRoute?.children || []);
107
106
  }, []);
108
107
 
109
- /**
110
- * 在菜单树中查找当前路径对应的节点链(不含布局包裹层),
111
- * 取最深层节点的 name 作为页签标题;未命中返回 null(如 404,不建页签)。
112
- */
113
- const currentTab = useMemo(() => {
114
- const findMenuPath = (
115
- items: RouteConfigItem[],
116
- targetPath: string,
117
- acc: RouteConfigItem[] = []
118
- ): RouteConfigItem[] | null =>
119
- items
120
- .filter((it) => it.name && !it.hideInMenu)
121
- .reduce<RouteConfigItem[] | null>((found, it) => {
122
- if (found) return found;
123
- const cur = [...acc, it];
124
- if (it.fullPath === targetPath) return cur;
125
- if (it.children?.length) {
126
- const r = findMenuPath(it.children, targetPath, cur);
127
- if (r) return r;
128
- }
129
- return null;
130
- }, null);
131
- const routes = findMenuPath(menus, location.pathname);
132
- if (!routes?.length) return null;
133
- const target = routes[routes.length - 1];
134
- return { key: target.fullPath, title: target.name, path: target.fullPath };
135
- }, [menus, location.pathname]);
136
-
137
- // 路由变化 → 同步页签:新页面追加页签,已有页面仅激活
138
- useEffect(() => {
139
- if (!currentTab) return;
140
- setTabs((prev) => {
141
- const exists = prev.some((tab) => tab.key === currentTab.key);
142
- return exists ? prev : [...prev, currentTab];
143
- });
144
- }, [currentTab]);
145
-
146
- /**
147
- * 关闭页签:当前激活页签被关时跳相邻页签,全部关完回落默认页;
148
- * 同时销毁 KeepAlive 缓存,释放内存。
149
- */
150
- const handleCloseTab = useCallback(
151
- (tabKey: string) => {
152
- setTabs((prev) => {
153
- const idx = prev.findIndex((tab) => tab.key === tabKey);
154
- if (idx < 0) return prev;
155
- const next = prev.filter((tab) => tab.key !== tabKey);
156
- if (tabKey === location.pathname) {
157
- // 优先左侧页签,没有则右侧,再没有回落默认页
158
- const neighbor = next[idx - 1] || next[idx] || null;
159
- navigate(neighbor ? neighbor.path : '/page-data');
160
- }
161
- return next;
162
- });
163
- aliveRef.current?.destroy(tabKey);
164
- },
165
- [location.pathname, navigate, aliveRef]
166
- );
167
-
168
108
  /**
169
109
  * 渲染菜单Items
170
110
  */
@@ -278,45 +218,18 @@ export default function BackgroundLayout() {
278
218
  </div>
279
219
  </div>
280
220
  <div className={styles.right}>
281
- {/* 页签条(胶囊状):替换原面包屑位置,已打开页面切换保留状态 */}
221
+ {/* 页签条:取代面包屑,已打开页面切换保留状态(KeepAlive 缓存) */}
282
222
  <div className={styles.route}>
283
- <div className={styles.tabbar}>
284
- {tabs.map((tab) => {
285
- const active = tab.key === location.pathname;
286
- return (
287
- <div
288
- key={tab.key}
289
- className={`${styles.tab} ${active ? styles.tabActive : ''}`}
290
- onClick={() => !active && navigate(tab.path)}
291
- role="button"
292
- tabIndex={0}
293
- onKeyDown={(e) => {
294
- if (e.key === 'Enter' || e.key === ' ') navigate(tab.path);
295
- }}
296
- >
297
- <span className={styles.tabTitle}>{tab.title}</span>
298
- <span
299
- className={styles.tabClose}
300
- title="关闭页签"
301
- onClick={(e) => {
302
- e.stopPropagation();
303
- handleCloseTab(tab.key);
304
- }}
305
- role="button"
306
- tabIndex={0}
307
- onKeyDown={(e) => {
308
- if (e.key === 'Enter' || e.key === ' ') {
309
- e.stopPropagation();
310
- handleCloseTab(tab.key);
311
- }
312
- }}
313
- >
314
- ×
315
- </span>
316
- </div>
317
- );
318
- })}
319
- </div>
223
+ <PageTabs
224
+ tabs={tabs}
225
+ activeKey={activeKey}
226
+ onTabClick={activeTab}
227
+ onTabClose={closeTab}
228
+ onCloseOthers={closeOthers}
229
+ onCloseAll={closeAll}
230
+ onRefresh={refreshTab}
231
+ onTabReorder={moveTab}
232
+ />
320
233
  </div>
321
234
  <div className={styles.contentview}>
322
235
  {/*
@@ -0,0 +1,133 @@
1
+ import React from 'react';
2
+ import { Dropdown } from 'antd';
3
+ import { CloseOutlined, ReloadOutlined, MinusCircleOutlined } from '@ant-design/icons';
4
+ import { useSortable } from '@dnd-kit/sortable';
5
+ import { CSS } from '@dnd-kit/utilities';
6
+ import type { PageTabItem } from './usePageTabs';
7
+ import styles from './index.module.less';
8
+
9
+ /**
10
+ * 可拖拽单签(PageTabs 内部子组件,非公共组件),整签可拖。
11
+ *
12
+ * 结构分两层,有且只有一个理由:激活签要被 antd Dropdown 包裹做右键批量菜单,
13
+ * 而 rc-trigger 会 cloneElement 覆写 child 的 ref —— sortable 的测量 ref(setNodeRef)
14
+ * 只能挂在外层把手层,让 Dropdown 的 child 保持 ref-free:
15
+ * - 外层把手层:setNodeRef + 让位 transform + dnd attributes/listeners
16
+ * - 内层 .tab:原样保留全部既有交互(点击/Enter 跳转、关闭按钮、hover、无障碍焦点)
17
+ *
18
+ * dnd listeners 挂外层、事件由内层冒泡触发;点击与拖拽靠 4px 位移阈值区分
19
+ * (PointerSensor 配置在 index.tsx)。attributes 自带的 role/tabIndex 摘除,
20
+ * 保持内层 .tab 是唯一 tab stop(不产生双焦点位)。
21
+ */
22
+ interface SortableTabProps {
23
+ tab: PageTabItem;
24
+ /** 挂 data-tab-key 供容器层 scrollIntoView 锚定激活签 */
25
+ dataTabKey: string;
26
+ isActive: boolean;
27
+ onTabClick: (tab: PageTabItem) => void;
28
+ onTabClose: (tab: PageTabItem) => void;
29
+ onRefresh: () => void;
30
+ onCloseOthers: () => void;
31
+ onCloseAll: () => void;
32
+ }
33
+
34
+ export default function SortableTab({
35
+ tab,
36
+ dataTabKey,
37
+ isActive,
38
+ onTabClick,
39
+ onTabClose,
40
+ onRefresh,
41
+ onCloseOthers,
42
+ onCloseAll
43
+ }: SortableTabProps) {
44
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
45
+ id: tab.key
46
+ });
47
+
48
+ // attributes 的 role/tabIndex 摘除(见头部注释),保留 aria-roledescription 等播报属性;
49
+ // delete 不产生未用变量(@typescript-eslint/no-unused-vars 无 argsIgnorePattern 兜底)
50
+ const dragAttributes = { ...attributes };
51
+ delete dragAttributes.role;
52
+ delete dragAttributes.tabIndex;
53
+ // 右键留给 Dropdown 批量菜单、中键留给浏览器:仅主键进拖拽传感器
54
+ const dragListeners: typeof listeners = {
55
+ ...listeners,
56
+ onPointerDown: (event: React.PointerEvent) => {
57
+ if (event.button !== 0) return;
58
+ listeners?.onPointerDown?.(event);
59
+ }
60
+ };
61
+
62
+ // 批量菜单(刷新/关闭其他/全部)只对激活页签有意义,非激活页签不弹空菜单
63
+ const menuItems = [
64
+ {
65
+ key: 'refresh',
66
+ label: '刷新当前页签',
67
+ icon: <ReloadOutlined />,
68
+ onClick: onRefresh
69
+ },
70
+ {
71
+ key: 'closeOthers',
72
+ label: '关闭其他页签',
73
+ icon: <MinusCircleOutlined />,
74
+ onClick: onCloseOthers
75
+ },
76
+ {
77
+ key: 'closeAll',
78
+ label: '关闭全部页签',
79
+ icon: <CloseOutlined />,
80
+ onClick: onCloseAll
81
+ }
82
+ ];
83
+
84
+ // 让位平移挂外层(transform 与 transition 同属 sortable 节点);拖起态叠加 1.05 放大
85
+ // 与描边阴影(视觉「拎起来了」),transition 在拖起中置空保证实时跟手,落位/复位才走过渡
86
+ const dragTransform = isDragging
87
+ ? `${CSS.Translate.toString(transform)} scale(1.05)`
88
+ : CSS.Translate.toString(transform);
89
+ const tabNode = (
90
+ <div
91
+ data-tab-key={dataTabKey}
92
+ className={`${styles.tab} ${isActive ? styles.active : ''} ${isDragging ? styles.dragging : ''
93
+ }`}
94
+ role="button"
95
+ tabIndex={0}
96
+ title={tab.title}
97
+ onClick={() => onTabClick(tab)}
98
+ onKeyDown={(e) => {
99
+ if (e.key === 'Enter') onTabClick(tab);
100
+ }}
101
+ >
102
+ <span className={styles.tabTitle}>{tab.title}</span>
103
+ <CloseOutlined
104
+ className={styles.closeIcon}
105
+ onClick={(e) => {
106
+ e.stopPropagation();
107
+ onTabClose(tab);
108
+ }}
109
+ />
110
+ </div>
111
+ );
112
+
113
+ return (
114
+ <div
115
+ ref={setNodeRef}
116
+ style={{
117
+ transform: dragTransform,
118
+ transition: isDragging ? undefined : transition
119
+ }}
120
+ className={`${styles.sortableWrap} ${isDragging ? styles.wrapDragging : ''}`}
121
+ {...dragAttributes}
122
+ {...dragListeners}
123
+ >
124
+ {isActive ? (
125
+ <Dropdown menu={{ items: menuItems }} trigger={['contextMenu']}>
126
+ {tabNode}
127
+ </Dropdown>
128
+ ) : (
129
+ tabNode
130
+ )}
131
+ </div>
132
+ );
133
+ }
@@ -0,0 +1,105 @@
1
+ // 多页签条(《后台多页签KeepAlive方案》§3.3):视觉令牌源 docs/design.style.md
2
+ // 白底胶囊无描边、选中主色底 #009393 反白
3
+ // 2026-10-29 拖拽调序:整签可拖(cursor grab)、让位动画、拖起态主色描边+投影
4
+ .pageTabs {
5
+ display: flex;
6
+ flex-direction: row;
7
+ align-items: center;
8
+ gap: 8px;
9
+ width: 100%;
10
+ height: 100%;
11
+ // 页签多时横向滚动,隐藏滚动条(sidebar 同款口径)
12
+ overflow-x: auto;
13
+ overflow-y: hidden;
14
+
15
+ &::-webkit-scrollbar {
16
+ height: 0;
17
+ }
18
+ }
19
+
20
+ // 拖拽把手包装层:activator 与 sortable 节点同体(整签可拖),自身不占额外语义
21
+ .sortableWrap {
22
+ display: flex;
23
+ flex-shrink: 0;
24
+ cursor: grab;
25
+
26
+ &:active {
27
+ cursor: grabbing;
28
+ }
29
+ }
30
+
31
+ .tab {
32
+ display: flex;
33
+ flex-direction: row;
34
+ align-items: center;
35
+ justify-content: center;
36
+ gap: 6px;
37
+ height: 32px;
38
+ padding: 0 15px;
39
+ flex-shrink: 0;
40
+ box-sizing: border-box;
41
+ border: none;
42
+ border-radius: 999px;
43
+ background-color: #ffffff;
44
+ color: #666666;
45
+ font-size: 14px;
46
+ line-height: 1;
47
+ white-space: nowrap;
48
+ cursor: pointer;
49
+ user-select: none;
50
+ min-width: 80px;
51
+
52
+ &:hover {
53
+ color: #009393;
54
+ }
55
+
56
+ .tabTitle {
57
+ max-width: 160px;
58
+ overflow: hidden;
59
+ text-overflow: ellipsis;
60
+ }
61
+
62
+ .closeIcon {
63
+ // 常显:非选中页签也可直接关闭
64
+ font-size: 11px;
65
+
66
+ &:hover {
67
+ color: #ff3234;
68
+ }
69
+ }
70
+ }
71
+
72
+ // 选中页签:主色实心胶囊反白(含关闭按钮)
73
+ .active {
74
+ background-color: black;
75
+ color: #ffffff;
76
+ font-weight: 600;
77
+
78
+ &:hover {
79
+ color: #ffffff;
80
+ }
81
+
82
+ .closeIcon {
83
+ visibility: visible;
84
+ color: #ffffff;
85
+
86
+ &:hover {
87
+ color: #ffffff;
88
+ }
89
+ }
90
+ }
91
+
92
+ // 拖起中的签:主色 1px 描边 + 投影(design.style.md:1px 描边口径),保持原底色/选中色。
93
+ // 放大效果由 SortableTab 内联 transform 拼进 dnd-kit 平移(两处 CSS transform 会互相
94
+ // 抵掉,故这里不写 transform,只管描边阴影与层级)
95
+ .dragging {
96
+ border: 1px solid #009393;
97
+ box-shadow: 0 4px 12px rgba(0, 147, 147, 0.25);
98
+ }
99
+
100
+ // 拖起中的把手层:浮于其余签之上(跨多签拖拽不被压住)
101
+ .wrapDragging {
102
+ z-index: 10;
103
+ position: relative;
104
+ cursor: grabbing;
105
+ }
@@ -0,0 +1,110 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import {
3
+ DndContext,
4
+ PointerSensor,
5
+ KeyboardSensor,
6
+ useSensor,
7
+ useSensors,
8
+ closestCenter,
9
+ type DragEndEvent,
10
+ type Modifier
11
+ } from '@dnd-kit/core';
12
+ import {
13
+ SortableContext,
14
+ horizontalListSortingStrategy,
15
+ sortableKeyboardCoordinates
16
+ } from '@dnd-kit/sortable';
17
+ import type { PageTabItem } from './usePageTabs';
18
+ import SortableTab from './SortableTab';
19
+ import styles from './index.module.less';
20
+
21
+ /**
22
+ * 多页签条(《后台多页签KeepAlive方案》§3.3/§3.5):取代面包屑的 TagsView。
23
+ * 纯展示组件:数据与操作全由 usePageTabs 持有,本组件只渲染与转发事件。
24
+ * 右键页签呼出批量菜单(关闭其他/全部/刷新当前);页签常显关闭按钮
25
+ * (含工作台,唯一禁关规则「最后一个页签」由 usePageTabs.closeTab 兜底)。
26
+ *
27
+ * 2026-10-29 拖拽调序(@dnd-kit):
28
+ * - PointerSensor distance 4px —— 点击/Enter 跳转与拖拽按位移阈值区分,不误触
29
+ * - KeyboardSensor + sortableKeyboardCoordinates —— Space 拾起/方向键移动/Esc 取消
30
+ * - closestCenter 横向胶囊签条落点判定;DndContext 内建 autoScroll 溢出时边缘自动滚
31
+ * - 激活签变化时滚入视口(新签 append 最右后,溢出场景取代原「置顶天然可见」)
32
+ */
33
+ interface PageTabsProps {
34
+ tabs: PageTabItem[];
35
+ activeKey: string;
36
+ onTabClick: (tab: PageTabItem) => void;
37
+ onTabClose: (tab: PageTabItem) => void;
38
+ onCloseOthers: () => void;
39
+ onCloseAll: () => void;
40
+ onRefresh: () => void;
41
+ /** 拖拽调序:把 fromKey 挪到 overKey 当前所在位(使用方 = usePageTabs.moveTab) */
42
+ onTabReorder: (fromKey: string, overKey: string) => void;
43
+ }
44
+
45
+ /**
46
+ * 横向拖拽限制:把跟随位移的 y 钉死为 0(官方 restrictToHorizontalAxis 的就地复刻 ——
47
+ * core 6.3.1 的 npm 包未内置该 modifier,只有 Modifier 类型与 applyModifiers)。
48
+ * 只影响拖起后的跟手渲染,不影响落点判定(closestCenter 本就只看水平中心距)。
49
+ */
50
+ const restrictToHorizontalAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
51
+
52
+ export default function PageTabs({
53
+ tabs,
54
+ activeKey,
55
+ onTabClick,
56
+ onTabClose,
57
+ onCloseOthers,
58
+ onCloseAll,
59
+ onRefresh,
60
+ onTabReorder
61
+ }: PageTabsProps) {
62
+ const containerRef = useRef<HTMLDivElement>(null);
63
+
64
+ // 激活签(新开/切回)滚入视口:inline nearest 只在溢出方向补滚,已可见则不动
65
+ useEffect(() => {
66
+ containerRef.current
67
+ ?.querySelector<HTMLElement>(`[data-tab-key="${CSS.escape(activeKey)}"]`)
68
+ ?.scrollIntoView({ inline: 'nearest', behavior: 'smooth' });
69
+ }, [activeKey, tabs.length]);
70
+
71
+ const sensors = useSensors(
72
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
73
+ // 键盘拾起/落键只用 Space:默认配置含 Enter,会与页签 Enter 跳转打架
74
+ useSensor(KeyboardSensor, {
75
+ coordinateGetter: sortableKeyboardCoordinates,
76
+ keyboardCodes: { start: ['Space'], end: ['Space'], cancel: ['Escape'] }
77
+ })
78
+ );
79
+
80
+ const handleDragEnd = ({ active, over }: DragEndEvent) => {
81
+ if (over && active.id !== over.id) onTabReorder(String(active.id), String(over.id));
82
+ };
83
+
84
+ return (
85
+ <div className={styles.pageTabs} ref={containerRef}>
86
+ <DndContext
87
+ sensors={sensors}
88
+ collisionDetection={closestCenter}
89
+ modifiers={[restrictToHorizontalAxis]}
90
+ onDragEnd={handleDragEnd}
91
+ >
92
+ <SortableContext items={tabs.map((t) => t.key)} strategy={horizontalListSortingStrategy}>
93
+ {tabs.map((tab) => (
94
+ <SortableTab
95
+ key={tab.key}
96
+ dataTabKey={tab.key}
97
+ tab={tab}
98
+ isActive={tab.key === activeKey}
99
+ onTabClick={onTabClick}
100
+ onTabClose={onTabClose}
101
+ onRefresh={onRefresh}
102
+ onCloseOthers={onCloseOthers}
103
+ onCloseAll={onCloseAll}
104
+ />
105
+ ))}
106
+ </SortableContext>
107
+ </DndContext>
108
+ </div>
109
+ );
110
+ }