9 Commits
16 changed files with 354 additions and 156 deletions
+1 -1
View File
@@ -1 +1 @@
2.1.0 2.2.0
+109 -41
View File
@@ -39,10 +39,10 @@ import NotesView from './components/NoteEditor.jsx';
import VaultView from './components/VaultDrawer.jsx'; import VaultView from './components/VaultDrawer.jsx';
import SidebarPane from './components/SidebarPane.jsx'; import SidebarPane from './components/SidebarPane.jsx';
import ActivityBar from './components/ActivityBar.jsx'; import ActivityBar from './components/ActivityBar.jsx';
import CollapseArrow from './components/CollapseArrow.jsx';
import ToolWindow from './components/ToolWindow.jsx'; import ToolWindow from './components/ToolWindow.jsx';
import TabStrip from './components/TabStrip.jsx'; import TabStrip from './components/TabStrip.jsx';
import ContentView from './components/ContentView.jsx'; import ContentView from './components/ContentView.jsx';
import TerminalPanel from './components/TerminalPanel.jsx';
import StatusBar from './components/StatusBar.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';
@@ -61,6 +61,21 @@ import SnackHost, { showSnack } from '../../admin/snack.jsx';
* ============================================================ */ * ============================================================ */
const LAYOUT_KEY = 'workbench.layout'; const LAYOUT_KEY = 'workbench.layout';
const MAIN_ACTIVE_KEY = 'workbench.mainActive'; // 主区激活标签(刷新后恢复上次激活项)
/** 侧边栏默认宽度(px):箭头/活动栏图标展开时的目标宽度 */
const SIDEBAR_DEFAULT_WIDTH = { left: 240, right: 300 };
/** 视为「已折叠」的宽度阈值(px)。RRP v4 isCollapsed 用零容差比较(尺寸必须精确等于 0),
* 布局归一化把面板挤到亚像素宽度时会误判为「未折叠」,导致 expand() 失效、侧栏卡死。
* 右侧栏是布局末尾的柔性面板最易被挤压,因此用阈值 + resize() 兜底。 */
const SIDEBAR_COLLAPSED_PX = 24;
function persistMainActive(key) {
try {
if (key) localStorage.setItem(MAIN_ACTIVE_KEY, key);
else localStorage.removeItem(MAIN_ACTIVE_KEY);
} catch { /* 隐私模式忽略 */ }
}
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */ /** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
function readLayout() { function readLayout() {
@@ -196,23 +211,38 @@ function WorkbenchContent() {
const [aboutOpen, setAboutOpen] = 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 [activeMainKey, setActiveMainKey] = useState(null); // 主区激活标签:同步从 localStorage 恢复(workbench.mainActive),刷新后回到上次激活项
const mainActiveInit = useRef(null);
const [activeMainKey, setActiveMainKey] = useState(() => {
let v = null;
try { v = localStorage.getItem(MAIN_ACTIVE_KEY); } catch { /* ignore */ }
mainActiveInit.current = v;
return v;
});
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label} const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
const searchRef = useRef(null); const searchRef = useRef(null);
// ---- react-resizable-panels 引用 ---- // ---- react-resizable-panels 引用 ----
const groupRef = useRef(null); const groupRef = useRef(null);
const mainRef = useRef(null); // 主面板(始终 ≥minSize 可见),用作 setLayout 像素↔百分比换算探针
const leftRef = useRef(null); const leftRef = useRef(null);
const rightRef = useRef(null); const rightRef = useRef(null);
const termRef = useRef(null);
const savedLayout = useRef(readLayout()); const savedLayout = useRef(readLayout());
// 侧边栏折叠状态(供折叠箭头方向/语义;拖拽折叠也会经 onResize 同步)
const [leftCollapsed, setLeftCollapsed] = useState(
() => !!savedLayout.current && savedLayout.current.left === 0
);
const [rightCollapsed, setRightCollapsed] = useState(
() => !!savedLayout.current && savedLayout.current.right === 0
);
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48 // v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
const defaultLayout = savedLayout.current const defaultLayout = savedLayout.current
? { ? {
'activity-l': 3, 'activity-l': 3,
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)), 'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
main: Math.max(5, Math.min(90, savedLayout.current.main)), // main 上限 75%:防止恢复时主区过大挤压两侧栏(右侧栏在布局末尾最易被挤到亚像素卡死)
main: Math.max(20, Math.min(75, savedLayout.current.main)),
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)), 'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
'activity-r': 3, 'activity-r': 3,
} }
@@ -238,13 +268,44 @@ function WorkbenchContent() {
useEffect(() => () => clearTimeout(layoutTimer.current), []); useEffect(() => () => clearTimeout(layoutTimer.current), []);
/** 程序化调整侧边栏宽度:走 groupRef.setLayout() 整体替换布局。
* 不能再依赖 ref.collapse()/resize()/expand()——RRP 命令式 API 按 pivot 相邻面板分配
* delta,右侧栏的右邻 activity-r 是刚性面板(minSize=maxSize=48px),delta 被其 clamp
* 全部吸收 → 布局不变 → 静默 no-op;拖拽走 Separator(邻面板 main 柔性)所以只有拖拽有效。
* setLayout() 不走 pivotK() 直接归一化 + 逐面板 clamp,目标尺寸落在目标面板上。 */
const applySidebarSize = useCallback((side, targetPx) => {
const group = groupRef.current;
if (!group) return;
const cur = group.getLayout();
// defaultLayoutDeferred 期间 getLayout() 返回 {}setLayout 也不应用——直接跳过
if (!cur || Object.keys(cur).length === 0) return;
// 用任一可见面板换算组宽度:groupPx = inPixels / (asPercentage/100)
let groupPx = 0;
for (const probe of [mainRef.current, rightRef.current, leftRef.current]) {
try {
const g = probe?.getSize();
if (g && g.inPixels > 0 && g.asPercentage > 0) {
groupPx = g.inPixels / (g.asPercentage / 100);
break;
}
} catch { /* 面板尚未就绪 */ }
}
const id = side === 'left' ? 'sidebar-l' : 'sidebar-r';
if (groupPx > 0) {
group.setLayout({ ...cur, [id]: (targetPx / groupPx) * 100 });
} else if (targetPx === 0) {
group.setLayout({ ...cur, [id]: 0 }); // 折叠:无法换算也直接置 0
} else {
group.setLayout({ ...cur, [id]: side === 'left' ? 20 : 25 }); // 退化固定百分比,K 的 clamp 兜底
}
}, []);
const toggleSidebar = useCallback((side) => { const toggleSidebar = useCallback((side) => {
const ref = side === 'left' ? leftRef.current : rightRef.current; // 用状态(阈值判定)而非 ref.isCollapsed()RRP 零容差比较在亚像素宽度下会误判
if (!ref) return; const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
if (ref.isCollapsed()) ref.expand(); applySidebarSize(side, collapsed ? SIDEBAR_DEFAULT_WIDTH[side] : 0);
else ref.collapse(); setTimeout(saveLayoutNow, 0); // setLayout 的 onLayoutChanged 不带 isUserInteraction,须手动兜底写盘
setTimeout(saveLayoutNow, 0); }, [leftCollapsed, rightCollapsed, applySidebarSize, saveLayoutNow]);
}, [saveLayoutNow]);
// ---- 视图(活动栏 / 侧栏 / 标签页) ---- // ---- 视图(活动栏 / 侧栏 / 标签页) ----
const leftActive = layout.activeViewOf('left'); const leftActive = layout.activeViewOf('left');
@@ -318,20 +379,27 @@ function WorkbenchContent() {
}; };
const mainTabs = buildMainTabs(); const mainTabs = buildMainTabs();
// 活动主标签:优先记住的用户选择,失效则回退到第一个 // 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey) const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
? activeMainKey ? activeMainKey
: (mainTabs[0]?.key ?? null); : (mainTabs[0]?.key ?? null);
// 打开面板 / 历史导航时自动切到对应 iframe 标签 // 打开面板 / 历史导航时自动切到对应 iframe 标签
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
// 之后每次 activeId 变化都同步并持久化。
useEffect(() => { useEffect(() => {
if (activeId != null) setActiveMainKey('panel:' + activeId); if (activeId == null) return;
if (mainActiveInit.current) { mainActiveInit.current = null; return; }
const key = 'panel:' + activeId;
setActiveMainKey(key);
persistMainActive(key);
}, [activeId]); }, [activeId]);
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id)); const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
const handleSelectTab = (key) => { const handleSelectTab = (key) => {
setActiveMainKey(key); setActiveMainKey(key);
persistMainActive(key);
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length)); if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
}; };
const handleCloseTab = (key) => { const handleCloseTab = (key) => {
@@ -339,6 +407,14 @@ function WorkbenchContent() {
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR); else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
}; };
/** 侧边栏 Panel onResize:阈值判定折叠状态(拖拽折叠/亚像素挤压都覆盖) */
const onSidebarResize = useCallback((side) => (size) => {
const px = size && typeof size === 'object' ? size.inPixels : size;
const collapsed = px < SIDEBAR_COLLAPSED_PX;
if (side === 'left') setLeftCollapsed(collapsed);
else setRightCollapsed(collapsed);
}, []);
const categories = [...new Set((links || []).map((p) => p.category || '默认'))]; const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
const handleOpenExternal = () => { const handleOpenExternal = () => {
@@ -371,10 +447,11 @@ function WorkbenchContent() {
mode={layout.mode[viewId]} mode={layout.mode[viewId]}
onSetMode={(m) => setViewModeSafely(viewId, m)} onSetMode={(m) => setViewModeSafely(viewId, m)}
onFloat={() => showSnack('浮动模式即将上线', 'info')} onFloat={() => showSnack('浮动模式即将上线', 'info')}
closeTitle="关闭侧边栏"
onClose={() => { onClose={() => {
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
const side = layout.sideOf[viewId]; const side = layout.sideOf[viewId];
const ref = side === 'left' ? leftRef.current : rightRef.current; toggleSidebar(side);
ref?.collapse();
}} }}
/> />
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}> <Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
@@ -401,17 +478,16 @@ function WorkbenchContent() {
); );
}; };
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起 */ /** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起VSCode 语义) */
const handleActivateView = (viewId, side) => { const handleActivateView = (viewId, side) => {
const ref = side === 'left' ? leftRef.current : rightRef.current; const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
const alreadyActive = layout.activeViewOf(side) === viewId; const alreadyActive = layout.activeViewOf(side) === viewId;
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR); setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
layout.activateView(viewId); layout.activateView(viewId);
if (ref) { // setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
if (alreadyActive && !ref.isCollapsed()) ref.collapse(); if (alreadyActive && !collapsed) applySidebarSize(side, 0);
else ref.expand(); else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
setTimeout(saveLayoutNow, 0); setTimeout(saveLayoutNow, 0);
}
}; };
// ---- 菜单栏配置 ---- // ---- 菜单栏配置 ----
@@ -444,8 +520,8 @@ function WorkbenchContent() {
}, },
{ {
label: '终端', items: [ label: '终端', items: [
{ label: '切换终端面板', Icon: TerminalIcon, action: () => layout.toggleTerminal() }, // 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
{ label: '新建终端会话', Icon: AddIcon, action: () => { layout.openTerminal(); termRef.current?.newSession(); } }, { label: '终端功能即将回归', Icon: TerminalIcon },
], ],
}, },
{ {
@@ -579,7 +655,7 @@ function WorkbenchContent() {
</Panel> </Panel>
<ResizeSeparator disabled /> <ResizeSeparator disabled />
{/* 左侧侧边栏 */} {/* 左侧侧边栏 */}
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef}> <Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
<SidebarPane <SidebarPane
side="left" side="left"
views={leftPaneViews} views={leftPaneViews}
@@ -590,8 +666,10 @@ function WorkbenchContent() {
</Panel> </Panel>
<ResizeSeparator /> <ResizeSeparator />
{/* 主区:标签条 + 内容 + 底部终端 */} {/* 主区:标签条 + 内容 + 底部终端 */}
<Panel id="main" minSize={320}> <Panel id="main" minSize={320} panelRef={mainRef}>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', minWidth: 0 }}> {/* 注意:RRP Panel 内容容器是 block 布局(maxHeight:100% + flexGrow:1),
这里必须用 height:'100%' 而非 flex:1,否则高度塌陷导致内容挤顶部 */}
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden', position: 'relative' }}>
{mainTabs.length > 0 && ( {mainTabs.length > 0 && (
<TabStrip <TabStrip
tabs={mainTabs} tabs={mainTabs}
@@ -604,23 +682,15 @@ function WorkbenchContent() {
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}> <Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
{mainBody} {mainBody}
</Box> </Box>
{/* 底部终端:默认隐藏,keep-alive 不销毁会话 */} {/* 侧边栏折叠箭头:贴主区整高的边缘(即侧边栏与主区 Separator 位置)垂直居中,
<Box 折叠为 0 后主区仍在 → 箭头始终可点,支持再点击展开 */}
sx={{ <CollapseArrow side="left" collapsed={leftCollapsed} onToggle={() => toggleSidebar('left')} />
height: layout.terminalOpen ? 240 : 0, <CollapseArrow side="right" collapsed={rightCollapsed} onToggle={() => toggleSidebar('right')} />
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>
</Panel> </Panel>
<ResizeSeparator /> <ResizeSeparator />
{/* 右侧侧边栏 */} {/* 右侧侧边栏 */}
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef}> <Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef} onResize={onSidebarResize('right')}>
<SidebarPane <SidebarPane
side="right" side="right"
views={rightPaneViews} views={rightPaneViews}
@@ -637,8 +707,6 @@ function WorkbenchContent() {
views={rightBarViews} views={rightBarViews}
activeId={rightActive} activeId={rightActive}
onActivate={(id) => handleActivateView(id, 'right')} onActivate={(id) => handleActivateView(id, 'right')}
terminalOpen={layout.terminalOpen}
onToggleTerminal={layout.toggleTerminal}
/> />
</Panel> </Panel>
</Group> </Group>
@@ -415,11 +415,16 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
if (!inst || !el) return; if (!inst || !el) return;
if (inst.term) { if (inst.term) {
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { inst.fit?.fit(); manager.current.sendResize(id); } catch {} try {
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
} catch {}
}); });
return; return;
} }
const term = new Terminal({ let term;
let fit = null;
try {
term = new Terminal({
fontSize: 13, fontSize: 13,
fontFamily: FONT, fontFamily: FONT,
lineHeight: 1.35, lineHeight: 1.35,
@@ -427,11 +432,20 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
scrollback: 3000, scrollback: 3000,
theme: themeObj, theme: themeObj,
}); });
const fit = new FitAddon(); fit = new FitAddon();
term.loadAddon(fit); term.loadAddon(fit);
term.loadAddon(new Unicode11Addon()); term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11'; term.unicode.activeVersion = '11';
term.open(el); term.open(el);
inst.term = term;
inst.fit = fit;
} catch (e) {
// xterm /
console.error('[TerminalPanel] xterm 初始化失败:', e);
patchStatus(id, 'error');
setBanner({ type: 'error', text: `终端初始化失败:${e?.message || '未知错误'}` });
return;
}
term.onData((d) => { term.onData((d) => {
const ws = inst.ws; const ws = inst.ws;
if (!ws || ws.readyState !== 1) return; if (!ws || ws.readyState !== 1) return;
@@ -449,11 +463,12 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
} }
}); });
ro.observe(el); ro.observe(el);
inst.term = term;
inst.fit = fit;
inst.ro = ro; inst.ro = ro;
// rAF fit 0
requestAnimationFrame(() => { requestAnimationFrame(() => {
try { fit.fit(); manager.current.sendResize(id); } catch {} try {
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit.fit(); manager.current.sendResize(id); }
} catch {}
}); });
manager.current.connectSession(id); manager.current.connectSession(id);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -672,6 +687,26 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
}} }}
> >
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} /> <Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(banner 可被关掉) */}
{s.status !== 'connected' && s.status !== 'ended' && (
<Box
role="status"
sx={{
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3,
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5,
bgcolor: s.status === 'error' ? 'error.main' : 'warning.main',
color: s.status === 'error' ? 'error.contrastText' : 'warning.contrastText',
borderTop: 1, borderColor: 'divider',
}}
>
{s.status !== 'error' && <CircularProgress size={12} color="inherit" />}
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{s.status === 'connecting' ? '正在连接终端服务…'
: s.status === 'reconnecting' ? '连接中断,正在重试…'
: '终端服务不可用'}
</Typography>
</Box>
)}
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */} {/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
{s.status === 'ended' && ( {s.status === 'ended' && (
<Box <Box
@@ -1,7 +1,6 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip'; import Tooltip from '@mui/material/Tooltip';
import TerminalIcon from '@mui/icons-material/Terminal';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
@@ -16,7 +15,6 @@ import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-
* - 无障碍:role=toolbar,仅活动项 tabIndex=0roving), * - 无障碍:role=toolbar,仅活动项 tabIndex=0roving),
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器; * ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane * 侧栏内由 aria-live 播报当前视图(见 SidebarPane
* - 右侧底部固定「终端」呼出按钮(不参与拖拽)
* ============================================================ */ * ============================================================ */
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) { function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
@@ -73,33 +71,8 @@ function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }
); );
} }
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({ export default function ActivityBar({
side, views, activeId, onActivate, side, views, activeId, onActivate,
terminalOpen = false, onToggleTerminal,
}) { }) {
const right = side === 'right'; const right = side === 'right';
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side }); const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
@@ -146,7 +119,6 @@ export default function ActivityBar({
</Box> </Box>
</SortableContext> </SortableContext>
<Box sx={{ flex: 1 }} /> <Box sx={{ flex: 1 }} />
{right && <TerminalButton open={terminalOpen} onToggle={onToggleTerminal} />}
</Box> </Box>
); );
} }
@@ -0,0 +1,48 @@
import React from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
/* ============================================================
* CollapseArrow:侧边栏折叠箭头(VSCode 风格)
*
* - 由父级绝对定位到主区边缘(即侧边栏与主区的 Separator 位置)垂直居中;
* 侧边栏折叠为 0 后主区仍在,箭头始终可见可点,支持"再点击展开"。
* - 箭头始终指向栏的方向:展开时指栏(点击收起),折叠后反向(点击展开)。
* - 可见性:**常驻可见**——不透明背景(background.paper+ 实心边框 + 阴影,
* opacity 展开 0.9 / 折叠 1,hover 全亮加深阴影;不再半透明叠加 iframe 混色。
* - 28x28 触控目标,zIndex 12 盖在 iframe 内容之上。
* ============================================================ */
export default function CollapseArrow({ side, collapsed = false, onToggle }) {
const right = side === 'right';
const label = collapsed ? `展开${right ? '右' : '左'}侧边栏` : `折叠${right ? '右' : '左'}侧边栏`;
// 展开 → 指向栏(收起动作);折叠 → 反向(展开动作)
const Icon = collapsed
? (right ? ChevronLeftIcon : ChevronRightIcon)
: (right ? ChevronRightIcon : ChevronLeftIcon);
return (
<Tooltip title={label} placement={right ? 'left' : 'right'}>
<IconButton
size="small"
aria-label={label}
onClick={onToggle}
sx={{
position: 'absolute', top: '50%', [right ? 'right' : 'left']: 2,
transform: 'translateY(-50%)', zIndex: 12,
width: 28, height: 28, minWidth: 28, p: 0,
opacity: collapsed ? 1 : 0.9,
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
boxShadow: (t) => t.shadows[2],
transition: 'opacity .12s ease, background-color .12s ease, box-shadow .12s ease',
'&:hover': { opacity: 1, bgcolor: 'action.hover', boxShadow: (t) => t.shadows[4] },
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
}}
>
<Icon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
);
}
@@ -1,11 +1,28 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import LanguageIcon from '@mui/icons-material/Language';
/* ============================================================ /* ============================================================
* 面板图标:Google favicon 服务 + 失败回退(首字母块 / 地球图标 * 面板图标:Google favicon 服务 + 失败回退(站点首字母 + 确定性 MD3 容器色
*
* - 成功:显示 favicon(不变)
* - 失败/无 host:显示首字母色块(类似 Google Avatar)——
* 背景色从 MD3 容器色(primary/secondary/tertiary.container)按
* 域名/标题哈希确定性选取,比默认地球图标更可辨识。
* ============================================================ */ * ============================================================ */
/** 确定性回退色板:MD3 token(不硬编码 hex),按哈希循环选取 */
const FALLBACK_PALETTE = [
{ bg: 'primary.container', fg: 'primary.onContainer' },
{ bg: 'secondary.container', fg: 'secondary.onContainer' },
{ bg: 'tertiary.container', fg: 'tertiary.onContainer' },
];
function hashString(s) {
let h = 0;
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function hostOf(url) { function hostOf(url) {
try { try {
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url; const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
@@ -17,19 +34,24 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
const [error, setError] = useState(false); const [error, setError] = useState(false);
const host = hostOf(url); const host = hostOf(url);
if (!host) { // 首字母 + 确定性颜色
const letter = ((title && title[0]) || (host && host[0]) || '?').toUpperCase();
const { bg, fg } = FALLBACK_PALETTE[hashString(host || title || '?') % FALLBACK_PALETTE.length];
// 无 host(内部地址/空)或 favicon 加载失败(404/API 不可达)→ 首字母色块
if (!host || error) {
return ( return (
<Box <Box
aria-hidden aria-hidden
sx={{ sx={{
width: size, height: size, borderRadius: '50%', flexShrink: 0, width: size, height: size, borderRadius: size / 4, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'primary.container', color: 'primary.onContainer', bgcolor: bg, color: fg,
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1, fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
...sx, ...sx,
}} }}
> >
{(title[0] || '?').toUpperCase()} {letter}
</Box> </Box>
); );
} }
@@ -43,9 +65,6 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
bgcolor: 'background.paper', ...sx, bgcolor: 'background.paper', ...sx,
}} }}
> >
{error ? (
<LanguageIcon sx={{ fontSize: size * 0.7, color: 'text.disabled' }} />
) : (
<img <img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`} src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
alt="" alt=""
@@ -55,7 +74,6 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
onError={() => setError(true)} onError={() => setError(true)}
style={{ display: 'block' }} style={{ display: 'block' }}
/> />
)}
</Box> </Box>
); );
} }
@@ -70,11 +70,11 @@ export default function PanelsView({
return m; return m;
}, [links]); }, [links]);
const pinnedPanels = links.filter((p) => pinned.includes(p.id)); const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
const renderItem = (p) => { const renderItem = (p) => {
const selected = String(p.id) === String(activeId); const selected = String(p.id) === String(activeId);
const isPinned = pinned.includes(p.id); const isPinned = pinned.includes(String(p.id));
const mode = modes[p.id] || OPEN_MODE_EMBED; const mode = modes[p.id] || OPEN_MODE_EMBED;
return ( return (
<Box <Box
@@ -17,7 +17,7 @@ import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入 * - 纯展示组件,无内部状态;模式/关闭行为由父级注入
* ============================================================ */ * ============================================================ */
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, actions = null, children }) { export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, closeTitle = '关闭', actions = null, children }) {
const modeBtn = (m, label, MIcon) => ( const modeBtn = (m, label, MIcon) => (
<Tooltip key={m} title={label}> <Tooltip key={m} title={label}>
<IconButton <IconButton
@@ -65,11 +65,11 @@ export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onCl
)} )}
{actions} {actions}
{onClose && ( {onClose && (
<Tooltip title="关闭"> <Tooltip title={closeTitle}>
<IconButton <IconButton
size="small" size="small"
onClick={onClose} onClick={onClose}
aria-label="关闭" aria-label={closeTitle}
sx={{ width: 26, height: 26, borderRadius: 1 }} sx={{ width: 26, height: 26, borderRadius: 1 }}
> >
<CloseIcon sx={{ fontSize: 15 }} /> <CloseIcon sx={{ fontSize: 15 }} />
@@ -18,8 +18,9 @@ import PanelIcon from './PanelIcon.jsx';
/* ============================================================ /* ============================================================
* ToolbarVSCode 风格顶栏(高度 56px * ToolbarVSCode 风格顶栏(高度 56px
* *
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 缩小地址栏(只读) | 搜索 | 返回 * Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 地址栏(自适应填满) | 搜索 | 返回
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签 * - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签
* flex:1 填满顶栏剩余空间,左侧内容变宽时自然缩减
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入) * - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
* ============================================================ */ * ============================================================ */
@@ -51,7 +52,7 @@ export default function Toolbar({
component="span" component="span"
aria-label="Rain Work" aria-label="Rain Work"
sx={{ sx={{
fontSize: 15, fontWeight: 700, letterSpacing: 0.3, mr: 0.75, flexShrink: 0, fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text', background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
WebkitTextFillColor: 'transparent', color: 'transparent', WebkitTextFillColor: 'transparent', color: 'transparent',
userSelect: 'none', userSelect: 'none',
@@ -75,12 +76,13 @@ 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>
{/* 缩小地址栏(只读 */} {/* 地址栏(只读,flex:1 自适应填满顶栏剩余空间;无 maxWidth 上限,
左侧菜单/导航变宽时自然缩减,顶栏始终占满) */}
<Box <Box
role="textbox" role="textbox"
aria-label="当前面板地址" aria-label="当前面板地址"
sx={{ sx={{
flex: 1, minWidth: 120, maxWidth: 360, display: 'flex', alignItems: 'center', gap: 0.75, flex: 1, minWidth: 120, maxWidth: 'none', display: 'flex', alignItems: 'center', gap: 0.75,
mx: 0.75, height: 34, px: 1.25, mx: 0.75, height: 34, px: 1.25,
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25, bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
'&:focus-within': { borderColor: 'primary.main' }, '&:focus-within': { borderColor: 'primary.main' },
+17 -15
View File
@@ -51,6 +51,23 @@ export default function useNotes() {
useEffect(() => { refresh(); }, [refresh]); useEffect(() => { refresh(); }, [refresh]);
// ---- 保存(PUT ----
// 注意:必须声明在引用它的 useEffect(会话恢复 / save-request 监听)之前,
// 否则渲染期求值依赖数组会触发 TDZCannot access before initialization)。
const saveContent = useCallback((name, text) => {
setSaving(true);
return notesApi.updateNote(name, text)
.then((res) => {
setSavedContent(text);
sessionCache.text = text;
setNotes((prev) => (prev
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
: prev));
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setSaving(false));
}, []);
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等); // ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
// 无缓存但记住了文件名→从服务端读取 ---- // 无缓存但记住了文件名→从服务端读取 ----
useEffect(() => { useEffect(() => {
@@ -94,21 +111,6 @@ export default function useNotes() {
} }
}, [activeName]); }, [activeName]);
// ---- 保存(PUT ----
const saveContent = useCallback((name, text) => {
setSaving(true);
return notesApi.updateNote(name, text)
.then((res) => {
setSavedContent(text);
sessionCache.text = text;
setNotes((prev) => (prev
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
: prev));
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setSaving(false));
}, []);
// ---- 打开文件 ---- // ---- 打开文件 ----
const open = useCallback((name) => { const open = useCallback((name) => {
setBusy(true); setBusy(true);
@@ -36,14 +36,31 @@ function write(key, value) {
export default function usePanels() { export default function usePanels() {
const [links, setLinks] = useState(null); // null = 加载中 const [links, setLinks] = useState(null); // null = 加载中
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [openIds, setOpenIds] = useState(() => read(STORE.open, [])); // 初始化即清洗(治愈已污染的 localStorage 存量数据):
const [activeId, setActiveId] = useState(() => read(STORE.active, null)); // 面板 id 统一为字符串 + 去重。此前 openPanel 被传入数字(p.id)与字符串
// (handleSelectTab 的 key.slice) 两种类型,prev.includes 严格比较漏判 →
// 同一面板以两份加入 openIds → 双 iframe 并排"分屏"、closePanel 关不掉。
const [openIds, setOpenIds] = useState(() => {
const a = read(STORE.open, []);
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
});
const [activeId, setActiveId] = useState(() => {
const v = read(STORE.active, null);
return v == null ? null : String(v);
});
const [history, setHistory] = useState(() => { const [history, setHistory] = useState(() => {
const h = read(STORE.history, null); const h = read(STORE.history, null);
return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 }; if (h && Array.isArray(h.ids)) {
const ids = [...new Set(h.ids.map(String))];
return { ids, index: Math.min(Number(h.index) || -1, ids.length - 1) };
}
return { ids: [], index: -1 };
}); });
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(() => {
const a = read(STORE.pinned, []);
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
});
const [modes, setModes] = useState(() => read(STORE.modes, {})); const [modes, setModes] = useState(() => read(STORE.modes, {}));
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化) const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -68,11 +85,12 @@ export default function usePanels() {
const arr = Array.isArray(ls) ? ls : []; const arr = Array.isArray(ls) ? ls : [];
setLinks(arr); setLinks(arr);
const valid = new Set(arr.map((p) => String(p.id))); const valid = new Set(arr.map((p) => String(p.id)));
setOpenIds((prev) => prev.filter((id) => valid.has(id))); // String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
setPinned((prev) => prev.filter((id) => valid.has(id))); setOpenIds((prev) => prev.map(String).filter((id) => valid.has(id)));
setPinned((prev) => prev.map(String).filter((id) => valid.has(id)));
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id)))); setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
setHistory((prev) => { setHistory((prev) => {
const ids = prev.ids.filter((id) => valid.has(id)); const ids = prev.ids.map(String).filter((id) => valid.has(id));
return { ids, index: Math.min(prev.index, ids.length - 1) }; return { ids, index: Math.min(prev.index, ids.length - 1) };
}); });
return arr; return arr;
@@ -99,7 +117,8 @@ export default function usePanels() {
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ---- // ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象 // panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
const openPanel = useCallback((id, panelOverride) => { const openPanel = useCallback((id, panelOverride) => {
const panel = panelOverride || (links && links.find((p) => String(p.id) === String(id))); id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
const panel = panelOverride || (links && links.find((p) => String(p.id) === id));
if (!panel) return false; if (!panel) return false;
touch(id); touch(id);
const mode = modes[id] || OPEN_MODE_EMBED; const mode = modes[id] || OPEN_MODE_EMBED;
@@ -108,7 +127,7 @@ export default function usePanels() {
return false; return false;
} }
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; } if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
if (String(activeId) === String(id)) return false; if (activeId === id) return false;
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id])); setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setHistory((prev) => { setHistory((prev) => {
const ids = prev.ids.slice(0, prev.index + 1); const ids = prev.ids.slice(0, prev.index + 1);
@@ -143,6 +162,7 @@ export default function usePanels() {
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ---- // ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
const closePanel = useCallback((id) => { const closePanel = useCallback((id) => {
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
const nextOpen = openIds.filter((x) => x !== id); const nextOpen = openIds.filter((x) => x !== id);
setOpenIds(nextOpen); setOpenIds(nextOpen);
delete recency.current[id]; delete recency.current[id];
@@ -157,6 +177,7 @@ export default function usePanels() {
}, [history, openIds]); }, [history, openIds]);
const togglePin = useCallback((id) => { const togglePin = useCallback((id) => {
id = String(id); // 防止固定集合再次被数字污染
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}, []); }, []);
@@ -49,17 +49,48 @@ function write(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ } try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
} }
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧 */ /** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
function sanitizeOrder(saved) { function sanitizeOrder(saved) {
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER; if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
const seen = new Set(); const seen = new Set();
const clean = (arr) => arr.filter((id) => VIEW_IDS.includes(id) && !seen.has((seen.add(id), id))); const clean = (arr) => {
const out = [];
arr.forEach((id) => {
if (!VIEW_IDS.includes(id) || seen.has(id)) return; // 先查重,后登记
seen.add(id);
out.push(id);
});
return out;
};
const left = clean(saved.left); const left = clean(saved.left);
const right = clean(saved.right); const right = clean(saved.right);
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); }); VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
// 兜底:持久化可能把视图全挪到一侧(或空数组),刷新后另一侧空白打不开。
// 保证每侧至少一个视图(panels 优先左侧,notes 次之)。
if (left.length === 0 && right.length > 0) {
const preferred = ['panels', 'notes'].find((id) => right.includes(id));
const move = preferred || right[0];
left.push(move);
right.splice(right.indexOf(move), 1);
}
if (right.length === 0 && left.length > 0) {
const preferred = ['notes', 'vault'].find((id) => left.includes(id));
const move = preferred || left[0];
right.push(move);
left.splice(left.indexOf(move), 1);
}
return { left, right }; return { left, right };
} }
/** 校验 active:指向的视图必须仍在本侧,否则回退到该侧第一个 */
function sanitizeActive(saved, order) {
if (!saved || typeof saved !== 'object') return { ...DEFAULT_ACTIVE };
const pick = (side, fallback) => (
order[side].includes(saved[side]) ? saved[side] : (order[side][0] || fallback)
);
return { left: pick('left', 'panels'), right: pick('right', 'notes') };
}
/** 校验 mode:只接受 sidebar/tabfloat 为保留值,不持久化激活) */ /** 校验 mode:只接受 sidebar/tabfloat 为保留值,不持久化激活) */
function sanitizeMode(saved) { function sanitizeMode(saved) {
const out = { ...DEFAULT_MODE }; const out = { ...DEFAULT_MODE };
@@ -73,10 +104,7 @@ function sanitizeMode(saved) {
export default function useWorkbenchLayout() { export default function useWorkbenchLayout() {
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null))); const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
const [active, setActive] = useState(() => { const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
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 [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false)); const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
+2 -1
View File
@@ -21,7 +21,8 @@ export { default as SidebarPane } from './components/SidebarPane.jsx';
export { default as ToolWindow } from './components/ToolWindow.jsx'; export { default as ToolWindow } from './components/ToolWindow.jsx';
export { default as TabStrip } from './components/TabStrip.jsx'; export { default as TabStrip } from './components/TabStrip.jsx';
export { default as ContentView } from './components/ContentView.jsx'; export { default as ContentView } from './components/ContentView.jsx';
export { default as TerminalPanel } from './components/TerminalPanel.jsx'; // 终端面板临时下线:组件归档到 archive/(前端入口已移除,恢复时改回 components/ 路径)
export { default as TerminalPanel } from './archive/TerminalPanel.jsx';
export { default as StatusBar } from './components/StatusBar.jsx'; export { default as StatusBar } from './components/StatusBar.jsx';
export { default as MenuBar } from './components/MenuBar.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';
+7 -7
View File
@@ -1,12 +1,12 @@
{ {
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.1.0", "version": "2.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.1.0", "version": "2.2.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@@ -567,7 +567,7 @@
} }
}, },
"node_modules/@emotion/utils": { "node_modules/@emotion/utils": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz", "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==", "integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT" "license": "MIT"
@@ -1402,7 +1402,7 @@
} }
}, },
"node_modules/concat-stream": { "node_modules/concat-stream": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [ "engines": [
@@ -1562,7 +1562,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/depd": { "node_modules/depd": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT", "license": "MIT",
@@ -1639,7 +1639,7 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/encodeurl": { "node_modules/encodeurl": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT", "license": "MIT",
@@ -2639,7 +2639,7 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "rainweb-links", "name": "rainweb-links",
"version": "2.1.0", "version": "2.2.0",
"description": "链接聚合管理平台", "description": "链接聚合管理平台",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+3
View File
@@ -21,6 +21,9 @@ module.exports = defineConfig({
proxy: { proxy: {
'/api': 'http://localhost:3101', '/api': 'http://localhost:3101',
'/uploads': 'http://localhost:3101', '/uploads': 'http://localhost:3101',
// Web 终端 WS/ws/terminal 必须走 WebSocket 代理,否则 dev 下解锁后
// 连接永远失败 → 终端空转白屏(生产由 server.js 的 upgrade 事件处理)
'/ws': { target: 'ws://localhost:3101', ws: true },
}, },
}, },
}); });