2 Commits
25 changed files with 3111 additions and 739 deletions
+14
View File
@@ -122,6 +122,20 @@ function initTables() {
db.exec(`CREATE TABLE IF NOT EXISTS site_settings ( db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`); key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
// 审计日志表(终端会话/密码失败等安全事件落库)
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
user_id INTEGER DEFAULT 0,
username TEXT DEFAULT '',
detail TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)');
// 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长
//(写入路径上 terminal.js 每 50 条也会触发一次同规则清理)
db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
// Indexes for performance // Indexes for performance
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)'); db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
+19
View File
@@ -0,0 +1,19 @@
import { request } from './client.js';
// ── Web 终端(安全敏感:root shellPIN + 短 TTL token 双认证) ──
// 后端契约见 routes/terminal.js
// GET /api/terminal/status → { hasPin, sessions, maxSessions, idleTimeoutMs }
// POST /api/terminal/auth → { token, expiresIn, setup?, needs_setup?, message? }
/** 检查终端专用密码是否已设置 + 当前会话数 */
export function terminalStatus() {
return request('/terminal/status');
}
/**
* 设置(首次)/校验终端密码,成功发放 5 分钟短 TTL 终端 token。
* 失败抛错:400 密码不合法 / 401 密码错误 / 429 尝试次数过多
*/
export function terminalAuth(pin) {
return request('/terminal/auth', { method: 'POST', body: { pin } });
}
+496 -174
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
@@ -6,35 +6,80 @@ import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import WidgetsIcon from '@mui/icons-material/Widgets';
import AddIcon from '@mui/icons-material/Add';
import ErrorIcon from '@mui/icons-material/Error';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorIcon from '@mui/icons-material/Error';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import HomeIcon from '@mui/icons-material/Home'; import HomeIcon from '@mui/icons-material/Home';
import WidgetsIcon from '@mui/icons-material/Widgets';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import TerminalIcon from '@mui/icons-material/Terminal';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import { Group, Panel, Separator } from 'react-resizable-panels';
import { useTheme } from '@mui/material/styles';
import {
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core';
import { getToken } from '../../api/client.js'; import { getToken } from '../../api/client.js';
import { me } from '../../api/auth.js'; import { me } from '../../api/auth.js';
import usePanels from './hooks/usePanels.js'; import usePanels from './hooks/usePanels.js';
import useWorkbenchLayout, {
VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB,
} from './hooks/useWorkbenchLayout.js';
import Toolbar from './components/Toolbar.jsx'; import Toolbar from './components/Toolbar.jsx';
import Sidebar from './components/Sidebar.jsx'; import PanelsView from './components/Sidebar.jsx';
import NotesView from './components/NoteEditor.jsx';
import VaultView from './components/VaultDrawer.jsx';
import SidebarPane from './components/SidebarPane.jsx';
import ActivityBar from './components/ActivityBar.jsx';
import ToolWindow from './components/ToolWindow.jsx';
import TabStrip from './components/TabStrip.jsx';
import ContentView from './components/ContentView.jsx';
import TerminalPanel from './components/TerminalPanel.jsx';
import StatusBar from './components/StatusBar.jsx';
import PanelFrame from './components/PanelFrame.jsx'; import PanelFrame from './components/PanelFrame.jsx';
import AddPanelDialog from './components/AddPanelDialog.jsx'; import AddPanelDialog from './components/AddPanelDialog.jsx';
import VaultDrawer from './components/VaultDrawer.jsx';
import NoteEditor from './components/NoteEditor.jsx';
import PanelIcon from './components/PanelIcon.jsx'; import PanelIcon from './components/PanelIcon.jsx';
import SnackHost, { showSnack } from '../../admin/snack.jsx'; import SnackHost, { showSnack } from '../../admin/snack.jsx';
/* ============================================================ /* ============================================================
* Workbench面板工作台页面入口(路由 /admin/workbench * Workbench:工作台页面VSCode 风格改造,Lane A
* - 鉴权守卫:无 token → /login.html;非 admin → 403 提示返回 *
* - 组装 Toolbar + Sidebar + PanelFrame 区 + AddPanelDialog * 布局(react-resizable-panelsv4 APIGroup/Panel/Separator):
* - 挂载 VaultDrawerLane B 提供),LRU 保留最多 3 个活跃 iframe * 顶栏 56 | 活动栏48 侧栏(可拖 180-360,可折叠) 主区(标签+内容+底部终端) 侧栏 活动栏48 | 状态栏 24
* - 左侧活动栏=主控视图(面板),右侧=功能视图(记事本/密码箱)+ 底部终端按钮
* - dnd-kit:视图标签可在左右活动栏之间拖拽换边(workbench.viewOrder 持久化)
* - 视图双模式:侧边栏(默认)/ 标签页(进主区 TabStrip);浮动为 P2 占位
* - 面板 iframe 仍走 PanelFrameLRU 3),弹窗方式走 Dialog
* ============================================================ */ * ============================================================ */
const LAYOUT_KEY = 'workbench.layout';
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
function readLayout() {
try {
const raw = localStorage.getItem(LAYOUT_KEY);
const a = raw ? JSON.parse(raw) : null;
if (!a || typeof a !== 'object') return null;
return {
left: Number(a.left) || 0,
main: Number(a.main) || 0,
right: Number(a.right) || 0,
};
} catch { return null; }
}
function writeLayout(v) {
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(v)); } catch { /* 隐私模式忽略 */ }
}
function AdminGuard({ children }) { function AdminGuard({ children }) {
const [state, setState] = useState('checking'); // checking | denied | ok const [state, setState] = useState('checking'); // checking | denied | ok
@@ -78,84 +123,6 @@ function AdminGuard({ children }) {
return children; return children;
} }
/** 打开面板标签条:切换 + 关闭(浏览器标签风格,支持方向键/Home/End 键盘导航) */
function TabStrip({ openPanels, activeId, onSelect, onClose, onAdd }) {
const stripRef = useRef(null);
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
const moveTo = (i) => {
const n = openPanels.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
onSelect(openPanels[j].id);
tabEls()[j]?.focus();
};
const handleKeyDown = (e, index) => {
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(index + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveTo(0); }
else if (e.key === 'End') { e.preventDefault(); moveTo(openPanels.length - 1); }
};
return (
<Box
ref={stripRef}
role="tablist"
aria-label="已打开的面板"
sx={{
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.5,
height: 40, flexShrink: 0, bgcolor: 'background.paper',
borderBottom: 1, borderColor: 'divider', overflowX: 'auto', overflowY: 'hidden',
}}
>
{openPanels.map((p, index) => {
const active = String(p.id) === String(activeId);
return (
<Box
key={p.id}
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(p.id)}
onKeyDown={(e) => handleKeyDown(e, index)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
height: 28, borderRadius: 1, cursor: 'pointer', flexShrink: 0, outline: 'none',
bgcolor: active ? 'primary.container' : 'transparent',
color: active ? 'primary.onContainer' : 'text.secondary',
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
}}
>
<PanelIcon url={p.embed_url || p.url} title={p.title} size={14} />
<Typography
sx={{
fontSize: 12, fontWeight: active ? 600 : 500, maxWidth: 120,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{p.title}
</Typography>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onClose(p.id); }}
aria-label={`关闭面板 ${p.title}`}
sx={{ width: 20, height: 20, borderRadius: 0.5, '& svg': { fontSize: 14 } }}
>
<CloseIcon />
</IconButton>
</Box>
);
})}
<Tooltip title="添加面板">
<IconButton size="small" onClick={onAdd} sx={{ ml: 0.25, flexShrink: 0 }} aria-label="添加面板">
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
</Box>
);
}
function EmptyState({ icon, title, hint, action, onAction }) { function EmptyState({ icon, title, hint, action, onAction }) {
return ( return (
<Box <Box
@@ -174,26 +141,203 @@ function EmptyState({ icon, title, hint, action, onAction }) {
); );
} }
function AboutDialog({ open, onClose }) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs">
<DialogTitle>关于 Rain Work</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 1 }}>
Rain Work 工作台 v2.1.0 管理面板记事本密码箱与终端的一体化工作区
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
终端为管理员 Root Shell需单独设置终端密码记事本文件保存在服务器 workspace 目录
面板可拖拽到左右任一活动栏按你的习惯排布
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>关闭</Button>
</DialogActions>
</Dialog>
);
}
/** 面板分隔条:可拖拽 = 4px 命中区 + 悬停高亮;不可拖拽 = 1px 细线 */
function ResizeSeparator({ disabled = false }) {
const muiTheme = useTheme();
const [hover, setHover] = useState(false);
return (
<Separator
disabled={disabled}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
width: disabled ? 1 : 4,
background: hover ? muiTheme.palette.primary.main : 'transparent',
transition: 'background .12s ease',
}}
/>
);
}
function WorkbenchContent() { function WorkbenchContent() {
const panels = usePanels(); const panels = usePanels();
const layout = useWorkbenchLayout();
const { const {
links, loadError, reload, panelsById, renderedIds, openPanels, links, loadError, reload,
openPanels, renderedIds,
activeId, activePanel, modalPanel, closeModal, activeId, activePanel, modalPanel, closeModal,
canBack, canForward, goBack, goForward, canBack, canForward, goBack, goForward,
openPanel, closePanel, togglePin, toggleGroup, setMode, openPanel, closePanel, togglePin, toggleGroup, setMode,
pinned, groups, collapsed, setCollapsed, pinned, groups, modes,
search, setSearch, filteredPanels, openFirstMatch, search, setSearch, filteredPanels, openFirstMatch,
} = panels; } = panels;
// 主面板与弹窗面板各自独立的刷新信号(弹窗刷新不连带主面板) const [addOpen, setAddOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [mainRefresh, setMainRefresh] = useState(0); const [mainRefresh, setMainRefresh] = useState(0);
const [modalRefresh, setModalRefresh] = useState(0); const [modalRefresh, setModalRefresh] = useState(0);
const [addOpen, setAddOpen] = useState(false); const [activeMainKey, setActiveMainKey] = useState(null);
const [vaultOpen, setVaultOpen] = useState(false); const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
// 记事本:noteOpen 开关,noteMode 'float'(右下角浮窗) / 'fixed'(右侧固定栏) const searchRef = useRef(null);
const [noteOpen, setNoteOpen] = useState(false);
const [noteMode, setNoteMode] = useState('float'); // ---- react-resizable-panels 引用 ----
const toggleNoteMode = () => setNoteMode((m) => (m === 'float' ? 'fixed' : 'float')); const groupRef = useRef(null);
const leftRef = useRef(null);
const rightRef = useRef(null);
const termRef = useRef(null);
const savedLayout = useRef(readLayout());
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
const defaultLayout = savedLayout.current
? {
'activity-l': 3,
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
main: Math.max(5, Math.min(90, savedLayout.current.main)),
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
'activity-r': 3,
}
: undefined;
const persistLayout = useCallback((l) => {
const v = (id) => Math.round(l?.[id] ?? 0);
writeLayout({ left: v('sidebar-l'), main: v('main'), right: v('sidebar-r') });
}, []);
/** 立即保存布局(折叠/展开等程序化变更时兜底,拖拽走 onLayoutChanged */
const saveLayoutNow = useCallback(() => {
try { persistLayout(groupRef.current?.getLayout()); } catch { /* 布局尚未就绪 */ }
}, [persistLayout]);
// 拖拽期间 onLayoutChanged 高频触发:200ms 防抖后再写 localStorage,避免阻塞主线程
const layoutTimer = useRef(null);
const handleLayoutChanged = useCallback((layout, { isUserInteraction }) => {
if (!isUserInteraction) return;
clearTimeout(layoutTimer.current);
layoutTimer.current = setTimeout(() => persistLayout(layout), 200);
}, [persistLayout]);
useEffect(() => () => clearTimeout(layoutTimer.current), []);
const toggleSidebar = useCallback((side) => {
const ref = side === 'left' ? leftRef.current : rightRef.current;
if (!ref) return;
if (ref.isCollapsed()) ref.expand();
else ref.collapse();
setTimeout(saveLayoutNow, 0);
}, [saveLayoutNow]);
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
const leftActive = layout.activeViewOf('left');
const rightActive = layout.activeViewOf('right');
const leftBarViews = layout.order.left.map((id) => VIEWS[id]).filter(Boolean);
const rightBarViews = layout.order.right.map((id) => VIEWS[id]).filter(Boolean);
// 侧栏只渲染 sidebar 模式的视图(tab 模式的进主区标签页)
const leftPaneViews = leftBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
const rightPaneViews = rightBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
const isViewActive = (vid) => {
if (layout.mode[vid] === VIEW_MODE_TAB) return effectiveActiveKey === 'view:' + vid;
const side = layout.sideOf[vid];
return side ? layout.activeViewOf(side) === vid : false;
};
/** 主区标签列表(每次渲染重建,保证 render() 闭包拿到最新状态) */
const buildMainTabs = () => {
const tabs = [];
openPanels.forEach((p) => {
tabs.push({
key: 'panel:' + p.id,
type: 'panel',
title: p.title,
closable: true,
icon: <PanelIcon url={p.embed_url || p.url} title={p.title} size={14} />,
panel: p,
});
});
VIEW_IDS.forEach((id) => {
if (layout.mode[id] !== VIEW_MODE_TAB) return;
const v = VIEWS[id];
tabs.push({
key: 'view:' + id,
type: id,
title: v.label,
closable: true,
icon: <v.Icon sx={{ fontSize: 14, color: 'text.secondary' }} />,
render: () => renderViewContent(id),
});
});
return tabs;
};
// 视图内容渲染(先定义:buildMainTabs / renderPaneView 均依赖)
const renderViewContent = (viewId) => {
if (viewId === 'panels') {
return (
<PanelsView
loading={links === null && !loadError}
error={loadError}
onRetry={reload}
links={links || []}
filtered={filteredPanels}
search={search}
activeId={activeId}
pinned={pinned}
onSelect={openPanel}
onPin={togglePin}
groups={groups}
onToggleGroup={toggleGroup}
modes={modes}
onSetMode={setMode}
onAdd={() => setAddOpen(true)}
/>
);
}
if (viewId === 'notes') return <NotesView />;
if (viewId === 'vault') return <VaultView open={isViewActive('vault')} />;
return null;
};
const mainTabs = buildMainTabs();
// 活动主标签:优先记住的用户选择,失效则回退到第一个
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
? activeMainKey
: (mainTabs[0]?.key ?? null);
// 打开面板 / 历史导航时自动切到对应 iframe 标签
useEffect(() => {
if (activeId != null) setActiveMainKey('panel:' + activeId);
}, [activeId]);
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
const handleSelectTab = (key) => {
setActiveMainKey(key);
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
};
const handleCloseTab = (key) => {
if (key.startsWith('panel:')) closePanel(key.slice('panel:'.length));
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
};
const categories = [...new Set((links || []).map((p) => p.category || '默认'))]; const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
@@ -207,18 +351,150 @@ function WorkbenchContent() {
reload().then(() => openPanel(panel.id, panel)); reload().then(() => openPanel(panel.id, panel));
}; };
const content = (() => { /** 模式切换入口:切换前派发保存事件(记事本立即 flush 未落盘草稿),再切模式 */
if (links === null && !loadError) { const setViewModeSafely = useCallback((viewId, mode) => {
// 加载态 if (viewId === 'notes') {
try { window.dispatchEvent(new CustomEvent('workbench:notes-save-request')); } catch { /* ignore */ }
}
layout.setViewMode(viewId, mode);
}, [layout]);
/** 侧栏内单个视图:ToolWindow 标题栏 + 内容 */
const renderPaneView = (viewId) => {
const v = VIEWS[viewId];
const VIcon = v.Icon;
return ( return (
<>
<ToolWindow
icon={<VIcon sx={{ fontSize: 17, color: 'primary.main' }} />}
title={v.label}
mode={layout.mode[viewId]}
onSetMode={(m) => setViewModeSafely(viewId, m)}
onFloat={() => showSnack('浮动模式即将上线', 'info')}
onClose={() => {
const side = layout.sideOf[viewId];
const ref = side === 'left' ? leftRef.current : rightRef.current;
ref?.collapse();
}}
/>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{renderViewContent(viewId)}
</Box>
</>
);
};
const renderPlaceholder = (side) => {
const vid = layout.activeViewOf(side);
const v = VIEWS[vid];
if (!v) return null;
return (
<>
<v.Icon sx={{ fontSize: 28, color: 'text.disabled' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{v.label}已在标签页中打开
</Typography>
<Button size="small" variant="outlined" onClick={() => setViewModeSafely(vid, VIEW_MODE_SIDEBAR)}>
移回侧边栏
</Button>
</>
);
};
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起 */
const handleActivateView = (viewId, side) => {
const ref = side === 'left' ? leftRef.current : rightRef.current;
const alreadyActive = layout.activeViewOf(side) === viewId;
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
layout.activateView(viewId);
if (ref) {
if (alreadyActive && !ref.isCollapsed()) ref.collapse();
else ref.expand();
setTimeout(saveLayoutNow, 0);
}
};
// ---- 菜单栏配置 ----
const menus = [
{
label: '文件', items: [
{ label: '添加面板…', Icon: AddIcon, action: () => setAddOpen(true) },
{ label: '刷新面板列表', Icon: RefreshIcon, action: () => reload() },
{ divider: true },
{ label: '返回前台', Icon: HomeIcon, action: () => { window.location.href = '/'; } },
],
},
{
label: '编辑', items: [
{ label: '搜索面板', Icon: SearchIcon, action: () => searchRef.current?.focus() },
{ label: '刷新当前面板', Icon: RefreshIcon, action: () => { if (activePanel) setMainRefresh((n) => n + 1); } },
],
},
{
label: '视图', items: [
{ label: '切换左侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('left') },
{ label: '切换右侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('right') },
{ divider: true },
...VIEW_IDS.map((id) => ({
label: VIEWS[id].label,
Icon: VIEWS[id].Icon,
action: () => { setViewModeSafely(id, VIEW_MODE_SIDEBAR); layout.activateView(id); },
})),
],
},
{
label: '终端', items: [
{ label: '切换终端面板', Icon: TerminalIcon, action: () => layout.toggleTerminal() },
{ label: '新建终端会话', Icon: AddIcon, action: () => { layout.openTerminal(); termRef.current?.newSession(); } },
],
},
{
label: '帮助', items: [
{ label: '关于工作台', Icon: InfoOutlinedIcon, action: () => setAboutOpen(true) },
{ label: '管理后台', Icon: SettingsIcon, action: () => { window.location.href = '/admin/dashboard'; } },
],
},
];
// ---- dnd-kit 拖拽(活动栏标签换边 / 同侧排序;键盘操作走 Tab+Enter 激活) ----
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const handleDragStart = (event) => {
const id = String(event.active.id);
if (!id.startsWith('view:')) return;
const v = VIEWS[id.slice('view:'.length)];
if (v) setActiveDrag({ id: v.id, label: v.label, Icon: v.Icon });
};
const handleDragEnd = (event) => {
setActiveDrag(null);
const { active, over } = event;
if (!over) return;
const vid = String(active.id).replace(/^view:/, '');
if (!VIEWS[vid]) return;
const overStr = String(over.id);
if (overStr.startsWith('activity:')) {
const side = overStr.slice('activity:'.length);
layout.moveView(vid, side);
} else if (overStr.startsWith('view:')) {
const overVid = overStr.slice('view:'.length);
const mySide = layout.sideOf[vid];
const overSide = layout.sideOf[overVid];
if (mySide && overSide && mySide === overSide) layout.reorderView(vid, overVid);
else if (overSide) layout.moveView(vid, overSide);
}
};
// ---- 主区内容(加载/错误/空态;有标签时一律走 ContentView 分发) ----
let mainBody;
if (links === null && !loadError) {
mainBody = (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}> <Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={36} /> <CircularProgress size={36} />
</Box> </Box>
); );
} } else if (loadError) {
if (loadError) { mainBody = (
// 错误态
return (
<EmptyState <EmptyState
icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />} icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />}
title="面板列表加载失败" title="面板列表加载失败"
@@ -227,10 +503,8 @@ function WorkbenchContent() {
onAction={reload} onAction={reload}
/> />
); );
} } else if (mainTabs.length === 0 && links.length === 0) {
if (links.length === 0) { mainBody = (
// 空态:无面板引导添加
return (
<EmptyState <EmptyState
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />} icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />}
title="还没有任何面板" title="还没有任何面板"
@@ -239,10 +513,8 @@ function WorkbenchContent() {
onAction={() => setAddOpen(true)} onAction={() => setAddOpen(true)}
/> />
); );
} } else if (mainTabs.length === 0) {
if (!activePanel) { mainBody = (
// 已打开的面板都被关闭
return (
<EmptyState <EmptyState
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />} icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />}
title="未打开面板" title="未打开面板"
@@ -251,25 +523,21 @@ function WorkbenchContent() {
onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }} onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }}
/> />
); );
} } else {
// 正常态:LRU 渲染(当前 + 最近 2 个) mainBody = (
return renderedIds.map((id) => { <ContentView
const panel = panelsById[id]; tabs={mainTabs}
if (!panel) return null; activeKey={effectiveActiveKey}
return (
<PanelFrame
key={id}
panel={panel}
active={String(id) === String(activeId)}
refreshNonce={mainRefresh} refreshNonce={mainRefresh}
panelRenderKeys={panelRenderKeys}
/> />
); );
}); }
})();
return ( return (
<> <>
<Toolbar <Toolbar
menus={menus}
canBack={canBack} canBack={canBack}
canForward={canForward} canForward={canForward}
onBack={goBack} onBack={goBack}
@@ -282,58 +550,119 @@ function WorkbenchContent() {
onSearchEnter={() => { onSearchEnter={() => {
if (!openFirstMatch() && search.trim()) showSnack('未找到匹配的面板', 'info'); if (!openFirstMatch() && search.trim()) showSnack('未找到匹配的面板', 'info');
}} }}
onAdd={() => setAddOpen(true)} searchInputRef={searchRef}
onOpenVault={() => setVaultOpen(true)}
notesOpen={noteOpen}
onOpenNotes={() => setNoteOpen(true)}
collapsed={collapsed}
onToggleSidebar={() => setCollapsed((c) => !c)}
onBackHome={() => { window.location.href = '/'; }} onBackHome={() => { window.location.href = '/'; }}
/> />
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, width: '100%' }}> <DndContext
<Sidebar sensors={sensors}
loading={links === null && !loadError} collisionDetection={closestCenter}
error={loadError} onDragStart={handleDragStart}
onRetry={reload} onDragEnd={handleDragEnd}
links={links || []} onDragCancel={() => setActiveDrag(null)}
filtered={filteredPanels}
search={search}
activeId={activeId}
pinned={pinned}
onSelect={openPanel}
onPin={togglePin}
groups={groups}
onToggleGroup={toggleGroup}
modes={panels.modes}
onSetMode={setMode}
onAdd={() => setAddOpen(true)}
collapsed={collapsed}
/>
<Box
component="main"
sx={{
flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column',
bgcolor: 'background.default', overflow: 'hidden',
// 记事本固定态:右侧占 280px,主内容区推挤
mr: noteOpen && noteMode === 'fixed' ? '280px' : 0,
transition: 'margin-right .2s ease',
}}
> >
{openPanels.length > 0 && ( <Group
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={handleLayoutChanged}
groupRef={groupRef}
style={{ flex: 1, minHeight: 0, display: 'flex' }}
>
{/* 左侧活动栏 */}
<Panel id="activity-l" defaultSize={48} minSize={48} maxSize={48}>
<ActivityBar
side="left"
views={leftBarViews}
activeId={leftActive}
onActivate={(id) => handleActivateView(id, 'left')}
/>
</Panel>
<ResizeSeparator disabled />
{/* 左侧侧边栏 */}
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef}>
<SidebarPane
side="left"
views={leftPaneViews}
activeId={leftActive}
renderView={renderPaneView}
renderPlaceholder={() => renderPlaceholder('left')}
/>
</Panel>
<ResizeSeparator />
{/* 主区:标签条 + 内容 + 底部终端 */}
<Panel id="main" minSize={320}>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{mainTabs.length > 0 && (
<TabStrip <TabStrip
openPanels={openPanels} tabs={mainTabs}
activeId={activeId} activeKey={effectiveActiveKey}
onSelect={openPanel} onSelect={handleSelectTab}
onClose={closePanel} onClose={handleCloseTab}
onAdd={() => setAddOpen(true)} onAdd={() => setAddOpen(true)}
/> />
)} )}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}> <Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
{content} {mainBody}
</Box>
{/* 底部终端:默认隐藏,keep-alive 不销毁会话 */}
<Box
sx={{
height: layout.terminalOpen ? 240 : 0,
flexShrink: 0, overflow: 'hidden',
transition: 'height .18s ease',
display: 'flex', flexDirection: 'column',
borderTop: layout.terminalOpen ? 1 : 0, borderColor: 'divider',
}}
>
<TerminalPanel ref={termRef} open={layout.terminalOpen} />
</Box> </Box>
</Box> </Box>
</Panel>
<ResizeSeparator />
{/* 右侧侧边栏 */}
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef}>
<SidebarPane
side="right"
views={rightPaneViews}
activeId={rightActive}
renderView={renderPaneView}
renderPlaceholder={() => renderPlaceholder('right')}
/>
</Panel>
<ResizeSeparator disabled />
{/* 右侧活动栏 */}
<Panel id="activity-r" defaultSize={48} minSize={48} maxSize={48}>
<ActivityBar
side="right"
views={rightBarViews}
activeId={rightActive}
onActivate={(id) => handleActivateView(id, 'right')}
terminalOpen={layout.terminalOpen}
onToggleTerminal={layout.toggleTerminal}
/>
</Panel>
</Group>
{/* 拖拽浮层(纯展示,禁止渲染 useSortable 组件) */}
<DragOverlay dropAnimation={null}>
{activeDrag ? (
<Box
sx={{
px: 1.25, py: 1, borderRadius: 2,
bgcolor: 'background.paper', border: 1, borderColor: 'primary.main',
boxShadow: (t) => t.shadows[6],
display: 'flex', alignItems: 'center', gap: 1,
fontSize: 12.5, fontWeight: 600,
}}
>
<activeDrag.Icon sx={{ fontSize: 16, color: 'primary.main' }} />
{activeDrag.label}
</Box> </Box>
) : null}
</DragOverlay>
</DndContext>
<StatusBar left={`已打开 ${openPanels.length} 个面板${activePanel ? ` · ${activePanel.title}` : ''}`} />
<AddPanelDialog <AddPanelDialog
open={addOpen} open={addOpen}
@@ -343,14 +672,6 @@ function WorkbenchContent() {
onSetMode={setMode} onSetMode={setMode}
/> />
{/* 密码库抽屉(Lane B 提供完整实现,此处仅挂载) */}
<VaultDrawer open={vaultOpen} onClose={() => setVaultOpen(false)} />
{/* 记事本:float=右下角拖拽浮窗 / fixed=右侧固定栏;保持挂载以保留编辑状态 */}
{noteOpen && (
<NoteEditor mode={noteMode} onToggleMode={toggleNoteMode} onClose={() => setNoteOpen(false)} />
)}
{/* 弹窗打开方式:Dialog 内嵌 PanelFrame */} {/* 弹窗打开方式:Dialog 内嵌 PanelFrame */}
{modalPanel && ( {modalPanel && (
<Dialog <Dialog
@@ -388,6 +709,7 @@ function WorkbenchContent() {
</Dialog> </Dialog>
)} )}
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
<SnackHost /> <SnackHost />
</> </>
); );
@@ -0,0 +1,152 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import TerminalIcon from '@mui/icons-material/Terminal';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
/* ============================================================
* ActivityBarVSCode 风格活动栏(宽 48px,竖排图标)
*
* - 左侧:主控视图(面板…);右侧:功能视图(记事本/密码箱…)
* - 活动项左侧 2.5px primary 指示条 + selected 底
* - 点击:未激活 → 激活并展开侧栏;已激活 → 收起侧栏(VSCode 语义)
* - 拖拽(@dnd-kit sortable):标签可拖到对侧活动栏 → 视图换边;
* 同侧可排序。父级 DndContext 负责 moveView / reorderView 落库。
* - 无障碍:role=toolbar,仅活动项 tabIndex=0roving),
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane
* - 右侧底部固定「终端」呼出按钮(不参与拖拽)
* ============================================================ */
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: 'view:' + view.id });
const VIcon = view.Icon;
return (
<Tooltip title={view.label} placement="right">
<Box
ref={setNodeRef}
data-activity-item
{...attributes}
{...listeners}
role="button"
tabIndex={active ? 0 : -1}
aria-label={view.label}
aria-pressed={active}
aria-controls={active ? `wb-sidebar-${side}` : undefined}
onClick={() => onActivate(view.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActivate(view.id); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(index + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveFocus(0); }
else if (e.key === 'End') { e.preventDefault(); moveFocus(count - 1); }
}}
sx={{
position: 'relative', width: 48, height: 48, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'grab', touchAction: 'none', outline: 'none',
color: active ? 'primary.onContainer' : 'text.secondary',
bgcolor: active ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3, outlineStyle: 'solid' },
opacity: isDragging ? 0.35 : 1,
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
transition,
}}
>
{active && (
<Box
aria-hidden
sx={{
position: 'absolute', left: 0, top: 10, bottom: 10, width: 2.5,
bgcolor: 'primary.main', borderRadius: 1,
}}
/>
)}
<VIcon sx={{ fontSize: 22 }} />
</Box>
</Tooltip>
);
}
function TerminalButton({ open, onToggle }) {
return (
<Tooltip title="终端" placement="right">
<Box
component="button"
type="button"
onClick={onToggle}
aria-label="终端"
aria-pressed={open}
sx={{
width: 48, height: 48, flexShrink: 0, border: 'none', background: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
color: open ? 'primary.main' : 'text.secondary',
bgcolor: open ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: open ? 'action.selected' : 'action.hover' },
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3 },
}}
>
<TerminalIcon sx={{ fontSize: 22 }} />
</Box>
</Tooltip>
);
}
export default function ActivityBar({
side, views, activeId, onActivate,
terminalOpen = false, onToggleTerminal,
}) {
const right = side === 'right';
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
const barRef = useRef(null);
const itemEls = () => Array.from(barRef.current?.querySelectorAll('[data-activity-item]') || []);
const moveFocus = (i) => {
const n = views.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
itemEls()[j]?.focus();
};
return (
<Box
ref={setNodeRef}
role="toolbar"
aria-label={right ? '右侧活动栏' : '左侧活动栏'}
aria-orientation="vertical"
aria-controls={`wb-sidebar-${side}`}
sx={{
width: 48, height: '100%', flexShrink: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center',
bgcolor: isOver ? 'action.hover' : 'background.paper',
borderRight: right ? 0 : 1,
borderLeft: right ? 1 : 0,
borderColor: 'divider',
}}
>
<SortableContext items={views.map((v) => 'view:' + v.id)} strategy={verticalListSortingStrategy}>
<Box ref={barRef} sx={{ display: 'flex', flexDirection: 'column', pt: 0.5 }}>
{views.map((v, index) => (
<ActivityTab
key={v.id}
view={v}
side={side}
index={index}
count={views.length}
moveFocus={moveFocus}
active={activeId === v.id}
onActivate={onActivate}
/>
))}
</Box>
</SortableContext>
<Box sx={{ flex: 1 }} />
{right && <TerminalButton open={terminalOpen} onToggle={onToggleTerminal} />}
</Box>
);
}
@@ -0,0 +1,53 @@
import React from 'react';
import Box from '@mui/material/Box';
import PanelFrame from './PanelFrame.jsx';
import { tabBtnId, tabPanelId } from './TabStrip.jsx';
/* ============================================================
* ContentView:主区内容分发(按标签 type)
*
* - 'panel' → PanelFrameiframeLRU 由 panelRenderKeys 控制挂载)
* - 其他 react 类型 → tab.render() 返回对应组件(记事本/密码箱/面板列表…)
* 仅激活标签可见,其余保持挂载(keep-alive 保留 iframe 与编辑状态)。
* 每个标签包一层 role=tabpanel,与 TabStrip 的 tab 通过 id 配对(ARIA)。
* ============================================================ */
export default function ContentView({ tabs, activeKey, refreshNonce = 0, panelRenderKeys = null }) {
return (
<>
{tabs.map((tab) => {
const active = tab.key === activeKey;
if (tab.type === 'panel') {
// LRU 裁剪:只渲染当前 + 最近使用的 iframe,其余卸载省内存
if (panelRenderKeys && !panelRenderKeys.has(tab.key)) return null;
return (
<Box
key={tab.key}
id={tabPanelId(tab.key)}
role="tabpanel"
aria-labelledby={tabBtnId(tab.key)}
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
>
<PanelFrame
panel={tab.panel}
active={active}
refreshNonce={refreshNonce}
/>
</Box>
);
}
return (
<Box
key={tab.key}
id={tabPanelId(tab.key)}
role="tabpanel"
aria-labelledby={tabBtnId(tab.key)}
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
>
{tab.render ? tab.render() : null}
</Box>
);
})}
</>
);
}
@@ -0,0 +1,118 @@
import React, { useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import Typography from '@mui/material/Typography';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
/* ============================================================
* MenuBarVSCode 风格菜单栏(MUI Menu,配置数组驱动)
*
* menus = [{ label, items: [{ label, Icon?, action?, shortcut?, divider? }] }]
* - role=menubar / menuitem,顶层 roving tabindex + 方向键导航:
* ← → / Home / End 移动焦点,↓ / Enter / 空格 打开菜单
* - 菜单内部导航由 MUI Menu 自带(方向键 + Enter/Esc
* ============================================================ */
export default function MenuBar({ menus = [] }) {
const [openIdx, setOpenIdx] = useState(null);
const [anchorEl, setAnchorEl] = useState(null);
const [focusIdx, setFocusIdx] = useState(0); // roving tabindex 停靠点
const barRef = useRef(null);
const btnEls = () => Array.from(barRef.current?.querySelectorAll('[data-menu-btn]') || []);
const focusBtn = (i) => {
const n = menus.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
setFocusIdx(j);
btnEls()[j]?.focus();
};
const openMenu = (i) => {
setFocusIdx(i);
setAnchorEl(btnEls()[i]);
setOpenIdx(i);
};
const close = () => { setOpenIdx(null); setAnchorEl(null); };
const handleTopKeyDown = (e, i) => {
switch (e.key) {
case 'ArrowRight': e.preventDefault(); focusBtn(i + 1); break;
case 'ArrowLeft': e.preventDefault(); focusBtn(i - 1); break;
case 'Home': e.preventDefault(); focusBtn(0); break;
case 'End': e.preventDefault(); focusBtn(menus.length - 1); break;
case 'ArrowDown':
case 'Enter':
case ' ':
e.preventDefault();
openMenu(i); // MUI Menu 打开后自动聚焦首个菜单项
break;
default: break;
}
};
return (
<Box
ref={barRef}
component="nav"
role="menubar"
aria-label="主菜单"
sx={{ display: 'flex', alignItems: 'center' }}
>
{menus.map((menu, i) => (
<React.Fragment key={menu.label}>
<Button
data-menu-btn
role="menuitem"
aria-haspopup="true"
aria-expanded={openIdx === i}
tabIndex={focusIdx === i ? 0 : -1}
size="small"
onClick={(e) => {
setFocusIdx(i);
if (openIdx === i) { close(); return; }
setAnchorEl(e.currentTarget);
setOpenIdx(i);
}}
onKeyDown={(e) => handleTopKeyDown(e, i)}
sx={{
minWidth: 0, px: 1, height: 30, borderRadius: 1,
fontSize: 13, color: openIdx === i ? 'primary.main' : 'text.primary',
'&:hover': { bgcolor: openIdx === i ? 'action.selected' : 'action.hover' },
}}
>
{menu.label}
<KeyboardArrowDownIcon sx={{ fontSize: 14, ml: 0.25, color: 'text.disabled' }} />
</Button>
<Menu
open={openIdx === i}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
MenuListProps={{ sx: { py: 0.5, minWidth: 200 } }}
>
{menu.items.map((item, j) => (
item.divider ? (
<Divider key={`d-${j}`} sx={{ my: 0.5 }} />
) : (
<MenuItem
key={item.label + j}
dense
onClick={() => { close(); item.action?.(); }}
>
{item.Icon && <item.Icon sx={{ fontSize: 16, mr: 1.25, color: 'text.secondary' }} />}
<span style={{ flex: 1 }}>{item.label}</span>
{item.shortcut && (
<Typography variant="caption" sx={{ ml: 3, color: 'text.disabled' }}>{item.shortcut}</Typography>
)}
</MenuItem>
)
))}
</Menu>
</React.Fragment>
))}
</Box>
);
}
@@ -1,12 +1,11 @@
import React, { useRef, useState } from 'react'; import React, { useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import AppBar from '@mui/material/AppBar';
import MuiToolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
@@ -16,8 +15,6 @@ import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu'; import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem'; import MenuItem from '@mui/material/MenuItem';
import NoteAltIcon from '@mui/icons-material/NoteAlt'; import NoteAltIcon from '@mui/icons-material/NoteAlt';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
@@ -27,22 +24,16 @@ import useNotes from '../hooks/useNotes.js';
import ConfirmDialog from '../../../admin/ConfirmDialog.jsx'; import ConfirmDialog from '../../../admin/ConfirmDialog.jsx';
/* ============================================================ /* ============================================================
* NoteEditor:工作台「记事本」 * NotesView:记事本视图(VSCode 侧边栏 / 标签页内容)
* *
* 两种形态(由 mode 切换,组件保持挂载不丢编辑状态): * 由 NoteEditor 裁剪:去掉浮窗拖拽 / fixed 侧栏 / 自带标题栏,
* - float:右下角可拖拽浮窗(320×400),标题栏拖动 * 标题栏由父级 ToolWindow 提供。保留:
* - fixed:右侧固定侧边栏(280pxposition:fixed top:56), * 状态条(当前文件 + 保存状态)→ 编辑器(自动保存,Ctrl+S)
* 主内容区由 Workbench 加 margin-right 推挤 * → 文件管理器(新建/双击重命名/右键菜单/删除)
* * 常驻挂载(keep-alive),切换视图不丢草稿。
* 布局:顶部工具栏(标题 + 📌 固定/浮窗 + ✕ 关闭)→
* 状态条(当前文件 + 保存状态)→ 上半编辑器(textarea 自动保存)
* → 下半文件管理器(列表 + 新建/双击重命名/右键菜单/删除)
* ============================================================ */ * ============================================================ */
const FLOAT_W = 320; /** 精简时间格式:今天 HH:mm / 今年 MM-DD / 往年 YYYY-MM-DD */
const FLOAT_H = 400;
/* 精简时间格式:今天 HH:mm / 今年 MM-DD / 往年 YYYY-MM-DD */
function fmtTime(ms) { function fmtTime(ms) {
if (!ms) return ''; if (!ms) return '';
const d = new Date(ms); const d = new Date(ms);
@@ -59,7 +50,6 @@ function NameDialog({ open, title, initial, confirmText, exists, busy, onClose,
const [value, setValue] = useState(initial); const [value, setValue] = useState(initial);
const [err, setErr] = useState(''); const [err, setErr] = useState('');
// 每次打开时重置为 initial
React.useEffect(() => { React.useEffect(() => {
if (open) { setValue(initial); setErr(''); } if (open) { setValue(initial); setErr(''); }
}, [open, initial]); }, [open, initial]);
@@ -97,7 +87,7 @@ function NameDialog({ open, title, initial, confirmText, exists, busy, onClose,
); );
} }
export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) { export default function NotesView() {
const notes = useNotes(); const notes = useNotes();
const { const {
notes: list, loadError, refresh, notes: list, loadError, refresh,
@@ -105,47 +95,13 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
open, updateContent, saveNow, create, rename, remove, nextUntitled, open, updateContent, saveNow, create, rename, remove, nextUntitled,
} = notes; } = notes;
// ── 浮窗拖拽(标题栏) ──
const [pos, setPos] = useState(() => ({
x: Math.max(0, (typeof window !== 'undefined' ? window.innerWidth : 1200) - FLOAT_W - 16),
y: Math.max(0, (typeof window !== 'undefined' ? window.innerHeight : 800) - FLOAT_H - 72),
}));
const dragRef = useRef(null);
const startDrag = (e) => {
if (mode !== 'float') return;
if (e.target.closest('button')) return; // 不拦截按钮
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const base = pos;
dragRef.current = (ev) => {
const x = Math.min(Math.max(0, base.x + ev.clientX - startX), window.innerWidth - FLOAT_W);
const y = Math.min(Math.max(0, base.y + ev.clientY - startY), window.innerHeight - 56);
setPos({ x, y });
};
window.addEventListener('mousemove', dragRef.current);
window.addEventListener('mouseup', endDrag);
};
const endDrag = () => {
window.removeEventListener('mousemove', dragRef.current);
window.removeEventListener('mouseup', endDrag);
dragRef.current = null;
};
// ── 弹窗/菜单状态 ──
const [newOpen, setNewOpen] = useState(false); const [newOpen, setNewOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState(null); const [renameTarget, setRenameTarget] = useState(null);
const [delTarget, setDelTarget] = useState(null); const [delTarget, setDelTarget] = useState(null);
const [ctx, setCtx] = useState(null); // { name, anchorEl } const [ctx, setCtx] = useState(null); // { name, anchorEl }
const handleCreate = (name) => { const handleCreate = (name) => { setNewOpen(false); create(name); };
setNewOpen(false); const handleRename = (name) => { setRenameTarget(null); rename(ctx?.name || renameTarget, name); };
create(name);
};
const handleRename = (name) => {
setRenameTarget(null);
rename(ctx?.name || renameTarget, name);
};
const handleDelete = async () => { const handleDelete = async () => {
const name = delTarget; const name = delTarget;
setDelTarget(null); setDelTarget(null);
@@ -154,34 +110,9 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
const statusText = saving ? '保存中…' : dirty ? '未保存' : '已保存'; const statusText = saving ? '保存中…' : dirty ? '未保存' : '已保存';
// ── 头部工具栏(共享) ── return (
const header = ( <Box component="section" aria-label="记事本" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<AppBar position="static" elevation={0} {/* 状态条 */}
sx={{
flexShrink: 0, cursor: mode === 'float' ? 'grab' : 'default',
'&:active': { cursor: mode === 'float' ? 'grabbing' : 'default' },
borderRadius: 0,
}}
>
<MuiToolbar variant="dense" sx={{ minHeight: 44, px: 1, gap: 0.25, cursor: 'inherit' }} onMouseDown={startDrag}>
<NoteAltIcon sx={{ fontSize: 18, color: 'primary.main' }} />
<Typography sx={{ flex: 1, fontSize: 14, fontWeight: 600, ml: 0.5 }}>记事本</Typography>
<Tooltip title={mode === 'float' ? '固定到右侧' : '收回为浮窗'}>
<IconButton size="small" onMouseDown={(e) => e.stopPropagation()} onClick={onToggleMode} aria-label="固定/浮窗切换">
{mode === 'float' ? <PushPinOutlinedIcon fontSize="small" /> : <PushPinIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="关闭记事本">
<IconButton size="small" onMouseDown={(e) => e.stopPropagation()} onClick={onClose} aria-label="关闭记事本">
<CloseIcon fontSize="small" />
</IconButton>
</Tooltip>
</MuiToolbar>
</AppBar>
);
// ── 状态条(当前文件 + 保存状态) ──
const statusBar = (
<Box sx={{ px: 1.5, py: 0.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default', borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}> <Box sx={{ px: 1.5, py: 0.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default', borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
<Typography variant="caption" noWrap sx={{ flex: 1, color: 'text.secondary' }} title={activeName || ''}> <Typography variant="caption" noWrap sx={{ flex: 1, color: 'text.secondary' }} title={activeName || ''}>
{activeName || '未打开笔记'} {activeName || '未打开笔记'}
@@ -190,10 +121,8 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
{statusText} {statusText}
</Typography> </Typography>
</Box> </Box>
);
// ── 编辑器(上半) ── {/* 编辑器(上半) */}
const editor = (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', p: 0.5 }}> <Box sx={{ flex: 1, minHeight: 0, display: 'flex', p: 0.5 }}>
<TextField <TextField
fullWidth fullWidth
@@ -214,10 +143,8 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
}} }}
/> />
</Box> </Box>
);
// ── 文件管理器(下半) ── {/* 文件管理器(下半) */}
const fileManager = (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', borderTop: 1, borderColor: 'divider' }}> <Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', borderTop: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.25, py: 0.5, flexShrink: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', px: 1.25, py: 0.5, flexShrink: 0 }}>
<Typography sx={{ flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}> <Typography sx={{ flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}>
@@ -246,17 +173,23 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
{list.map((n) => { {list.map((n) => {
const active = n.name === activeName; const active = n.name === activeName;
return ( return (
<Box <ListItemButton
key={n.name} key={n.name}
onClick={() => open(n.name)} onClick={() => open(n.name)}
onDoubleClick={() => setRenameTarget(n.name)} onDoubleClick={() => setRenameTarget(n.name)}
onContextMenu={(e) => { e.preventDefault(); setCtx({ name: n.name, anchorEl: e.currentTarget }); }} onContextMenu={(e) => { e.preventDefault(); setCtx({ name: n.name, anchorEl: e.currentTarget }); }}
onKeyDown={(e) => {
// F2 重命名(标准文件管理器约定,避免与 Enter 打开冲突)
if (e.key === 'F2') { e.preventDefault(); setRenameTarget(n.name); }
}}
aria-label={`打开笔记 ${n.name}`}
sx={{ sx={{
display: 'flex', alignItems: 'center', cursor: 'pointer', minHeight: 40, minHeight: 40, borderRadius: 0, m: 0, px: 0,
bgcolor: active ? 'primary.container' : 'transparent', bgcolor: active ? 'primary.container' : 'transparent',
color: active ? 'primary.onContainer' : 'text.primary', color: active ? 'primary.onContainer' : 'text.primary',
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' }, '&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
'&:hover .nb-del': { opacity: 1 }, '&:hover .nb-del': { opacity: 1 },
'&:focus-visible .nb-del': { opacity: 1 },
}} }}
> >
<NoteAltIcon fontSize="small" sx={{ mx: 1, flexShrink: 0, opacity: 0.7 }} /> <NoteAltIcon fontSize="small" sx={{ mx: 1, flexShrink: 0, opacity: 0.7 }} />
@@ -271,21 +204,19 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
size="small" size="small"
onClick={(e) => { e.stopPropagation(); setDelTarget(n.name); }} onClick={(e) => { e.stopPropagation(); setDelTarget(n.name); }}
aria-label={`删除 ${n.name}`} aria-label={`删除 ${n.name}`}
sx={{ opacity: 0, width: 26, height: 26, mr: 0.5, transition: 'opacity .12s' }} sx={{ opacity: 0, width: 28, height: 28, mr: 0.5, transition: 'opacity .12s' }}
> >
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </ListItemButton>
); );
})} })}
</List> </List>
)} )}
</Box> </Box>
</Box> </Box>
);
// ── 右键菜单 ── {/* 右键菜单 */}
const ctxMenu = (
<Menu <Menu
open={!!ctx} open={!!ctx}
anchorEl={ctx?.anchorEl || null} anchorEl={ctx?.anchorEl || null}
@@ -299,39 +230,13 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
<DeleteOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />删除 <DeleteOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />删除
</MenuItem> </MenuItem>
</Menu> </Menu>
);
const existsList = (list || []).map((n) => n.name.toLowerCase());
const rootSx = mode === 'float'
? {
position: 'fixed', left: pos.x, top: pos.y,
width: FLOAT_W, height: FLOAT_H, zIndex: 1240, // 低于 MUI Dialog(1300),不遮挡弹窗
borderRadius: 2, overflow: 'hidden',
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
boxShadow: (t) => t.shadows[8], display: 'flex', flexDirection: 'column',
}
: {
position: 'fixed', top: 56, right: 0, bottom: 0,
width: 280, zIndex: 1100,
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider',
display: 'flex', flexDirection: 'column',
};
return (
<Box component="section" aria-label="记事本" sx={rootSx}>
{header}
{statusBar}
{editor}
{fileManager}
{ctxMenu}
<NameDialog <NameDialog
open={newOpen} open={newOpen}
title="新建笔记" title="新建笔记"
initial={nextUntitled} initial={nextUntitled}
confirmText="创建" confirmText="创建"
exists={existsList} exists={(list || []).map((n) => n.name.toLowerCase())}
busy={busy} busy={busy}
onClose={() => setNewOpen(false)} onClose={() => setNewOpen(false)}
onConfirm={handleCreate} onConfirm={handleCreate}
@@ -341,7 +246,7 @@ export default function NoteEditor({ mode = 'float', onToggleMode, onClose }) {
title="重命名笔记" title="重命名笔记"
initial={renameTarget || ''} initial={renameTarget || ''}
confirmText="保存" confirmText="保存"
exists={existsList.filter((n) => n !== renameTarget?.toLowerCase())} exists={(list || []).map((n) => n.name.toLowerCase()).filter((n) => n !== renameTarget?.toLowerCase())}
busy={busy} busy={busy}
onClose={() => setRenameTarget(null)} onClose={() => setRenameTarget(null)}
onConfirm={handleRename} onConfirm={handleRename}
@@ -163,7 +163,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
<Box <Box
sx={{ sx={{
position: 'relative', width: '100%', height: '100%', overflow: 'hidden', position: 'relative', width: '100%', height: '100%', overflow: 'hidden',
display: active ? 'block' : 'none', bgcolor: '#ffffff', flexShrink: 0, display: active ? 'block' : 'none', bgcolor: 'background.paper', flexShrink: 0,
}} }}
> >
{mounted && ( {mounted && (
@@ -174,7 +174,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
onLoad={handleLoad} onLoad={handleLoad}
sandbox={sandbox} sandbox={sandbox}
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: '#fff' }} style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
/> />
)} )}
@@ -1,4 +1,4 @@
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import React, { Fragment, useMemo, useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
@@ -27,63 +27,31 @@ import PanelIcon from './PanelIcon.jsx';
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js'; import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
/* ============================================================ /* ============================================================
* Sidebar:左侧面板列表(默认 240px,可拖拽 180-360px 并记忆 * PanelsView:面板列表视图(VSCode 侧边栏「面板」内容
* - 固定区(pinlocalStorage+ admin_links 按 category 分组 *
* - 固定区(pin+ admin_links 按 category 分组
* - 当前项:左侧 2px primary 指示条 + primary-container 选中底 * - 当前项:左侧 2px primary 指示条 + primary-container 选中底
* - 分组折叠(持久化)、右键切换打开方式、底部 + 添加 * - 分组折叠(持久化)、右键切换打开方式、底部 + 添加
* - 搜索时展示扁平结果列表 * - 搜索时展示扁平结果列表
* - 宽度/折叠由外部 react-resizable-panels 布局管理,本组件不再自持
* ============================================================ */ * ============================================================ */
const WIDTH_KEY = 'workbench.sidebarWidth';
const MODE_META = [ const MODE_META = [
{ mode: OPEN_MODE_EMBED, label: '内嵌打开', Icon: WebAssetIcon }, { mode: OPEN_MODE_EMBED, label: '内嵌打开', Icon: WebAssetIcon },
{ mode: OPEN_MODE_TAB, label: '新标签打开', Icon: OpenInNewIcon }, { mode: OPEN_MODE_TAB, label: '新标签打开', Icon: OpenInNewIcon },
{ mode: OPEN_MODE_MODAL, label: '弹窗打开', Icon: OpenInFullIcon }, { mode: OPEN_MODE_MODAL, label: '弹窗打开', Icon: OpenInFullIcon },
]; ];
export default function Sidebar({ export default function PanelsView({
loading = false, error = '', onRetry, loading = false, error = '', onRetry,
links = [], filtered = [], search = '', links = [], filtered = [], search = '',
activeId = null, pinned = [], onSelect, onPin, activeId = null, pinned = [], onSelect, onPin,
groups = {}, onToggleGroup, groups = {}, onToggleGroup,
modes = {}, onSetMode, modes = {}, onSetMode,
onAdd, collapsed = false, onAdd,
}) { }) {
const [width, setWidth] = useState(() => {
const w = Number(localStorage.getItem(WIDTH_KEY));
return w >= 180 && w <= 360 ? w : 240;
});
const [dragging, setDragging] = useState(false);
const dragRef = useRef(null);
useEffect(() => {
try { localStorage.setItem(WIDTH_KEY, String(width)); } catch { /* ignore */ }
}, [width]);
const handleDown = (e) => {
e.preventDefault();
const startX = e.clientX;
const startW = width;
setDragging(true);
dragRef.current = (ev) => {
const w = Math.min(360, Math.max(180, startW + ev.clientX - startX));
setWidth(w);
};
window.addEventListener('mousemove', dragRef.current);
window.addEventListener('mouseup', handleUp);
};
const handleUp = () => {
window.removeEventListener('mousemove', dragRef.current);
window.removeEventListener('mouseup', handleUp);
dragRef.current = null;
setDragging(false);
};
// 右键菜单:切换打开方式
const [ctx, setCtx] = useState(null); // { id, anchorEl } const [ctx, setCtx] = useState(null); // { id, anchorEl }
// 分组
const groupOrder = useMemo(() => { const groupOrder = useMemo(() => {
const order = []; const seen = new Set(); const order = []; const seen = new Set();
links.forEach((p) => { links.forEach((p) => {
@@ -269,50 +237,14 @@ export default function Sidebar({
})(); })();
return ( return (
<Fragment> <Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Box
component="aside"
aria-label="面板侧边栏"
sx={{
position: 'relative', flexShrink: 0, width: collapsed ? 0 : width,
minWidth: 0, display: 'flex', flexDirection: 'column',
bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider',
overflow: 'hidden', transition: dragging ? 'none' : 'width .2s ease',
}}
>
<Box sx={{ px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
<WidgetsIcon sx={{ fontSize: 18, color: 'primary.main' }} />
<Typography sx={{ fontSize: 13, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}>
面板工作台
</Typography>
</Box>
<Divider sx={{ flexShrink: 0 }} />
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}> <Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
{listBody} {listBody}
</Box> </Box>
<Divider sx={{ flexShrink: 0 }} /> <Divider sx={{ flexShrink: 0 }} />
<Box sx={{ p: 1, flexShrink: 0 }}> <Box sx={{ p: 1, flexShrink: 0 }}>
<Button fullWidth size="small" startIcon={<AddIcon />} onClick={onAdd}>添加面板</Button> <Button fullWidth size="small" startIcon={<AddIcon />} onClick={onAdd}>添加面板</Button>
</Box> </Box>
</Box>
{!collapsed && (
<Box
onMouseDown={handleDown}
onDoubleClick={() => setWidth(240)}
role="separator"
aria-orientation="vertical"
title="拖拽调整宽度"
sx={{
width: 5, flexShrink: 0, alignSelf: 'stretch', cursor: 'col-resize',
bgcolor: 'divider', opacity: dragging ? 1 : 0.45,
transition: 'background-color .12s ease', zIndex: 5,
'&:hover': { bgcolor: 'primary.main', opacity: 0.7 },
}}
/>
)}
{/* 右键菜单:切换打开方式 */} {/* 右键菜单:切换打开方式 */}
<Menu <Menu
@@ -335,6 +267,6 @@ export default function Sidebar({
</MenuItem> </MenuItem>
))} ))}
</Menu> </Menu>
</Fragment> </Box>
); );
} }
@@ -0,0 +1,70 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
/* ============================================================
* SidebarPaneVSCode 风格侧边栏容器(一侧一个)
*
* - 该侧全部 sidebar 模式视图**常驻挂载**display 显隐切换),
* 切换视图/切标签页不丢编辑状态(记事本草稿、密码箱解锁态)
* - 每个视图由 ToolWindow 提供标题栏;内容按 activeView 显示
* - 视图处于 tab 模式时该视图不在侧栏 → 显示占位 + 「移回侧边栏」
* - 无障碍:容器 id=wb-sidebar-{side} 供活动栏 aria-controls
* aria-live 播报当前视图,配合活动栏焦点提示
* ============================================================ */
export default function SidebarPane({ side, views, activeId, renderView, renderPlaceholder }) {
const right = side === 'right';
const activeView = views.find((v) => v.id === activeId);
return (
<Box
component="aside"
id={`wb-sidebar-${side}`}
aria-label={right ? '右侧侧边栏' : '左侧侧边栏'}
sx={{
height: '100%', minWidth: 0, overflow: 'hidden', position: 'relative',
display: 'flex', flexDirection: 'column',
bgcolor: 'background.paper',
}}
>
{/* 活动栏激活后的屏幕阅读器播报(视觉隐藏) */}
<Box
aria-live="polite"
sx={{
position: 'absolute', width: 1, height: 1, overflow: 'hidden',
clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', m: -1, p: 0, border: 0,
}}
>
{activeView ? `当前视图:${activeView.label}` : '侧边栏为空'}
</Box>
{views.map((v) => {
const active = activeId === v.id;
return (
<Box
key={v.id}
sx={{
flex: 1, minHeight: 0, display: active ? 'flex' : 'none',
flexDirection: 'column',
}}
>
{renderView(v.id)}
</Box>
);
})}
{views.length === 0 && (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 3, textAlign: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
从另一侧活动栏拖一个功能标签到这里
</Typography>
</Box>
)}
{views.length > 0 && !views.some((v) => v.id === activeId) && renderPlaceholder && (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3, textAlign: 'center' }}>
{renderPlaceholder()}
</Box>
)}
</Box>
);
}
@@ -0,0 +1,30 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
/* ============================================================
* StatusBar:底部状态栏(可选,VSCode 风格)
* 左:当前上下文;右:主机:端口 + 版本
* ============================================================ */
export default function StatusBar({ left, right = null }) {
const host = typeof window !== 'undefined' ? window.location.host : '';
return (
<Box
role="status"
sx={{
height: 24, flexShrink: 0, display: 'flex', alignItems: 'center',
px: 1.5, gap: 1.5, bgcolor: 'primary.container', color: 'primary.onContainer',
fontSize: 11, borderTop: 1, borderColor: 'divider',
}}
>
<Box
component="span"
aria-hidden
sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: 'success.main', flexShrink: 0 }}
/>
<Typography noWrap sx={{ fontSize: 11, fontWeight: 500, flex: 1, minWidth: 0 }}>{left}</Typography>
<Typography noWrap sx={{ fontSize: 11, opacity: 0.85 }}>{right || host}</Typography>
</Box>
);
}
@@ -0,0 +1,113 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
/* ============================================================
* TabStrip:主区标签页条(VSCode 风格,浏览器标签交互)
*
* - tabs{ key, title, icon?, closable? }iframe 面板与
* tab 模式视图共用一套标签(由 Workbench 拼装)
* - 键盘:← → 切换、Home/End 首尾(roving tabindex
* - ARIA:每个 tab 与 ContentView 的 tabpanel 通过 id 配对
* tabBtnId / tabPanelId,冒号等字符已做无害化)
* ============================================================ */
/** tab 按钮元素 idtab.key 可能含 ':',替换为下划线保证合法 */
export function tabBtnId(key) {
return 'wb-tab-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
}
/** 对应 tabpanel 元素 id */
export function tabPanelId(key) {
return 'wb-panel-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
}
export default function TabStrip({ tabs, activeKey, onSelect, onClose, onAdd }) {
const stripRef = useRef(null);
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
const moveTo = (i) => {
const n = tabs.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
onSelect(tabs[j].key);
tabEls()[j]?.focus();
};
const handleKeyDown = (e, index) => {
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(index + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveTo(0); }
else if (e.key === 'End') { e.preventDefault(); moveTo(tabs.length - 1); }
};
return (
<Box
ref={stripRef}
role="tablist"
aria-label="已打开的面板与视图"
sx={{
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.5,
height: 40, flexShrink: 0, bgcolor: 'background.paper',
borderBottom: 1, borderColor: 'divider',
overflowX: 'auto', overflowY: 'hidden',
}}
>
{tabs.map((tab, index) => {
const active = tab.key === activeKey;
return (
<Box
key={tab.key}
id={tabBtnId(tab.key)}
role="tab"
aria-selected={active}
aria-label={tab.title}
aria-controls={tabPanelId(tab.key)}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(tab.key)}
onKeyDown={(e) => handleKeyDown(e, index)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
height: 28, borderRadius: 1, cursor: 'pointer', flexShrink: 0, outline: 'none',
bgcolor: active ? 'primary.container' : 'transparent',
color: active ? 'primary.onContainer' : 'text.secondary',
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
}}
>
{tab.icon}
<Typography
noWrap
sx={{
fontSize: 12, fontWeight: active ? 600 : 500, maxWidth: 140,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{tab.title}
</Typography>
{tab.closable && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onClose(tab.key); }}
aria-label={`关闭 ${tab.title}`}
sx={{ width: 20, height: 20, borderRadius: 0.5, '& svg': { fontSize: 14 } }}
>
<CloseIcon />
</IconButton>
)}
</Box>
);
})}
{onAdd && (
<Tooltip title="添加面板">
<IconButton size="small" onClick={onAdd} sx={{ ml: 0.25, flexShrink: 0 }} aria-label="添加面板">
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
}
@@ -0,0 +1,717 @@
import React, {
forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
} from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import CircularProgress from '@mui/material/CircularProgress';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import ErrorIcon from '@mui/icons-material/Error';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import TerminalIcon from '@mui/icons-material/Terminal';
import RefreshIcon from '@mui/icons-material/Refresh';
import Stack from '@mui/material/Stack';
import { useTheme } from '@mui/material/styles';
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { terminalStatus, terminalAuth } from '../../../api/terminal.js';
import { showSnack } from '../../../admin/snack.jsx';
/* ============================================================
* TerminalPanel:底部终端面板(xterm.js v6
*
* 安全门禁(后端 routes/terminal.js 契约):
* /api/terminal/status → hasPin ? /api/terminal/auth(pin) → 短 TTL token
* → ws(s)://host/ws/terminal?token=…(Root shelladmin + 终端密码双认证)
*
* - 最多 3 个会话(标签页切换,keep-alivedisplay 显隐不销毁)
* - 消息协议:输入/输出二进制;控制 JSON {type:'auth'|'resize'|'ping'|'bye'}
* - 断线指数退避重连 [250,500,1000,2000,4000,8000],恢复后 term.reset()
* - 关闭码:4001 token 过期→回 PIN 门;1013/503 会话满;1011 node-pty 不可用;
* 1000(exit)/4000(空闲) 为服务端主动终止→会话结束,需用户显式「重新连接」
* (不自动重生 root shell);仅 1006 等网络异常自动重连
* - CJK 必装 unicode11,字体栈 JetBrains Mono + Noto Sans Mono CJK SC
* - 明暗主题由 MUI MD3 palette 派生,随 data-theme 切换自动更新
* ============================================================ */
const MAX_SESSIONS = 3;
const BACKOFF = [250, 500, 1000, 2000, 4000, 8000];
const PING_MS = 30000;
const TOKEN_MARGIN_MS = 4 * 60 * 1000; // 5 分钟 TTL,提前 1 分钟视为过期
const FONT = '"JetBrains Mono","Noto Sans Mono CJK SC","Microsoft YaHei",monospace';
/* ---------- 颜色工具(由 MD3 palette 派生,不硬编码) ---------- */
function hexToRgb(hex) {
let h = String(hex).replace('#', '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(h)) return [128, 128, 128];
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mixHex(a, b, w) {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
return `#${ca.map((v, i) => Math.round(v + (cb[i] - v) * w).toString(16).padStart(2, '0')).join('')}`;
}
function xtermTheme(muiTheme) {
const p = muiTheme.palette;
const dark = p.mode === 'dark';
const bg = p.background.paper;
const black = mixHex(p.text.primary, bg, 0.6);
const brightBlack = mixHex(p.text.primary, bg, 0.2);
const base = [
black, // 0 黑
p.error.main, // 1 红
p.success.main, // 2 绿
p.warning.main, // 3 黄
p.info.main, // 4 蓝
p.tertiary.main, // 5 品红
mixHex(p.secondary.main, p.info.main, 0.5), // 6 青
p.text.primary, // 7 白
];
const bright = base.map((c) => mixHex(c, dark ? '#ffffff' : '#000000', dark ? 0.55 : 0.45));
const name = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
const out = {
background: bg,
foreground: p.text.primary,
cursor: p.primary.main,
cursorAccent: bg,
selectionBackground: mixHex(p.primary.main, bg, 0.72),
brightBlack,
};
name.forEach((n, i) => { out[n] = base[i]; out['bright' + n[0].toUpperCase() + n.slice(1)] = bright[i]; });
return out;
}
/* ---------- WebSocket URL ---------- */
// 认证 token 由 /api/terminal/auth 种 HttpOnly cookieterminal_token5 分钟),
// WS 握手同源自动携带,不再拼 ?token= 到 URL(避免落入 access log)。
function wsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/ws/terminal`;
}
/**
* 终端面板
* @param {{open: boolean}} props open=底部面板是否展开(展开才查状态;收起 keep-alive
* @ref newSession() / lock()
*/
const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
const muiTheme = useTheme();
const themeObj = useMemo(() => xtermTheme(muiTheme), [muiTheme]);
// ---- 门禁状态:checking | needs-setup | locked | ready | error ----
const [gate, setGate] = useState('checking');
const [gateError, setGateError] = useState('');
const [pinInput, setPinInput] = useState('');
const [showPin, setShowPin] = useState(false);
const [pinBusy, setPinBusy] = useState(false);
// ---- 会话:{id,title,status} + 底层实例(term/ws 等) ----
const [sessions, setSessions] = useState([]);
const [activeId, setActiveId] = useState(null);
const [banner, setBanner] = useState(null); // {type:'warn'|'error', text}
const instRef = useRef({}); // id → {term,fit,ro,ws,backoffIdx,timers,autoReconnect,closed}
const tokenRef = useRef({ value: null, expiresAt: 0 });
const seqRef = useRef(0);
const stripRef = useRef(null); // 会话标签条(方向键 roving tabindex 用)
const patchStatus = useCallback((id, status) => {
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, status } : s)));
}, []);
/* ---------- WS 连接层(一次性初始化,全走 ref,无陈旧闭包) ---------- */
const manager = useRef(null);
if (!manager.current) {
const M = {};
M.sendResize = (id) => {
const inst = instRef.current[id];
if (!inst || !inst.ws || inst.ws.readyState !== 1 || !inst.term) return;
try {
inst.ws.send(JSON.stringify({ type: 'resize', cols: inst.term.cols, rows: inst.term.rows }));
} catch { /* ignore */ }
};
M.handleControl = (id, text) => {
try {
const msg = JSON.parse(text);
if (msg.type === 'bye') { /* server 主动结束,交由 close 处理 */ }
// 'pong' / 未知控制消息忽略
} catch { /* 非 JSON 文本忽略 */ }
};
M.scheduleReconnect = (id) => {
const inst = instRef.current[id];
if (!inst || inst.closed) return;
const delay = BACKOFF[Math.min(inst.backoffIdx, BACKOFF.length - 1)];
inst.backoffIdx = Math.min(inst.backoffIdx + 1, BACKOFF.length - 1);
clearTimeout(inst.reconnectTimer);
inst.reconnectTimer = setTimeout(() => M.connectSession(id), delay);
};
M.connectSession = async (id) => {
const inst = instRef.current[id];
if (!inst || inst.closed) return;
// 防双 WS 竞态:关闭旧连接并置空其回调(避免旧 onclose 触发多余重连)
try {
if (inst.ws) { inst.ws.onclose = null; inst.ws.onerror = null; try { inst.ws.close(); } catch {} }
} catch {}
inst.ws = null;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
inst.gen = (inst.gen || 0) + 1;
const gen = inst.gen;
patchStatus(id, 'connecting');
// 无有效 token → 回 PIN 门(4001 过期或从未认证)
const token = tokenRef.current.value;
if (!token) {
inst.autoReconnect = false;
setGate('locked');
setGateError('终端密码已过期,请重新输入');
return;
}
// 先查状态:后端不可用 / 会话满 给明确提示,避免盲连
try {
const st = await terminalStatus();
if (st.sessions >= st.maxSessions) {
inst.autoReconnect = false;
patchStatus(id, 'error');
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
return;
}
} catch {
if (!inst.closed && gen === inst.gen) {
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
patchStatus(id, 'reconnecting');
M.scheduleReconnect(id);
}
return;
}
let ws = null;
try { ws = new WebSocket(wsUrl()); } catch { /* 构造失败走 onerror/onclose */ }
if (!ws) { M.scheduleReconnect(id); return; }
inst.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (inst.closed || gen !== inst.gen) { try { ws.close(); } catch {} return; }
inst.backoffIdx = 0;
setBanner(null);
try { ws.send(JSON.stringify({ type: 'auth', token })); } catch {}
M.sendResize(id);
// 断线恢复后清屏,避免旧输出残留
try { inst.term?.reset(); } catch {}
patchStatus(id, 'connected');
inst.pingTimer = setInterval(() => {
if (inst.closed || gen !== inst.gen || ws.readyState !== 1) return;
try { ws.send(JSON.stringify({ type: 'ping' })); } catch {}
}, PING_MS);
};
ws.onmessage = (ev) => {
if (inst.closed || gen !== inst.gen) return;
if (typeof ev.data === 'string') { M.handleControl(id, ev.data); return; }
try {
inst.term?.write(ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data);
} catch { /* ignore */ }
};
ws.onerror = () => { try { ws.close(); } catch {} };
ws.onclose = (ev) => {
if (inst.closed || gen !== inst.gen) return;
clearInterval(inst.pingTimer);
inst.ws = null;
// 关闭码语义(后端 routes/terminal.js 定义)
if (ev.code === 4001) { // token 无效/过期 → 回 PIN 门重新认证
tokenRef.current = { value: null, expiresAt: 0 };
inst.autoReconnect = false;
setGate('locked');
setGateError('终端密码已过期,请重新输入');
return;
}
if (ev.code === 1013 || ev.code === 503) { // 会话超限
inst.autoReconnect = false;
patchStatus(id, 'error');
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
return;
}
if (ev.code === 1011) { // pty 启动失败 / node-pty 未安装
inst.autoReconnect = false;
patchStatus(id, 'error');
setBanner({ type: 'error', text: '终端服务不可用:node-pty 未安装或启动失败' });
return;
}
// 服务端主动结束会话:exit(1000) / 空闲超时(4000)
// 视为会话终止,不自动重生新 root shell;由用户显式「重新连接」
if (ev.code === 1000 || ev.code === 4000) {
inst.autoReconnect = false;
inst.endedReason = ev.code === 4000 ? 'idle' : 'exit';
patchStatus(id, 'ended');
return;
}
// 其余(1006 网络异常等)→ 指数退避自动重连
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
if (inst.autoReconnect) { patchStatus(id, 'reconnecting'); M.scheduleReconnect(id); }
else patchStatus(id, 'error');
};
};
manager.current = M;
}
/* ---------- 门禁 ---------- */
const checkGate = useCallback(async () => {
setGate('checking');
setGateError('');
try {
const st = await terminalStatus();
if (tokenRef.current.value && Date.now() < tokenRef.current.expiresAt) { setGate('ready'); return; }
setGate(st.hasPin ? 'locked' : 'needs-setup');
} catch (e) {
setGate('error');
setGateError(e.message || '无法连接终端服务');
}
}, []);
// 首次展开面板才查状态(收起时 keep-alive,不重复请求)
useEffect(() => {
if (open && gate === 'checking') checkGate();
}, [open, gate, checkGate]);
const submitPin = useCallback(async () => {
const pin = pinInput.trim();
if (!pin) { setGateError('请输入终端密码'); return; }
setPinBusy(true);
setGateError('');
try {
const res = await terminalAuth(pin);
tokenRef.current = { value: res.token, expiresAt: Date.now() + TOKEN_MARGIN_MS };
setPinInput('');
setShowPin(false);
setGate('ready');
// 有会话在等 token → 全部重新连接
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
});
} catch (e) {
setGateError(e.message || '认证失败');
setPinInput('');
} finally {
setPinBusy(false);
}
}, [pinInput]);
/* ---------- 会话生命周期 ---------- */
const newSession = useCallback(() => {
if (gate !== 'ready') {
showSnack('请先解锁终端', 'info');
return;
}
const live = Object.keys(instRef.current).filter((k) => instRef.current[k] && !instRef.current[k].closed);
if (live.length >= MAX_SESSIONS) {
setBanner({ type: 'error', text: `终端会话数已达上限(最多 ${MAX_SESSIONS} 个)` });
return;
}
seqRef.current += 1;
const id = 't' + seqRef.current;
instRef.current[id] = {
id, term: null, fit: null, ro: null, ws: null,
backoffIdx: 0, reconnectTimer: null, pingTimer: null,
autoReconnect: true, closed: false, gen: 0,
};
setSessions((prev) => [...prev, { id, title: `终端 ${seqRef.current}`, status: 'connecting' }]);
setActiveId(id);
setBanner(null);
}, [gate]);
const closeSession = useCallback((id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.closed = true;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
try { if (inst.ws && inst.ws.readyState === 1) inst.ws.send(JSON.stringify({ type: 'bye' })); } catch {}
try { inst.ws?.close(1000, 'bye'); } catch {}
try { inst.term?.dispose(); } catch {}
try { inst.ro?.disconnect(); } catch {}
instRef.current[id] = null;
// 状态更新不嵌套:先算剩余列表,再分别 set(避免 updater 内副作用)
const remaining = sessions.filter((s) => s.id !== id);
setSessions(remaining);
setActiveId((cur) => (cur === id ? (remaining[remaining.length - 1]?.id ?? null) : cur));
setBanner(null);
}, [sessions]);
const lockTerminal = useCallback(() => {
tokenRef.current = { value: null, expiresAt: 0 };
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.closed = true;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
try { inst.ws?.close(1000, 'bye'); } catch {}
try { inst.term?.dispose(); } catch {}
try { inst.ro?.disconnect(); } catch {}
instRef.current[id] = null;
});
setSessions([]);
setActiveId(null);
setBanner(null);
setGate('locked');
}, []);
useImperativeHandle(ref, () => ({ newSession, lock: lockTerminal }), [newSession, lockTerminal]);
// 解锁后自动建首个会话
useEffect(() => {
if (gate === 'ready' && sessions.length === 0) newSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gate]);
// 卸载时统一销毁(keep-alive 场景下不触发)
useEffect(() => () => {
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.closed = true;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
try { inst.ws?.close(); } catch {}
try { inst.term?.dispose(); } catch {}
try { inst.ro?.disconnect(); } catch {}
});
}, []);
// 主题随 muiTheme / data-theme 更新(后台主题静态,运行期一般不触发)
useEffect(() => {
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (inst?.term) inst.term.options.theme = themeObj;
});
}, [themeObj]);
/** xterm 挂载点:会话创建后容器出现时调用;隐藏恢复时重新 fit */
const mountTerminal = useCallback((id, el) => {
const inst = instRef.current[id];
if (!inst || !el) return;
if (inst.term) {
requestAnimationFrame(() => {
try { inst.fit?.fit(); manager.current.sendResize(id); } catch {}
});
return;
}
const term = new Terminal({
fontSize: 13,
fontFamily: FONT,
lineHeight: 1.35,
cursorBlink: true,
scrollback: 3000,
theme: themeObj,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
term.open(el);
term.onData((d) => {
const ws = inst.ws;
if (!ws || ws.readyState !== 1) return;
try { ws.send(new TextEncoder().encode(d)); } catch {}
});
term.onResize(({ cols, rows }) => {
const ws = inst.ws;
if (ws && ws.readyState === 1) {
try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch {}
}
});
const ro = new ResizeObserver(() => {
if (el.clientWidth > 0 && el.clientHeight > 0) {
try { fit.fit(); manager.current.sendResize(id); } catch {}
}
});
ro.observe(el);
inst.term = term;
inst.fit = fit;
inst.ro = ro;
requestAnimationFrame(() => {
try { fit.fit(); manager.current.sendResize(id); } catch {}
});
manager.current.connectSession(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeObj]);
/* ---------- 渲染 ---------- */
// PIN / 设置门禁
if (gate === 'checking' || gate === 'needs-setup' || gate === 'locked' || gate === 'error') {
return (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
{gate === 'checking' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5 }}>
<CircularProgress size={26} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>正在检查终端服务</Typography>
</Box>
)}
{gate === 'error' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center' }}>
<ErrorIcon sx={{ fontSize: 38, color: 'error.main' }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{gateError || '无法连接终端服务'}</Typography>
<Button size="small" variant="outlined" onClick={checkGate}>重试</Button>
</Box>
)}
{(gate === 'needs-setup' || gate === 'locked') && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitPin(); }} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3 }}>
<TerminalIcon sx={{ fontSize: 34, color: 'primary.main', mb: 0.5 }} />
<Typography sx={{ fontWeight: 600, fontSize: 15 }}>
{gate === 'needs-setup' ? '首次使用:设置终端密码' : '输入终端密码解锁'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', mb: 1 }}>
{gate === 'needs-setup'
? '终端为管理员 Root Shell,请设置专用密码(至少 8 位,与账号密码独立)'
: '终端密码与账号密码相互独立,仅管理员可访问'}
</Typography>
<TextField
autoFocus
size="small"
type={showPin ? 'text' : 'password'}
label="终端密码"
value={pinInput}
onChange={(e) => { setPinInput(e.target.value); if (gateError) setGateError(''); }}
error={!!gateError}
helperText={gateError || ' '}
autoComplete={gate === 'needs-setup' ? 'new-password' : 'current-password'}
slotProps={{
htmlInput: { maxLength: 128, 'aria-label': '终端密码' },
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" edge="end" aria-label={showPin ? '隐藏密码' : '显示密码'} onClick={() => setShowPin((v) => !v)}>
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
sx={{ width: 300, maxWidth: '100%' }}
/>
<Button
type="submit"
variant="contained"
disabled={pinBusy || !pinInput}
startIcon={pinBusy ? <CircularProgress size={16} color="inherit" /> : <LockOpenIcon />}
sx={{ mt: 1 }}
>
{pinBusy ? '处理中…' : gate === 'needs-setup' ? '设置并连接' : '解锁终端'}
</Button>
</Box>
)}
</Box>
);
}
// ready:会话标签栏 + 终端
const live = sessions.length;
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
const moveSessionTab = (i) => {
const n = sessions.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
setActiveId(sessions[j].id);
tabEls()[j]?.focus();
};
const handleTabKeyDown = (e, index) => {
if (e.key === 'ArrowRight') { e.preventDefault(); moveSessionTab(index + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveSessionTab(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveSessionTab(0); }
else if (e.key === 'End') { e.preventDefault(); moveSessionTab(sessions.length - 1); }
};
/** 会话结束后显式重连:新建 WS 连接 = 服务端 spawn 新 shell */
const reconnectSession = (id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.autoReconnect = true;
delete inst.endedReason;
manager.current.connectSession(id);
};
return (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
{/* 会话标签栏 */}
<Box
ref={stripRef}
role="tablist"
aria-label="终端会话"
sx={{
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, height: 32, flexShrink: 0,
borderBottom: 1, borderColor: 'divider',
}}
>
{sessions.map((s, index) => {
const active = s.id === activeId;
return (
<Box
key={s.id}
id={`wb-term-tab-${s.id}`}
role="tab"
aria-selected={active}
aria-controls={`wb-term-panel-${s.id}`}
tabIndex={active ? 0 : -1}
onClick={() => setActiveId(s.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
height: 24, borderRadius: 0.5, cursor: 'pointer', flexShrink: 0, outline: 'none',
bgcolor: active ? 'action.selected' : 'transparent',
color: active ? 'text.primary' : 'text.secondary',
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
}}
>
<Box
component="span"
aria-hidden
sx={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
bgcolor: s.status === 'connected' ? 'success.main'
: s.status === 'error' ? 'error.main'
: s.status === 'ended' ? 'text.disabled' : 'warning.main',
}}
/>
<Typography sx={{ fontSize: 12, fontWeight: active ? 600 : 500 }}>{s.title}</Typography>
{live > 1 && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); closeSession(s.id); }}
aria-label={`关闭 ${s.title}`}
sx={{ width: 24, height: 24, borderRadius: 0.5, '& svg': { fontSize: 15 } }}
>
<CloseIcon />
</IconButton>
)}
</Box>
);
})}
{live < MAX_SESSIONS && (
<Tooltip title="新建终端会话">
<IconButton size="small" onClick={newSession} aria-label="新建终端会话" sx={{ ml: 0.25 }}>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
<Box sx={{ flex: 1 }} />
<Tooltip title="锁定终端(需重新输入密码)">
<IconButton size="small" onClick={lockTerminal} aria-label="锁定终端" sx={{ mr: 0.25 }}>
<LockIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
{/* 连接状态横幅 */}
{banner && (
<Box
role="status"
sx={{
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5, flexShrink: 0,
bgcolor: banner.type === 'error' ? 'error.main' : 'warning.main',
color: banner.type === 'error' ? 'error.contrastText' : 'warning.contrastText',
borderBottom: 1, borderColor: 'divider',
}}
>
<Box
component="span"
aria-hidden
sx={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, bgcolor: 'currentColor', opacity: 0.8 }}
/>
<Typography variant="caption" sx={{ flex: 1, fontWeight: 500 }}>{banner.text}</Typography>
<Button
size="small"
onClick={() => Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
})}
sx={{ minHeight: 24, px: 1, color: 'inherit' }}
>
重试
</Button>
<IconButton size="small" onClick={() => setBanner(null)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Box>
)}
{/* 终端区 */}
<Box sx={{ flex: 1, minHeight: 0, position: 'relative', bgcolor: 'background.paper' }}>
{sessions.map((s) => (
<Box
key={s.id}
id={`wb-term-panel-${s.id}`}
role="tabpanel"
aria-labelledby={`wb-term-tab-${s.id}`}
sx={{
position: 'absolute', inset: 0,
display: activeId === s.id ? 'block' : 'none',
}}
>
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
{s.status === 'ended' && (
<Box
role="status"
sx={{
position: 'absolute', inset: 0, zIndex: 2,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.25,
bgcolor: 'background.paper', px: 3, textAlign: 'center',
}}
>
<TerminalIcon sx={{ fontSize: 28, color: 'text.disabled' }} />
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
{instRef.current[s.id]?.endedReason === 'idle' ? '会话已空闲断开' : '终端会话已结束'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{instRef.current[s.id]?.endedReason === 'idle'
? '长时间无输入,连接被服务端关闭。点击「重新连接」可新建会话。'
: '输入 exit 退出或进程已结束。点击「重新连接」可新建会话。'}
</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" startIcon={<RefreshIcon fontSize="small" />} onClick={() => reconnectSession(s.id)}>
重新连接
</Button>
<Button size="small" variant="text" onClick={() => closeSession(s.id)}>
关闭会话
</Button>
</Stack>
</Box>
)}
</Box>
))}
{sessions.length === 0 && (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<TerminalIcon sx={{ fontSize: 30, color: 'text.disabled' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>点击 新建终端会话</Typography>
</Box>
)}
</Box>
</Box>
);
});
export default TerminalPanel;
@@ -0,0 +1,81 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import TabIcon from '@mui/icons-material/Tab';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import CloseIcon from '@mui/icons-material/Close';
import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js';
/* ============================================================
* ToolWindow:通用可停靠面板(侧边栏 / 标签页双模式 + 浮动占位)
*
* - 标题栏:图标 + 标题 + 模式切换(侧边栏/标签页)+ 浮动占位按钮
* (P2 未实现,点击只触发 onFloat 提示)+ 关闭按钮 + 自定义 actions
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
* ============================================================ */
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, actions = null, children }) {
const modeBtn = (m, label, MIcon) => (
<Tooltip key={m} title={label}>
<IconButton
size="small"
onClick={() => onSetMode?.(m)}
aria-label={label}
aria-pressed={mode === m}
sx={{
width: 26, height: 26, borderRadius: 1,
color: mode === m ? 'primary.main' : 'text.secondary',
bgcolor: mode === m ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: mode === m ? 'action.selected' : 'action.hover' },
}}
>
<MIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
);
return (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 0.25,
px: 1.25, height: 44, flexShrink: 0,
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
}}
>
{icon}
<Typography noWrap sx={{ flex: 1, minWidth: 0, ml: 0.25, fontSize: 13, fontWeight: 600 }}>
{title}
</Typography>
{modeBtn(VIEW_MODE_SIDEBAR, '侧边栏模式', ViewSidebarIcon)}
{modeBtn(VIEW_MODE_TAB, '标签页模式', TabIcon)}
{onFloat && (
<Tooltip title="浮动模式(即将上线)">
<IconButton
size="small"
onClick={onFloat}
aria-label="浮动模式"
sx={{ width: 26, height: 26, borderRadius: 1 }}
>
<OpenInFullIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
{actions}
{onClose && (
<Tooltip title="关闭">
<IconButton
size="small"
onClick={onClose}
aria-label="关闭"
sx={{ width: 26, height: 26, borderRadius: 1 }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
}
@@ -5,43 +5,66 @@ import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import InputBase from '@mui/material/InputBase'; import InputBase from '@mui/material/InputBase';
import { useTheme } from '@mui/material/styles';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import KeyIcon from '@mui/icons-material/Key';
import NoteAltIcon from '@mui/icons-material/NoteAlt';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import HomeIcon from '@mui/icons-material/Home';
import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import HomeIcon from '@mui/icons-material/Home';
import MenuBar from './MenuBar.jsx';
import PanelIcon from './PanelIcon.jsx'; import PanelIcon from './PanelIcon.jsx';
/* ============================================================ /* ============================================================
* Toolbar浏览器式顶栏(高度 56pxsurface-container 底 * ToolbarVSCode 风格顶栏(高度 56px
* ◀ 后退 / ▶ 前进(历史栈)· ↻ 刷新 · 只读地址栏 · 🔍 搜索 *
* + 添加 · 🔑 密码库 · 侧边栏折叠 · 返回前台 * Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 缩小地址栏(只读) | 搜索 | 返回
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
* ============================================================ */ * ============================================================ */
export default function Toolbar({ export default function Toolbar({
menus = [],
canBack = false, canForward = false, onBack, onForward, onRefresh, canBack = false, canForward = false, onBack, onForward, onRefresh,
currentPanel = null, onOpenExternal, currentPanel = null, onOpenExternal,
search = '', onSearchChange, onSearchEnter, search = '', onSearchChange, onSearchEnter,
onAdd, onOpenVault, notesOpen = false, onOpenNotes, searchInputRef = null, onBackHome,
collapsed = false, onToggleSidebar, onBackHome,
}) { }) {
const muiTheme = useTheme();
const searchRef = useRef(null); const searchRef = useRef(null);
const inputRef = searchInputRef || searchRef;
const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : ''; const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : '';
const logoGradient = `linear-gradient(135deg, ${muiTheme.palette.primary.main} 0%, ${muiTheme.palette.tertiary.main} 100%)`;
return ( return (
<Box <Box
component="header" component="header"
sx={{ sx={{
height: 56, flexShrink: 0, display: 'flex', alignItems: 'center', height: 56, flexShrink: 0, display: 'flex', alignItems: 'center',
gap: 0.5, px: { xs: 0.5, sm: 1 }, zIndex: 10, gap: 0.5, px: 1, zIndex: 10,
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider', bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
}} }}
> >
{/* 品牌:纯文字 Logo,MD3 渐变字重 */}
<Box
component="span"
aria-label="Rain Work"
sx={{
fontSize: 15, fontWeight: 700, letterSpacing: 0.3, mr: 0.75, flexShrink: 0,
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
WebkitTextFillColor: 'transparent', color: 'transparent',
userSelect: 'none',
}}
>
Rain Work
</Box>
<MenuBar menus={menus} />
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 导航:后退 / 前进 / 刷新 */}
<Tooltip title="后退"> <Tooltip title="后退">
<span><IconButton size="small" onClick={onBack} disabled={!canBack} aria-label="后退"><ArrowBackIcon fontSize="small" /></IconButton></span> <span><IconButton size="small" onClick={onBack} disabled={!canBack} aria-label="后退"><ArrowBackIcon fontSize="small" /></IconButton></span>
</Tooltip> </Tooltip>
@@ -52,87 +75,66 @@ export default function Toolbar({
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span> <span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
</Tooltip> </Tooltip>
{/* 只读地址栏 */} {/* 缩小地址栏(只读) */}
<Box <Box
role="textbox"
aria-label="当前面板地址"
sx={{ sx={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 120, maxWidth: 360, display: 'flex', alignItems: 'center', gap: 0.75,
mx: { xs: 0.5, sm: 1.5 }, height: 36, px: 1.5, mx: 0.75, height: 34, px: 1.25,
bgcolor: 'background.default', border: 1, borderColor: 'divider', bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
borderRadius: 1.5, '&:focus-within': { borderColor: 'primary.main' }, '&:focus-within': { borderColor: 'primary.main' },
}} }}
> >
{currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={18} />} {currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={16} />}
<InputBase <InputBase
readOnly readOnly
fullWidth fullWidth
value={currentUrl} value={currentUrl}
placeholder="未选择面板" placeholder="未打开面板"
inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 13 } }} inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 12.5 } }}
sx={{ color: 'text.secondary' }} sx={{ color: 'text.secondary' }}
/> />
{currentPanel && ( {currentPanel && (
<Tooltip title="在新标签页打开原站"> <Tooltip title="在新标签页打开原站">
<IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站"> <IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站">
<OpenInNewIcon fontSize="small" /> <OpenInNewIcon sx={{ fontSize: 16 }} />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
)} )}
</Box> </Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}> {/* 搜索 */}
{/* 搜索(面板名/URL/分组,Enter 直达) */} <Box sx={{ position: 'relative', flexShrink: 0 }}>
<Box sx={{ position: 'relative' }}>
<SearchIcon <SearchIcon
sx={{ sx={{
position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)', position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)',
fontSize: 17, color: 'text.secondary', pointerEvents: 'none', fontSize: 16, color: 'text.secondary', pointerEvents: 'none',
}} }}
/> />
<InputBase <InputBase
ref={searchRef} ref={inputRef}
value={search} value={search}
onChange={(e) => onSearchChange(e.target.value)} onChange={(e) => onSearchChange?.(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter(); searchRef.current?.blur(); } }} onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter?.(); inputRef.current?.blur(); } }}
placeholder="搜索面板" placeholder="搜索面板"
inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 13 } }} inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 12.5 } }}
sx={{ sx={{
height: 36, width: { xs: 96, sm: 150 }, px: 0.5, height: 34, width: { xs: 100, sm: 150 }, px: 0.5,
bgcolor: 'background.default', border: 1, borderColor: 'divider', bgcolor: 'background.default', border: 1, borderColor: 'divider',
borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' }, borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' },
}} }}
/> />
</Box> </Box>
<Tooltip title="添加面板">
<span><IconButton size="small" onClick={onAdd} aria-label="添加面板"><AddIcon fontSize="small" /></IconButton></span>
</Tooltip>
<Tooltip title="记事本">
<span>
<IconButton
size="small"
onClick={onOpenNotes}
aria-label="记事本"
color={notesOpen ? 'primary' : 'default'}
>
<NoteAltIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="密码库">
<span><IconButton size="small" onClick={onOpenVault} aria-label="密码库"><KeyIcon fontSize="small" /></IconButton></span>
</Tooltip>
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
<Tooltip title={collapsed ? '展开侧边栏' : '收起侧边栏'}>
<span><IconButton size="small" onClick={onToggleSidebar} aria-label="切换侧边栏"><ViewSidebarIcon fontSize="small" /></IconButton></span>
</Tooltip>
</Box>
{/* 返回前台 */}
<Button <Button
color="inherit" color="inherit"
onClick={onBackHome} onClick={onBackHome}
startIcon={<HomeIcon sx={{ fontSize: 18 }} />} startIcon={<HomeIcon sx={{ fontSize: 17 }} />}
sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.5 }, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }} sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.25 }, flexShrink: 0, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }}
> >
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>返回前台</Box> <Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, fontSize: 13 }}>返回</Box>
</Button> </Button>
</Box> </Box>
); );
@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
@@ -12,10 +11,7 @@ import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import Collapse from '@mui/material/Collapse'; import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
@@ -28,19 +24,18 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ErrorIcon from '@mui/icons-material/Error'; import ErrorIcon from '@mui/icons-material/Error';
import useVault from '../hooks/useVault.js'; import useVault from '../hooks/useVault.js';
import { showSnack } from '../../../admin/snack.jsx';
/** /* ============================================================
* 工作台 · 密码库抽屉(Lane B,只读)。 * VaultView:密码箱视图(VSCode 侧边栏 / 标签页内容)
* *
* 交互流程: * 由 VaultDrawer 裁剪:去掉 MUI Drawer 壳与自带标题栏/关闭按钮
* loading →(pinStatus)→ noPin(引导去 /passwords.html)| lockedPIN 输入) * (由父级 ToolWindow 提供),锁定按钮并入解锁后的搜索行。
* → unlock → unlocked(搜索 + 条目列表,点击条目展开详情逐字段复制) * 标题栏之外全部逻辑保留:
* 「锁定」按钮走 lock API;关闭抽屉不锁定,方便切面板取用。 * loading → noPin(引导设置)| lockedPIN 输入)→ unlocked(搜索+条目)
* * 「锁定」走 lock API;切换视图不锁定,方便取用。
* 接口:{ open, onClose, title? }title 可选,默认「密码库」,兼容占位版约定)。 * @param {{open: boolean}} open 视图是否处于激活(激活时重新同步服务端状态)
* 自包含:不依赖主工作台的任何代码,只依赖 useVault hook 与 api/passwords.js。 * ============================================================ */
* 深浅色随后台 MD3 主题(createMD3Theme)自动适配。
*/
/** 剪贴板写入:优先 Async Clipboard,失败时降级 execCommand(非安全上下文/LAN 部署) */ /** 剪贴板写入:优先 Async Clipboard,失败时降级 execCommand(非安全上下文/LAN 部署) */
async function copyText(text) { async function copyText(text) {
@@ -67,14 +62,16 @@ async function copyText(text) {
/** 展开详情中的单字段行:标签 + 值(网址可点开)+ 复制按钮 */ /** 展开详情中的单字段行:标签 + 值(网址可点开)+ 复制按钮 */
function DetailField({ label, value, href, secret, onCopy }) { function DetailField({ label, value, href, secret, onCopy }) {
// 防注入:仅放行 http(s)/mailto,其余协议降级为纯文本
const safeHref = href && /^(https?|mailto):/i.test(href) ? href : null;
return ( return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.75, minWidth: 0 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.75, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', width: 40, flexShrink: 0 }}>{label}</Typography> <Typography variant="caption" sx={{ color: 'text.secondary', width: 40, flexShrink: 0 }}>{label}</Typography>
{href ? ( {safeHref ? (
<Typography <Typography
variant="body2" variant="body2"
component="a" component="a"
href={href} href={safeHref}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
title={value} title={value}
@@ -169,28 +166,18 @@ function VaultEntryRow({ entry, expanded, onToggle, onCopy }) {
} }
/** /**
* 密码库抽屉 * 密码箱视图
* @param {boolean} open 是否打开(每次打开会重新同步服务端会话状态) * @param {boolean} open 视图是否激活(激活时重新同步服务端会话状态)
* @param {() => void} onClose 关闭回调(仅关闭,不锁定)
* @param {string} [title] 抽屉标题,默认「密码库」
*/ */
export default function VaultDrawer({ open, onClose, title = '密码库' }) { export default function VaultView({ open = false }) {
const vault = useVault({ open }); const vault = useVault({ open });
// PIN 输入屏的本地状态 // PIN 输入屏的本地状态
const [pinInput, setPinInput] = useState(''); const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState(''); const [pinError, setPinError] = useState('');
const [showPin, setShowPin] = useState(false); const [showPin, setShowPin] = useState(false);
// 展开中的条目 id(重新打开时清空)
// 展开中的条目 id(每次打开清空)
const [expandedId, setExpandedId] = useState(null); const [expandedId, setExpandedId] = useState(null);
// 每次打开自增,作为内容 key:保证重新打开时 autoFocus 生效
const [openCount, setOpenCount] = useState(0);
const [snack, setSnack] = useState({ open: false, msg: '', severity: 'success' });
const notify = useCallback((msg, severity = 'success') => {
setSnack({ open: true, msg, severity });
}, []);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -198,7 +185,6 @@ export default function VaultDrawer({ open, onClose, title = '密码库' }) {
setPinError(''); setPinError('');
setShowPin(false); setShowPin(false);
setExpandedId(null); setExpandedId(null);
setOpenCount((c) => c + 1);
} }
}, [open]); }, [open]);
@@ -210,29 +196,28 @@ export default function VaultDrawer({ open, onClose, title = '密码库' }) {
await vault.unlock(pin); await vault.unlock(pin);
setPinInput(''); setPinInput('');
setShowPin(false); setShowPin(false);
notify('已解锁'); showSnack('已解锁');
} catch (e) { } catch (e) {
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'
setPinError(e.message || '解锁失败'); setPinError(e.message || '解锁失败');
setPinInput(''); setPinInput('');
} }
}, [pinInput, vault, notify]); }, [pinInput, vault]);
const handleLock = useCallback(async () => { const handleLock = useCallback(async () => {
try { try {
await vault.lock(); await vault.lock();
setExpandedId(null); setExpandedId(null);
notify('已锁定'); showSnack('已锁定');
} catch (e) { } catch (e) {
notify(e.message || '锁定失败', 'error'); showSnack(e.message || '锁定失败', 'error');
} }
}, [vault, notify]); }, [vault]);
const copyToClipboard = useCallback(async (text, label) => { const copyToClipboard = useCallback(async (text, label) => {
const ok = await copyText(text); const ok = await copyText(text);
if (ok) notify(`${label} 已复制`); if (ok) showSnack(`${label} 已复制`);
else notify('复制失败', 'error'); else showSnack('复制失败', 'error');
}, [notify]); }, []);
/* ---------- 各状态下的主体 ---------- */ /* ---------- 各状态下的主体 ---------- */
@@ -319,8 +304,9 @@ export default function VaultDrawer({ open, onClose, title = '密码库' }) {
const empty = filteredEntries.length === 0; const empty = filteredEntries.length === 0;
return ( return (
<Box> <Box>
{/* 搜索 + 锁定(标题栏由 ToolWindow 提供,锁定并入此行) */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<TextField <TextField
autoFocus
fullWidth fullWidth
size="small" size="small"
placeholder="搜索标题或用户名" placeholder="搜索标题或用户名"
@@ -343,8 +329,15 @@ export default function VaultDrawer({ open, onClose, title = '密码库' }) {
) : null, ) : null,
}, },
}} }}
sx={{ mb: 1 }}
/> />
<Tooltip title="锁定密码库">
<span>
<IconButton aria-label="锁定密码库" onClick={handleLock} disabled={vault.locking} size="small">
<LockIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
{entries === null && ( {entries === null && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
@@ -388,69 +381,10 @@ export default function VaultDrawer({ open, onClose, title = '密码库' }) {
}; };
return ( return (
<Drawer <Box component="section" aria-label="密码箱" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
anchor="right" <Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', px: 1.5, py: 1.5 }}>
open={open}
onClose={onClose}
sx={{
'& .MuiDrawer-paper': {
width: 360,
maxWidth: '100vw',
borderRight: 'none',
borderLeft: (t) => `1px solid ${t.palette.divider}`,
boxShadow: (t) => t.shadows[8],
backgroundImage: 'none',
},
}}
>
<Box
key={openCount}
role="dialog"
aria-modal="true"
aria-labelledby="vault-drawer-title"
sx={{ width: 360, maxWidth: '100vw', height: '100%', display: 'flex', flexDirection: 'column' }}
>
{/* 头部:标题 + 锁定 + 关闭 */}
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5 }}>
<KeyIcon sx={{ color: 'primary.main', mr: 0.5 }} />
<Typography variant="h6" id="vault-drawer-title" sx={{ flexGrow: 1, fontSize: 18 }}>{title}</Typography>
{vault.status === 'unlocked' && (
<Tooltip title="锁定密码库">
<span>
<IconButton aria-label="锁定密码库" onClick={handleLock} disabled={vault.locking} size="small">
<LockIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="关闭">
<IconButton aria-label="关闭密码库" onClick={onClose} edge="end">
<CloseIcon />
</IconButton>
</Tooltip>
</Box>
<Divider />
{/* 主体 */}
<Box sx={{ flexGrow: 1, overflowY: 'auto', px: 2, py: 2 }}>
{renderBody()} {renderBody()}
</Box> </Box>
</Box> </Box>
<Snackbar
open={snack.open}
autoHideDuration={2200}
onClose={() => setSnack((s) => ({ ...s, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert
severity={snack.severity}
variant="filled"
onClose={() => setSnack((s) => ({ ...s, open: false }))}
>
{snack.msg}
</Alert>
</Snackbar>
</Drawer>
); );
} }
+63 -5
View File
@@ -8,21 +8,34 @@ import { showSnack } from '../../../admin/snack.jsx';
* - 列表加载/错误态、当前编辑文件(activeName)、草稿内容 + 服务端快照 * - 列表加载/错误态、当前编辑文件(activeName)、草稿内容 + 服务端快照
* - 自动保存:输入防抖 900msCtrl+S 立即保存(dirty = 草稿 ≠ 快照) * - 自动保存:输入防抖 900msCtrl+S 立即保存(dirty = 草稿 ≠ 快照)
* - 新建 / 重命名(新文件 + 删旧文件)/ 删除,全程复用 notes API * - 新建 / 重命名(新文件 + 删旧文件)/ 删除,全程复用 notes API
* - 会话保持(sidebar↔tab 模式切换/切视图会重挂载组件):
* · 草稿存模块级 sessionCache,重挂载瞬间恢复,光标上下文不丢
* · activeName 持久化到 localStorageworkbench.notesActive),跨刷新恢复上次文件
* · 监听 window 'workbench:notes-save-request'Workbench 切换模式前派发),
* 立即 flush 未落盘的 <900ms 草稿
* ============================================================ */ * ============================================================ */
const AUTOSAVE_MS = 900; const AUTOSAVE_MS = 900;
const NOTES_ACTIVE_KEY = 'workbench.notesActive';
/** 模块级草稿缓存:组件重挂载后直接恢复,不依赖服务端往返 */
const sessionCache = { name: null, text: '' };
export default function useNotes() { export default function useNotes() {
const [notes, setNotes] = useState(null); // null = 加载中 const [notes, setNotes] = useState(null); // null = 加载中
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [activeName, setActiveName] = useState(null); // 当前编辑的笔记名 const [activeName, setActiveName] = useState(() => {
const [content, setContent] = useState(''); // 编辑器草稿 try { return localStorage.getItem(NOTES_ACTIVE_KEY) || null; } catch { return null; }
const [savedContent, setSavedContent] = useState(''); // 服务端快照 });
// 草稿与快照初始化取自缓存(若缓存有同名文件),避免模式切换丢内容
const [content, setContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
const [savedContent, setSavedContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
const [saving, setSaving] = useState(false); // 保存请求进行中 const [saving, setSaving] = useState(false); // 保存请求进行中
const [busy, setBusy] = useState(false); // 打开/新建/重命名进行中 const [busy, setBusy] = useState(false); // 打开/新建/重命名进行中
const draftRef = useRef({ name: null, text: '' }); // 最新草稿(供防抖回调读取) const draftRef = useRef({ name: null, text: '' }); // 最新草稿(供防抖回调读取)
const debounceRef = useRef(null); const debounceRef = useRef(null);
const restoredRef = useRef(false); // 恢复会话只执行一次
// ---- 列表加载(静默刷新:保留现有列表,避免自动保存时闪烁) ---- // ---- 列表加载(静默刷新:保留现有列表,避免自动保存时闪烁) ----
const refresh = useCallback(() => { const refresh = useCallback(() => {
@@ -38,6 +51,40 @@ export default function useNotes() {
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
// 无缓存但记住了文件名→从服务端读取 ----
useEffect(() => {
if (restoredRef.current) return;
if (activeName === null) { restoredRef.current = true; return; }
if (sessionCache.name === activeName) {
restoredRef.current = true;
saveContent(activeName, sessionCache.text);
return;
}
restoredRef.current = true;
open(activeName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- activeName 持久化(模式切换/刷新后恢复上次文件) ----
useEffect(() => {
try {
if (activeName) localStorage.setItem(NOTES_ACTIVE_KEY, activeName);
else localStorage.removeItem(NOTES_ACTIVE_KEY);
} catch { /* 隐私模式忽略 */ }
}, [activeName]);
// ---- 模式切换前的显式保存:Workbench 派发 workbench:notes-save-request ----
useEffect(() => {
const handler = () => {
clearTimeout(debounceRef.current);
const d = draftRef.current;
if (d.name) saveContent(d.name, d.text);
};
window.addEventListener('workbench:notes-save-request', handler);
return () => window.removeEventListener('workbench:notes-save-request', handler);
}, [saveContent]);
// 当前文件被外部删除/失效时清空编辑器 // 当前文件被外部删除/失效时清空编辑器
useEffect(() => { useEffect(() => {
if (activeName === null) { if (activeName === null) {
@@ -53,6 +100,7 @@ export default function useNotes() {
return notesApi.updateNote(name, text) return notesApi.updateNote(name, text)
.then((res) => { .then((res) => {
setSavedContent(text); setSavedContent(text);
sessionCache.text = text;
setNotes((prev) => (prev setNotes((prev) => (prev
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n)) ? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
: prev)); : prev));
@@ -71,15 +119,19 @@ export default function useNotes() {
setContent(text); setContent(text);
setSavedContent(text); setSavedContent(text);
draftRef.current = { name, text }; draftRef.current = { name, text };
sessionCache.name = name;
sessionCache.text = text;
}) })
.catch((e) => showSnack(e.message, 'error')) .catch((e) => showSnack(e.message, 'error'))
.finally(() => setBusy(false)); .finally(() => setBusy(false));
}, []); }, []);
// ---- 编辑(防抖自动保存) ---- // ---- 编辑(防抖自动保存 + 实时写缓存 ----
const updateContent = useCallback((text) => { const updateContent = useCallback((text) => {
setContent(text); setContent(text);
draftRef.current = { name: activeName, text }; draftRef.current = { name: activeName, text };
sessionCache.name = activeName;
sessionCache.text = text;
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => { debounceRef.current = setTimeout(() => {
const d = draftRef.current; const d = draftRef.current;
@@ -87,7 +139,7 @@ export default function useNotes() {
}, AUTOSAVE_MS); }, AUTOSAVE_MS);
}, [activeName, saveContent]); }, [activeName, saveContent]);
// ---- 立即保存(Ctrl+S / 切换文件前) ---- // ---- 立即保存(Ctrl+S / 切换文件前 / save-request ----
const saveNow = useCallback(() => { const saveNow = useCallback(() => {
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
const d = draftRef.current; const d = draftRef.current;
@@ -104,6 +156,8 @@ export default function useNotes() {
setContent(''); setContent('');
setSavedContent(''); setSavedContent('');
draftRef.current = { name: res.name, text: '' }; draftRef.current = { name: res.name, text: '' };
sessionCache.name = res.name;
sessionCache.text = '';
return refresh(); return refresh();
}) })
.catch((e) => showSnack(e.message, 'error')) .catch((e) => showSnack(e.message, 'error'))
@@ -126,6 +180,8 @@ export default function useNotes() {
setActiveName(newName); setActiveName(newName);
setSavedContent(text); setSavedContent(text);
draftRef.current = { name: newName, text }; draftRef.current = { name: newName, text };
sessionCache.name = newName;
sessionCache.text = text;
} }
await refresh(); await refresh();
showSnack('重命名成功'); showSnack('重命名成功');
@@ -144,6 +200,8 @@ export default function useNotes() {
if (activeName === name) { if (activeName === name) {
clearTimeout(debounceRef.current); clearTimeout(debounceRef.current);
setActiveName(null); setActiveName(null);
sessionCache.name = null;
sessionCache.text = '';
} }
await refresh(); await refresh();
} catch (e) { } catch (e) {
@@ -17,7 +17,6 @@ const STORE = {
open: 'workbench.open', // 打开的面板 id 数组 open: 'workbench.open', // 打开的面板 id 数组
active: 'workbench.active', // 当前面板 id active: 'workbench.active', // 当前面板 id
history: 'workbench.history', // { ids: [], index: n } 历史栈 history: 'workbench.history', // { ids: [], index: n } 历史栈
collapsed: 'workbench.sidebarCollapsed',
groups: 'workbench.collapsedGroups', // { 分组名: true } 折叠 groups: 'workbench.collapsedGroups', // { 分组名: true } 折叠
pinned: 'workbench.pinned', // 固定面板 id 数组 pinned: 'workbench.pinned', // 固定面板 id 数组
modes: 'workbench.openModes', // { id: 'embed'|'tab'|'modal' } modes: 'workbench.openModes', // { id: 'embed'|'tab'|'modal' }
@@ -43,10 +42,6 @@ export default function usePanels() {
const h = read(STORE.history, null); const h = read(STORE.history, null);
return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 }; return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 };
}); });
const [collapsed, setCollapsed] = useState(() => {
const stored = read(STORE.collapsed, null);
return stored === null ? window.innerWidth < 768 : !!stored; // 移动端默认收起
});
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠 const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
const [pinned, setPinned] = useState(() => read(STORE.pinned, [])); const [pinned, setPinned] = useState(() => read(STORE.pinned, []));
const [modes, setModes] = useState(() => read(STORE.modes, {})); const [modes, setModes] = useState(() => read(STORE.modes, {}));
@@ -60,7 +55,6 @@ export default function usePanels() {
useEffect(() => write(STORE.open, openIds), [openIds]); useEffect(() => write(STORE.open, openIds), [openIds]);
useEffect(() => write(STORE.active, activeId), [activeId]); useEffect(() => write(STORE.active, activeId), [activeId]);
useEffect(() => write(STORE.history, history), [history]); useEffect(() => write(STORE.history, history), [history]);
useEffect(() => write(STORE.collapsed, collapsed), [collapsed]);
useEffect(() => write(STORE.groups, groups), [groups]); useEffect(() => write(STORE.groups, groups), [groups]);
useEffect(() => write(STORE.pinned, pinned), [pinned]); useEffect(() => write(STORE.pinned, pinned), [pinned]);
useEffect(() => write(STORE.modes, modes), [modes]); useEffect(() => write(STORE.modes, modes), [modes]);
@@ -231,8 +225,8 @@ export default function usePanels() {
goBack, goForward, goBack, goForward,
// 操作 // 操作
openPanel, closePanel, togglePin, toggleGroup, setMode, openPanel, closePanel, togglePin, toggleGroup, setMode,
// 侧边栏 // 分组 / 固定 / 打开方式
pinned, groups, collapsed, setCollapsed, modes, pinned, groups, modes,
// 搜索 // 搜索
search, setSearch, filteredPanels, openFirstMatch, search, setSearch, filteredPanels, openFirstMatch,
}; };
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import WidgetsIcon from '@mui/icons-material/Widgets';
import NoteAltIcon from '@mui/icons-material/NoteAlt';
import KeyIcon from '@mui/icons-material/Key';
/* ============================================================
* useWorkbenchLayoutVSCode 风格工作台布局状态(Lane A 新增)
*
* - 视图注册表 VIEWS:左侧主控(面板)+ 右侧功能区(记事本/密码箱)
* - order{ left: [viewId], right: [viewId] } —— 视图在左右活动栏的归属
* (dnd-kit 拖拽换边后持久化,key: workbench.viewOrder
* - active{ left: viewId, right: viewId } —— 每侧当前显示的视图
* - mode{ [viewId]: 'sidebar' | 'tab' | 'float' } —— 展示模式
* sidebar 默认;tab 进主区标签页;float 为 P2 占位,本期不实现)
* - terminalOpen:底部终端面板是否展开
* ============================================================ */
/** 视图注册表:id → 元信息(新增视图只需在此登记 + Workbench 提供渲染) */
export const VIEWS = {
panels: { id: 'panels', label: '面板', Icon: WidgetsIcon, hint: '面板工作台' },
notes: { id: 'notes', label: '记事本', Icon: NoteAltIcon, hint: '笔记与文件' },
vault: { id: 'vault', label: '密码箱', Icon: KeyIcon, hint: '密码管理' },
};
export const VIEW_IDS = Object.keys(VIEWS);
export const VIEW_MODE_SIDEBAR = 'sidebar';
export const VIEW_MODE_TAB = 'tab';
export const VIEW_MODE_FLOAT = 'float'; // P2:本期仅占位
const STORE = {
order: 'workbench.viewOrder', // { left: [], right: [] }
active: 'workbench.viewActive', // { left: id, right: id }
mode: 'workbench.viewMode', // { id: 'sidebar'|'tab' }
terminal: 'workbench.terminalOpen',
};
const DEFAULT_ORDER = { left: ['panels'], right: ['notes', 'vault'] };
const DEFAULT_ACTIVE = { left: 'panels', right: 'notes' };
const DEFAULT_MODE = { panels: VIEW_MODE_SIDEBAR, notes: VIEW_MODE_SIDEBAR, vault: VIEW_MODE_SIDEBAR };
function read(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch { return fallback; }
}
function write(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
}
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧 */
function sanitizeOrder(saved) {
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
const seen = new Set();
const clean = (arr) => arr.filter((id) => VIEW_IDS.includes(id) && !seen.has((seen.add(id), id)));
const left = clean(saved.left);
const right = clean(saved.right);
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
return { left, right };
}
/** 校验 mode:只接受 sidebar/tabfloat 为保留值,不持久化激活) */
function sanitizeMode(saved) {
const out = { ...DEFAULT_MODE };
if (saved && typeof saved === 'object') {
VIEW_IDS.forEach((id) => {
if (saved[id] === VIEW_MODE_SIDEBAR || saved[id] === VIEW_MODE_TAB) out[id] = saved[id];
});
}
return out;
}
export default function useWorkbenchLayout() {
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
const [active, setActive] = useState(() => {
const saved = read(STORE.active, null);
return saved && saved.left && saved.right ? saved : DEFAULT_ACTIVE;
});
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
useEffect(() => write(STORE.order, order), [order]);
useEffect(() => write(STORE.active, active), [active]);
useEffect(() => write(STORE.mode, mode), [mode]);
useEffect(() => write(STORE.terminal, terminalOpen), [terminalOpen]);
/** 视图 id → 所在侧 */
const sideOf = useMemo(() => {
const m = {};
order.left.forEach((id) => { m[id] = 'left'; });
order.right.forEach((id) => { m[id] = 'right'; });
return m;
}, [order]);
const viewsOf = useCallback((side) => [...order[side]], [order]);
/** 每侧有效激活视图(active 指向已移走的视图时回退到第一个) */
const activeViewOf = useCallback(
(side) => (order[side].includes(active[side]) ? active[side] : order[side][0] || null),
[order, active]
);
/** 激活某视图(无论在哪侧) */
const activateView = useCallback((id) => {
const side = sideOf[id];
if (!side) return;
setActive((prev) => ({ ...prev, [side]: id }));
}, [sideOf]);
const setViewMode = useCallback((id, m) => {
if (!VIEW_IDS.includes(id)) return;
setMode((prev) => ({ ...prev, [id]: m }));
}, []);
/** 拖拽换边:视图从一侧移到另一侧,并激活目标侧 */
const moveView = useCallback((id, toSide) => {
if (toSide !== 'left' && toSide !== 'right') return;
setOrder((prev) => {
const fromSide = (['left', 'right']).find((s) => prev[s].includes(id));
if (!fromSide || fromSide === toSide) return prev;
const next = {
left: [...prev.left.filter((x) => x !== id)],
right: [...prev.right.filter((x) => x !== id)],
};
next[toSide].push(id);
return next;
});
setActive((prev) => ({ ...prev, [toSide]: id }));
}, []);
/** 同侧排序(dnd-kit arrayMove 语义) */
const reorderView = useCallback((id, overId) => {
setOrder((prev) => {
const side = (['left', 'right']).find((s) => prev[s].includes(id));
const overSide = (['left', 'right']).find((s) => prev[s].includes(overId));
if (!side || side !== overSide) return prev;
const list = prev[side];
const from = list.indexOf(id);
const to = list.indexOf(overId);
if (from < 0 || to < 0 || from === to) return prev;
const next = [...list];
next.splice(from, 1);
next.splice(to, 0, id);
return { ...prev, [side]: next };
});
}, []);
const toggleTerminal = useCallback(() => setTerminalOpen((v) => !v), []);
const openTerminal = useCallback(() => setTerminalOpen(true), []);
return {
order, sideOf, viewsOf,
active, activeViewOf, activateView,
mode, setViewMode,
moveView, reorderView,
terminalOpen, toggleTerminal, openTerminal,
};
}
+18 -3
View File
@@ -1,17 +1,32 @@
/* ============================================================ /* ============================================================
* 工作台模块导出(frontend/src/tools/workbench * 工作台模块导出(frontend/src/tools/workbench
* 便于未来升级 / 替换 / 独立打包 * 便于未来升级 / 替换 / 独立打包
*
* 组件命名与文件名的兼容说明:
* - Sidebar.jsx 导出 PanelsView(面板列表视图,VSCode 侧栏内容)
* - NoteEditor.jsx 导出 NotesView(记事本视图)
* - VaultDrawer.jsx 导出 VaultView(密码箱视图)
* ============================================================ */ * ============================================================ */
export { default as Workbench } from './Workbench.jsx'; export { default as Workbench } from './Workbench.jsx';
export { default as Toolbar } from './components/Toolbar.jsx'; export { default as Toolbar } from './components/Toolbar.jsx';
export { default as Sidebar } from './components/Sidebar.jsx';
export { default as PanelFrame } from './components/PanelFrame.jsx'; export { default as PanelFrame } from './components/PanelFrame.jsx';
export { default as AddPanelDialog } from './components/AddPanelDialog.jsx'; export { default as AddPanelDialog } from './components/AddPanelDialog.jsx';
export { default as PanelIcon } from './components/PanelIcon.jsx'; export { default as PanelIcon } from './components/PanelIcon.jsx';
export { default as VaultDrawer } from './components/VaultDrawer.jsx'; export { default as PanelsView } from './components/Sidebar.jsx';
export { default as NoteEditor } from './components/NoteEditor.jsx'; export { default as NotesView } from './components/NoteEditor.jsx';
export { default as VaultView } from './components/VaultDrawer.jsx';
export { default as ActivityBar } from './components/ActivityBar.jsx';
export { default as SidebarPane } from './components/SidebarPane.jsx';
export { default as ToolWindow } from './components/ToolWindow.jsx';
export { default as TabStrip } from './components/TabStrip.jsx';
export { default as ContentView } from './components/ContentView.jsx';
export { default as TerminalPanel } from './components/TerminalPanel.jsx';
export { default as StatusBar } from './components/StatusBar.jsx';
export { default as MenuBar } from './components/MenuBar.jsx';
export { default as useNotes } from './hooks/useNotes.js'; export { default as useNotes } from './hooks/useNotes.js';
export { default as useWorkbenchLayout } from './hooks/useWorkbenchLayout.js';
export { VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB, VIEW_MODE_FLOAT } from './hooks/useWorkbenchLayout.js';
export { export {
default as usePanels, default as usePanels,
OPEN_MODE_EMBED, OPEN_MODE_EMBED,
+136 -1
View File
@@ -8,10 +8,15 @@
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.1.0", "version": "2.1.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.3.1", "@mui/icons-material": "^9.3.1",
"@mui/material": "^9.3.1", "@mui/material": "^9.3.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/xterm": "^6.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -22,10 +27,13 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"marked": "^18.0.5", "marked": "^18.0.5",
"multer": "^2.2.0", "multer": "^2.2.0",
"node-pty": "^1.0.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2" "react-resizable-panels": "^4.12.2",
"react-router-dom": "^7.18.2",
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
@@ -371,6 +379,59 @@
"node": ">=20.19.0" "node": ">=20.19.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emotion/babel-plugin": { "node_modules/@emotion/babel-plugin": {
"version": "11.13.5", "version": "11.13.5",
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
@@ -1149,6 +1210,27 @@
} }
} }
}, },
"node_modules/@xterm/addon-fit": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
"license": "MIT"
},
"node_modules/@xterm/addon-unicode11": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
"license": "MIT",
"workspaces": [
"addons/*"
]
},
"node_modules/accepts": { "node_modules/accepts": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -2618,6 +2700,22 @@
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-pty": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0"
}
},
"node_modules/node-pty/node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
@@ -2885,6 +2983,16 @@
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-resizable-panels": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.12.2.tgz",
"integrity": "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.2", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
@@ -3359,6 +3467,12 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -3542,6 +3656,27 @@
"node": "^22.14.0 || >=24.0.0" "node": "^22.14.0 || >=24.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+9 -1
View File
@@ -11,10 +11,15 @@
"cli": "node cli.js" "cli": "node cli.js"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.3.1", "@mui/icons-material": "^9.3.1",
"@mui/material": "^9.3.1", "@mui/material": "^9.3.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/xterm": "^6.0.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^13.0.3", "better-sqlite3": "^13.0.3",
"cors": "^2.8.5", "cors": "^2.8.5",
@@ -25,10 +30,13 @@
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"marked": "^18.0.5", "marked": "^18.0.5",
"multer": "^2.2.0", "multer": "^2.2.0",
"node-pty": "^1.0.0",
"nodemailer": "^9.0.1", "nodemailer": "^9.0.1",
"react": "^19.2.8", "react": "^19.2.8",
"react-dom": "^19.2.8", "react-dom": "^19.2.8",
"react-router-dom": "^7.18.2" "react-resizable-panels": "^4.12.2",
"react-router-dom": "^7.18.2",
"ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^6.0.5", "@vitejs/plugin-react": "^6.0.5",
+2
View File
@@ -68,8 +68,10 @@ html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { *, *::before, *::after {
animation-duration: 0.01ms !important; animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important; transition-duration: 0.01ms !important;
} }
html { scroll-behavior: auto; }
} }
body { body {
+529
View File
@@ -0,0 +1,529 @@
// Web 终端后端 —— 安全敏感模块:终端 = RCE 级攻击面。
// 安全措施:
// 1. 专用终端密码二次确认:PBKDF2-SHA256 600k(与密码箱 routes/passwords.js 同参数,异步版不阻塞事件循环);
// 首次调用(未设置密码)即完成设置(needs_setup 流程),强度要求 8 位以上
// 2. 短 TTL token5 分钟):jwt 带 terminal:true 声明,普通登录 token 不可直接连终端;
// 认证成功后种 HttpOnly SameSite=Strict 短 TTL cookieterminal_token),WS upgrade 优先取 cookie
// ?token= query 仅为旧前端回退(不推荐,会落 access log);带失败限流 + 请求级限流防 PBKDF2 DoS
// 3. WS handshake Origin 校验(须与站点域名一致;仅 NODE_ENV=development 时放行 localhost)——纵深防御,非认证边界
// 4. 并发会话上限(MAX_SESSIONS=3):handleUpgrade 内同步预占槽位,杜绝升级回调前的竞态突破
// 5. pty.spawn 使用参数数组传参(防命令注入);kill 时连进程组一起终止(forkpty 子进程为 session leader
// 负 PID 即杀整组),杜绝 & 后台任务变孤儿进程
// 6. 审计:会话开始/结束/密码失败等写审计日志(console.log 带时间戳 + audit_logs 表);
// 拒绝类事件(Origin/token/超限)降频落审计防日志洪泛;audit_logs 按 90 天保留策略惰性清理
// 7. 空闲超时(15 分钟无输入)自动断开
// 8. ws close/error → pty.kill() + 进程组清理 + 监听器/计时器全量销毁
// 流控:pty 输出缓冲超限时 pause(仅在 onData 暂停),恢复检查放在 ws 消息处理 + 兜底轮询
//
// 部署注意:
// - node-pty 是原生模块,需要服务器编译环境(build-essential / python3),
// 安装失败时先 rm -rf node_modules 再 npm install(项目已有此重装先例)。
// - sudoers 配置(把 rainweb 换成实际运行用户):
// ⚠️ 实测 NOPASSWD: /usr/bin/sudo -i 写法匹配不到(sudo 匹配规则时 -i 会被规范化
// 为实际执行的 login shell 命令,精确命令规则失效),必须放宽为:
// echo "rainweb ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/rainweb-terminal
// sudo chmod 440 /etc/sudoers.d/rainweb-terminal && sudo visudo -c
// 验证:以运行用户执行 `sudo -n -i id` 应直接输出 root 身份且不询问密码。
// 注意:NOPASSWD: ALL 等同把运行用户提权为免密 root,务必确认运行用户无交互登录面。
// - 后端若置于反向代理之后并希望审计 IP 取真实来源 IP,需设置环境变量 TRUST_PROXY=1
// (否则一律用 socket 直连地址,避免伪造 X-Forwarded-For)。
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const { WebSocketServer, WebSocket } = require('ws');
const db = require('../db');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router();
// ---------- 常量 ----------
const TOKEN_TTL = '5m'; // 短 TTL token 有效期
const TOKEN_COOKIE = 'terminal_token'; // HttpOnly 短 TTL cookie 名
const TOKEN_COOKIE_MAX_AGE = 300; // cookie 有效期(秒),与 TOKEN_TTL 一致
const MAX_SESSIONS = 3; // 最大并发终端会话数
const IDLE_TIMEOUT = 15 * 60 * 1000; // 空闲超时:15 分钟无输入自动断开
const PIN_MIN_LEN = 8; // 终端=root shellPIN 强度要求 8 位以上
const PBKDF2_ITERATIONS = 600000; // 与 routes/passwords.js 一致(OWASP 2023 建议)
const MAX_PTY_BUFFER = 1024 * 1024; // pty 输出缓冲上限(超过则暂停 pty 做流控)
const WS_MAX_PAYLOAD = 1024 * 1024; // ws 单帧消息上限,超限立即断开
const RESUME_POLL_MS = 2000; // 流控恢复兜底轮询间隔
const WS_CLOSE_BAD_TOKEN = 4001; // token 无效
const WS_CLOSE_OVERLOAD = 1013; // 会话数超限
const WS_CLOSE_IDLE = 4000; // 空闲超时
const WS_CLOSE_BAD_MSG = 4002; // 非法消息
// PIN 失败限流(与 passwords.js unlock 同模式):5 分钟 3 次失败锁定 5 分钟
const authFails = new Map();
const FAIL_LIMIT = 3;
const FAIL_WINDOW = 5 * 60 * 1000;
const LOCK_DURATION = 5 * 60 * 1000;
// /auth 请求级限流:防止异步 PBKDF2 被并发滥用造成 CPU DoS
// (失败锁定只在校验之后生效,无法挡住每请求一次的 600k 迭代计算)
const authRateLimit = rateLimit({
windowMs: 5 * 60 * 1000,
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: '尝试次数过多,请稍后再试' },
});
// 拒绝类事件审计降频:同一来源 IP 在窗口内最多记一条(防日志洪泛)
const REJECT_AUDIT_INTERVAL = 10 * 1000;
const rejectAudit = new Map(); // ip -> lastTs
// 并发终端会话集合(计数与统一清理)
const activeSessions = new Set();
// node-pty 懒加载:未安装时 REST 接口(status/auth)仍可用,仅 WS 会话被拒绝
let ptyModule = null;
try { ptyModule = require('node-pty'); } catch (e) { /* 未安装:连接时提示 */ }
// 审计写入计数:每 50 条惰性清理一次过期记录(保留策略,见 db.js)
let auditWriteCount = 0;
// ---------- 审计 ----------
// ctx: { user, ip }user 为 JWT payload
function audit(action, detail, ctx) {
const userId = ctx && ctx.user ? ctx.user.id : 0;
const username = ctx && ctx.user ? ctx.user.username : '';
const ip = ctx && ctx.ip ? ctx.ip : '';
console.log(`[${new Date().toISOString()}] [TERMINAL] ${action} user=${username}(${userId}) ip=${ip} ${detail}`);
// 审计落库(audit_logs 表由 db.js initTables 建表;db.run 内部已容错)
db.run('INSERT INTO audit_logs (action, user_id, username, detail, ip) VALUES (?, ?, ?, ?, ?)',
[action, userId, username, detail, ip]);
// 保留策略:每 50 条写入惰性清理 90 天前的记录(created_at 为 UTC datetime,与建表一致)
if (++auditWriteCount % 50 === 0) {
db.run("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
}
}
// 客户端真实 IP:仅当 TRUST_PROXY=1 时信任 X-Forwarded-For(否则客户端可任意伪造该头)
function getClientIp(request) {
if (process.env.TRUST_PROXY === '1' && request.headers['x-forwarded-for']) {
return String(request.headers['x-forwarded-for']).split(',')[0].trim() || '';
}
return (request.socket.remoteAddress || '').toString();
}
// 拒绝事件降频审计:同 IP 10 秒内只记一条
function auditRejectOnce(ip, action, detail) {
const now = Date.now();
if ((rejectAudit.get(ip) || 0) > now - REJECT_AUDIT_INTERVAL) return;
rejectAudit.set(ip, now);
audit(action, detail, { ip });
}
// ---------- PIN(终端专用密码)----------
const PIN_SALT_KEY = 'terminal_pin_salt';
const PIN_ITER_KEY = 'terminal_pin_iter';
const PIN_HASH_KEY = 'terminal_pin_hash';
function getPinRecord() {
const salt = db.getSetting(PIN_SALT_KEY);
const hash = db.getSetting(PIN_HASH_KEY);
if (!salt || !hash) return null;
const iter = parseInt(db.getSetting(PIN_ITER_KEY), 10) || PBKDF2_ITERATIONS;
return { salt: Buffer.from(salt, 'hex'), hash: Buffer.from(hash, 'hex'), iter };
}
// 异步 PBKDF2:不阻塞事件循环(600k 迭代约几十毫秒,同步版会卡住所有请求)
function derivePinHash(pin, salt, iter) {
return new Promise((resolve, reject) => {
crypto.pbkdf2(pin, salt, iter, 32, 'sha256', (err, key) => {
if (err) reject(err); else resolve(key);
});
});
}
async function setPin(pin) {
const salt = crypto.randomBytes(16);
const hash = await derivePinHash(pin, salt, PBKDF2_ITERATIONS);
db.setSetting(PIN_SALT_KEY, salt.toString('hex'));
db.setSetting(PIN_ITER_KEY, String(PBKDF2_ITERATIONS));
db.setSetting(PIN_HASH_KEY, hash.toString('hex'));
}
async function verifyPin(pin) {
const rec = getPinRecord();
if (!rec) return false;
const derived = await derivePinHash(pin, rec.salt, rec.iter);
// 常数时间比较,防时序侧信道
return crypto.timingSafeEqual(derived, rec.hash);
}
// ---------- PIN 失败限流 ----------
function isLocked() {
const rec = authFails.get('global');
return !!(rec && rec.lockedUntil && Date.now() < rec.lockedUntil);
}
function recordFail() {
const now = Date.now();
let rec = authFails.get('global');
if (!rec || now - rec.firstTs > FAIL_WINDOW) rec = { count: 0, firstTs: now, lockedUntil: 0 };
rec.count += 1;
if (rec.count >= FAIL_LIMIT) { rec.lockedUntil = now + LOCK_DURATION; rec.count = 0; }
authFails.set('global', rec);
}
// ---------- 短 TTL token(参考 proxy.js----------
function issueToken(user) {
// terminal:true 声明:只有本接口签发的 token 才能连接终端,普通登录 token 无效
return jwt.sign(
{ id: user.id, username: user.username, role: user.role, terminal: true },
SECRET,
{ expiresIn: TOKEN_TTL }
);
}
function verifyToken(token) {
if (!token || typeof token !== 'string') return null;
try {
const payload = jwt.verify(token, SECRET);
if (!payload.terminal) return null; // 必须为终端专用 token
if (payload.role !== 'admin') return null; // 防篡改
const u = db.get('SELECT role FROM users WHERE id = ?', [payload.id]);
if (!u || u.role !== 'admin') return null; // 复查角色(用户被删/降权立即失效)
return payload;
} catch { return null; }
}
// 认证成功 → 种 HttpOnly 短 TTL cookieWS 握手(同源 GET /ws/terminal)自动携带,
// root shell 凭证不再进 URL / access log。Path 精确限定 /ws/terminalHttpOnly 防 XSS 读取,
// SameSite=Strict 防跨站发送。Secure 按 req.secureHTTPS 连接)条件附加。
function setTerminalCookie(res, token) {
res.setHeader('Set-Cookie',
`terminal_token=${token}; Path=/ws/terminal; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_COOKIE_MAX_AGE}` +
(res.req && res.req.secure ? '; Secure' : ''));
}
// 极简 Cookie 解析(upgrade 事件不经 Express,无 cookie-parser
function parseCookies(header) {
const out = {};
if (!header) return out;
for (const part of String(header).split(';')) {
const idx = part.indexOf('=');
if (idx === -1) continue;
const name = part.slice(0, idx).trim();
const value = part.slice(idx + 1).trim();
if (name) { try { out[name] = decodeURIComponent(value); } catch { out[name] = value; } }
}
return out;
}
// ---------- REST API ----------
// GET /api/terminal/status —— 是否已设置密码 + 当前会话数
router.get('/status', authMiddleware, adminOnly, (req, res) => {
res.json({
hasPin: !!getPinRecord(),
sessions: activeSessions.size,
maxSessions: MAX_SESSIONS,
idleTimeoutMs: IDLE_TIMEOUT,
});
});
// POST /api/terminal/auth —— 设置/校验终端专用密码,通过后发放 5 分钟短 TTL token
// 并种 HttpOnly cookieterminal_token)。
// 首次(未设置密码)调用即完成设置(前端按 /status 的 hasPin 决定显示设置表单或密码输入框),
// 响应带 setup:true 标记。响应仍含 token 字段(保持 API 契约),前端无需再从 URL 传递。
router.post('/auth', authRateLimit, authMiddleware, adminOnly, async (req, res) => {
const pin = req.body && req.body.pin;
if (!pin || typeof pin !== 'string') return res.status(400).json({ error: '请输入终端密码' });
if (pin.length > 128) return res.status(400).json({ error: '终端密码过长' });
if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
const rec = getPinRecord();
const ctx = { user: req.user, ip: getClientIp(req) };
if (!rec) {
// 首次设置:无密码 → 设置并发放 token
if (pin.length < PIN_MIN_LEN) {
return res.status(400).json({ error: `终端密码至少 ${PIN_MIN_LEN}` });
}
try {
await setPin(pin);
} catch (e) {
return res.status(500).json({ error: '密码处理失败' });
}
authFails.delete('global');
audit('PIN_SETUP', '终端专用密码已设置', ctx);
const token = issueToken(req.user);
setTerminalCookie(res, token);
return res.json({
needs_setup: false,
setup: true,
token,
expiresIn: TOKEN_TTL,
message: '终端密码已设置',
});
}
let pinOk = false;
try { pinOk = await verifyPin(pin); } catch { pinOk = false; }
if (!pinOk) {
recordFail();
audit('PIN_FAIL', '终端密码校验失败', ctx);
if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
return res.status(401).json({ error: '终端密码错误' });
}
authFails.delete('global');
audit('PIN_OK', '终端密码校验通过,发放短 TTL token', ctx);
const token = issueToken(req.user);
setTerminalCookie(res, token);
res.json({ token, expiresIn: TOKEN_TTL });
});
// ---------- WS /ws/terminal ----------
const isDev = process.env.NODE_ENV === 'development';
// Origin 校验:必须与站点域名一致(site_url 或请求 Host 头)。
// localhost 豁免仅 NODE_ENV=development 时生效(生产环境非浏览器客户端无法借 localhost 绕过)。
// 注意:这是纵深防御,不是认证边界——真正把关的是 token/cookie 校验。
function originAllowed(request) {
const origin = request.headers.origin;
if (!origin) return false;
try {
const o = new URL(origin);
const allowed = [];
const siteUrl = db.getSetting('site_url');
if (siteUrl) { try { allowed.push(new URL(siteUrl).host); } catch {} }
if (request.headers.host) allowed.push(request.headers.host);
if (isDev && ['127.0.0.1', 'localhost', '::1'].includes(o.hostname)) return true;
return allowed.includes(o.host);
} catch { return false; }
}
// pty 安全:参数数组传参(防命令注入),禁止字符串拼接;sudo -i 获得 root shell。
// cwd 必须用运行用户可访问的目录:直接给 '/root' 时 spawn 不抛错,
// 而是 shell 启动后 chdir(2) 失败(Permission denied)——sudo -i 本身会把
// 工作目录与 HOME 切到 /root,所以这里给运行用户当前目录即可。
function spawnPty(cols, rows) {
if (!ptyModule) return null; // node-pty 未安装
const opts = { name: 'xterm-color', cols, rows, cwd: process.cwd() };
return ptyModule.spawn('sudo', ['-i'], opts);
}
// 终止 pty:先杀 shell,再连进程组一起杀(forkpty 子进程是 session leaderpgid == pid
// 负 PID 即杀整组),500ms 后兜底 SIGKILL,杜绝 `&` 后台任务变孤儿进程。
function killPty(pty) {
if (!pty) return;
const pid = pty.pid;
try { pty.kill(); } catch {} // 标准接口:向 shell 发 SIGKILL
if (pid && Number.isInteger(pid)) {
try { process.kill(-pid, 'SIGTERM'); } catch {}
const t = setTimeout(() => {
try { process.kill(-pid, 'SIGKILL'); } catch {}
}, 500);
if (t.unref) t.unref();
}
}
// WS 服务:noServer 模式,由 server.js 的 http server 'upgrade' 事件转交;
// maxPayload 限制单帧大小,超限由 ws 自动关闭(1009)。
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
// server.js 调用:返回 true 表示已接管该 upgrade 请求(含拒绝),false 表示非终端路径
function handleUpgrade(request, socket, head) {
let pathname = null;
try { pathname = new URL(request.url, 'http://localhost').pathname; } catch {}
if (pathname !== '/ws/terminal') return false;
const ip = getClientIp(request);
// 1) Origin 校验(纵深防御;拒绝事件降频落审计)
if (!originAllowed(request)) {
auditRejectOnce(ip, 'WS_ORIGIN_REJECT', 'Origin 校验失败');
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
socket.destroy();
return true;
}
// 2) token 校验:优先 HttpOnly cookie(推荐),?token= query 仅作旧前端回退(不推荐,落 access log)
const cookies = parseCookies(request.headers.cookie);
let token = cookies[TOKEN_COOKIE];
if (!token) {
try { token = new URL(request.url, 'http://localhost').searchParams.get('token'); } catch {}
}
const payload = verifyToken(token);
if (!payload) {
auditRejectOnce(ip, 'WS_TOKEN_REJECT', 'token 无效或过期');
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return true;
}
// 3) 并发会话上限:同步预占槽位再检查(升级回调前执行,杜绝并发竞态突破上限)
const slot = { pending: true };
activeSessions.add(slot);
if (activeSessions.size > MAX_SESSIONS) {
activeSessions.delete(slot);
auditRejectOnce(ip, 'WS_OVERLOAD', '并发会话数超限被拒绝');
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
socket.destroy();
return true;
}
// 升级中途失败(socket 断开等)时释放预占槽位,防止泄漏
const releaseSlot = () => { activeSessions.delete(slot); };
socket.once('close', releaseSlot);
socket.once('error', releaseSlot);
// 4) 升级并触发连接处理(槽位随会话移交)
wss.handleUpgrade(request, socket, head, (ws) => {
socket.removeListener('close', releaseSlot);
socket.removeListener('error', releaseSlot);
if (ws.readyState !== WebSocket.OPEN) { // 升级异常:释放槽位
activeSessions.delete(slot);
return;
}
wss.emit('connection', ws, request, payload, slot);
});
return true;
}
wss.on('connection', (ws, request, payload, slot) => {
// 复用 handleUpgrade 预占的槽位(已在 activeSessions 中,幂等 add),
// 升级失败路径已由 releaseSlot/readyState 检查负责释放。
const session = slot || {};
session.id = session.id || crypto.randomBytes(4).toString('hex');
session.startedAt = session.startedAt || Date.now();
activeSessions.add(session);
let ptyProc = null;
let idleTimer = null;
let resumeTimer = null;
let closed = false;
const ctx = { user: payload, ip: getClientIp(request) };
audit('SESSION_START', `终端会话 ${session.id} 建立`, ctx);
function cleanup() {
if (closed) return;
closed = true;
clearTimeout(idleTimer);
clearInterval(resumeTimer);
// 销毁 pty:kill() + 进程组清理(含后台任务),事件回调为一次性注册随对象失效
killPty(ptyProc);
ptyProc = null;
activeSessions.delete(session);
}
function scheduleIdle() {
clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (closed) return;
audit('SESSION_IDLE_TIMEOUT', `终端会话 ${session.id} 空闲 ${IDLE_TIMEOUT / 60000} 分钟无输入,自动断开`, ctx);
try { ws.close(WS_CLOSE_IDLE, 'idle timeout'); } catch {}
cleanup();
}, IDLE_TIMEOUT);
if (idleTimer.unref) idleTimer.unref(); // 不阻止进程退出
}
// 流控恢复:onData 暂停后不再触发回调,恢复检查必须在消息路径/轮询中执行
function maybeResume() {
if (closed || !session._paused || !ptyProc) return;
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < MAX_PTY_BUFFER / 2) {
session._paused = false;
try { ptyProc.resume(); } catch {}
}
}
ws.on('message', (data, isBinary) => {
if (closed) return;
maybeResume(); // 任意帧到达即尝试恢复(resize/输入/控制消息均可)
// 二进制帧 = pty 输入(触发空闲计时刷新)
if (isBinary) {
if (!ptyProc) return;
scheduleIdle();
try { ptyProc.write(data); } catch {}
return;
}
// 文本帧 = JSON 控制消息
let msg;
try { msg = JSON.parse(data.toString('utf8')); } catch {
try { ws.close(WS_CLOSE_BAD_MSG, 'invalid json'); } catch {}
cleanup(); return;
}
if (!msg || typeof msg.type !== 'string') {
try { ws.close(WS_CLOSE_BAD_MSG, 'bad message'); } catch {}
cleanup(); return;
}
switch (msg.type) {
case 'auth':
// 连接后可选再发 token 二次认证;无效立即断开(4001)
if (!verifyToken(msg.token)) {
audit('SESSION_AUTH_FAIL', `终端会话 ${session.id} 二次认证失败`, ctx);
try { ws.close(WS_CLOSE_BAD_TOKEN, 'bad token'); } catch {}
cleanup();
}
break;
case 'resize': {
const cols = Math.min(Math.max(parseInt(msg.cols, 10) || 80, 2), 500);
const rows = Math.min(Math.max(parseInt(msg.rows, 10) || 24, 2), 200);
if (ptyProc) { try { ptyProc.resize(cols, rows); } catch {} }
break;
}
case 'ping':
try { ws.send(JSON.stringify({ type: 'pong' })); } catch {}
break;
case 'bye':
audit('SESSION_BYE', `终端会话 ${session.id} 主动退出`, ctx);
try { ws.close(1000, 'bye'); } catch {}
cleanup();
break;
default:
break; // 未知控制消息忽略
}
});
ws.on('error', (err) => {
audit('SESSION_ERROR', `终端会话 ${session.id} WebSocket 错误: ${err.message}`, ctx);
cleanup();
});
ws.on('close', () => {
audit('SESSION_END', `终端会话 ${session.id} 连接关闭`, ctx);
cleanup();
});
// 启动 ptysudo -i → root shell
try {
ptyProc = spawnPty(80, 24);
} catch (e) {
audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} pty 启动失败: ${e.message}`, ctx);
try { ws.close(1011, 'pty spawn failed'); } catch {}
cleanup(); return;
}
if (!ptyProc) {
audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} 拒绝:node-pty 未安装或无法加载`, ctx);
try { ws.close(1011, 'node-pty not installed'); } catch {}
cleanup(); return;
}
// 兜底恢复轮询:即使客户端不再发任何帧,也能在缓冲排空后恢复 pty
resumeTimer = setInterval(() => maybeResume(), RESUME_POLL_MS);
if (resumeTimer.unref) resumeTimer.unref();
// pty 输出 → ws 二进制帧(binaryType=arraybuffer
ptyProc.onData((data) => {
if (closed || ws.readyState !== WebSocket.OPEN) return;
// 仅做暂停判断(数据流动时天然触发);恢复由 maybeResume(消息/轮询)负责
if (!session._paused && ws.bufferedAmount > MAX_PTY_BUFFER) {
session._paused = true;
try { ptyProc.pause(); } catch {}
}
try { ws.send(data, { binary: true }); } catch {}
});
ptyProc.onExit(({ exitCode, signal }) => {
audit('SESSION_EXIT', `终端会话 ${session.id} 进程退出 code=${exitCode} signal=${signal}`, ctx);
cleanup();
if (ws.readyState === WebSocket.OPEN) { try { ws.close(1000, 'pty exited'); } catch {} }
});
scheduleIdle();
audit('SESSION_READY', `终端会话 ${session.id} pty 就绪`, ctx);
});
module.exports = router;
module.exports.handleUpgrade = handleUpgrade;
+11 -1
View File
@@ -34,6 +34,7 @@ const proxyRoutes = require('./routes/proxy');
const importRoutes = require('./routes/import'); const importRoutes = require('./routes/import');
const noteRoutes = require('./routes/notes'); const noteRoutes = require('./routes/notes');
const feedRoutes = require('./routes/feed'); const feedRoutes = require('./routes/feed');
const terminalRoutes = require('./routes/terminal');
const { blogSSR, forumSSR, sitemapXml } = require('./ssr'); const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
const app = express(); const app = express();
@@ -113,6 +114,7 @@ app.use('/api/setup', setupRoutes);
app.use('/api/proxy', proxyRoutes); app.use('/api/proxy', proxyRoutes);
app.use('/api/import', importRoutes); app.use('/api/import', importRoutes);
app.use('/api/notes', noteRoutes); app.use('/api/notes', noteRoutes);
app.use('/api/terminal', terminalRoutes);
// Version & Update // Version & Update
const version = require('fs').readFileSync('./VERSION', 'utf8').trim(); const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
@@ -188,9 +190,17 @@ async function start() {
} }
await getDb(); await getDb();
app.listen(PORT, '0.0.0.0', () => { const server = app.listen(PORT, '0.0.0.0', () => {
console.log(`RainWeb running on port ${PORT}`); console.log(`RainWeb running on port ${PORT}`);
}); });
// Web 终端 WS 升级:upgrade 事件不经 Express 中间件栈,
// 在此提前接管 /ws/terminal(避开 SPA catch-all),其余升级请求直接关闭。
// 安全校验(Origin/token/会话上限)均在 routes/terminal.js handleUpgrade 内完成。
server.on('upgrade', (request, socket, head) => {
if (!terminalRoutes.handleUpgrade(request, socket, head)) {
socket.destroy();
}
});
} catch (err) { } catch (err) {
console.error('Failed to start:', err.message); console.error('Failed to start:', err.message);
process.exit(1); process.exit(1);