create-ssnode-cli 0.2.5 → 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.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/index.js +17 -21
  3. package/package.json +1 -1
  4. package/secrets.js +32 -34
  5. package/templates/backend/Dockerfile +18 -8
  6. package/templates/backend/config/config.base.yaml +130 -0
  7. package/templates/backend/config/config.develop.yaml +8 -75
  8. package/templates/backend/config/config.prod.yaml +12 -67
  9. package/templates/backend/docker-compose.yml +30 -22
  10. package/templates/backend/ops/docker/config.sh +30 -15
  11. package/templates/backend/ops/docker/install.sh +25 -34
  12. package/templates/backend/ops/docker/run-container.sh +100 -0
  13. package/templates/backend/ops/docker/start.sh +37 -8
  14. package/templates/backend/ops/docker/stop.sh +1 -1
  15. package/templates/backend/ops/ensure-encrypt.cjs +217 -0
  16. package/templates/backend/ops/install.sh +249 -148
  17. package/templates/backend/ops/lib-encrypt.sh +98 -0
  18. package/templates/backend/ops/lib.sh +22 -0
  19. package/templates/backend/ops/pm2/config.sh +11 -6
  20. package/templates/backend/ops/pm2/install.sh +27 -3
  21. package/templates/backend/ops/pm2/start.sh +1 -1
  22. package/templates/backend/ops/start.sh +7 -5
  23. package/templates/backend/ops/status.sh +7 -5
  24. package/templates/backend/ops/stop.sh +7 -5
  25. package/templates/backend/ops/systemd/config.sh +13 -6
  26. package/templates/backend/ops/systemd/install.sh +28 -1
  27. package/templates/backend/ops/systemd/start.sh +1 -1
  28. package/templates/backend/ops/versionswitch.sh +34 -122
  29. package/templates/backend/package-lock.json +7561 -0
  30. package/templates/backend/package.json +3 -1
  31. package/templates/backend/scripts/build.cjs +104 -10
  32. package/templates/backend/scripts/config-crypt.cjs +108 -0
  33. package/templates/backend/scripts/lib/ssh.cjs +72 -3
  34. package/templates/backend/scripts/publish.cjs +17 -9
  35. package/templates/backend/scripts/pulldocker.cjs +208 -0
  36. package/templates/backend/scripts/pushdocker.cjs +323 -0
  37. package/templates/backend/scripts/rollback.cjs +6 -3
  38. package/templates/backend/scripts/server.config.example.cjs +43 -15
  39. package/templates/backend/scripts/slowsql-report.mjs +2 -2
  40. package/templates/backend/src/common/api-log/api-log.service.ts +1 -1
  41. package/templates/backend/src/common/config-crypto.ts +133 -0
  42. package/templates/backend/src/config/config.interface.ts +22 -3
  43. package/templates/backend/src/config/configuration.ts +173 -86
  44. package/templates/backend/src/database/migrations/0001-init.migration.ts +1 -1
  45. package/templates/backend/src/database/slow-sql.logger.ts +1 -1
  46. package/templates/backend/src/database/sqlite.config.ts +2 -2
  47. package/templates/backend/src/main.ts +3 -3
  48. package/templates/backend/src/modules/ops/ops-probe.interface.ts +3 -3
  49. package/templates/backend/src/modules/ops/ops.module.ts +2 -11
  50. package/templates/backend/src/modules/user/user.service.ts +2 -2
  51. package/templates/docs/config-encryption.md +164 -0
  52. package/templates/root/AGENTS.md +5 -4
  53. package/templates/root/CLAUDE.md +1 -1
  54. package/templates/root/README.md +25 -15
  55. package/templates/root/_gitignore +5 -0
  56. package/templates/root/scripts/publish.cjs +20 -2
  57. package/templates/root/scripts/rollback.cjs +20 -2
  58. package/templates/web/config/router.config.ts +8 -0
  59. package/templates/web/package.json +5 -1
  60. package/templates/web/scripts/compress-src-images.cjs +332 -0
  61. package/templates/web/scripts/lib/interactive.cjs +134 -0
  62. package/templates/web/scripts/publish.cjs +299 -200
  63. package/templates/web/scripts/rollback.cjs +110 -41
  64. package/templates/web/scripts/server.config.example.json +17 -0
  65. package/templates/web/src/layouts/Background.module.less +0 -4
  66. package/templates/web/src/layouts/Background.tsx +48 -74
  67. package/templates/web/src/layouts/components/PageTabs/SortableTab.tsx +133 -0
  68. package/templates/web/src/layouts/components/PageTabs/index.module.less +105 -0
  69. package/templates/web/src/layouts/components/PageTabs/index.tsx +110 -0
  70. package/templates/web/src/layouts/components/PageTabs/usePageTabs.ts +224 -0
  71. package/templates/web/src/pages/Backend/Image/index.tsx +4 -4
  72. package/templates/web/src/pages/Maintenance/index.module.less +177 -0
  73. package/templates/web/src/pages/Maintenance/index.tsx +142 -0
  74. package/templates/web/src/services/api.ts +4 -0
  75. package/templates/web/src/services/app-request.ts +8 -1
  76. package/templates/web/src/services/maintenance.ts +67 -0
  77. package/templates/web/src/services/request.ts +5 -0
  78. package/transforms.js +21 -28
  79. package/templates/backend/ops/docker/.env.example +0 -40
  80. package/templates/backend/ops/sqlite/README.md +0 -57
  81. package/templates/backend/ops/sqlite/backup.sh +0 -65
  82. package/templates/backend/ops/sqlite/config.sh +0 -91
  83. package/templates/backend/ops/sqlite/healthcheck.sh +0 -25
  84. package/templates/backend/ops/sqlite/restore.sh +0 -105
  85. package/templates/backend/src/modules/ops/sqlite-backup-list.probe.ts +0 -100
  86. package/templates/backend/src/modules/ops/sqlite-backup-log.probe.ts +0 -63
  87. package/templates/backend/src/modules/ops/sqlite-backup.probe.ts +0 -144
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * description 一键回退脚本 (安全加固版)
3
- * v1.2
4
- * 执行命令:node rollback.js --env deploy
3
+ * v1.3
4
+ * 执行命令:npm run rollback
5
5
  * v1.2: 支持秘钥登录(privateKeyPath / passphrase)
