2 Commits
Author SHA1 Message Date
miaomiao 9400f76c7e fix: 工作台审查修复 - 短 TTL proxy token/沙箱剥离/加载提示/独立刷新/键盘导航/懒加载分包 2026-08-07 21:57:56 +08:00
miaomiao 03d5c97315 feat: 面板工作台模块(tools/workbench)- 浏览器式侧边栏/工具栏/iframe 管理/密码库抽屉
- Workbench 独立页(/admin/workbench,adminOnly 守卫,全屏布局,MUI 风格)
- 工具栏:历史栈/刷新/地址栏/搜索/添加/密码库/侧栏折叠/返回
- 侧边栏:分组+固定区+折叠+拖拽调宽+打开方式(内嵌/新标签/弹窗)
- PanelFrame:懒加载/保留切换/LRU(3)/15s 超时失败回退/proxy 模式
- VaultDrawer:PIN 解锁/搜索/详情复制/锁定(复用 passwords API)
- 后台顶栏「工作台」入口 + 路由注册;localStorage 持久化
2026-08-07 21:43:06 +08:00
14 changed files with 2315 additions and 2 deletions
+83
View File
@@ -0,0 +1,83 @@
# 工作台模块项目计划(tools/workbench
> 独立完整项目流程:调研 ✓ → 设计规范 ✓ → 本计划 → 实现 → 审查 → 验证。
> 布局调研:lib-1VSCode 四层 / Dashy Workspace / Vivaldi Web Panels / Arc pinned / Homepage groups + iframe 技术建议)。
> 设计规范:frontend-pack/design skill8px 网格、语义色 token、层次/留白/焦点环/深浅色/加载空错误态)。
## 1. 模块定位
「面板工作台」:浏览器式多面板聚合界面(左侧侧边栏 + 顶部工具栏 + 右侧 iframe 内容区),叠加密码库快捷取用。仅管理员(adminOnly)可进入。
## 2. 技术方案
| 项 | 决定 |
|---|---|
| 风格 | **MUI**(与后台一致,MD3 主题复用 frontend/src/admin/theme.js |
| 路由 | 独立页面 `/workbench`React Router,挂管理后台路由树外或独立 BrowserRouter 区——用后台同一入口 admin 应用内加路由最简,basename /admin 下 `/admin/workbench`?——**决定:独立入口**(admin.html 已有),路由挂 `/admin/workbench`,与 Dashboard 等并列,顶栏入口按钮跳转;整页全屏布局不带 AdminLayout 侧栏(工作台自带侧边栏) |
| 代码位置 | `frontend/src/tools/workbench/`(独立工具区,页面+组件+密码库组件+工具函数全在这,方便后续升级) |
| 鉴权 | 入口守卫:无 token → 跳 /login.html;非 admin → 403 提示返回(复用后台守卫模式) |
| 数据 | admin_links API(复用 frontend/src/api/adminLinks.js+ passwords API(复用) |
| proxy | `/api/proxy/fetch?url=&token=`iframe query token,复用 Embed.jsx 的 needsProxy 逻辑) |
## 3. 文件结构
```
frontend/src/tools/workbench/
├── Workbench.jsx # 页面入口(路由挂载点 + 鉴权守卫 + 布局组装)
├── components/
│ ├── Toolbar.jsx # 顶部工具栏(◀▶↻ 地址栏 搜索 添加 密码库 侧栏开关 返回)
│ ├── Sidebar.jsx # 左侧边栏(固定区 + 分组列表 + 折叠 + 拖拽调宽可选)
│ ├── PanelFrame.jsx # iframe 内容区(加载态/超时/失败回退/LRU 保留)
│ ├── AddPanelDialog.jsx # 添加面板弹窗(URL/命名/分组/图标 + 打开方式)
│ └── VaultDrawer.jsx # 密码库抽屉(PIN 解锁/搜索/复制,MUI)
├── hooks/
│ ├── usePanels.js # 面板数据/打开集合/历史栈/持久化(localStorage)
│ └── useVault.js # 密码库解锁状态/条目加载(复用 passwords API
└── index.js # 模块导出(便于未来升级替换)
```
## 4. 功能清单
### 核心(必须)
- [ ] 鉴权守卫(adminOnly
- [ ] 工具栏:◀ 后退 / ▶ 前进(当前面板历史栈)、↻ 刷新、只读地址栏、🔍 搜索(面板名/URL/分组)、+添加、🔑 密码库、侧边栏折叠、返回前台
- [ ] 侧边栏:admin_links 按 category 分组 + 固定区(pin),点击切换,当前项左侧 2px primary 指示条 + surface-container 底(MUI 化:ListItemButton selected 态)
- [ ] iframe 区:懒加载(首次激活创建)、display:none 切换保留状态、**超时+load 双保险加载态**、失败(超时/空白启发式)显示「在新标签打开」回退按钮
- [ ] 打开方式:内嵌(默认)/ 新标签(target=_blank/ 弹窗(modal)——面板项右键或添加时选择,存面板设置
- [ ] 添加面板弹窗:URL + 标题 + 分组 + 图标(favicon 预览)+ 打开方式;保存到 admin_links(复用现有 API
- [ ] localStorage 持久化:打开面板集合、当前面板、历史栈、侧边栏折叠态
- [ ] 密码库抽屉:PIN 解锁(复用 /api/passwords/unlock,服务端会话互通)→ 条目列表(标题/用户名)+ 搜索 + 详情(密码明文)+ 一键复制(clipboard);未设 PIN 引导去 /passwords.html;只读取用不编辑
- [ ] 空态/加载态/错误态:无面板引导添加、加载骨架、加载失败重试
### 进阶(尽力)
- [ ] LRU iframe 保留(最多 3 个活跃,超出销毁最久未用,记录 URL 重建)
- [ ] 每面板定时刷新(可选 30s/5min/30min
- [ ] 侧边栏拖拽调宽 + 记忆
- [ ] 分组折叠状态持久化
## 5. 设计规范(design skill + 调研要点)
- 8px 网格间距;一屏内不超过 2 种圆角(MUI 默认 8px/20px pill
- 语义色 tokenMD3):工具栏/侧边栏 surface-container、选中 primary-container、hover surface-variant
- 选中态:左侧 2px primary 指示条(VSCode 模式)或 MUI selected 态,二选一统一
- 深色模式默认适配(后台已有 data-theme 机制)
- 焦点环(focus-visible)、语义 HTMLnav/main/aside/button)、触控目标 ≥40px
- 加载态用骨架/居中 spinner;空态带图标+CTA;错误态带重试
- iframe title 属性必须;allow 权限策略按面板
- 工具栏高度 48-56px、侧边栏默认 240px(可 180-360 拖拽)
## 6. 实施与质量流程
1. 派 designer 按本计划实现(一个会话,MUI + 复用后台主题/api)
2. ora 审查(鉴权/安全/iframe 处理/状态管理/无障碍)→ 修复
3. 验证:npm run build + 后台入口跳转 + admin 权限拦截 + 面板切换/proxy/密码库全链路冒烟
4. commit + 用户验收
## 7. 验收标准
- [ ] 非 admin 无法进入(无 token 跳登录、非 admin 403
- [ ] 面板分组/固定/切换/关闭/添加/持久化全部可用
- [ ] proxy 面板正常嵌入;X-Frame-Options 拒绝的面板有回退提示
- [ ] 密码库解锁→搜索→复制链路通(与服务端会话互通)
- [ ] 深浅色、320/768/1440 三档响应式正常
- [ ] build 通过、无 console 错误
+5
View File
@@ -15,6 +15,7 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import HomeIcon from '@mui/icons-material/Home'; import HomeIcon from '@mui/icons-material/Home';
import GridViewIcon from '@mui/icons-material/GridView';
import LogoutIcon from '@mui/icons-material/Logout'; import LogoutIcon from '@mui/icons-material/Logout';
import DashboardIcon from '@mui/icons-material/Dashboard'; import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings'; import SettingsIcon from '@mui/icons-material/Settings';
@@ -88,6 +89,10 @@ export default function AdminLayout() {
</IconButton> </IconButton>
)} )}
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography> <Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
<Button color="inherit" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
</Button>
<Button color="inherit" href="/" title="返回前台"> <Button color="inherit" href="/" title="返回前台">
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台 <HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
</Button> </Button>
+18 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { lazy, Suspense, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles'; import { ThemeProvider } from '@mui/material/styles';
@@ -25,6 +25,8 @@ import Announcements from './pages/Announcements.jsx';
import Links from './pages/Links.jsx'; import Links from './pages/Links.jsx';
import Uploads from './pages/Uploads.jsx'; import Uploads from './pages/Uploads.jsx';
import ImportDb from './pages/ImportDb.jsx'; import ImportDb from './pages/ImportDb.jsx';
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
// 生产:/admin/* 由后端 fallback 到 dist/admin.htmlPhase 6 需求),basename=/admin // 生产:/admin/* 由后端 fallback 到 dist/admin.htmlPhase 6 需求),basename=/admin
// 开发:vite 多入口直接访问 /admin.htmlbasename 自适应为空。 // 开发:vite 多入口直接访问 /admin.htmlbasename 自适应为空。
@@ -76,6 +78,21 @@ function AdminApp() {
<CssBaseline /> <CssBaseline />
<BrowserRouter basename={basename}> <BrowserRouter basename={basename}>
<Routes> <Routes>
{/* 工作台:独立全屏布局,不套 AdminLayout(自带 Toolbar+Sidebar+内容区);懒加载分包 */}
<Route
path="/workbench"
element={
<Suspense
fallback={
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
}
>
<Workbench />
</Suspense>
}
/>
<Route element={<AdminLayout />}> <Route element={<AdminLayout />}>
<Route index element={<Navigate to="/dashboard" replace />} /> <Route index element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/dashboard" element={<Dashboard />} />
+391
View File
@@ -0,0 +1,391 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
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 LockIcon from '@mui/icons-material/Lock';
import HomeIcon from '@mui/icons-material/Home';
import { getToken } from '../../api/client.js';
import { me } from '../../api/auth.js';
import usePanels from './hooks/usePanels.js';
import Toolbar from './components/Toolbar.jsx';
import Sidebar from './components/Sidebar.jsx';
import PanelFrame from './components/PanelFrame.jsx';
import AddPanelDialog from './components/AddPanelDialog.jsx';
import VaultDrawer from './components/VaultDrawer.jsx';
import PanelIcon from './components/PanelIcon.jsx';
import SnackHost, { showSnack } from '../../admin/snack.jsx';
/* ============================================================
* Workbench:面板工作台页面入口(路由 /admin/workbench
* - 鉴权守卫:无 token → /login.html;非 admin → 403 提示返回
* - 组装 Toolbar + Sidebar + PanelFrame 区 + AddPanelDialog
* - 挂载 VaultDrawerLane B 提供),LRU 保留最多 3 个活跃 iframe
* ============================================================ */
function AdminGuard({ children }) {
const [state, setState] = useState('checking'); // checking | denied | ok
useEffect(() => {
if (!getToken()) { window.location.href = '/login.html'; return; }
me()
.then((u) => {
if (u.role !== 'admin') setState('denied');
else setState('ok');
})
.catch(() => { window.location.href = '/login.html'; });
}, []);
if (state === 'checking') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
}
if (state === 'denied') {
return (
<Box
sx={{
height: '100vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 2, px: 3, textAlign: 'center',
}}
>
<LockIcon sx={{ fontSize: 48, color: 'error.main' }} />
<Typography variant="h6">需要管理员权限</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
面板工作台仅对管理员开放如需使用请使用管理员账号登录或返回前台
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" startIcon={<HomeIcon />} href="/">返回前台</Button>
<Button variant="contained" href="/login.html">重新登录</Button>
</Box>
</Box>
);
}
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 }) {
return (
<Box
sx={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center',
}}
>
{icon}
<Box>
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>{title}</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{hint}</Typography>
</Box>
{action && <Button variant="contained" startIcon={<AddIcon />} onClick={onAction}>{action}</Button>}
</Box>
);
}
function WorkbenchContent() {
const panels = usePanels();
const {
links, loadError, reload, panelsById, renderedIds, openPanels,
activeId, activePanel, modalPanel, closeModal,
canBack, canForward, goBack, goForward,
openPanel, closePanel, togglePin, toggleGroup, setMode,
pinned, groups, collapsed, setCollapsed,
search, setSearch, filteredPanels, openFirstMatch,
} = panels;
// 主面板与弹窗面板各自独立的刷新信号(弹窗刷新不连带主面板)
const [mainRefresh, setMainRefresh] = useState(0);
const [modalRefresh, setModalRefresh] = useState(0);
const [addOpen, setAddOpen] = useState(false);
const [vaultOpen, setVaultOpen] = useState(false);
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
const handleOpenExternal = () => {
if (activePanel) window.open(activePanel.embed_url || activePanel.url, '_blank', 'noopener');
};
const handleSaved = (panel) => {
showSnack(`已添加「${panel.title}`);
setAddOpen(false);
reload().then(() => openPanel(panel.id, panel));
};
const content = (() => {
if (links === null && !loadError) {
// 加载态
return (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={36} />
</Box>
);
}
if (loadError) {
// 错误态
return (
<EmptyState
icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />}
title="面板列表加载失败"
hint={loadError}
action="重试"
onAction={reload}
/>
);
}
if (links.length === 0) {
// 空态:无面板引导添加
return (
<EmptyState
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />}
title="还没有任何面板"
hint="添加常用后台 / 监控 / 工具站到工作台,一个界面全部打开"
action="添加第一个面板"
onAction={() => setAddOpen(true)}
/>
);
}
if (!activePanel) {
// 已打开的面板都被关闭
return (
<EmptyState
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />}
title="未打开面板"
hint="从左侧选择一个面板开始,或搜索后按 Enter 直达"
action="打开一个面板"
onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }}
/>
);
}
// 正常态:LRU 渲染(当前 + 最近 2 个)
return renderedIds.map((id) => {
const panel = panelsById[id];
if (!panel) return null;
return (
<PanelFrame
key={id}
panel={panel}
active={String(id) === String(activeId)}
refreshNonce={mainRefresh}
/>
);
});
})();
return (
<>
<Toolbar
canBack={canBack}
canForward={canForward}
onBack={goBack}
onForward={goForward}
onRefresh={() => { if (activePanel) setMainRefresh((n) => n + 1); }}
currentPanel={activePanel}
onOpenExternal={handleOpenExternal}
search={search}
onSearchChange={setSearch}
onSearchEnter={() => {
if (!openFirstMatch() && search.trim()) showSnack('未找到匹配的面板', 'info');
}}
onAdd={() => setAddOpen(true)}
onOpenVault={() => setVaultOpen(true)}
collapsed={collapsed}
onToggleSidebar={() => setCollapsed((c) => !c)}
onBackHome={() => { window.location.href = '/'; }}
/>
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, width: '100%' }}>
<Sidebar
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={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' }}
>
{openPanels.length > 0 && (
<TabStrip
openPanels={openPanels}
activeId={activeId}
onSelect={openPanel}
onClose={closePanel}
onAdd={() => setAddOpen(true)}
/>
)}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}>
{content}
</Box>
</Box>
</Box>
<AddPanelDialog
open={addOpen}
onClose={() => setAddOpen(false)}
categories={categories}
onSaved={handleSaved}
onSetMode={setMode}
/>
{/* 密码库抽屉(Lane B 提供完整实现,此处仅挂载) */}
<VaultDrawer open={vaultOpen} onClose={() => setVaultOpen(false)} />
{/* 弹窗打开方式:Dialog 内嵌 PanelFrame */}
{modalPanel && (
<Dialog
open
fullWidth
maxWidth="lg"
onClose={closeModal}
sx={{ '& .MuiDialog-paper': { height: '85vh', maxHeight: '85vh' } }}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, pr: 1 }}>
<PanelIcon url={modalPanel.embed_url || modalPanel.url} title={modalPanel.title} size={22} />
<Typography noWrap sx={{ flex: 1, fontSize: 16, fontWeight: 500 }}>
{modalPanel.title}
</Typography>
<Tooltip title="在新标签页打开">
<IconButton
size="small"
onClick={() => window.open(modalPanel.embed_url || modalPanel.url, '_blank', 'noopener')}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="刷新">
<IconButton size="small" onClick={() => setModalRefresh((n) => n + 1)}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={closeModal} aria-label="关闭弹窗">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 0, display: 'flex', flex: 1, minHeight: 0 }}>
<PanelFrame panel={modalPanel} active refreshNonce={modalRefresh} />
</DialogContent>
</Dialog>
)}
<SnackHost />
</>
);
}
export default function Workbench() {
return (
<AdminGuard>
<Box
sx={{
display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden',
bgcolor: 'background.default',
}}
>
<WorkbenchContent />
</Box>
</AdminGuard>
);
}
@@ -0,0 +1,192 @@
import React, { useMemo, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import WebAssetIcon from '@mui/icons-material/WebAsset';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import { createAdminLink } from '../../../api/adminLinks.js';
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
/* ============================================================
* AddPanelDialog:添加面板
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
* + 嵌入 URL + 代理开关;保存到 admin_links(复用现有 API
* ============================================================ */
const MODE_META = [
{ mode: OPEN_MODE_EMBED, label: '内嵌', Icon: WebAssetIcon },
{ mode: OPEN_MODE_TAB, label: '新标签', Icon: OpenInNewIcon },
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
];
function normalizeUrl(raw) {
const s = (raw || '').trim();
if (!s) return '';
return /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : 'https://' + s;
}
export default function AddPanelDialog({ open, onClose, categories = [], onSaved, onSetMode }) {
const [form, setForm] = useState({
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
use_proxy: false, embed_url: '',
});
const [err, setErr] = useState('');
const host = useMemo(() => {
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
}, [form.url]);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const save = async () => {
const title = form.title.trim();
const url = normalizeUrl(form.url);
if (!title) { setErr('请输入面板标题'); return; }
if (!url) { setErr('请输入面板链接'); return; }
try {
const panel = await createAdminLink({
title,
url,
embed_url: form.embed_url.trim(),
category: form.category.trim() || '默认',
use_proxy: form.use_proxy ? 1 : 0,
description: '',
icon: '',
sort_order: 0,
version: '',
});
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
setErr('');
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
onSaved(panel);
} catch (e) {
setErr(e.message || '保存失败');
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>添加面板</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="标题 *"
value={form.title}
onChange={set('title')}
margin="normal"
placeholder="例如:服务器监控"
autoFocus
/>
<TextField
fullWidth
label="链接 URL *"
value={form.url}
onChange={set('url')}
margin="normal"
placeholder="example.com 或 https://…"
error={!!err && !form.url.trim()}
/>
{/* favicon 实时预览 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1, mb: 0.5 }}>
<Box
sx={{
width: 32, height: 32, borderRadius: 1.5, border: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'background.paper', overflow: 'hidden',
}}
>
{host ? (
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
alt=""
width={20}
height={20}
loading="lazy"
style={{ display: 'block' }}
onError={(e) => { e.target.style.visibility = 'hidden'; }}
/>
) : (
<WebAssetIcon sx={{ fontSize: 18, color: 'text.disabled' }} />
)}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{host ? `站点:${host}` : '输入链接后预览站点图标'}
</Typography>
</Box>
<Autocomplete
freeSolo
fullWidth
value={form.category}
onChange={(e, v) => setForm((p) => ({ ...p, category: v || '' }))}
onInputChange={(e, v) => setForm((p) => ({ ...p, category: v }))}
options={categories}
renderInput={(params) => (
<TextField {...params} label="分组" margin="normal" placeholder="默认" />
)}
/>
<Typography variant="caption" sx={{ display: 'block', mt: 1, mb: 0.5, color: 'text.secondary' }}>
打开方式
</Typography>
<ToggleButtonGroup
exclusive
fullWidth
size="small"
value={form.openMode}
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, openMode: v })); }}
aria-label="打开方式"
>
{MODE_META.map(({ mode, label, Icon }) => (
<ToggleButton key={mode} value={mode} sx={{ py: 0.75 }}>
<Icon fontSize="small" sx={{ mr: 0.5 }} />{label}
</ToggleButton>
))}
</ToggleButtonGroup>
{form.openMode === OPEN_MODE_EMBED && (
<Box sx={{ mt: 1.5 }}>
<FormControlLabel
control={
<Switch
checked={form.use_proxy}
onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))}
/>
}
label="通过代理嵌入(绕过 X-Frame-Options 限制)"
/>
<TextField
fullWidth
label="嵌入 URL(可选,iframe 专用)"
value={form.embed_url}
onChange={set('embed_url')}
margin="dense"
placeholder="留空则使用上面的链接"
/>
</Box>
)}
{err && (
<Typography variant="body2" sx={{ mt: 1.5, color: 'error.main' }}>{err}</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>取消</Button>
<Button variant="contained" onClick={save}>添加</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,266 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import IconButton from '@mui/material/IconButton';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorIcon from '@mui/icons-material/Error';
import CloseIcon from '@mui/icons-material/Close';
import { getToken } from '../../../api/client.js';
/* ============================================================
* PanelFrame:单个面板的 iframe 容器
*
* - 懒加载:首次激活才创建 iframe DOM,之后 display:none 保留状态
* - 超时 + load 双保险:加载中显示覆盖层;超时(X-Frame-Options
* 拒绝 / 目标无响应)显示失败提示 + 「在新标签页打开」回退
* - proxy 模式:use_proxy=1 或 HTTPS 页内嵌 HTTP 目标时走
* /api/proxy/fetch?url=&token=proxy 剥 X-Frame-Options/CSP)。
* token 用 /api/proxy/token 签发的 5 分钟短 TTL JWT,避免把 7 天
* 主 token 暴露在 iframe URL 中(模块级缓存,4 分钟刷新一次)。
* - 站内 URL(/ 开头)为同源内容:剥离 allow-same-origin 防其访问
* 父页面 DOM;跨源面板保留 allow-same-origin(多数站点依赖)。
* - LRU 由父级 Workbench 控制挂载/卸载(此组件不自行卸载)
* ============================================================ */
const LOAD_TIMEOUT_MS = 15000;
const TOKEN_TTL_MS = 4 * 60 * 1000; // 缓存 4 分钟(短 token 5 分钟有效)
const HINT_TTL_MS = 5000; // 加载成功后的「空白?新标签打开」提示条 5 秒自动消失
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
let proxyTokenCache = { promise: null, expiresAt: 0 };
function fetchProxyToken() {
const now = Date.now();
if (proxyTokenCache.promise && now < proxyTokenCache.expiresAt) return proxyTokenCache.promise;
const p = fetch('/api/proxy/token', {
headers: { Authorization: 'Bearer ' + (getToken() || '') },
})
.then(async (res) => {
if (!res.ok) throw new Error('proxy token 获取失败');
const data = await res.json();
return data.token;
})
.catch((e) => {
proxyTokenCache.promise = null;
throw e;
});
proxyTokenCache.promise = p;
proxyTokenCache.expiresAt = now + TOKEN_TTL_MS;
return p;
}
export default function PanelFrame({ panel, active = false, refreshNonce = 0 }) {
const [mounted, setMounted] = useState(active);
const [status, setStatus] = useState('loading'); // loading | loaded | timeout
const [proxySrc, setProxySrc] = useState('');
const [showHint, setShowHint] = useState(false);
const iframeRef = useRef(null);
const timerRef = useRef(null);
const hintTimerRef = useRef(null);
const statusRef = useRef('loading');
const targetUrl = panel.embed_url || panel.url || '';
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
// 站内 URL(同源内容):沙箱剥离 allow-same-origin,内容无法操作父页面 DOM
const isInternal = targetUrl.startsWith('/');
const sandbox = isInternal
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
// proxy 模式:先取 5 分钟短 TTL token 再拼 iframe URL(失败进入超时回退态)
useEffect(() => {
let alive = true;
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
setProxySrc('');
fetchProxyToken()
.then((tok) => {
if (alive) {
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
}
})
.catch(() => {
if (alive) {
statusRef.current = 'timeout';
setStatus('timeout');
}
});
return () => { alive = false; };
}, [needsProxy, targetUrl]);
const src = needsProxy ? proxySrc : targetUrl;
// 懒加载:首次激活才创建 iframe
useEffect(() => {
if (active) setMounted(true);
}, [active]);
const startTimer = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (statusRef.current === 'loading') {
statusRef.current = 'timeout';
setStatus('timeout');
}
}, LOAD_TIMEOUT_MS);
};
// 挂载 / 地址变化:进入加载态并启动超时
useEffect(() => {
if (!mounted || !src) return;
statusRef.current = 'loading';
setStatus('loading');
setShowHint(false);
startTimer();
return () => clearTimeout(timerRef.current);
}, [mounted, src]);
// 刷新信号(工具栏 ↻):仅对激活面板 reload,且重新进入加载态
const lastNonce = useRef(refreshNonce);
useEffect(() => {
if (!active || refreshNonce === lastNonce.current) return;
lastNonce.current = refreshNonce;
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshNonce]);
const handleLoad = () => {
clearTimeout(timerRef.current);
statusRef.current = 'loaded';
setStatus('loaded');
// 直接嵌入(非 proxy)时,加载成功仍可能被 X-Frame-Options 拒成空白页,
// 浮动提示「在新标签打开」;非阻塞,5 秒自动消失或可关闭。
if (!needsProxy) {
setShowHint(true);
clearTimeout(hintTimerRef.current);
hintTimerRef.current = setTimeout(() => setShowHint(false), HINT_TTL_MS);
}
};
const handleRetry = () => {
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
};
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
return (
<Box
sx={{
position: 'relative', width: '100%', height: '100%', overflow: 'hidden',
display: active ? 'block' : 'none', bgcolor: '#ffffff', flexShrink: 0,
}}
>
{mounted && (
<iframe
ref={iframeRef}
title={panel.title || '面板'}
src={src}
onLoad={handleLoad}
sandbox={sandbox}
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: '#fff' }}
/>
)}
{status === 'loading' && (
<Box
sx={{
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5,
bgcolor: 'background.default',
}}
>
<CircularProgress size={32} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
正在加载 {panel.title || '面板'}
</Typography>
</Box>
)}
{status === 'timeout' && (
<Box
sx={{
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
bgcolor: 'background.default', px: 3, textAlign: 'center',
}}
>
<ErrorIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
<Box>
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>面板加载超时</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
目标站点可能拒绝嵌入X-Frame-Options或响应过慢
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
startIcon={<OpenInNewIcon fontSize="small" />}
href={targetUrl}
target="_blank"
rel="noopener"
>
在新标签页打开
</Button>
<Button
size="small"
variant="text"
startIcon={<RefreshIcon fontSize="small" />}
onClick={handleRetry}
>
重试
</Button>
</Stack>
</Box>
)}
{/* 加载成功后的非阻塞提示条:空白页可能被 X-Frame-Options 拒绝 */}
{showHint && status === 'loaded' && !needsProxy && (
<Box
role="status"
sx={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', bottom: 16,
zIndex: 3, maxWidth: '92%', display: 'flex', alignItems: 'center', gap: 0.5,
px: 1.5, py: 0.5, borderRadius: 2,
bgcolor: 'primary.container', color: 'primary.onContainer',
border: 1, borderColor: 'divider', boxShadow: (t) => t.shadows[3],
}}
>
<Typography variant="caption" sx={{ fontWeight: 500 }}>
若页面空白可能被 X-Frame-Options 拒绝
</Typography>
<Button
size="small"
component="a"
href={targetUrl}
target="_blank"
rel="noopener"
sx={{ minHeight: 28, px: 1, color: 'primary.onContainer', fontWeight: 700 }}
>
在新标签打开
</Button>
<IconButton size="small" onClick={() => setShowHint(false)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
}
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import LanguageIcon from '@mui/icons-material/Language';
/* ============================================================
* 面板图标:Google favicon 服务 + 失败回退(首字母块 / 地球图标)
* ============================================================ */
function hostOf(url) {
try {
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
return new URL(u).hostname;
} catch { return ''; }
}
export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
const [error, setError] = useState(false);
const host = hostOf(url);
if (!host) {
return (
<Box
aria-hidden
sx={{
width: size, height: size, borderRadius: '50%', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'primary.container', color: 'primary.onContainer',
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
...sx,
}}
>
{(title[0] || '?').toUpperCase()}
</Box>
);
}
return (
<Box
aria-hidden
sx={{
width: size, height: size, borderRadius: size / 4, overflow: 'hidden',
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
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=""
width={size}
height={size}
loading="lazy"
onError={() => setError(true)}
style={{ display: 'block' }}
/>
)}
</Box>
);
}
@@ -0,0 +1,340 @@
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import AddIcon from '@mui/icons-material/Add';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import WebAssetIcon from '@mui/icons-material/WebAsset';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import WidgetsIcon from '@mui/icons-material/Widgets';
import ErrorIcon from '@mui/icons-material/Error';
import PanelIcon from './PanelIcon.jsx';
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
/* ============================================================
* Sidebar:左侧面板列表(默认 240px,可拖拽 180-360px 并记忆)
* - 固定区(pinlocalStorage+ admin_links 按 category 分组
* - 当前项:左侧 2px primary 指示条 + primary-container 选中底
* - 分组折叠(持久化)、右键切换打开方式、底部 + 添加
* - 搜索时展示扁平结果列表
* ============================================================ */
const WIDTH_KEY = 'workbench.sidebarWidth';
const MODE_META = [
{ mode: OPEN_MODE_EMBED, label: '内嵌打开', Icon: WebAssetIcon },
{ mode: OPEN_MODE_TAB, label: '新标签打开', Icon: OpenInNewIcon },
{ mode: OPEN_MODE_MODAL, label: '弹窗打开', Icon: OpenInFullIcon },
];
export default function Sidebar({
loading = false, error = '', onRetry,
links = [], filtered = [], search = '',
activeId = null, pinned = [], onSelect, onPin,
groups = {}, onToggleGroup,
modes = {}, onSetMode,
onAdd, collapsed = false,
}) {
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 groupOrder = useMemo(() => {
const order = []; const seen = new Set();
links.forEach((p) => {
const c = p.category || '默认';
if (!seen.has(c)) { seen.add(c); order.push(c); }
});
return order;
}, [links]);
const byGroup = useMemo(() => {
const m = {};
links.forEach((p) => {
const c = p.category || '默认';
(m[c] = m[c] || []).push(p);
});
return m;
}, [links]);
const pinnedPanels = links.filter((p) => pinned.includes(p.id));
const renderItem = (p) => {
const selected = String(p.id) === String(activeId);
const isPinned = pinned.includes(p.id);
const mode = modes[p.id] || OPEN_MODE_EMBED;
return (
<Box
key={p.id}
sx={{
display: 'flex', alignItems: 'center', position: 'relative',
'&:hover .wb-pin': { opacity: 1 },
}}
>
<ListItemButton
selected={selected}
onClick={() => onSelect(p.id)}
onContextMenu={(e) => { e.preventDefault(); setCtx({ id: p.id, anchorEl: e.currentTarget }); }}
aria-label={`打开面板 ${p.title}`}
sx={{
flex: 1, minHeight: 40, borderRadius: 0, m: 0, pl: 2, pr: 4,
position: 'relative',
'&.Mui-selected': {
bgcolor: 'primary.container', color: 'primary.onContainer',
'&:hover': { bgcolor: 'primary.container' },
},
'&:hover': { bgcolor: 'action.hover' },
}}
>
{selected && (
<Box
aria-hidden
sx={{
position: 'absolute', left: 0, top: '22%', bottom: '22%',
width: 2, bgcolor: 'primary.main', borderRadius: 1,
}}
/>
)}
<PanelIcon url={p.embed_url || p.url} title={p.title} size={20} sx={{ mr: 1 }} />
<ListItemText
primary={p.title}
primaryTypographyProps={{ fontSize: 13, fontWeight: selected ? 600 : 500, noWrap: true }}
sx={{ my: 0, minWidth: 0 }}
/>
{mode !== OPEN_MODE_EMBED && (
<Box
component="span"
title={MODE_META.find((m) => m.mode === mode)?.label}
sx={{ mr: 0.5, fontSize: 10, color: 'text.disabled', whiteSpace: 'nowrap' }}
>
{mode === OPEN_MODE_TAB ? '新标签' : '弹窗'}
</Box>
)}
</ListItemButton>
<IconButton
className="wb-pin"
size="small"
onClick={() => onPin(p.id)}
aria-label={isPinned ? `取消固定 ${p.title}` : `固定 ${p.title}`}
sx={{
position: 'absolute', right: 4, opacity: isPinned ? 1 : 0,
transition: 'opacity .12s ease', color: isPinned ? 'warning.main' : 'text.secondary',
}}
>
{isPinned ? <StarIcon fontSize="small" /> : <StarBorderIcon fontSize="small" />}
</IconButton>
</Box>
);
};
const sectionLabel = (text) => (
<Typography
component="div"
sx={{
px: 2, pt: 1.25, pb: 0.5, fontSize: 11, fontWeight: 700,
letterSpacing: 0.6, color: 'text.secondary',
}}
>
{text}
</Typography>
);
const listBody = (() => {
if (loading) {
return (
<Box sx={{ px: 2, pt: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="rounded" height={36} sx={{ my: 1, bgcolor: 'action.hover' }} />
))}
</Box>
);
}
if (error) {
return (
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
<ErrorIcon sx={{ fontSize: 32, color: 'error.main', mb: 1 }} />
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>面板加载失败</Typography>
<Button size="small" variant="outlined" onClick={onRetry}>重试</Button>
</Box>
);
}
if (links.length === 0) {
return (
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
<WidgetsIcon sx={{ fontSize: 32, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>还没有面板点击下方按钮添加</Typography>
</Box>
);
}
if (search.trim()) {
return (
<>
{sectionLabel(`搜索结果(${filtered.length}`)}
{filtered.length === 0 ? (
<Typography variant="body2" sx={{ px: 2, py: 2, color: 'text.secondary' }}>无匹配面板</Typography>
) : (
<List dense disablePadding>{filtered.map(renderItem)}</List>
)}
</>
);
}
return (
<>
{pinnedPanels.length > 0 && (
<>
{sectionLabel('固定')}
<List dense disablePadding>{pinnedPanels.map(renderItem)}</List>
<Divider sx={{ mx: 2, my: 0.5 }} />
</>
)}
{groupOrder.map((name) => {
const collapsedGroup = !!groups[name];
const items = byGroup[name] || [];
return (
<Fragment key={name}>
<ListItemButton
onClick={() => onToggleGroup(name)}
aria-expanded={!collapsedGroup}
sx={{
minHeight: 36, borderRadius: 0, m: 0, px: 1.5,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<FolderOpenIcon fontSize="small" sx={{ color: 'text.secondary' }} />
</ListItemIcon>
<ListItemText
primary={name}
primaryTypographyProps={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: 'text.secondary' }}
sx={{ my: 0 }}
/>
<Typography variant="caption" sx={{ mr: 0.5, color: 'text.disabled' }}>{items.length}</Typography>
{collapsedGroup
? <ExpandMoreIcon fontSize="small" sx={{ color: 'text.secondary' }} />
: <ExpandLessIcon fontSize="small" sx={{ color: 'text.secondary' }} />}
</ListItemButton>
<Collapse in={!collapsedGroup} unmountOnExit timeout={150}>
<List dense disablePadding>{items.map(renderItem)}</List>
</Collapse>
</Fragment>
);
})}
</>
);
})();
return (
<Fragment>
<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' }}>
{listBody}
</Box>
<Divider sx={{ flexShrink: 0 }} />
<Box sx={{ p: 1, flexShrink: 0 }}>
<Button fullWidth size="small" startIcon={<AddIcon />} onClick={onAdd}>添加面板</Button>
</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
open={!!ctx}
anchorEl={ctx?.anchorEl || null}
onClose={() => setCtx(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
{MODE_META.map(({ mode, label, Icon }) => (
<MenuItem
key={mode}
selected={(modes[ctx?.id] || OPEN_MODE_EMBED) === mode}
onClick={() => {
if (ctx) { onSetMode(ctx.id, mode); onSelect(ctx.id); }
setCtx(null);
}}
>
<Icon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
{label}
</MenuItem>
))}
</Menu>
</Fragment>
);
}
@@ -0,0 +1,126 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import InputBase from '@mui/material/InputBase';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RefreshIcon from '@mui/icons-material/Refresh';
import SearchIcon from '@mui/icons-material/Search';
import AddIcon from '@mui/icons-material/Add';
import KeyIcon from '@mui/icons-material/Key';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import HomeIcon from '@mui/icons-material/Home';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import PanelIcon from './PanelIcon.jsx';
/* ============================================================
* Toolbar:浏览器式顶栏(高度 56pxsurface-container 底)
* ◀ 后退 / ▶ 前进(历史栈)· ↻ 刷新 · 只读地址栏 · 🔍 搜索
* + 添加 · 🔑 密码库 · 侧边栏折叠 · 返回前台
* ============================================================ */
export default function Toolbar({
canBack = false, canForward = false, onBack, onForward, onRefresh,
currentPanel = null, onOpenExternal,
search = '', onSearchChange, onSearchEnter,
onAdd, onOpenVault,
collapsed = false, onToggleSidebar, onBackHome,
}) {
const searchRef = useRef(null);
const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : '';
return (
<Box
component="header"
sx={{
height: 56, flexShrink: 0, display: 'flex', alignItems: 'center',
gap: 0.5, px: { xs: 0.5, sm: 1 }, zIndex: 10,
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
}}
>
<Tooltip title="后退">
<span><IconButton size="small" onClick={onBack} disabled={!canBack} aria-label="后退"><ArrowBackIcon fontSize="small" /></IconButton></span>
</Tooltip>
<Tooltip title="前进">
<span><IconButton size="small" onClick={onForward} disabled={!canForward} aria-label="前进"><ArrowForwardIcon fontSize="small" /></IconButton></span>
</Tooltip>
<Tooltip title="刷新当前面板">
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
</Tooltip>
{/* 只读地址栏 */}
<Box
sx={{
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 1,
mx: { xs: 0.5, sm: 1.5 }, height: 36, px: 1.5,
bgcolor: 'background.default', border: 1, borderColor: 'divider',
borderRadius: 1.5, '&:focus-within': { borderColor: 'primary.main' },
}}
>
{currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={18} />}
<InputBase
readOnly
fullWidth
value={currentUrl}
placeholder="未选择面板"
inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 13 } }}
sx={{ color: 'text.secondary' }}
/>
{currentPanel && (
<Tooltip title="在新标签页打开原站">
<IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站">
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{/* 搜索(面板名/URL/分组,Enter 直达) */}
<Box sx={{ position: 'relative' }}>
<SearchIcon
sx={{
position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)',
fontSize: 17, color: 'text.secondary', pointerEvents: 'none',
}}
/>
<InputBase
ref={searchRef}
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter(); searchRef.current?.blur(); } }}
placeholder="搜索面板"
inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 13 } }}
sx={{
height: 36, width: { xs: 96, sm: 150 }, px: 0.5,
bgcolor: 'background.default', border: 1, borderColor: 'divider',
borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' },
}}
/>
</Box>
<Tooltip title="添加面板">
<span><IconButton size="small" onClick={onAdd} aria-label="添加面板"><AddIcon 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
color="inherit"
onClick={onBackHome}
startIcon={<HomeIcon sx={{ fontSize: 18 }} />}
sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.5 }, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }}
>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>返回前台</Box>
</Button>
</Box>
);
}
@@ -0,0 +1,456 @@
import React, { useCallback, useEffect, useState } from 'react';
import Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider';
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 CloseIcon from '@mui/icons-material/Close';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import KeyIcon from '@mui/icons-material/Key';
import SearchIcon from '@mui/icons-material/Search';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ErrorIcon from '@mui/icons-material/Error';
import useVault from '../hooks/useVault.js';
/**
* 工作台 · 密码库抽屉(Lane B,只读)。
*
* 交互流程:
* loading →(pinStatus)→ noPin(引导去 /passwords.html)| lockedPIN 输入)
* → unlock → unlocked(搜索 + 条目列表,点击条目展开详情逐字段复制)
* 「锁定」按钮走 lock API;关闭抽屉不锁定,方便切面板取用。
*
* 接口:{ open, onClose, title? }title 可选,默认「密码库」,兼容占位版约定)。
* 自包含:不依赖主工作台的任何代码,只依赖 useVault hook 与 api/passwords.js。
* 深浅色随后台 MD3 主题(createMD3Theme)自动适配。
*/
/** 剪贴板写入:优先 Async Clipboard,失败时降级 execCommand(非安全上下文/LAN 部署) */
async function copyText(text) {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch { return false; }
}
}
/** 展开详情中的单字段行:标签 + 值(网址可点开)+ 复制按钮 */
function DetailField({ label, value, href, secret, onCopy }) {
return (
<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>
{href ? (
<Typography
variant="body2"
component="a"
href={href}
target="_blank"
rel="noopener noreferrer"
title={value}
sx={{
flexGrow: 1, minWidth: 0, color: 'primary.main',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{value}
</Typography>
) : (
<Typography
variant="body2"
title={value}
sx={{
flexGrow: 1, minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: secret ? 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' : undefined,
letterSpacing: secret ? '0.04em' : undefined,
}}
>
{value || '—'}
</Typography>
)}
{onCopy ? (
<IconButton size="small" aria-label={`复制${label}`} onClick={onCopy} sx={{ flexShrink: 0 }}>
<ContentCopyIcon sx={{ fontSize: 15 }} />
</IconButton>
) : null}
</Box>
);
}
/** 单条密码:行内标题 + 用户名 + 复制密码,点击展开详情 */
function VaultEntryRow({ entry, expanded, onToggle, onCopy }) {
return (
<ListItem disablePadding sx={{ display: 'block' }}>
<ListItemButton
onClick={onToggle}
selected={expanded}
aria-expanded={expanded}
sx={{ pr: 1 }}
>
<ListItemIcon>
<KeyIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={entry.title}
secondary={entry.username || '无用户名'}
primaryTypographyProps={{ noWrap: true, fontWeight: 500 }}
secondaryTypographyProps={{ noWrap: true }}
/>
<Tooltip title="复制密码">
<IconButton
size="small"
aria-label={`复制 ${entry.title} 的密码`}
onClick={(e) => { e.stopPropagation(); onCopy(entry.password, '密码'); }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<ExpandMoreIcon
fontSize="small"
sx={{
color: 'text.secondary', ml: 0.25,
transform: expanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s ease',
}}
/>
</ListItemButton>
<Collapse in={expanded} unmountOnExit>
<Box sx={{ px: 2, py: 1, mx: 1, mb: 1, bgcolor: 'action.hover', borderRadius: 2 }}>
<DetailField
label="用户名"
value={entry.username}
onCopy={entry.username ? () => onCopy(entry.username, '用户名') : undefined}
/>
<DetailField
label="密码"
value={entry.password}
secret
onCopy={entry.password ? () => onCopy(entry.password, '密码') : undefined}
/>
{entry.url ? (
<DetailField label="网址" value={entry.url} href={entry.url} onCopy={() => onCopy(entry.url, '网址')} />
) : null}
{entry.notes ? <DetailField label="备注" value={entry.notes} /> : null}
</Box>
</Collapse>
</ListItem>
);
}
/**
* 密码库抽屉
* @param {boolean} open 是否打开(每次打开会重新同步服务端会话状态)
* @param {() => void} onClose 关闭回调(仅关闭,不锁定)
* @param {string} [title] 抽屉标题,默认「密码库」
*/
export default function VaultDrawer({ open, onClose, title = '密码库' }) {
const vault = useVault({ open });
// PIN 输入屏的本地状态
const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState('');
const [showPin, setShowPin] = useState(false);
// 展开中的条目 id(每次打开清空)
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(() => {
if (open) {
setPinInput('');
setPinError('');
setShowPin(false);
setExpandedId(null);
setOpenCount((c) => c + 1);
}
}, [open]);
const submitUnlock = useCallback(async () => {
const pin = pinInput.trim();
if (!pin) { setPinError('请输入 PIN 码'); return; }
setPinError('');
try {
await vault.unlock(pin);
setPinInput('');
setShowPin(false);
notify('已解锁');
} catch (e) {
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'
setPinError(e.message || '解锁失败');
setPinInput('');
}
}, [pinInput, vault, notify]);
const handleLock = useCallback(async () => {
try {
await vault.lock();
setExpandedId(null);
notify('已锁定');
} catch (e) {
notify(e.message || '锁定失败', 'error');
}
}, [vault, notify]);
const copyToClipboard = useCallback(async (text, label) => {
const ok = await copyText(text);
if (ok) notify(`${label} 已复制`);
else notify('复制失败', 'error');
}, [notify]);
/* ---------- 各状态下的主体 ---------- */
const renderBody = () => {
switch (vault.status) {
case 'loading':
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 2 }}>
<CircularProgress size={28} />
<Typography variant="body2" color="text.secondary">正在检查密码库状态</Typography>
</Box>
);
case 'error':
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
<ErrorIcon color="error" sx={{ fontSize: 40 }} />
<Typography variant="body2" color="text.secondary">{vault.statusError}</Typography>
<Button variant="outlined" onClick={vault.checkStatus} sx={{ mt: 1 }}>重试</Button>
</Box>
);
case 'noPin':
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
<LockOpenIcon sx={{ fontSize: 44, color: 'text.disabled' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>未设置 PIN</Typography>
<Typography variant="body2" color="text.secondary">请先到密码库页设置 PIN 后再使用</Typography>
<Button variant="contained" component="a" href="/passwords.html" sx={{ mt: 1 }}>前往密码库页</Button>
</Box>
);
case 'locked':
return (
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitUnlock(); }} sx={{ mt: 1 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 500 }}>输入 PIN 码解锁</Typography>
<TextField
autoFocus
fullWidth
size="small"
type={showPin ? 'text' : 'password'}
label="PIN 码"
value={pinInput}
onChange={(e) => { setPinInput(e.target.value); if (pinError) setPinError(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitUnlock(); }}
error={!!pinError}
helperText={pinError || ' '}
autoComplete="off"
spellCheck={false}
slotProps={{
htmlInput: { maxLength: 20, 'aria-label': 'PIN 码' },
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
size="small"
edge="end"
aria-label={showPin ? '隐藏 PIN' : '显示 PIN'}
onClick={() => setShowPin((v) => !v)}
>
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<Button
variant="contained"
fullWidth
disabled={vault.unlocking || !pinInput}
onClick={submitUnlock}
startIcon={vault.unlocking ? <CircularProgress size={18} color="inherit" /> : <LockOpenIcon />}
sx={{ mt: 2 }}
>
{vault.unlocking ? '解锁中…' : '解锁'}
</Button>
</Box>
);
case 'unlocked':
default: {
const { entries, entriesError, filteredEntries, search, setSearch } = vault;
const empty = filteredEntries.length === 0;
return (
<Box>
<TextField
autoFocus
fullWidth
size="small"
placeholder="搜索标题或用户名"
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
htmlInput: { 'aria-label': '搜索密码' },
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
endAdornment: search ? (
<InputAdornment position="end">
<IconButton size="small" edge="end" aria-label="清空搜索" onClick={() => setSearch('')}>
<CloseIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null,
},
}}
sx={{ mb: 1 }}
/>
{entries === null && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={26} />
</Box>
)}
{entriesError && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, py: 3, textAlign: 'center' }}>
<ErrorIcon color="error" />
<Typography variant="body2" color="text.secondary">加载失败{entriesError}</Typography>
<Button size="small" variant="outlined" onClick={vault.loadEntries}>重试</Button>
</Box>
)}
{entries && !entriesError && empty && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
{search ? '无匹配的密码记录' : '暂无密码记录'}
</Typography>
</Box>
)}
{entries && !entriesError && !empty && (
<List disablePadding>
{filteredEntries.map((e) => (
<VaultEntryRow
key={e.id}
entry={e}
expanded={expandedId === e.id}
onToggle={() => setExpandedId(expandedId === e.id ? null : e.id)}
onCopy={copyToClipboard}
/>
))}
</List>
)}
</Box>
);
}
}
};
return (
<Drawer
anchor="right"
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()}
</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>
);
}
@@ -0,0 +1,239 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { listAdminLinks } from '../../../api/adminLinks.js';
/* ============================================================
* 工作台面板状态(Lane A 核心 hook
*
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStoragekey: workbench.*)。
* ============================================================ */
export const OPEN_MODE_EMBED = 'embed'; // 内嵌 iframe
export const OPEN_MODE_TAB = 'tab'; // 新标签页
export const OPEN_MODE_MODAL = 'modal'; // 弹窗打开
const STORE = {
open: 'workbench.open', // 打开的面板 id 数组
active: 'workbench.active', // 当前面板 id
history: 'workbench.history', // { ids: [], index: n } 历史栈
collapsed: 'workbench.sidebarCollapsed',
groups: 'workbench.collapsedGroups', // { 分组名: true } 折叠
pinned: 'workbench.pinned', // 固定面板 id 数组
modes: 'workbench.openModes', // { id: 'embed'|'tab'|'modal' }
};
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 { /* 隐私模式等场景忽略 */ }
}
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));
const [history, setHistory] = useState(() => {
const h = read(STORE.history, null);
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 [pinned, setPinned] = useState(() => read(STORE.pinned, []));
const [modes, setModes] = useState(() => read(STORE.modes, {}));
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
const [search, setSearch] = useState('');
// LRU 引用(内存态,不需要持久化):面板 id -> 最近使用时间戳
const recency = useRef({});
// ---- 持久化 ----
useEffect(() => write(STORE.open, openIds), [openIds]);
useEffect(() => write(STORE.active, activeId), [activeId]);
useEffect(() => write(STORE.history, history), [history]);
useEffect(() => write(STORE.collapsed, collapsed), [collapsed]);
useEffect(() => write(STORE.groups, groups), [groups]);
useEffect(() => write(STORE.pinned, pinned), [pinned]);
useEffect(() => write(STORE.modes, modes), [modes]);
// ---- 加载面板列表(reconcile 全部用函数式更新,消除陈旧闭包竞态) ----
const reload = useCallback(() => {
setLoadError('');
setLinks(null);
return listAdminLinks()
.then((ls) => {
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)));
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
setHistory((prev) => {
const ids = prev.ids.filter((id) => valid.has(id));
return { ids, index: Math.min(prev.index, ids.length - 1) };
});
return arr;
})
.catch((e) => { setLoadError(e.message || '加载失败'); });
}, []);
useEffect(() => { reload(); }, [reload]);
// activeId 兜底(派生逻辑):重载后当前面板已被删除时,
// 从历史栈 / 打开集合取最近一个,避免指向不存在的面板
useEffect(() => {
if (!links) return;
const valid = new Set(links.map((p) => String(p.id)));
if (activeId && valid.has(String(activeId))) return;
const fallback = (history.index >= 0 ? history.ids[history.index] : null)
?? openIds[0]
?? null;
setActiveId(fallback);
}, [links, activeId, history, openIds]);
const touch = useCallback((id) => { recency.current[id] = Date.now(); }, []);
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
const openPanel = useCallback((id, panelOverride) => {
const panel = panelOverride || (links && links.find((p) => String(p.id) === String(id)));
if (!panel) return false;
touch(id);
const mode = modes[id] || OPEN_MODE_EMBED;
if (mode === OPEN_MODE_TAB) {
window.open(panel.embed_url || panel.url, '_blank', 'noopener');
return false;
}
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
if (String(activeId) === String(id)) return false;
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setHistory((prev) => {
const ids = prev.ids.slice(0, prev.index + 1);
if (ids[ids.length - 1] === id) return prev;
ids.push(id);
return { ids, index: ids.length - 1 };
});
setActiveId(id);
return true;
}, [activeId, links, modes, touch]);
// ---- 历史栈:◀ 后退 / ▶ 前进 ----
const goBack = useCallback(() => {
if (history.index <= 0) return;
const index = history.index - 1;
const id = history.ids[index];
if (id == null) return;
setHistory({ ...history, index });
setActiveId(id);
touch(id);
}, [history, touch]);
const goForward = useCallback(() => {
if (history.index >= history.ids.length - 1) return;
const index = history.index + 1;
const id = history.ids[index];
if (id == null) return;
setHistory({ ...history, index });
setActiveId(id);
touch(id);
}, [history, touch]);
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
const closePanel = useCallback((id) => {
const nextOpen = openIds.filter((x) => x !== id);
setOpenIds(nextOpen);
delete recency.current[id];
const histIds = history.ids.filter((x) => x !== id);
let index = history.index;
const removedAt = history.ids.indexOf(id);
if (removedAt !== -1 && removedAt < index) index -= 1;
if (index > histIds.length - 1) index = histIds.length - 1;
setHistory({ ids: histIds, index: index < 0 ? -1 : index });
const nextActive = histIds[index] ?? nextOpen[0] ?? null;
if (nextActive) setActiveId(nextActive); else setActiveId(null);
}, [history, openIds]);
const togglePin = useCallback((id) => {
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}, []);
const toggleGroup = useCallback((name) => {
setGroups((prev) => ({ ...prev, [name]: !prev[name] }));
}, []);
const setMode = useCallback((id, mode) => {
setModes((prev) => ({ ...prev, [id]: mode }));
}, []);
const closeModal = useCallback(() => setModalId(null), []);
// ---- LRU 渲染集:当前面板 + 最近用过的 2 个(最多 3 个活跃 iframe ----
const renderedIds = useMemo(() => {
const active = activeId ? String(activeId) : null;
if (!active) return [];
const others = openIds.filter((id) => String(id) !== active);
const sorted = others
.map((id) => ({ id, ts: recency.current[id] || 0 }))
.sort((a, b) => b.ts - a.ts)
.map((x) => x.id);
return [active, ...sorted.slice(0, 2)];
}, [activeId, openIds]);
const panelsById = useMemo(() => {
const m = {};
(links || []).forEach((p) => { m[p.id] = p; });
return m;
}, [links]);
const activePanel = activeId ? (panelsById[activeId] || null) : null;
const modalPanel = modalId ? (panelsById[modalId] || null) : null;
const openPanels = openIds.map((id) => panelsById[id]).filter(Boolean);
// ---- 搜索过滤(面板名 / URL / 分组) ----
const filteredPanels = useMemo(() => {
if (!search.trim()) return links || [];
const q = search.trim().toLowerCase();
return (links || []).filter((p) =>
String(p.title || '').toLowerCase().includes(q)
|| String(p.url || '').toLowerCase().includes(q)
|| String(p.embed_url || '').toLowerCase().includes(q)
|| String(p.category || '').toLowerCase().includes(q)
);
}, [links, search]);
// 搜索框 Enter:直达第一个匹配面板(空搜索不触发)
const openFirstMatch = useCallback(() => {
if (!search.trim()) return false;
const first = filteredPanels[0];
if (first) { openPanel(first.id); setSearch(''); return true; }
return false;
}, [filteredPanels, openPanel, search]);
return {
// 数据
links, loadError, reload, panelsById,
// 打开集合
openIds, openCount: openIds.length, openPanels, renderedIds,
activeId, activePanel, modalId, modalPanel, closeModal,
// 历史栈
history,
canBack: history.index > 0,
canForward: history.index >= 0 && history.index < history.ids.length - 1,
goBack, goForward,
// 操作
openPanel, closePanel, togglePin, toggleGroup, setMode,
// 侧边栏
pinned, groups, collapsed, setCollapsed, modes,
// 搜索
search, setSearch, filteredPanels, openFirstMatch,
};
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as pwApi from '../../../api/passwords.js';
/**
* 密码库抽屉的状态管理(Lane B)。
*
* 状态机 status
* loading —— 正在检查 PIN 状态(pinStatus
* error —— 检查失败(可重试)
* noPin —— 未设置 PIN(引导去密码库页)
* locked —— 已设 PIN 但未解锁(PIN 输入屏)
* unlocked —— 已解锁(搜索 + 条目列表)
*
* 约定:关闭抽屉不锁定,只有 lock() 才锁定——方便切面板取用。
* 会话在服务端内存中滑动过期(1 小时),每次打开抽屉会重新同步一次状态。
*/
export default function useVault({ open } = {}) {
const [status, setStatus] = useState('loading');
const [statusError, setStatusError] = useState('');
const [entries, setEntries] = useState(null); // null = 条目加载中
const [entriesError, setEntriesError] = useState('');
const [search, setSearch] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [locking, setLocking] = useState(false);
/** 拉取条目列表(解锁后),失败进入错误态并保留重试按钮 */
const loadEntries = useCallback(async () => {
setEntries(null);
setEntriesError('');
try {
const list = await pwApi.listEntries();
setEntries(list || []);
} catch (e) {
setEntriesError(e.message || '加载失败');
setEntries([]);
}
}, []);
/** 同步服务端 PIN / 会话状态 */
const checkStatus = useCallback(async () => {
setStatus('loading');
setStatusError('');
try {
const s = await pwApi.pinStatus();
if (!s.hasPin) setStatus('noPin');
else if (s.unlocked) { setStatus('unlocked'); loadEntries(); }
else setStatus('locked');
} catch (e) {
setStatusError(e.message || '检查密码库状态失败');
setStatus('error');
}
}, [loadEntries]);
// 每次抽屉打开(含重新打开)都同步一次状态;
// 若父组件保持挂载仅切换 open,也在这里触发。
useEffect(() => {
if (open) checkStatus();
}, [open, checkStatus]);
/** 解锁:成功则进入条目列表;失败抛出后端错误(401 PIN 错误 / 429 次数过多) */
const unlock = useCallback(async (pin) => {
setUnlocking(true);
try {
await pwApi.unlock(pin);
setStatus('unlocked');
setSearch('');
await loadEntries();
} finally {
setUnlocking(false);
}
}, [loadEntries]);
/** 锁定:清空本地条目与搜索,回到 PIN 输入屏 */
const lock = useCallback(async () => {
setLocking(true);
try {
await pwApi.lock();
setStatus('locked');
setEntries(null);
setEntriesError('');
setSearch('');
} finally {
setLocking(false);
}
}, []);
/** 按标题 / 用户名过滤(不区分大小写) */
const filteredEntries = useMemo(() => {
const q = search.trim().toLowerCase();
const base = entries || [];
if (!q) return base;
return base.filter((e) =>
(e.title || '').toLowerCase().includes(q) ||
(e.username || '').toLowerCase().includes(q));
}, [entries, search]);
return {
status, statusError,
entries, entriesError,
search, setSearch, filteredEntries,
unlocking, locking,
checkStatus, loadEntries, unlock, lock,
};
}
+18
View File
@@ -0,0 +1,18 @@
/* ============================================================
* 工作台模块导出(frontend/src/tools/workbench
* 便于未来升级 / 替换 / 独立打包
* ============================================================ */
export { default as Workbench } from './Workbench.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 AddPanelDialog } from './components/AddPanelDialog.jsx';
export { default as PanelIcon } from './components/PanelIcon.jsx';
export { default as VaultDrawer } from './components/VaultDrawer.jsx';
export {
default as usePanels,
OPEN_MODE_EMBED,
OPEN_MODE_TAB,
OPEN_MODE_MODAL,
} from './hooks/usePanels.js';
+16 -1
View File
@@ -1,7 +1,8 @@
const express = require('express'); const express = require('express');
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const { authMiddleware, adminOnly } = require('../middleware/auth'); const jwt = require('jsonwebtoken');
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
const router = express.Router(); const router = express.Router();
// SSRF 防护:拒绝内网/回环/链路本地地址 // SSRF 防护:拒绝内网/回环/链路本地地址
@@ -59,6 +60,8 @@ function proxyRequest(target, res, maxRedirects = 5) {
delete headers['X-Frame-Options']; delete headers['X-Frame-Options'];
delete headers['content-security-policy']; delete headers['content-security-policy'];
delete headers['Content-Security-Policy']; delete headers['Content-Security-Policy'];
// 防止被代理页面内部资源请求把本站来源(/admin 等)泄露给第三方域名
headers['Referrer-Policy'] = 'no-referrer';
// Inject <base> tag so relative URLs resolve to the original domain // Inject <base> tag so relative URLs resolve to the original domain
const contentType = (headers['content-type'] || '').toLowerCase(); const contentType = (headers['content-type'] || '').toLowerCase();
@@ -118,6 +121,18 @@ function queryTokenAuth(req, res, next) {
next(); next();
} }
// 短 TTL 代理 token:仅供 iframe 的 ?token= 认证使用(5 分钟有效),
// 避免把 7 天长 TTL 主 token 暴露在 iframe URL 中(URL 可见于网络面板/历史记录/日志)。
// 前端 PanelFrame 每次组装 proxy URL 前先从此接口取短 token。
router.get('/token', authMiddleware, adminOnly, (req, res) => {
const token = jwt.sign(
{ id: req.user.id, username: req.user.username, role: req.user.role, proxy: true },
SECRET,
{ expiresIn: '5m' }
);
res.json({ token });
});
router.get('/fetch', queryTokenAuth, authMiddleware, adminOnly, (req, res) => { router.get('/fetch', queryTokenAuth, authMiddleware, adminOnly, (req, res) => {
if (!req.query.url) return res.status(400).json({ error: '缺少 url 参数' }); if (!req.query.url) return res.status(400).json({ error: '缺少 url 参数' });
proxyRequest(req.query.url, res); proxyRequest(req.query.url, res);