Compare commits
9
Commits
2b748fc48e
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0def382511 | ||
|
|
636d13407f | ||
|
|
f9abb877e7 | ||
|
|
c947e62364 | ||
|
|
ded6fdb753 | ||
|
|
9c34374b95 | ||
|
|
8a3e213e40 | ||
|
|
2de07710da | ||
|
|
e16130bf8f |
@@ -39,10 +39,10 @@ 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 CollapseArrow from './components/CollapseArrow.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 AddPanelDialog from './components/AddPanelDialog.jsx';
|
||||
@@ -61,6 +61,21 @@ import SnackHost, { showSnack } from '../../admin/snack.jsx';
|
||||
* ============================================================ */
|
||||
|
||||
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 不入库 */
|
||||
function readLayout() {
|
||||
@@ -196,23 +211,38 @@ function WorkbenchContent() {
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [mainRefresh, setMainRefresh] = 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 searchRef = useRef(null);
|
||||
|
||||
// ---- react-resizable-panels 引用 ----
|
||||
const groupRef = useRef(null);
|
||||
const mainRef = useRef(null); // 主面板(始终 ≥minSize 可见),用作 setLayout 像素↔百分比换算探针
|
||||
const leftRef = useRef(null);
|
||||
const rightRef = useRef(null);
|
||||
const termRef = useRef(null);
|
||||
|
||||
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
|
||||
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)),
|
||||
// main 上限 75%:防止恢复时主区过大挤压两侧栏(右侧栏在布局末尾最易被挤到亚像素卡死)
|
||||
main: Math.max(20, Math.min(75, savedLayout.current.main)),
|
||||
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
|
||||
'activity-r': 3,
|
||||
}
|
||||
@@ -238,13 +268,44 @@ function WorkbenchContent() {
|
||||
|
||||
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() 不走 pivot:K() 直接归一化 + 逐面板 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 ref = side === 'left' ? leftRef.current : rightRef.current;
|
||||
if (!ref) return;
|
||||
if (ref.isCollapsed()) ref.expand();
|
||||
else ref.collapse();
|
||||
setTimeout(saveLayoutNow, 0);
|
||||
}, [saveLayoutNow]);
|
||||
// 用状态(阈值判定)而非 ref.isCollapsed():RRP 零容差比较在亚像素宽度下会误判
|
||||
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||
applySidebarSize(side, collapsed ? SIDEBAR_DEFAULT_WIDTH[side] : 0);
|
||||
setTimeout(saveLayoutNow, 0); // setLayout 的 onLayoutChanged 不带 isUserInteraction,须手动兜底写盘
|
||||
}, [leftCollapsed, rightCollapsed, applySidebarSize, saveLayoutNow]);
|
||||
|
||||
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
|
||||
const leftActive = layout.activeViewOf('left');
|
||||
@@ -318,20 +379,27 @@ function WorkbenchContent() {
|
||||
};
|
||||
|
||||
const mainTabs = buildMainTabs();
|
||||
// 活动主标签:优先记住的用户选择,失效则回退到第一个
|
||||
// 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
|
||||
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
||||
? activeMainKey
|
||||
: (mainTabs[0]?.key ?? null);
|
||||
|
||||
// 打开面板 / 历史导航时自动切到对应 iframe 标签
|
||||
// 打开面板 / 历史导航时自动切到对应 iframe 标签。
|
||||
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
|
||||
// 之后每次 activeId 变化都同步并持久化。
|
||||
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]);
|
||||
|
||||
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
|
||||
|
||||
const handleSelectTab = (key) => {
|
||||
setActiveMainKey(key);
|
||||
persistMainActive(key);
|
||||
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
|
||||
};
|
||||
const handleCloseTab = (key) => {
|
||||
@@ -339,6 +407,14 @@ function WorkbenchContent() {
|
||||
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 handleOpenExternal = () => {
|
||||
@@ -371,10 +447,11 @@ function WorkbenchContent() {
|
||||
mode={layout.mode[viewId]}
|
||||
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
||||
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
||||
closeTitle="关闭侧边栏"
|
||||
onClose={() => {
|
||||
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
|
||||
const side = layout.sideOf[viewId];
|
||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
||||
ref?.collapse();
|
||||
toggleSidebar(side);
|
||||
}}
|
||||
/>
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||
@@ -401,17 +478,16 @@ function WorkbenchContent() {
|
||||
);
|
||||
};
|
||||
|
||||
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起 */
|
||||
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起(VSCode 语义) */
|
||||
const handleActivateView = (viewId, side) => {
|
||||
const ref = side === 'left' ? leftRef.current : rightRef.current;
|
||||
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||
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();
|
||||
// setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
|
||||
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
|
||||
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
|
||||
setTimeout(saveLayoutNow, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- 菜单栏配置 ----
|
||||
@@ -444,8 +520,8 @@ function WorkbenchContent() {
|
||||
},
|
||||
{
|
||||
label: '终端', items: [
|
||||
{ label: '切换终端面板', Icon: TerminalIcon, action: () => layout.toggleTerminal() },
|
||||
{ label: '新建终端会话', Icon: AddIcon, action: () => { layout.openTerminal(); termRef.current?.newSession(); } },
|
||||
// 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
|
||||
{ label: '终端功能即将回归', Icon: TerminalIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -579,7 +655,7 @@ function WorkbenchContent() {
|
||||
</Panel>
|
||||
<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
|
||||
side="left"
|
||||
views={leftPaneViews}
|
||||
@@ -590,8 +666,10 @@ function WorkbenchContent() {
|
||||
</Panel>
|
||||
<ResizeSeparator />
|
||||
{/* 主区:标签条 + 内容 + 底部终端 */}
|
||||
<Panel id="main" minSize={320}>
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<Panel id="main" minSize={320} panelRef={mainRef}>
|
||||
{/* 注意: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 && (
|
||||
<TabStrip
|
||||
tabs={mainTabs}
|
||||
@@ -604,23 +682,15 @@ function WorkbenchContent() {
|
||||
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
||||
{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>
|
||||
{/* 侧边栏折叠箭头:贴主区整高的边缘(即侧边栏与主区 Separator 位置)垂直居中,
|
||||
折叠为 0 后主区仍在 → 箭头始终可点,支持再点击展开 */}
|
||||
<CollapseArrow side="left" collapsed={leftCollapsed} onToggle={() => toggleSidebar('left')} />
|
||||
<CollapseArrow side="right" collapsed={rightCollapsed} onToggle={() => toggleSidebar('right')} />
|
||||
</Box>
|
||||
</Panel>
|
||||
<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
|
||||
side="right"
|
||||
views={rightPaneViews}
|
||||
@@ -637,8 +707,6 @@ function WorkbenchContent() {
|
||||
views={rightBarViews}
|
||||
activeId={rightActive}
|
||||
onActivate={(id) => handleActivateView(id, 'right')}
|
||||
terminalOpen={layout.terminalOpen}
|
||||
onToggleTerminal={layout.toggleTerminal}
|
||||
/>
|
||||
</Panel>
|
||||
</Group>
|
||||
|
||||
+41
-6
@@ -415,11 +415,16 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
||||
if (!inst || !el) return;
|
||||
if (inst.term) {
|
||||
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;
|
||||
}
|
||||
const term = new Terminal({
|
||||
let term;
|
||||
let fit = null;
|
||||
try {
|
||||
term = new Terminal({
|
||||
fontSize: 13,
|
||||
fontFamily: FONT,
|
||||
lineHeight: 1.35,
|
||||
@@ -427,11 +432,20 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
||||
scrollback: 3000,
|
||||
theme: themeObj,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.loadAddon(new Unicode11Addon());
|
||||
term.unicode.activeVersion = '11';
|
||||
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) => {
|
||||
const ws = inst.ws;
|
||||
if (!ws || ws.readyState !== 1) return;
|
||||
@@ -449,11 +463,12 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
inst.term = term;
|
||||
inst.fit = fit;
|
||||
inst.ro = ro;
|
||||
// rAF fit 同样做尺寸守卫:容器零尺寸时跳过,避免 0 行列渲染成空白
|
||||
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);
|
||||
// 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%' }} />
|
||||
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(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/空闲超时后不自动重生,显式重连 */}
|
||||
{s.status === 'ended' && (
|
||||
<Box
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -16,7 +15,6 @@ import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-
|
||||
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
||||
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
||||
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
||||
* - 右侧底部固定「终端」呼出按钮(不参与拖拽)
|
||||
* ============================================================ */
|
||||
|
||||
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({
|
||||
side, views, activeId, onActivate,
|
||||
terminalOpen = false, onToggleTerminal,
|
||||
}) {
|
||||
const right = side === 'right';
|
||||
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
||||
@@ -146,7 +119,6 @@ export default function ActivityBar({
|
||||
</Box>
|
||||
</SortableContext>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{right && <TerminalButton open={terminalOpen} onToggle={onToggleTerminal} />}
|
||||
</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 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) {
|
||||
try {
|
||||
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 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 (
|
||||
<Box
|
||||
aria-hidden
|
||||
sx={{
|
||||
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||
width: size, height: size, borderRadius: size / 4, flexShrink: 0,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||
bgcolor: bg, color: fg,
|
||||
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{(title[0] || '?').toUpperCase()}
|
||||
{letter}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -43,9 +65,6 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
||||
bgcolor: 'background.paper', ...sx,
|
||||
}}
|
||||
>
|
||||
{error ? (
|
||||
<LanguageIcon sx={{ fontSize: size * 0.7, color: 'text.disabled' }} />
|
||||
) : (
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||
alt=""
|
||||
@@ -55,7 +74,6 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
||||
onError={() => setError(true)}
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,11 +70,11 @@ export default function PanelsView({
|
||||
return m;
|
||||
}, [links]);
|
||||
|
||||
const pinnedPanels = links.filter((p) => pinned.includes(p.id));
|
||||
const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
|
||||
|
||||
const renderItem = (p) => {
|
||||
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;
|
||||
return (
|
||||
<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) => (
|
||||
<Tooltip key={m} title={label}>
|
||||
<IconButton
|
||||
@@ -65,11 +65,11 @@ export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onCl
|
||||
)}
|
||||
{actions}
|
||||
{onClose && (
|
||||
<Tooltip title="关闭">
|
||||
<Tooltip title={closeTitle}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
aria-label="关闭"
|
||||
aria-label={closeTitle}
|
||||
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||
>
|
||||
<CloseIcon sx={{ fontSize: 15 }} />
|
||||
|
||||
@@ -18,8 +18,9 @@ import PanelIcon from './PanelIcon.jsx';
|
||||
/* ============================================================
|
||||
* Toolbar:VSCode 风格顶栏(高度 56px)
|
||||
*
|
||||
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 缩小地址栏(只读) | 搜索 | 返回
|
||||
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签
|
||||
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 地址栏(自适应填满) | 搜索 | 返回
|
||||
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签;
|
||||
* flex:1 填满顶栏剩余空间,左侧内容变宽时自然缩减
|
||||
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
||||
* ============================================================ */
|
||||
|
||||
@@ -51,7 +52,7 @@ export default function Toolbar({
|
||||
component="span"
|
||||
aria-label="Rain Work"
|
||||
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',
|
||||
WebkitTextFillColor: 'transparent', color: 'transparent',
|
||||
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>
|
||||
</Tooltip>
|
||||
|
||||
{/* 缩小地址栏(只读) */}
|
||||
{/* 地址栏(只读,flex:1 自适应填满顶栏剩余空间;无 maxWidth 上限,
|
||||
左侧菜单/导航变宽时自然缩减,顶栏始终占满) */}
|
||||
<Box
|
||||
role="textbox"
|
||||
aria-label="当前面板地址"
|
||||
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,
|
||||
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
|
||||
'&:focus-within': { borderColor: 'primary.main' },
|
||||
|
||||
@@ -51,6 +51,23 @@ export default function useNotes() {
|
||||
|
||||
useEffect(() => { refresh(); }, [refresh]);
|
||||
|
||||
// ---- 保存(PUT) ----
|
||||
// 注意:必须声明在引用它的 useEffect(会话恢复 / save-request 监听)之前,
|
||||
// 否则渲染期求值依赖数组会触发 TDZ(Cannot 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(() => {
|
||||
@@ -94,21 +111,6 @@ export default function useNotes() {
|
||||
}
|
||||
}, [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) => {
|
||||
setBusy(true);
|
||||
|
||||
@@ -36,14 +36,31 @@ function write(key, value) {
|
||||
export default function usePanels() {
|
||||
const [links, setLinks] = useState(null); // null = 加载中
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [openIds, setOpenIds] = useState(() => read(STORE.open, []));
|
||||
const [activeId, setActiveId] = useState(() => read(STORE.active, null));
|
||||
// 初始化即清洗(治愈已污染的 localStorage 存量数据):
|
||||
// 面板 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 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 [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 [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -68,11 +85,12 @@ export default function usePanels() {
|
||||
const arr = Array.isArray(ls) ? ls : [];
|
||||
setLinks(arr);
|
||||
const valid = new Set(arr.map((p) => String(p.id)));
|
||||
setOpenIds((prev) => prev.filter((id) => valid.has(id)));
|
||||
setPinned((prev) => prev.filter((id) => valid.has(id)));
|
||||
// String 归一化后再过滤,防止存量数字/字符串混杂 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))));
|
||||
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 arr;
|
||||
@@ -99,7 +117,8 @@ export default function usePanels() {
|
||||
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
||||
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||
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;
|
||||
touch(id);
|
||||
const mode = modes[id] || OPEN_MODE_EMBED;
|
||||
@@ -108,7 +127,7 @@ export default function usePanels() {
|
||||
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]));
|
||||
setHistory((prev) => {
|
||||
const ids = prev.ids.slice(0, prev.index + 1);
|
||||
@@ -143,6 +162,7 @@ export default function usePanels() {
|
||||
|
||||
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
||||
const closePanel = useCallback((id) => {
|
||||
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
|
||||
const nextOpen = openIds.filter((x) => x !== id);
|
||||
setOpenIds(nextOpen);
|
||||
delete recency.current[id];
|
||||
@@ -157,6 +177,7 @@ export default function usePanels() {
|
||||
}, [history, openIds]);
|
||||
|
||||
const togglePin = useCallback((id) => {
|
||||
id = String(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 { /* 隐私模式忽略 */ }
|
||||
}
|
||||
|
||||
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧 */
|
||||
/** 校验 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 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 right = clean(saved.right);
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 校验 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/tab(float 为保留值,不持久化激活) */
|
||||
function sanitizeMode(saved) {
|
||||
const out = { ...DEFAULT_MODE };
|
||||
@@ -73,10 +104,7 @@ function sanitizeMode(saved) {
|
||||
|
||||
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 [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
|
||||
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
||||
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
||||
|
||||
|
||||
@@ -21,7 +21,8 @@ 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';
|
||||
// 终端面板临时下线:组件归档到 archive/(前端入口已移除,恢复时改回 components/ 路径)
|
||||
export { default as TerminalPanel } from './archive/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';
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rainweb-links",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -567,7 +567,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emotion/utils": {
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||
"license": "MIT"
|
||||
@@ -1402,7 +1402,7 @@
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
@@ -1562,7 +1562,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
@@ -1639,7 +1639,7 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
@@ -2639,7 +2639,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rainweb-links",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"description": "链接聚合管理平台",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -21,6 +21,9 @@ module.exports = defineConfig({
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3101',
|
||||
'/uploads': 'http://localhost:3101',
|
||||
// Web 终端 WS:/ws/terminal 必须走 WebSocket 代理,否则 dev 下解锁后
|
||||
// 连接永远失败 → 终端空转白屏(生产由 server.js 的 upgrade 事件处理)
|
||||
'/ws': { target: 'ws://localhost:3101', ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user