6
+ * v1.3: 交互化——多服务器目标菜单选择(key(host));服务器有 <dist>_releases 版本归档且
7
+ * 可交互时,罗列全部版本(新→旧)由用户选择回退目标;无归档(旧服务器)或 CI 非交互时
8
+ * 回落 dist_bak 备份链逻辑
6
9
  */
7
10
  const shelljs = require('shelljs');
8
11
  const fs = require('fs');
9
12
  const os = require('os');
10
13
  const { NodeSSH } = require('node-ssh');
14
+ const { selectEnvKey, selectRollbackVersion, confirm, closeInteractive } = require('./lib/interactive.cjs');
11
15
  const serverConfig = require('./server.config.json');
12
16
 
13
17
  class DynamicOutput {
@@ -82,67 +86,131 @@ function buildSSHConfig(config) {
82
86
  return sshConfig;
83
87
  }
84
88
 
85
- // 初始化环境配置
89
+ // 初始化环境配置(--env <key> 显式选目标;未传时 TTY 下交互选,非 TTY(CI)静默用 deploy 保持旧行为。
90
+ // key 拼错/不存在时 fail-fast,静默回落 deploy 会发错机器)
86
91
  const findEnvIndex = process.argv.indexOf('--env');
87
- let enviromentConfig = serverConfig.deploy;
92
+ let enviromentConfig;
88
93
  if (findEnvIndex !== -1) {
89
94
  const configKey = process.argv[findEnvIndex + 1];
90
- enviromentConfig = serverConfig[configKey] || serverConfig.deploy;
95
+ if (!configKey) {
96
+ shelljs.echo('❌ --env 缺少值: --env <目标名>(server.config.json 里的 key)');
97
+ process.exit(1);
98
+ }
99
+ if (!serverConfig[configKey]) {
100
+ shelljs.echo(`❌ --env ${configKey} 在 server.config.json 中不存在(可用: ${Object.keys(serverConfig).join(', ')})`);
101
+ process.exit(1);
102
+ }
103
+ enviromentConfig = serverConfig[configKey];
91
104
  }
92
105
 
93
106
  async function main() {
94
107
  const startTime = Date.now();
95
108
  try {
109
+ // 未显式 --env:TTY 交互选目标(菜单 `key(host)`),非 TTY 静默回落 deploy(CI 兼容)
110
+ if (!enviromentConfig) {
111
+ const envKey = process.stdin.isTTY
112
+ ? await selectEnvKey(serverConfig)
113
+ : (Object.keys(serverConfig).length === 1 ? Object.keys(serverConfig)[0] : 'deploy');
114
+ enviromentConfig = serverConfig[envKey];
115
+ output.final(`🎯 回滚目标: ${envKey}(${enviromentConfig.host})`);
116
+ }
117
+
96
118
  output.update('🚀 正在建立安全连接...');
97
119
  await ssh.connect(buildSSHConfig(enviromentConfig));
98
120
 
99
121
  const remotePath = enviromentConfig.serverWebPath;
122
+ const serverWebDist = enviromentConfig.serverWebDist || 'dist';
123
+ const releasesDir = `${serverWebDist}_releases`;
100
124
  output.update('🔍 正在检索历史备份版本...');
101
125
 
102
- // 检查是否存在一级备份目录和二级备份压缩包
103
- const [checkBakDir, checkLastBakFile] = await Promise.all([
104
- ssh.execCommand('ls -d dist_bak', { cwd: remotePath }),
105
- ssh.execCommand('ls dist_last_bak.tar.gz', { cwd: remotePath })
106
- ]);
107
-
108
- const hasBakDir = checkBakDir.code === 0;
109
- const hasLastBakFile = checkLastBakFile.code === 0;
126
+ // 新版发布归档(<dist>_releases,由 publish v1.7+ 每次发布创建),按 mtime 新→旧
127
+ const lsReleases = await ssh.execCommand(`ls -1t ${releasesDir}/*.tar.gz`, { cwd: remotePath });
128
+ const releases = (lsReleases.code === 0 ? lsReleases.stdout : '')
129
+ .split('\n')
130
+ .map((s) => s.trim())
131
+ .filter(Boolean)
132
+ .map((p) => {
133
+ const name = p.split('/').pop();
134
+ const m = name.match(/^v(.+)_(\d{14})\.tar\.gz$/);
135
+ const ts = m ? m[2] : '';
136
+ return {
137
+ name,
138
+ version: m ? m[1] : name.replace(/\.tar\.gz$/, ''),
139
+ time: ts
140
+ ? `${ts.slice(0, 4)}-${ts.slice(4, 6)}-${ts.slice(6, 8)} ${ts.slice(8, 10)}:${ts.slice(10, 12)}:${ts.slice(12, 14)}`
141
+ : ''
142
+ };
143
+ });
110
144
 
111
145
  let rollbackCmds = [];
112
146
  let successMsg = '';
113
147
 
114
- if (hasBakDir) {
115
- // 场景 A: 正常回退至上一版本
116
- output.update('🔄 发现可用历史版本,正在执行回退并重组备份链...');
117
- rollbackCmds = [
118
- 'rm -rf dist_rollback_bak', // 清理旧的回退现场记录
119
- 'mv dist dist_rollback_bak', // 保护当前出故障的版本
120
- 'mv dist_bak dist' // 核心回退操作
121
- ];
122
-
123
- // 关键补充逻辑:如果二级备份存在,则将其解压填补到一级备份的空位
124
- if (hasLastBakFile) {
125
- rollbackCmds.push(
126
- 'tar zxvf dist_last_bak.tar.gz', // 这会解压出 dist_bak 文件夹
127
- 'rm -rf dist_last_bak.tar.gz' // 可选:删除已使用的旧压缩包,或保留
128
- );
148
+ if (releases.length && process.stdin.isTTY) {
149
+ // 场景 A(交互):罗列全部归档版本,用户选择回退目标
150
+ const chosen = await selectRollbackVersion(releases);
151
+ if (!(await confirm(`⚠️ 确认将 ${serverWebDist} 回退到版本 ${chosen.version}?`))) {
152
+ output.final('已取消回退。');
153
+ return;
129
154
  }
130
- successMsg = '已成功回退至上一个稳定版本,并自动同步备份链';
131
- } else if (hasLastBakFile) {
132
- // 场景 B: 一级备份丢失,直接从二级压缩包恢复
133
- output.update('📦 发现存档版本,正在紧急恢复...');
155
+ output.update(`🔄 正在回退至版本 ${chosen.version}...`);
134
156
  rollbackCmds = [
135
- 'rm -rf dist_rollback_bak',
136
- 'mv dist dist_rollback_bak',
137
- 'tar zxvf dist_last_bak.tar.gz',
138
- 'mv dist_bak dist'
157
+ `rm -rf ${serverWebDist}_rollback_bak`, // 清理旧的回退现场记录
158
+ `mv ${serverWebDist} ${serverWebDist}_rollback_bak`, // 保护当前出故障的版本
159
+ `tar zxf ${releasesDir}/${chosen.name}` // 归档内含 ${serverWebDist}/ 前缀,解包即复原
139
160
  ];
140
- successMsg = '已成功从存档文件恢复至较早的历史版本';
161
+ successMsg = `已成功回退至版本 ${chosen.version}`;
141
162
  } else {
142
- // 无可回退历史(dist_bak 与 dist_last_bak.tar.gz 均不存在)不算失败:
143
- // 联调/初部署后常见。仅输出说明并正常退出(exit 0),让根编排继续后续服务(backend)回滚。
144
- output.final('ℹ️ 未发现可回退的历史备份(dist_bak 与 dist_last_bak.tar.gz 均不存在),跳过 web 回滚。');
145
- return;
163
+ // 场景 B(旧服务器仅有 dist_bak 备份链,或 CI 非交互):沿用原备份链回退逻辑。
164
+ // 新版 publish 仍双写 dist_bak 链,故该路径对新部署也有效。
165
+ const [checkBakDir, checkLastBakFile] = await Promise.all([
166
+ ssh.execCommand(`ls -d ${serverWebDist}_bak`, { cwd: remotePath }),
167
+ ssh.execCommand(`ls ${serverWebDist}_last_bak.tar.gz`, { cwd: remotePath })
168
+ ]);
169
+
170
+ const hasBakDir = checkBakDir.code === 0;
171
+ const hasLastBakFile = checkLastBakFile.code === 0;
172
+
173
+ if (hasBakDir) {
174
+ // B-1: 正常回退至上一版本
175
+ output.update('🔄 发现可用历史版本,正在执行回退并重组备份链...');
176
+ rollbackCmds = [
177
+ `rm -rf ${serverWebDist}_rollback_bak`, // 清理旧的回退现场记录
178
+ `mv ${serverWebDist} ${serverWebDist}_rollback_bak`, // 保护当前出故障的版本
179
+ `mv ${serverWebDist}_bak ${serverWebDist}` // 核心回退操作
180
+ ];
181
+
182
+ // 关键补充逻辑:如果二级备份存在,则将其解压填补到一级备份的空位
183
+ if (hasLastBakFile) {
184
+ rollbackCmds.push(
185
+ `tar zxvf ${serverWebDist}_last_bak.tar.gz`, // 这会解压出 <dist>_bak 文件夹
186
+ `rm -rf ${serverWebDist}_last_bak.tar.gz` // 可选:删除已使用的旧压缩包,或保留
187
+ );
188
+ }
189
+ successMsg = '已成功回退至上一个稳定版本,并自动同步备份链';
190
+ } else if (hasLastBakFile) {
191
+ // B-2: 一级备份丢失,直接从二级压缩包恢复
192
+ output.update('📦 发现存档版本,正在紧急恢复...');
193
+ rollbackCmds = [
194
+ `rm -rf ${serverWebDist}_rollback_bak`,
195
+ `mv ${serverWebDist} ${serverWebDist}_rollback_bak`,
196
+ `tar zxvf ${serverWebDist}_last_bak.tar.gz`,
197
+ `mv ${serverWebDist}_bak ${serverWebDist}`
198
+ ];
199
+ successMsg = '已成功从存档文件恢复至较早的历史版本';
200
+ } else {
201
+ // 无可回退历史(备份链与版本归档均不存在)不算失败:
202
+ // 联调/初部署后常见。仅输出说明并正常退出(exit 0),让根编排继续后续服务(backend)回滚。
203
+ output.final(`ℹ️ 未发现可回退的历史备份(${serverWebDist}_bak、${serverWebDist}_last_bak.tar.gz 与 ${releasesDir} 均不存在),跳过 web 回滚。`);
204
+ return;
205
+ }
206
+
207
+ // 非归档路径是盲回退(仅上一版),交互下执行前确认一次
208
+ if (process.stdin.isTTY) {
209
+ if (!(await confirm('⚠️ 沿用备份链回退到上一版本,确认执行?'))) {
210
+ output.final('已取消回退。');
211
+ return;
212
+ }
213
+ }
146
214
  }
147
215
 
148
216
  // 按顺序执行指令
@@ -161,6 +229,7 @@ async function main() {
161
229
  output.final(`❌ 回退失败: ${error.message}`);
162
230
  process.exit(1);
163
231
  } finally {
232
+ closeInteractive(); // 释放 readline 的 stdin 句柄,进程才能自然退出
164
233
  ssh.dispose();
165
234
  }
166
235
  }
@@ -12,6 +12,7 @@
12
12
  "serverWebPath": "前端部署目录",
13
13
  "serverWebDist": "dist",
14
14
  "preview": "预览环境地址",
15
+ "keepVersions": 3,
15
16
  "splitIncludes": [
16
17
  "static"
17
18
  ],
@@ -32,5 +33,21 @@
32
33
  "preview": "预览环境地址",
33
34
  "splitIncludes": [],
34
35
  "splitUpload": true
36
+ },
37
+ "machine-b": {
38
+ "host": "服务器IP",
39
+ "port": 22,
40
+ "username": "服务器账号",
41
+ "authMode": "password || privateKey",
42
+ "password": "服务器密码",
43
+ "privateKey": {
44
+ "privateKeyPath": "秘钥地址",
45
+ "passphrase": "秘钥密码"
46
+ },
47
+ "serverWebPath": "前端部署目录",
48
+ "serverWebDist": "dist",
49
+ "preview": "预览环境地址",
50
+ "splitIncludes": [],
51
+ "splitUpload": false
35
52
  }
36
53
  }
@@ -191,10 +191,6 @@
191
191
  flex-direction: row;
192
192
  align-items: center;
193
193
  height: 44px;
194
-
195
- .breadcrumb {
196
- color: #333840;
197
- }
198
194
  }
199
195
 
200
196
  .contentview {
@@ -1,6 +1,7 @@
1
- import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
2
- import { Outlet, useLocation, useNavigate } from 'react-router-dom';
3
- import { Menu, Avatar, Breadcrumb, ConfigProvider, Dropdown } from 'antd';
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
+ import { useLocation, useNavigate, useOutlet } from 'react-router-dom';
3
+ import { Menu, Avatar, ConfigProvider, Dropdown } from 'antd';
4
+ import { useKeepAliveRef, KeepAlive } from 'keepalive-for-react';
4
5
  import {
5
6
  DownOutlined,
6
7
  UserOutlined,
@@ -9,7 +10,6 @@ import {
9
10
  MenuUnfoldOutlined,
10
11
  DesktopOutlined
11
12
  } from '@ant-design/icons';
12
- import PropTypes from 'prop-types';
13
13
  import dayjs from 'dayjs';
14
14
  import zhCn from 'dayjs/locale/zh-cn';
15
15
  import ZhCN from 'antd/locale/zh_CN';
@@ -19,32 +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
- * @param {*} route
28
- * @param {*} params
29
- * @returns
30
- */
31
- function BreadcrumbRoute({ route }) {
32
- const { title = '', path = '' } = route;
33
- return (
34
- <a href={path} key={path} style={{ color: 'unset' }}>
35
- {title}
36
- </a>
37
- );
38
- }
39
-
40
- BreadcrumbRoute.propTypes = {
41
- route: PropTypes.object
42
- };
43
-
44
- BreadcrumbRoute.defaultProps = {
45
- route: null
46
- };
47
-
48
27
  export default function BackgroundLayout() {
49
28
  //
50
29
  const location = useLocation();
@@ -56,22 +35,31 @@ export default function BackgroundLayout() {
56
35
  const [collapsed, setCollapsed] = useState(false);
57
36
 
58
37
  // 滑块菜单配置
59
- const [sliderMenuConfig, setSliderMenuConfig] = useState({
38
+ const [sliderMenuConfig, setSliderMenuConfig] = useState<{
39
+ selectKeys: string[];
40
+ openKeys: string[];
41
+ }>({
60
42
  selectKeys: [],
61
43
  openKeys: []
62
44
  });
63
45
 
64
- /**
65
- * 面包屑
66
- */
67
- const BreadcrumbRenderItem = useCallback(
68
- (route) => (
69
- <a key={route.path} href={route.path} style={{ color: 'unset', fontSize: 16 }}>
70
- {route.title}
71
- </a>
72
- ),
73
- []
74
- );
46
+ // KeepAlive 缓存句柄:关闭页签时销毁对应缓存
47
+ const aliveRef = useKeepAliveRef();
48
+
49
+ // 当前页面内容(包进 KeepAlive 前的 Outlet)
50
+ const outlet = useOutlet();
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);
75
63
 
76
64
  /**
77
65
  * 渲染菜单图标
@@ -117,32 +105,6 @@ export default function BackgroundLayout() {
117
105
  setMenus(bgLayoutRoute?.children || []);
118
106
  }, []);
119
107
 
120
- /**
121
- * 面包屑层级与左侧菜单保持一致:
122
- * 在菜单树(menus)中找到当前路径对应的节点链,作为面包屑。
123
- * 这样面包屑只展示菜单里真正可见的层级,不会掺入布局包裹层(系统/AuthLayout/Background)。
124
- */
125
- const breadcrumbRoutes = useMemo(() => {
126
- const findMenuPath = (
127
- items: RouteConfigItem[],
128
- targetPath: string,
129
- acc: RouteConfigItem[] = []
130
- ): RouteConfigItem[] | null =>
131
- items
132
- .filter((it) => it.name && !it.hideInMenu)
133
- .reduce<RouteConfigItem[] | null>((found, it) => {
134
- if (found) return found;
135
- const cur = [...acc, it];
136
- if (it.fullPath === targetPath) return cur;
137
- if (it.children?.length) {
138
- const r = findMenuPath(it.children, targetPath, cur);
139
- if (r) return r;
140
- }
141
- return null;
142
- }, null);
143
- return findMenuPath(menus, location.pathname) || [];
144
- }, [menus, location.pathname]);
145
-
146
108
  /**
147
109
  * 渲染菜单Items
148
110
  */
@@ -256,19 +218,31 @@ export default function BackgroundLayout() {
256
218
  </div>
257
219
  </div>
258
220
  <div className={styles.right}>
221
+ {/* 页签条:取代面包屑,已打开页面切换保留状态(KeepAlive 缓存) */}
259
222
  <div className={styles.route}>
260
- <Breadcrumb
261
- items={breadcrumbRoutes.map((item) => ({
262
- key: item.name,
263
- title: item.name,
264
- path: item.fullPath
265
- }))}
266
- className={styles.breadcrumb}
267
- itemRender={BreadcrumbRenderItem}
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}
268
232
  />
269
233
  </div>
270
234
  <div className={styles.contentview}>
271
- <Outlet />
235
+ {/*
236
+ KeepAlive 缓存页面组件,切页签不丢状态。三条约束:
237
+ 1. React 18 必须锁 keepalive-for-react@4.x(5.x+ 面向 React 19.2+);
238
+ 2. 项目勿开启 React.StrictMode,与 KeepAlive 开发模式不兼容;
239
+ 3. v4 缓存页失活后 effect 仍会执行,后台页不要写裸 setInterval 轮询,
240
+ 需要感知失活时用 useKeepAliveContext().active 控制。
241
+ 缓存 key 取 pathname(不含 search):同页不同查询参数共享一份缓存,与页签语义一致。
242
+ */}
243
+ <KeepAlive activeCacheKey={location.pathname} max={20} aliveRef={aliveRef}>
244
+ {outlet}
245
+ </KeepAlive>
272
246
  </div>
273
247
  </div>
274
248
  </div>
@@ -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
+ }