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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -111,13 +111,13 @@ export default function ImageManage() {
|
|
|
111
111
|
};
|
|
112
112
|
|
|
113
113
|
/** 列表行预览:与大屏同款 ImagePreview(点击缩略图全屏预览,可翻页) */
|
|
114
|
-
|
|
115
|
-
<ImagePreview
|
|
114
|
+
function RowPreview({ item }: { item: ImageItem }) {
|
|
115
|
+
return <ImagePreview
|
|
116
116
|
images={[{ imageUrl: item.url, description: item.name }]}
|
|
117
117
|
thumbnailSrc={item.url}
|
|
118
118
|
thumbnailSize={{ width: 56, height: 40 }}
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
/>;
|
|
120
|
+
}
|
|
121
121
|
|
|
122
122
|
const columns = [
|
|
123
123
|
{
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// 维护页:白底黑字简约风,与 404 页同一设计语言
|
|
2
|
+
.wrapper {
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100vh;
|
|
5
|
+
box-sizing: border-box;
|
|
6
|
+
margin: 0;
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: column;
|
|
9
|
+
align-items: center;
|
|
10
|
+
justify-content: center;
|
|
11
|
+
background: #fff;
|
|
12
|
+
color: #1a1a1a;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
position: relative;
|
|
15
|
+
|
|
16
|
+
// ============ 插画区:齿轮转动 + 扳手轻微摆动 ============
|
|
17
|
+
.illustration {
|
|
18
|
+
position: relative;
|
|
19
|
+
width: 320px;
|
|
20
|
+
height: 240px;
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
|
|
25
|
+
// 大齿轮(左):断续描边模拟轮齿,旋转动画
|
|
26
|
+
.gearBig {
|
|
27
|
+
transform-origin: 128px 128px;
|
|
28
|
+
animation: gearRotate 12s linear infinite;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 小齿轮(右上):反向旋转
|
|
32
|
+
.gearSmall {
|
|
33
|
+
transform-origin: 214px 62px;
|
|
34
|
+
animation: gearRotate 8s linear infinite reverse;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 扳手:绕卡口一端轻微摆动,像在"拧"
|
|
38
|
+
.wrench {
|
|
39
|
+
transform-origin: 245px 120px;
|
|
40
|
+
animation: wrenchSwing 2.4s ease-in-out infinite;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 底部阴影椭圆:轻微呼吸
|
|
44
|
+
.shadow {
|
|
45
|
+
animation: shadowBreath 2.4s ease-in-out infinite;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ============ 文案区 ============
|
|
50
|
+
.title {
|
|
51
|
+
margin-top: 8px;
|
|
52
|
+
font-size: 30px;
|
|
53
|
+
font-weight: 700;
|
|
54
|
+
letter-spacing: 2px;
|
|
55
|
+
color: #1a1a1a;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.subtitle {
|
|
59
|
+
margin-top: 12px;
|
|
60
|
+
font-size: 15px;
|
|
61
|
+
color: rgba(0, 0, 0, 0.45);
|
|
62
|
+
letter-spacing: 1px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 服务状态行(检测中 / 仍在维护)
|
|
66
|
+
.status {
|
|
67
|
+
margin-top: 20px;
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 8px;
|
|
71
|
+
font-size: 13px;
|
|
72
|
+
color: rgba(0, 0, 0, 0.55);
|
|
73
|
+
|
|
74
|
+
// 探测指示灯
|
|
75
|
+
.dot {
|
|
76
|
+
width: 8px;
|
|
77
|
+
height: 8px;
|
|
78
|
+
border-radius: 50%;
|
|
79
|
+
background: #faad14;
|
|
80
|
+
animation: dotBlink 1.2s ease-in-out infinite;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ============ 操作区 ============
|
|
85
|
+
.actions {
|
|
86
|
+
margin-top: 32px;
|
|
87
|
+
display: flex;
|
|
88
|
+
gap: 16px;
|
|
89
|
+
justify-content: center;
|
|
90
|
+
|
|
91
|
+
.retry {
|
|
92
|
+
padding: 11px 32px;
|
|
93
|
+
font-size: 15px;
|
|
94
|
+
font-weight: 500;
|
|
95
|
+
border-radius: 24px;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
color: #fff;
|
|
98
|
+
background: #000;
|
|
99
|
+
border: 1px solid #000;
|
|
100
|
+
box-sizing: border-box;
|
|
101
|
+
transition: all 0.2s ease;
|
|
102
|
+
|
|
103
|
+
&:hover {
|
|
104
|
+
opacity: 0.8;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
&:disabled {
|
|
108
|
+
cursor: not-allowed;
|
|
109
|
+
opacity: 0.4;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.home {
|
|
114
|
+
padding: 11px 32px;
|
|
115
|
+
font-size: 15px;
|
|
116
|
+
font-weight: 500;
|
|
117
|
+
border-radius: 24px;
|
|
118
|
+
cursor: pointer;
|
|
119
|
+
color: #000;
|
|
120
|
+
border: 1px solid #000;
|
|
121
|
+
background: transparent;
|
|
122
|
+
box-sizing: border-box;
|
|
123
|
+
transition: all 0.2s ease;
|
|
124
|
+
|
|
125
|
+
&:hover {
|
|
126
|
+
background: #000;
|
|
127
|
+
color: #fff;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ============ 动画 ============
|
|
134
|
+
@keyframes gearRotate {
|
|
135
|
+
from {
|
|
136
|
+
transform: rotate(0deg);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
to {
|
|
140
|
+
transform: rotate(360deg);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
@keyframes wrenchSwing {
|
|
145
|
+
0%,
|
|
146
|
+
100% {
|
|
147
|
+
transform: rotate(-6deg);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
50% {
|
|
151
|
+
transform: rotate(8deg);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@keyframes shadowBreath {
|
|
156
|
+
0%,
|
|
157
|
+
100% {
|
|
158
|
+
transform: scaleX(1);
|
|
159
|
+
opacity: 0.7;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
50% {
|
|
163
|
+
transform: scaleX(0.85);
|
|
164
|
+
opacity: 0.5;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@keyframes dotBlink {
|
|
169
|
+
0%,
|
|
170
|
+
100% {
|
|
171
|
+
opacity: 1;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
50% {
|
|
175
|
+
opacity: 0.25;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { useNavigate } from 'react-router-dom';
|
|
3
|
+
import { consumeReturnPath } from '../../services/maintenance';
|
|
4
|
+
import styles from './index.module.less';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 维护页:后端接口返回 502(服务不可用/发版重启)时由请求层自动整页跳入。
|
|
8
|
+
*
|
|
9
|
+
* 页面每 10 秒(以及点击「立即重试」时)用原生 fetch 探活无鉴权的
|
|
10
|
+
* GET /api/version —— 刻意不走 axios 实例:一来避免探活失败又被
|
|
11
|
+
* 502 拦截器拉回本页(死循环),二来探活不应受签名/鉴权头影响。
|
|
12
|
+
* 探测通过即自动跳回进入维护前的原页面。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** 自动探活间隔 */
|
|
16
|
+
const PROBE_INTERVAL = 10 * 1000;
|
|
17
|
+
|
|
18
|
+
export default function Maintenance() {
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const [probing, setProbing] = useState(false);
|
|
21
|
+
// 探活互斥(手动重试撞上定时探测时只跑一个)
|
|
22
|
+
const probingRef = useRef(false);
|
|
23
|
+
// 进入维护前的来源路径,恢复后跳回(只取一次)
|
|
24
|
+
const returnPathRef = useRef(consumeReturnPath());
|
|
25
|
+
|
|
26
|
+
const probe = useCallback(async () => {
|
|
27
|
+
if (probingRef.current) return;
|
|
28
|
+
probingRef.current = true;
|
|
29
|
+
setProbing(true);
|
|
30
|
+
try {
|
|
31
|
+
const res = await fetch(`${window.ENV?.requestBaseUrl || ''}/api/version`, {
|
|
32
|
+
cache: 'no-store'
|
|
33
|
+
});
|
|
34
|
+
if (res.ok) {
|
|
35
|
+
// 服务恢复,跳回来源页
|
|
36
|
+
navigate(returnPathRef.current);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
} catch (e) {
|
|
40
|
+
// 网络仍不通:继续等待下一轮探测
|
|
41
|
+
}
|
|
42
|
+
probingRef.current = false;
|
|
43
|
+
setProbing(false);
|
|
44
|
+
}, [navigate]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
// 进入页面先探一次,再起定时器
|
|
48
|
+
probe();
|
|
49
|
+
const timer = setInterval(probe, PROBE_INTERVAL);
|
|
50
|
+
return () => clearInterval(timer);
|
|
51
|
+
}, [probe]);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={styles.wrapper}>
|
|
55
|
+
{/* 插画:大齿轮 + 小齿轮反向转 + 扳手摆动(纯 SVG + CSS 动画,无外部资源) */}
|
|
56
|
+
<div className={styles.illustration}>
|
|
57
|
+
<svg width="320" height="240" viewBox="0 0 320 240" fill="none" aria-hidden="true">
|
|
58
|
+
{/* 底部阴影 */}
|
|
59
|
+
<ellipse
|
|
60
|
+
className={styles.shadow}
|
|
61
|
+
cx="160"
|
|
62
|
+
cy="230"
|
|
63
|
+
rx="92"
|
|
64
|
+
ry="9"
|
|
65
|
+
fill="#000"
|
|
66
|
+
opacity="0.06"
|
|
67
|
+
/>
|
|
68
|
+
{/* 大齿轮:断续描边模拟轮齿(pathLength 归一化保证齿距均匀) */}
|
|
69
|
+
<g className={styles.gearBig}>
|
|
70
|
+
<circle
|
|
71
|
+
cx="128"
|
|
72
|
+
cy="128"
|
|
73
|
+
r="64"
|
|
74
|
+
stroke="#1a1a1a"
|
|
75
|
+
strokeWidth="18"
|
|
76
|
+
pathLength="100"
|
|
77
|
+
strokeDasharray="3.3 1.7"
|
|
78
|
+
/>
|
|
79
|
+
<circle cx="128" cy="128" r="52" stroke="#1a1a1a" strokeWidth="10" />
|
|
80
|
+
<circle cx="128" cy="128" r="14" fill="#1a1a1a" />
|
|
81
|
+
</g>
|
|
82
|
+
{/* 小齿轮(灰)反向转 */}
|
|
83
|
+
<g className={styles.gearSmall}>
|
|
84
|
+
<circle
|
|
85
|
+
cx="214"
|
|
86
|
+
cy="62"
|
|
87
|
+
r="36"
|
|
88
|
+
stroke="rgba(0,0,0,0.32)"
|
|
89
|
+
strokeWidth="12"
|
|
90
|
+
pathLength="100"
|
|
91
|
+
strokeDasharray="4 2.5"
|
|
92
|
+
/>
|
|
93
|
+
<circle cx="214" cy="62" r="27" stroke="rgba(0,0,0,0.32)" strokeWidth="6" />
|
|
94
|
+
<circle cx="214" cy="62" r="6" fill="rgba(0,0,0,0.32)" />
|
|
95
|
+
</g>
|
|
96
|
+
{/* 扳手:粗杆 + 开口卡爪(不闭合圆 + 缺口),正卡在小齿轮下方 */}
|
|
97
|
+
<g className={styles.wrench}>
|
|
98
|
+
<line
|
|
99
|
+
x1="140"
|
|
100
|
+
y1="214"
|
|
101
|
+
x2="245"
|
|
102
|
+
y2="120"
|
|
103
|
+
stroke="#1a1a1a"
|
|
104
|
+
strokeWidth="10"
|
|
105
|
+
strokeLinecap="round"
|
|
106
|
+
/>
|
|
107
|
+
<circle
|
|
108
|
+
cx="245"
|
|
109
|
+
cy="120"
|
|
110
|
+
r="17"
|
|
111
|
+
stroke="#1a1a1a"
|
|
112
|
+
strokeWidth="10"
|
|
113
|
+
pathLength="100"
|
|
114
|
+
strokeDasharray="66 34"
|
|
115
|
+
transform="rotate(60 245 120)"
|
|
116
|
+
/>
|
|
117
|
+
<circle cx="245" cy="120" r="4" fill="#1a1a1a" />
|
|
118
|
+
</g>
|
|
119
|
+
</svg>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className={styles.title}>系统正在维护中</div>
|
|
123
|
+
<div className={styles.subtitle}>请耐心等待,我们正在加紧处理,很快就会恢复</div>
|
|
124
|
+
|
|
125
|
+
<div className={styles.status}>
|
|
126
|
+
<span className={styles.dot} />
|
|
127
|
+
<span>{probing ? '正在检测服务状态…' : '服务尚未恢复,每 10 秒自动重试'}</span>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
<div className={styles.actions}>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
className={styles.retry}
|
|
134
|
+
disabled={probing}
|
|
135
|
+
onClick={() => probe()}
|
|
136
|
+
>
|
|
137
|
+
{probing ? '检测中…' : '立即重试'}
|
|
138
|
+
</button>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -43,6 +43,10 @@ request.interceptors.response.use(
|
|
|
43
43
|
if (e?.config?.url?.includes('/api/login')) {
|
|
44
44
|
return Promise.reject(e);
|
|
45
45
|
}
|
|
46
|
+
// 502(后端不可用)由 request 层拦截器整页跳维护页,不弹全局错误提示
|
|
47
|
+
if (e?.response?.status === 502) {
|
|
48
|
+
return Promise.reject(e);
|
|
49
|
+
}
|
|
46
50
|
// 内部接口 URL(可能含内网 IP/端口)不弹给最终用户,只走 console
|
|
47
51
|
// eslint-disable-next-line no-console
|
|
48
52
|
console.error('request error:', e?.config?.url || e?.request?.responseURL, e?.message);
|