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.
- package/README.md +1 -1
- package/index.js +17 -21
- package/package.json +1 -1
- package/secrets.js +32 -34
- package/templates/backend/Dockerfile +18 -8
- package/templates/backend/config/config.base.yaml +130 -0
- package/templates/backend/config/config.develop.yaml +8 -75
- package/templates/backend/config/config.prod.yaml +12 -67
- package/templates/backend/docker-compose.yml +30 -22
- package/templates/backend/ops/docker/config.sh +30 -15
- package/templates/backend/ops/docker/install.sh +25 -34
- package/templates/backend/ops/docker/run-container.sh +100 -0
- package/templates/backend/ops/docker/start.sh +37 -8
- package/templates/backend/ops/docker/stop.sh +1 -1
- package/templates/backend/ops/ensure-encrypt.cjs +217 -0
- package/templates/backend/ops/install.sh +249 -148
- package/templates/backend/ops/lib-encrypt.sh +98 -0
- package/templates/backend/ops/lib.sh +22 -0
- package/templates/backend/ops/pm2/config.sh +11 -6
- package/templates/backend/ops/pm2/install.sh +27 -3
- package/templates/backend/ops/pm2/start.sh +1 -1
- package/templates/backend/ops/start.sh +7 -5
- package/templates/backend/ops/status.sh +7 -5
- package/templates/backend/ops/stop.sh +7 -5
- package/templates/backend/ops/systemd/config.sh +13 -6
- package/templates/backend/ops/systemd/install.sh +28 -1
- package/templates/backend/ops/systemd/start.sh +1 -1
- package/templates/backend/ops/versionswitch.sh +34 -122
- package/templates/backend/package-lock.json +7561 -0
- package/templates/backend/package.json +3 -1
- package/templates/backend/scripts/build.cjs +104 -10
- package/templates/backend/scripts/config-crypt.cjs +108 -0
- package/templates/backend/scripts/lib/ssh.cjs +72 -3
- package/templates/backend/scripts/publish.cjs +17 -9
- package/templates/backend/scripts/pulldocker.cjs +208 -0
- package/templates/backend/scripts/pushdocker.cjs +323 -0
- package/templates/backend/scripts/rollback.cjs +6 -3
- package/templates/backend/scripts/server.config.example.cjs +43 -15
- package/templates/backend/scripts/slowsql-report.mjs +2 -2
- package/templates/backend/src/common/api-log/api-log.service.ts +1 -1
- package/templates/backend/src/common/config-crypto.ts +133 -0
- package/templates/backend/src/config/config.interface.ts +22 -3
- package/templates/backend/src/config/configuration.ts +173 -86
- package/templates/backend/src/database/migrations/0001-init.migration.ts +1 -1
- package/templates/backend/src/database/slow-sql.logger.ts +1 -1
- package/templates/backend/src/database/sqlite.config.ts +2 -2
- package/templates/backend/src/main.ts +3 -3
- package/templates/backend/src/modules/ops/ops-probe.interface.ts +3 -3
- package/templates/backend/src/modules/ops/ops.module.ts +2 -11
- package/templates/backend/src/modules/user/user.service.ts +2 -2
- package/templates/docs/config-encryption.md +164 -0
- package/templates/root/AGENTS.md +5 -4
- package/templates/root/CLAUDE.md +1 -1
- package/templates/root/README.md +25 -15
- package/templates/root/_gitignore +5 -0
- package/templates/root/scripts/publish.cjs +20 -2
- package/templates/root/scripts/rollback.cjs +20 -2
- package/templates/web/config/router.config.ts +8 -0
- package/templates/web/package.json +5 -1
- package/templates/web/scripts/compress-src-images.cjs +332 -0
- package/templates/web/scripts/lib/interactive.cjs +134 -0
- package/templates/web/scripts/publish.cjs +299 -200
- package/templates/web/scripts/rollback.cjs +110 -41
- package/templates/web/scripts/server.config.example.json +17 -0
- package/templates/web/src/layouts/Background.module.less +0 -4
- package/templates/web/src/layouts/Background.tsx +48 -74
- 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
- package/templates/web/src/pages/Backend/Image/index.tsx +4 -4
- package/templates/web/src/pages/Maintenance/index.module.less +177 -0
- package/templates/web/src/pages/Maintenance/index.tsx +142 -0
- package/templates/web/src/services/api.ts +4 -0
- package/templates/web/src/services/app-request.ts +8 -1
- package/templates/web/src/services/maintenance.ts +67 -0
- package/templates/web/src/services/request.ts +5 -0
- package/transforms.js +21 -28
- package/templates/backend/ops/docker/.env.example +0 -40
- package/templates/backend/ops/sqlite/README.md +0 -57
- package/templates/backend/ops/sqlite/backup.sh +0 -65
- package/templates/backend/ops/sqlite/config.sh +0 -91
- package/templates/backend/ops/sqlite/healthcheck.sh +0 -25
- package/templates/backend/ops/sqlite/restore.sh +0 -105
- package/templates/backend/src/modules/ops/sqlite-backup-list.probe.ts +0 -100
- package/templates/backend/src/modules/ops/sqlite-backup-log.probe.ts +0 -63
- package/templates/backend/src/modules/ops/sqlite-backup.probe.ts +0 -144
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* description 一键回退脚本 (安全加固版)
|
|
3
|
-
* v1.
|
|
4
|
-
* 执行命令:
|
|
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
|
|
92
|
+
let enviromentConfig;
|
|
88
93
|
if (findEnvIndex !== -1) {
|
|
89
94
|
const configKey = process.argv[findEnvIndex + 1];
|
|
90
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 (
|
|
115
|
-
// 场景 A
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
'
|
|
119
|
-
|
|
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
|
-
|
|
131
|
-
} else if (hasLastBakFile) {
|
|
132
|
-
// 场景 B: 一级备份丢失,直接从二级压缩包恢复
|
|
133
|
-
output.update('📦 发现存档版本,正在紧急恢复...');
|
|
155
|
+
output.update(`🔄 正在回退至版本 ${chosen.version}...`);
|
|
134
156
|
rollbackCmds = [
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import React, { useRef, useEffect, useState, useCallback
|
|
2
|
-
import {
|
|
3
|
-
import { Menu, Avatar,
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
<
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
+
}
|