Compare commits
23
Commits
391cd1bdae
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0def382511 | ||
|
|
636d13407f | ||
|
|
f9abb877e7 | ||
|
|
c947e62364 | ||
|
|
ded6fdb753 | ||
|
|
9c34374b95 | ||
|
|
8a3e213e40 | ||
|
|
2de07710da | ||
|
|
e16130bf8f | ||
|
|
2b748fc48e | ||
|
|
717ebc44f4 | ||
|
|
f2740afe50 | ||
|
|
d9d2ad3f7d | ||
|
|
0ae8815f33 | ||
|
|
9400f76c7e | ||
|
|
03d5c97315 | ||
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 |
@@ -8,3 +8,4 @@ server.pid
|
|||||||
releases/
|
releases/
|
||||||
public/dist/
|
public/dist/
|
||||||
backups/
|
backups/
|
||||||
|
workspace/
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ function initTables() {
|
|||||||
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
|
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
|
||||||
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
|
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
|
||||||
|
|
||||||
|
// 审计日志表(终端会话/密码失败等安全事件落库)
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
user_id INTEGER DEFAULT 0,
|
||||||
|
username TEXT DEFAULT '',
|
||||||
|
detail TEXT DEFAULT '',
|
||||||
|
ip TEXT DEFAULT '',
|
||||||
|
created_at DATETIME DEFAULT (datetime('now')))`);
|
||||||
|
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)');
|
||||||
|
// 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长
|
||||||
|
//(写入路径上 terminal.js 每 50 条也会触发一次同规则清理)
|
||||||
|
db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
|
||||||
|
|
||||||
// Indexes for performance
|
// Indexes for performance
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 工作台模块项目计划(tools/workbench)
|
||||||
|
|
||||||
|
> 独立完整项目流程:调研 ✓ → 设计规范 ✓ → 本计划 → 实现 → 审查 → 验证。
|
||||||
|
> 布局调研:lib-1(VSCode 四层 / Dashy Workspace / Vivaldi Web Panels / Arc pinned / Homepage groups + iframe 技术建议)。
|
||||||
|
> 设计规范:frontend-pack/design skill(8px 网格、语义色 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)
|
||||||
|
- 语义色 token(MD3):工具栏/侧边栏 surface-container、选中 primary-container、hover surface-variant
|
||||||
|
- 选中态:左侧 2px primary 指示条(VSCode 模式)或 MUI selected 态,二选一统一
|
||||||
|
- 深色模式默认适配(后台已有 data-theme 机制)
|
||||||
|
- 焦点环(focus-visible)、语义 HTML(nav/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 错误
|
||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<div id="musicEmbed"></div>
|
<div id="musicEmbed"></div>
|
||||||
<div id="snackbar" class="snackbar"></div>
|
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.html(Phase 6 需求),basename=/admin;
|
// 生产:/admin/* 由后端 fallback 到 dist/admin.html(Phase 6 需求),basename=/admin;
|
||||||
// 开发:vite 多入口直接访问 /admin.html,basename 自适应为空。
|
// 开发:vite 多入口直接访问 /admin.html,basename 自适应为空。
|
||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { request } from './client.js';
|
||||||
|
|
||||||
|
// ── 工作台「记事本」(routes/notes.js,仅管理员) ────────────
|
||||||
|
/** 文件列表:[{ name, size, mtime }],按修改时间倒序 */
|
||||||
|
export function listNotes() {
|
||||||
|
return request('/notes');
|
||||||
|
}
|
||||||
|
/** 读取内容:{ name, content, size, mtime } */
|
||||||
|
export function getNote(filename) {
|
||||||
|
return request('/notes/' + encodeURIComponent(filename));
|
||||||
|
}
|
||||||
|
/** 创建:{ filename, content },同名返回 409 */
|
||||||
|
export function createNote(filename, content) {
|
||||||
|
return request('/notes', { method: 'POST', body: { filename, content } });
|
||||||
|
}
|
||||||
|
/** 更新内容:{ content } */
|
||||||
|
export function updateNote(filename, content) {
|
||||||
|
return request('/notes/' + encodeURIComponent(filename), { method: 'PUT', body: { content } });
|
||||||
|
}
|
||||||
|
/** 删除 */
|
||||||
|
export function deleteNote(filename) {
|
||||||
|
return request('/notes/' + encodeURIComponent(filename), { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { request } from './client.js';
|
||||||
|
|
||||||
|
// ── Web 终端(安全敏感:root shell,PIN + 短 TTL token 双认证) ──
|
||||||
|
// 后端契约见 routes/terminal.js:
|
||||||
|
// GET /api/terminal/status → { hasPin, sessions, maxSessions, idleTimeoutMs }
|
||||||
|
// POST /api/terminal/auth → { token, expiresIn, setup?, needs_setup?, message? }
|
||||||
|
|
||||||
|
/** 检查终端专用密码是否已设置 + 当前会话数 */
|
||||||
|
export function terminalStatus() {
|
||||||
|
return request('/terminal/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置(首次)/校验终端密码,成功发放 5 分钟短 TTL 终端 token。
|
||||||
|
* 失败抛错:400 密码不合法 / 401 密码错误 / 429 尝试次数过多
|
||||||
|
*/
|
||||||
|
export function terminalAuth(pin) {
|
||||||
|
return request('/terminal/auth', { method: 'POST', body: { pin } });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import * as captchaApi from '../api/captcha.js';
|
import * as captchaApi from '../api/captcha.js';
|
||||||
|
import { useDialog } from '../lib/utils.js';
|
||||||
|
|
||||||
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
|
||||||
let pending = null; // { action, type, resolve }
|
let pending = null; // { action, type, resolve }
|
||||||
@@ -140,15 +141,16 @@ function BuiltinCaptcha({ onFinish }) {
|
|||||||
onChange={(e) => setAnswer(e.target.value)}
|
onChange={(e) => setAnswer(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||||
placeholder="输入验证码"
|
placeholder="输入验证码"
|
||||||
|
aria-label="输入验证码"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}>
|
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||||||
<span className="material-icons">refresh</span>
|
<span className="material-icons">refresh</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
{error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
||||||
{powDone ? (
|
{powDone ? (
|
||||||
<>
|
<>
|
||||||
@@ -264,13 +266,21 @@ export default function CaptchaModalHost() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
|
||||||
|
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
|
||||||
|
|
||||||
if (!request) return null;
|
if (!request) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
className="dialog-overlay active"
|
className="dialog-overlay active"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="验证码"
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
style={{ display: 'flex', zIndex: 9999 }}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
>
|
>
|
||||||
{request.type === 'recaptcha' ? (
|
{request.type === 'recaptcha' ? (
|
||||||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export default function Layout() {
|
|||||||
<button
|
<button
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
onClick={handleToggleTheme}
|
onClick={handleToggleTheme}
|
||||||
|
aria-label="切换主题"
|
||||||
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
|
||||||
>
|
>
|
||||||
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
|
||||||
|
|||||||
@@ -49,9 +49,9 @@ export default function MusicEmbed({ settings }) {
|
|||||||
>
|
>
|
||||||
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
|
||||||
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
|
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
|
||||||
<div className="music-embed-icon" onClick={expand} title="展开播放器">
|
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
|
||||||
<span className="material-icons">music_note</span>
|
<span className="material-icons">music_note</span>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
/** HTML 转义:& < > " ' 全转义 */
|
/** HTML 转义:& < > " ' 全转义 */
|
||||||
export function escapeHtml(str) {
|
export function escapeHtml(str) {
|
||||||
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
|
||||||
@@ -45,3 +47,42 @@ export function showSnackbar(msg) {
|
|||||||
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
setTimeout(() => el.classList.remove('show', 'hide'), 300);
|
||||||
}, 2500);
|
}, 2500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 弹窗焦点管理(B3):把焦点移到容器内首个可聚焦元素(input/select/textarea/button) */
|
||||||
|
export function focusDialog(container) {
|
||||||
|
if (!container) return;
|
||||||
|
const el = container.querySelector('input, select, textarea, button, [tabindex]:not([tabindex="-1"])');
|
||||||
|
if (el && typeof el.focus === 'function') el.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹窗键盘/焦点管理(B3):
|
||||||
|
* - 打开时记录触发元素,并把焦点移到弹窗内首个可聚焦元素
|
||||||
|
* - Esc 关闭弹窗
|
||||||
|
* - 关闭后焦点还给触发元素
|
||||||
|
* 返回 { dialogRef, onKeyDown }:dialogRef 绑到弹窗容器(.dialog-overlay),onKeyDown 绑其键盘事件。
|
||||||
|
*/
|
||||||
|
export function useDialog(open, onClose) {
|
||||||
|
const dialogRef = useRef(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
triggerRef.current = document.activeElement;
|
||||||
|
focusDialog(dialogRef.current);
|
||||||
|
} else if (triggerRef.current && typeof triggerRef.current.focus === 'function' && document.body.contains(triggerRef.current)) {
|
||||||
|
triggerRef.current.focus();
|
||||||
|
triggerRef.current = null;
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const onKeyDown = useCallback((e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (onClose) onClose();
|
||||||
|
}
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return { dialogRef, onKeyDown };
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export default function Blog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="homepage-layout blog-layout">
|
<div className="homepage-layout blog-layout">
|
||||||
|
<h1 className="sr-only">博客</h1>
|
||||||
<BlogSidebar settings={settings} />
|
<BlogSidebar settings={settings} />
|
||||||
<div className="homepage-content">
|
<div className="homepage-content">
|
||||||
{/* 工具条:搜索框 + 归档 */}
|
{/* 工具条:搜索框 + 归档 */}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export default function BlogDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="article-actions">
|
<div className="article-actions">
|
||||||
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞">
|
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
|
||||||
<span className="like-count">{like.count}</span>
|
<span className="like-count">{like.count}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -225,7 +225,7 @@ export default function BlogDetail() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
|
||||||
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4>
|
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
|
||||||
|
|
||||||
{comments.length === 0 ? (
|
{comments.length === 0 ? (
|
||||||
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function Embed() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||||
|
<h1 className="sr-only">嵌入页面</h1>
|
||||||
<div
|
<div
|
||||||
className="embed-toolbar"
|
className="embed-toolbar"
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '0 12px', height: 48, background: 'var(--md-ref-surface-container)', borderBottom: '1px solid var(--md-ref-outline-variant)', flexShrink: 0 }}
|
||||||
@@ -67,7 +68,14 @@ export default function Embed() {
|
|||||||
Ignore X-Frame-Headers
|
Ignore X-Frame-Headers
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
扩展,或点击右上角「原站」在新标签页打开
|
扩展,或点击右上角「原站」在新标签页打开
|
||||||
<span onClick={() => setShowHint(false)} style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600 }}>✕</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowHint(false)}
|
||||||
|
aria-label="关闭提示"
|
||||||
|
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getToken } from '../api/client.js';
|
|||||||
import { uploadFile } from '../api/upload.js';
|
import { uploadFile } from '../api/upload.js';
|
||||||
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
|
||||||
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
import { showCaptcha } from '../components/CaptchaModal.jsx';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||||
|
|
||||||
/** 板块的子分类列表 */
|
/** 板块的子分类列表 */
|
||||||
function subCatsOf(cat) {
|
function subCatsOf(cat) {
|
||||||
@@ -133,8 +133,12 @@ export default function Forum() {
|
|||||||
const currentCat = categories.find((c) => c.id === currentCatId);
|
const currentCat = categories.find((c) => c.id === currentCatId);
|
||||||
const subCats = subCatsOf(currentCat);
|
const subCats = subCatsOf(currentCat);
|
||||||
|
|
||||||
|
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||||
|
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="forum-layout">
|
<div className="forum-layout">
|
||||||
|
<h1 className="sr-only">论坛</h1>
|
||||||
<aside className="forum-sidebar">
|
<aside className="forum-sidebar">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
|
||||||
@@ -144,7 +148,8 @@ export default function Forum() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="forum-cat-list">
|
<div className="forum-cat-list">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
key={c.id}
|
key={c.id}
|
||||||
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
|
||||||
onClick={() => selectCategory(c.id)}
|
onClick={() => selectCategory(c.id)}
|
||||||
@@ -153,17 +158,18 @@ export default function Forum() {
|
|||||||
{c.announcement ? (
|
{c.announcement ? (
|
||||||
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
|
||||||
<div
|
<button
|
||||||
|
type="button"
|
||||||
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
|
||||||
onClick={showAllPosts}
|
onClick={showAllPosts}
|
||||||
style={{ fontWeight: 500 }}
|
style={{ fontWeight: 500 }}
|
||||||
>
|
>
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
|
||||||
</div>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className="forum-content">
|
<div className="forum-content">
|
||||||
@@ -171,7 +177,7 @@ export default function Forum() {
|
|||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
|
||||||
<span className="chip" style={{ cursor: 'default', fontSize: 12, padding: '1px 8px', color: 'var(--md-ref-on-surface-variant)', border: '1px solid var(--md-ref-outline-variant)' }}>板块</span>
|
<span className="chip chip-static">板块</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
|
||||||
{currentCat.announcement && (
|
{currentCat.announcement && (
|
||||||
@@ -182,9 +188,9 @@ export default function Forum() {
|
|||||||
)}
|
)}
|
||||||
{subCats.length > 0 && (
|
{subCats.length > 0 && (
|
||||||
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
|
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
|
||||||
<span className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</span>
|
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
|
||||||
{subCats.map((s) => (
|
{subCats.map((s) => (
|
||||||
<span key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</span>
|
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -208,9 +214,9 @@ export default function Forum() {
|
|||||||
<span>{p.created_at}</span>
|
<span>{p.created_at}</span>
|
||||||
<span>{p.reply_count || 0} 回复</span>
|
<span>{p.reply_count || 0} 回复</span>
|
||||||
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
|
||||||
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'transparent', border: '1px solid var(--md-ref-outline-variant)', color: 'var(--md-ref-on-surface-variant)' }}>{catName}</span>
|
<span className="chip chip-static">{catName}</span>
|
||||||
{p.sub_category ? (
|
{p.sub_category ? (
|
||||||
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>{p.sub_category}</span>
|
<span className="chip chip-tonal">{p.sub_category}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -223,9 +229,14 @@ export default function Forum() {
|
|||||||
{/* 发帖弹窗 */}
|
{/* 发帖弹窗 */}
|
||||||
{showNewPost && (
|
{showNewPost && (
|
||||||
<div
|
<div
|
||||||
|
ref={npDialogRef}
|
||||||
className="dialog-overlay active"
|
className="dialog-overlay active"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="发布新帖"
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
style={{ display: 'flex', zIndex: 9999 }}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
|
||||||
|
onKeyDown={npDialogKey}
|
||||||
>
|
>
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h3>发布新帖</h3>
|
<h3>发布新帖</h3>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function ForumDetail() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h3>
|
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
|
||||||
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{post.author_name || '匿名'}</span>
|
<span>{post.author_name || '匿名'}</span>
|
||||||
<span>{post.created_at}</span>
|
<span>{post.created_at}</span>
|
||||||
@@ -158,12 +158,14 @@ export default function ForumDetail() {
|
|||||||
<div className="reply-meta">
|
<div className="reply-meta">
|
||||||
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
|
||||||
{canDeleteReply && (
|
{canDeleteReply && (
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="删除回复"
|
||||||
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
|
||||||
onClick={() => deleteReply(r.id)}
|
onClick={() => deleteReply(r.id)}
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</span>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="reply-body">{r.content}</div>
|
<div className="reply-body">{r.content}</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="homepage-layout">
|
<div className="homepage-layout">
|
||||||
|
<h1 className="sr-only">首页</h1>
|
||||||
<BlogSidebar settings={settings} showRecent forceShow />
|
<BlogSidebar settings={settings} showRecent forceShow />
|
||||||
<div className="homepage-content">
|
<div className="homepage-content">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import * as pwApi from '../api/passwords.js';
|
import * as pwApi from '../api/passwords.js';
|
||||||
import { me } from '../api/auth.js';
|
import { me } from '../api/auth.js';
|
||||||
import { getToken } from '../api/client.js';
|
import { getToken } from '../api/client.js';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
import { showSnackbar, useDialog } from '../lib/utils.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
|
||||||
@@ -250,93 +250,113 @@ export default function Passwords() {
|
|||||||
|
|
||||||
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
|
||||||
|
|
||||||
|
// 各弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||||
|
const { dialogRef: pinDlgRef, onKeyDown: pinDlgKey } = useDialog(pinDialog, () => setPinDialog(false));
|
||||||
|
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||||
|
const { dialogRef: detailDlgRef, onKeyDown: detailDlgKey } = useDialog(!!detailEntry, () => setDetailId(null));
|
||||||
|
const { dialogRef: genDlgRef, onKeyDown: genDlgKey } = useDialog(genDialog, () => setGenDialog(false));
|
||||||
|
const { dialogRef: warnDlgRef, onKeyDown: warnDlgKey } = useDialog(pinChangeWarn, () => setPinChangeWarn(false));
|
||||||
|
const { dialogRef: confirmDlgRef, onKeyDown: confirmDlgKey } = useDialog(confirmDialog, () => setConfirmDialog(false));
|
||||||
|
|
||||||
// 加载中
|
// 加载中
|
||||||
if (phase === 'loading') {
|
if (phase === 'loading') {
|
||||||
return <div className="loading"><div className="spinner"></div></div>;
|
return <div className="loading"><div className="spinner"></div></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PIN 屏(设置 或 解锁)
|
|
||||||
if (phase === 'setup' || phase === 'locked') {
|
|
||||||
return (
|
|
||||||
<div className="pin-overlay">
|
|
||||||
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
|
||||||
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
|
||||||
{phase === 'setup' ? '首次使用,请设置 PIN 码' : '请输入 PIN 码解锁'}
|
|
||||||
</div>
|
|
||||||
{pinError && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 8 }}>{pinError}</div>}
|
|
||||||
{phase === 'locked' && (
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="pin-input"
|
|
||||||
maxLength={6}
|
|
||||||
inputMode="numeric"
|
|
||||||
autoComplete="off"
|
|
||||||
value={pinInput}
|
|
||||||
onChange={(e) => setPinInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
||||||
<button className="btn btn-filled" onClick={phase === 'setup' ? openPinDialog : submitPin} disabled={pinBusy}>
|
|
||||||
{phase === 'setup' ? '设置' : (pinBusy ? '解锁中...' : '解锁')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已解锁:密码箱
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="admin-header">
|
{/* PIN 屏(设置 或 解锁)——全屏覆盖页,非弹窗 */}
|
||||||
<h2>已保存的密码</h2>
|
{phase === 'setup' || phase === 'locked' ? (
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="pin-overlay">
|
||||||
<button className="btn btn-tonal" onClick={lockVault}>
|
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
||||||
<span className="material-icons">lock</span> 锁定
|
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
||||||
</button>
|
{phase === 'setup' ? '首次使用,请设置 PIN 码' : '请输入 PIN 码解锁'}
|
||||||
<button className="btn btn-text" onClick={openPinChange}>
|
</div>
|
||||||
<span className="material-icons">edit</span> 修改 PIN
|
{pinError && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 8 }}>{pinError}</div>}
|
||||||
</button>
|
{phase === 'locked' && (
|
||||||
<button className="btn btn-filled" onClick={openAddDialog}>
|
<input
|
||||||
<span className="material-icons">add</span> 添加
|
type="password"
|
||||||
</button>
|
className="pin-input"
|
||||||
|
maxLength={6}
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="off"
|
||||||
|
value={pinInput}
|
||||||
|
onChange={(e) => setPinInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||||
|
<button className="btn btn-filled" onClick={phase === 'setup' ? openPinDialog : submitPin} disabled={pinBusy}>
|
||||||
|
{phase === 'setup' ? '设置' : (pinBusy ? '解锁中...' : '解锁')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
/* 已解锁:密码箱 */
|
||||||
{entries === null && <div className="loading"><div className="spinner"></div></div>}
|
<div>
|
||||||
{entriesError && (
|
<div className="admin-header">
|
||||||
<div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败: {entriesError}</p></div>
|
<h2>已保存的密码</h2>
|
||||||
)}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
{entries && entries.length === 0 && (
|
<button className="btn btn-tonal" onClick={lockVault}>
|
||||||
<div className="empty-state"><div className="empty-icon">🔒</div><p>暂无密码记录</p></div>
|
<span className="material-icons">lock</span> 锁定
|
||||||
)}
|
</button>
|
||||||
{entries && entries.length > 0 && (
|
<button className="btn btn-text" onClick={openPinChange}>
|
||||||
<div className="password-grid">
|
<span className="material-icons">edit</span> 修改 PIN
|
||||||
{entries.map((p) => (
|
</button>
|
||||||
<div key={p.id} className="card password-card" onClick={() => setDetailId(p.id)}>
|
<button className="btn btn-filled" onClick={openAddDialog}>
|
||||||
<div className="pw-title">{p.title}</div>
|
<span className="material-icons">add</span> 添加
|
||||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
</button>
|
||||||
<div className="pw-actions">
|
|
||||||
<button
|
|
||||||
className="btn-icon"
|
|
||||||
style={{ width: 32, height: 32, fontSize: 16 }}
|
|
||||||
title="复制密码"
|
|
||||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
|
||||||
>
|
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
|
||||||
|
{entries === null && <div className="loading"><div className="spinner"></div></div>}
|
||||||
|
{entriesError && (
|
||||||
|
<div className="empty-state"><div className="empty-icon">⚠️</div><p>加载失败: {entriesError}</p></div>
|
||||||
|
)}
|
||||||
|
{entries && entries.length === 0 && (
|
||||||
|
<div className="empty-state"><div className="empty-icon">🔒</div><p>暂无密码记录</p></div>
|
||||||
|
)}
|
||||||
|
{entries && entries.length > 0 && (
|
||||||
|
<div className="password-grid">
|
||||||
|
{entries.map((p) => (
|
||||||
|
<div key={p.id} className="password-card-wrap">
|
||||||
|
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
|
||||||
|
<div className="pw-title">{p.title}</div>
|
||||||
|
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||||
|
</button>
|
||||||
|
<div className="pw-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-icon"
|
||||||
|
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||||
|
title="复制密码"
|
||||||
|
aria-label={`复制 ${p.title} 的密码`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||||
|
>
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ===== 弹窗统一挂载在顶层(setup/locked 阶段也能弹出——功能修复关键,勿移入 phase 分支) ===== */}
|
||||||
|
{/* 结构与论坛发帖弹窗一致:.dialog-overlay + .dialog,MD3 CSS 变量天然适配浅/深色模式 */}
|
||||||
|
|
||||||
{/* 设置/修改 PIN 弹窗 */}
|
{/* 设置/修改 PIN 弹窗 */}
|
||||||
{pinDialog && (
|
{pinDialog && (
|
||||||
<div
|
<div
|
||||||
|
ref={pinDlgRef}
|
||||||
className="dialog-overlay active"
|
className="dialog-overlay active"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="设置 PIN 码"
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
style={{ display: 'flex', zIndex: 9999 }}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
|
||||||
|
onKeyDown={pinDlgKey}
|
||||||
>
|
>
|
||||||
<div className="dialog" style={{ maxWidth: 360 }}>
|
<div className="dialog" style={{ maxWidth: 360 }}>
|
||||||
<h3>设置 PIN 码</h3>
|
<h3>设置 PIN 码</h3>
|
||||||
@@ -360,9 +380,14 @@ export default function Passwords() {
|
|||||||
{/* 添加/编辑条目弹窗 */}
|
{/* 添加/编辑条目弹窗 */}
|
||||||
{pwDialog && (
|
{pwDialog && (
|
||||||
<div
|
<div
|
||||||
|
ref={pwDlgRef}
|
||||||
className="dialog-overlay active"
|
className="dialog-overlay active"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={editingId ? '编辑密码' : '添加密码'}
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
style={{ display: 'flex', zIndex: 9999 }}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||||
|
onKeyDown={pwDlgKey}
|
||||||
>
|
>
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
|
||||||
@@ -400,9 +425,14 @@ export default function Passwords() {
|
|||||||
{/* 详情弹窗 */}
|
{/* 详情弹窗 */}
|
||||||
{detailEntry && (
|
{detailEntry && (
|
||||||
<div
|
<div
|
||||||
|
ref={detailDlgRef}
|
||||||
className="dialog-overlay active"
|
className="dialog-overlay active"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={detailEntry.title}
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
style={{ display: 'flex', zIndex: 9999 }}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
|
||||||
|
onKeyDown={detailDlgKey}
|
||||||
>
|
>
|
||||||
<div className="dialog" style={{ maxWidth: 500 }}>
|
<div className="dialog" style={{ maxWidth: 500 }}>
|
||||||
<h3>{detailEntry.title}</h3>
|
<h3>{detailEntry.title}</h3>
|
||||||
@@ -410,16 +440,16 @@ export default function Passwords() {
|
|||||||
<div className="pw-field">
|
<div className="pw-field">
|
||||||
<span className="pw-label">用户名</span>
|
<span className="pw-label">用户名</span>
|
||||||
<span className="pw-value">{detailEntry.username || ''}</span>
|
<span className="pw-value">{detailEntry.username || ''}</span>
|
||||||
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="pw-field">
|
<div className="pw-field">
|
||||||
<span className="pw-label">密码</span>
|
<span className="pw-label">密码</span>
|
||||||
<span className="pw-value">{detailEntry.password}</span>
|
<span className="pw-value">{detailEntry.password}</span>
|
||||||
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
|
||||||
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{detailEntry.url ? (
|
{detailEntry.url ? (
|
||||||
<div className="pw-field">
|
<div className="pw-field">
|
||||||
@@ -447,7 +477,7 @@ export default function Passwords() {
|
|||||||
|
|
||||||
{/* 密码生成器弹窗 */}
|
{/* 密码生成器弹窗 */}
|
||||||
{genDialog && (
|
{genDialog && (
|
||||||
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
|
<div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
|
||||||
<div className="dialog" style={{ maxWidth: 380 }}>
|
<div className="dialog" style={{ maxWidth: 380 }}>
|
||||||
<h3>生成随机密码</h3>
|
<h3>生成随机密码</h3>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -476,7 +506,7 @@ export default function Passwords() {
|
|||||||
|
|
||||||
{/* 修改 PIN 警告确认弹窗 */}
|
{/* 修改 PIN 警告确认弹窗 */}
|
||||||
{pinChangeWarn && (
|
{pinChangeWarn && (
|
||||||
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
|
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h3>确认修改 PIN</h3>
|
<h3>确认修改 PIN</h3>
|
||||||
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
<p style={{ marginBottom: 24, fontSize: 16 }}>⚠️ 修改 PIN 后,现有密码条目将无法解密,需要重新添加。确定继续?</p>
|
||||||
@@ -490,7 +520,7 @@ export default function Passwords() {
|
|||||||
|
|
||||||
{/* 删除确认弹窗 */}
|
{/* 删除确认弹窗 */}
|
||||||
{confirmDialog && (
|
{confirmDialog && (
|
||||||
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
|
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h3>确认操作</h3>
|
<h3>确认操作</h3>
|
||||||
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录?</p>
|
||||||
@@ -501,6 +531,6 @@ export default function Passwords() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as profileApi from '../api/profile.js';
|
|||||||
import * as authApi from '../api/auth.js';
|
import * as authApi from '../api/auth.js';
|
||||||
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
import { avatarUrl, uploadAvatar } from '../api/upload.js';
|
||||||
import { getToken, notifyAuthChange } from '../api/client.js';
|
import { getToken, notifyAuthChange } from '../api/client.js';
|
||||||
import { showSnackbar } from '../lib/utils.js';
|
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
|
||||||
|
|
||||||
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
/** FileReader + Image 加载图片(头像裁剪前读取) */
|
||||||
function loadImage(file) {
|
function loadImage(file) {
|
||||||
@@ -38,6 +38,13 @@ export default function Profile() {
|
|||||||
const [confirmPw, setConfirmPw] = useState('');
|
const [confirmPw, setConfirmPw] = useState('');
|
||||||
const [pwBusy, setPwBusy] = useState(false);
|
const [pwBusy, setPwBusy] = useState(false);
|
||||||
|
|
||||||
|
// 修改密码弹窗键盘/焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
|
||||||
|
const { dialogRef: pwDlgRef, onKeyDown: pwDlgKey } = useDialog(pwDialog, () => setPwDialog(false));
|
||||||
|
// 发送验证码成功切到第二步时,把焦点移到验证码输入框
|
||||||
|
useEffect(() => {
|
||||||
|
if (pwDialog && pwStep === 2) focusDialog(pwDlgRef.current);
|
||||||
|
}, [pwDialog, pwStep]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -186,9 +193,14 @@ export default function Profile() {
|
|||||||
{/* 修改密码弹窗 */}
|
{/* 修改密码弹窗 */}
|
||||||
{pwDialog && (
|
{pwDialog && (
|
||||||
<div
|
<div
|
||||||
|
ref={pwDlgRef}
|
||||||
className="dialog-overlay active"
|
className="dialog-overlay active"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="修改密码"
|
||||||
style={{ display: 'flex', zIndex: 9999 }}
|
style={{ display: 'flex', zIndex: 9999 }}
|
||||||
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
|
||||||
|
onKeyDown={pwDlgKey}
|
||||||
>
|
>
|
||||||
<div className="dialog">
|
<div className="dialog">
|
||||||
<h3>修改密码</h3>
|
<h3>修改密码</h3>
|
||||||
|
|||||||
@@ -0,0 +1,799 @@
|
|||||||
|
import React, { useCallback, 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 DialogActions from '@mui/material/DialogActions';
|
||||||
|
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 RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||||
|
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||||
|
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import { Group, Panel, Separator } from 'react-resizable-panels';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import {
|
||||||
|
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import { getToken } from '../../api/client.js';
|
||||||
|
import { me } from '../../api/auth.js';
|
||||||
|
import usePanels from './hooks/usePanels.js';
|
||||||
|
import useWorkbenchLayout, {
|
||||||
|
VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB,
|
||||||
|
} from './hooks/useWorkbenchLayout.js';
|
||||||
|
import Toolbar from './components/Toolbar.jsx';
|
||||||
|
import PanelsView from './components/Sidebar.jsx';
|
||||||
|
import NotesView from './components/NoteEditor.jsx';
|
||||||
|
import VaultView from './components/VaultDrawer.jsx';
|
||||||
|
import SidebarPane from './components/SidebarPane.jsx';
|
||||||
|
import ActivityBar from './components/ActivityBar.jsx';
|
||||||
|
import CollapseArrow from './components/CollapseArrow.jsx';
|
||||||
|
import ToolWindow from './components/ToolWindow.jsx';
|
||||||
|
import TabStrip from './components/TabStrip.jsx';
|
||||||
|
import ContentView from './components/ContentView.jsx';
|
||||||
|
import StatusBar from './components/StatusBar.jsx';
|
||||||
|
import PanelFrame from './components/PanelFrame.jsx';
|
||||||
|
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
||||||
|
import PanelIcon from './components/PanelIcon.jsx';
|
||||||
|
import SnackHost, { showSnack } from '../../admin/snack.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Workbench:工作台页面(VSCode 风格改造,Lane A)
|
||||||
|
*
|
||||||
|
* 布局(react-resizable-panels,v4 API:Group/Panel/Separator):
|
||||||
|
* 顶栏 56 | 活动栏48 侧栏(可拖 180-360,可折叠) 主区(标签+内容+底部终端) 侧栏 活动栏48 | 状态栏 24
|
||||||
|
* - 左侧活动栏=主控视图(面板),右侧=功能视图(记事本/密码箱)+ 底部终端按钮
|
||||||
|
* - dnd-kit:视图标签可在左右活动栏之间拖拽换边(workbench.viewOrder 持久化)
|
||||||
|
* - 视图双模式:侧边栏(默认)/ 标签页(进主区 TabStrip);浮动为 P2 占位
|
||||||
|
* - 面板 iframe 仍走 PanelFrame(LRU 3),弹窗方式走 Dialog
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
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() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LAYOUT_KEY);
|
||||||
|
const a = raw ? JSON.parse(raw) : null;
|
||||||
|
if (!a || typeof a !== 'object') return null;
|
||||||
|
return {
|
||||||
|
left: Number(a.left) || 0,
|
||||||
|
main: Number(a.main) || 0,
|
||||||
|
right: Number(a.right) || 0,
|
||||||
|
};
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLayout(v) {
|
||||||
|
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(v)); } catch { /* 隐私模式忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminGuard({ children }) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AboutDialog({ open, onClose }) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs">
|
||||||
|
<DialogTitle>关于 Rain Work</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Typography variant="body2" sx={{ mb: 1 }}>
|
||||||
|
Rain Work 工作台 v2.1.0 — 管理面板、记事本、密码箱与终端的一体化工作区。
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
终端为管理员 Root Shell,需单独设置终端密码;记事本文件保存在服务器 workspace 目录;
|
||||||
|
面板可拖拽到左右任一活动栏按你的习惯排布。
|
||||||
|
</Typography>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>关闭</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 面板分隔条:可拖拽 = 4px 命中区 + 悬停高亮;不可拖拽 = 1px 细线 */
|
||||||
|
function ResizeSeparator({ disabled = false }) {
|
||||||
|
const muiTheme = useTheme();
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
return (
|
||||||
|
<Separator
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
width: disabled ? 1 : 4,
|
||||||
|
background: hover ? muiTheme.palette.primary.main : 'transparent',
|
||||||
|
transition: 'background .12s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkbenchContent() {
|
||||||
|
const panels = usePanels();
|
||||||
|
const layout = useWorkbenchLayout();
|
||||||
|
const {
|
||||||
|
links, loadError, reload,
|
||||||
|
openPanels, renderedIds,
|
||||||
|
activeId, activePanel, modalPanel, closeModal,
|
||||||
|
canBack, canForward, goBack, goForward,
|
||||||
|
openPanel, closePanel, togglePin, toggleGroup, setMode,
|
||||||
|
pinned, groups, modes,
|
||||||
|
search, setSearch, filteredPanels, openFirstMatch,
|
||||||
|
} = panels;
|
||||||
|
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
|
const [mainRefresh, setMainRefresh] = useState(0);
|
||||||
|
const [modalRefresh, setModalRefresh] = useState(0);
|
||||||
|
// 主区激活标签:同步从 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 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 上限 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,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const persistLayout = useCallback((l) => {
|
||||||
|
const v = (id) => Math.round(l?.[id] ?? 0);
|
||||||
|
writeLayout({ left: v('sidebar-l'), main: v('main'), right: v('sidebar-r') });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 立即保存布局(折叠/展开等程序化变更时兜底,拖拽走 onLayoutChanged) */
|
||||||
|
const saveLayoutNow = useCallback(() => {
|
||||||
|
try { persistLayout(groupRef.current?.getLayout()); } catch { /* 布局尚未就绪 */ }
|
||||||
|
}, [persistLayout]);
|
||||||
|
|
||||||
|
// 拖拽期间 onLayoutChanged 高频触发:200ms 防抖后再写 localStorage,避免阻塞主线程
|
||||||
|
const layoutTimer = useRef(null);
|
||||||
|
const handleLayoutChanged = useCallback((layout, { isUserInteraction }) => {
|
||||||
|
if (!isUserInteraction) return;
|
||||||
|
clearTimeout(layoutTimer.current);
|
||||||
|
layoutTimer.current = setTimeout(() => persistLayout(layout), 200);
|
||||||
|
}, [persistLayout]);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(layoutTimer.current), []);
|
||||||
|
|
||||||
|
/** 程序化调整侧边栏宽度:走 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) => {
|
||||||
|
// 用状态(阈值判定)而非 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');
|
||||||
|
const rightActive = layout.activeViewOf('right');
|
||||||
|
const leftBarViews = layout.order.left.map((id) => VIEWS[id]).filter(Boolean);
|
||||||
|
const rightBarViews = layout.order.right.map((id) => VIEWS[id]).filter(Boolean);
|
||||||
|
// 侧栏只渲染 sidebar 模式的视图(tab 模式的进主区标签页)
|
||||||
|
const leftPaneViews = leftBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
|
||||||
|
const rightPaneViews = rightBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
|
||||||
|
|
||||||
|
const isViewActive = (vid) => {
|
||||||
|
if (layout.mode[vid] === VIEW_MODE_TAB) return effectiveActiveKey === 'view:' + vid;
|
||||||
|
const side = layout.sideOf[vid];
|
||||||
|
return side ? layout.activeViewOf(side) === vid : false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 主区标签列表(每次渲染重建,保证 render() 闭包拿到最新状态) */
|
||||||
|
const buildMainTabs = () => {
|
||||||
|
const tabs = [];
|
||||||
|
openPanels.forEach((p) => {
|
||||||
|
tabs.push({
|
||||||
|
key: 'panel:' + p.id,
|
||||||
|
type: 'panel',
|
||||||
|
title: p.title,
|
||||||
|
closable: true,
|
||||||
|
icon: <PanelIcon url={p.embed_url || p.url} title={p.title} size={14} />,
|
||||||
|
panel: p,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
VIEW_IDS.forEach((id) => {
|
||||||
|
if (layout.mode[id] !== VIEW_MODE_TAB) return;
|
||||||
|
const v = VIEWS[id];
|
||||||
|
tabs.push({
|
||||||
|
key: 'view:' + id,
|
||||||
|
type: id,
|
||||||
|
title: v.label,
|
||||||
|
closable: true,
|
||||||
|
icon: <v.Icon sx={{ fontSize: 14, color: 'text.secondary' }} />,
|
||||||
|
render: () => renderViewContent(id),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return tabs;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 视图内容渲染(先定义:buildMainTabs / renderPaneView 均依赖)
|
||||||
|
const renderViewContent = (viewId) => {
|
||||||
|
if (viewId === 'panels') {
|
||||||
|
return (
|
||||||
|
<PanelsView
|
||||||
|
loading={links === null && !loadError}
|
||||||
|
error={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
links={links || []}
|
||||||
|
filtered={filteredPanels}
|
||||||
|
search={search}
|
||||||
|
activeId={activeId}
|
||||||
|
pinned={pinned}
|
||||||
|
onSelect={openPanel}
|
||||||
|
onPin={togglePin}
|
||||||
|
groups={groups}
|
||||||
|
onToggleGroup={toggleGroup}
|
||||||
|
modes={modes}
|
||||||
|
onSetMode={setMode}
|
||||||
|
onAdd={() => setAddOpen(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (viewId === 'notes') return <NotesView />;
|
||||||
|
if (viewId === 'vault') return <VaultView open={isViewActive('vault')} />;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mainTabs = buildMainTabs();
|
||||||
|
// 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
|
||||||
|
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
|
||||||
|
? activeMainKey
|
||||||
|
: (mainTabs[0]?.key ?? null);
|
||||||
|
|
||||||
|
// 打开面板 / 历史导航时自动切到对应 iframe 标签。
|
||||||
|
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
|
||||||
|
// 之后每次 activeId 变化都同步并持久化。
|
||||||
|
useEffect(() => {
|
||||||
|
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) => {
|
||||||
|
if (key.startsWith('panel:')) closePanel(key.slice('panel:'.length));
|
||||||
|
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 = () => {
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 模式切换入口:切换前派发保存事件(记事本立即 flush 未落盘草稿),再切模式 */
|
||||||
|
const setViewModeSafely = useCallback((viewId, mode) => {
|
||||||
|
if (viewId === 'notes') {
|
||||||
|
try { window.dispatchEvent(new CustomEvent('workbench:notes-save-request')); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
layout.setViewMode(viewId, mode);
|
||||||
|
}, [layout]);
|
||||||
|
|
||||||
|
/** 侧栏内单个视图:ToolWindow 标题栏 + 内容 */
|
||||||
|
const renderPaneView = (viewId) => {
|
||||||
|
const v = VIEWS[viewId];
|
||||||
|
const VIcon = v.Icon;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ToolWindow
|
||||||
|
icon={<VIcon sx={{ fontSize: 17, color: 'primary.main' }} />}
|
||||||
|
title={v.label}
|
||||||
|
mode={layout.mode[viewId]}
|
||||||
|
onSetMode={(m) => setViewModeSafely(viewId, m)}
|
||||||
|
onFloat={() => showSnack('浮动模式即将上线', 'info')}
|
||||||
|
closeTitle="关闭侧边栏"
|
||||||
|
onClose={() => {
|
||||||
|
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
|
||||||
|
const side = layout.sideOf[viewId];
|
||||||
|
toggleSidebar(side);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{renderViewContent(viewId)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPlaceholder = (side) => {
|
||||||
|
const vid = layout.activeViewOf(side);
|
||||||
|
const v = VIEWS[vid];
|
||||||
|
if (!v) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<v.Icon sx={{ fontSize: 28, color: 'text.disabled' }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
「{v.label}」已在标签页中打开
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => setViewModeSafely(vid, VIEW_MODE_SIDEBAR)}>
|
||||||
|
移回侧边栏
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起(VSCode 语义) */
|
||||||
|
const handleActivateView = (viewId, side) => {
|
||||||
|
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
|
||||||
|
const alreadyActive = layout.activeViewOf(side) === viewId;
|
||||||
|
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
|
||||||
|
layout.activateView(viewId);
|
||||||
|
// setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
|
||||||
|
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
|
||||||
|
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
|
||||||
|
setTimeout(saveLayoutNow, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 菜单栏配置 ----
|
||||||
|
const menus = [
|
||||||
|
{
|
||||||
|
label: '文件', items: [
|
||||||
|
{ label: '添加面板…', Icon: AddIcon, action: () => setAddOpen(true) },
|
||||||
|
{ label: '刷新面板列表', Icon: RefreshIcon, action: () => reload() },
|
||||||
|
{ divider: true },
|
||||||
|
{ label: '返回前台', Icon: HomeIcon, action: () => { window.location.href = '/'; } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '编辑', items: [
|
||||||
|
{ label: '搜索面板', Icon: SearchIcon, action: () => searchRef.current?.focus() },
|
||||||
|
{ label: '刷新当前面板', Icon: RefreshIcon, action: () => { if (activePanel) setMainRefresh((n) => n + 1); } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '视图', items: [
|
||||||
|
{ label: '切换左侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('left') },
|
||||||
|
{ label: '切换右侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('right') },
|
||||||
|
{ divider: true },
|
||||||
|
...VIEW_IDS.map((id) => ({
|
||||||
|
label: VIEWS[id].label,
|
||||||
|
Icon: VIEWS[id].Icon,
|
||||||
|
action: () => { setViewModeSafely(id, VIEW_MODE_SIDEBAR); layout.activateView(id); },
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '终端', items: [
|
||||||
|
// 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
|
||||||
|
{ label: '终端功能即将回归', Icon: TerminalIcon },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '帮助', items: [
|
||||||
|
{ label: '关于工作台', Icon: InfoOutlinedIcon, action: () => setAboutOpen(true) },
|
||||||
|
{ label: '管理后台', Icon: SettingsIcon, action: () => { window.location.href = '/admin/dashboard'; } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ---- dnd-kit 拖拽(活动栏标签换边 / 同侧排序;键盘操作走 Tab+Enter 激活) ----
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||||||
|
);
|
||||||
|
const handleDragStart = (event) => {
|
||||||
|
const id = String(event.active.id);
|
||||||
|
if (!id.startsWith('view:')) return;
|
||||||
|
const v = VIEWS[id.slice('view:'.length)];
|
||||||
|
if (v) setActiveDrag({ id: v.id, label: v.label, Icon: v.Icon });
|
||||||
|
};
|
||||||
|
const handleDragEnd = (event) => {
|
||||||
|
setActiveDrag(null);
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over) return;
|
||||||
|
const vid = String(active.id).replace(/^view:/, '');
|
||||||
|
if (!VIEWS[vid]) return;
|
||||||
|
const overStr = String(over.id);
|
||||||
|
if (overStr.startsWith('activity:')) {
|
||||||
|
const side = overStr.slice('activity:'.length);
|
||||||
|
layout.moveView(vid, side);
|
||||||
|
} else if (overStr.startsWith('view:')) {
|
||||||
|
const overVid = overStr.slice('view:'.length);
|
||||||
|
const mySide = layout.sideOf[vid];
|
||||||
|
const overSide = layout.sideOf[overVid];
|
||||||
|
if (mySide && overSide && mySide === overSide) layout.reorderView(vid, overVid);
|
||||||
|
else if (overSide) layout.moveView(vid, overSide);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 主区内容(加载/错误/空态;有标签时一律走 ContentView 分发) ----
|
||||||
|
let mainBody;
|
||||||
|
if (links === null && !loadError) {
|
||||||
|
mainBody = (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress size={36} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
} else if (loadError) {
|
||||||
|
mainBody = (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />}
|
||||||
|
title="面板列表加载失败"
|
||||||
|
hint={loadError}
|
||||||
|
action="重试"
|
||||||
|
onAction={reload}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (mainTabs.length === 0 && links.length === 0) {
|
||||||
|
mainBody = (
|
||||||
|
<EmptyState
|
||||||
|
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />}
|
||||||
|
title="还没有任何面板"
|
||||||
|
hint="添加常用后台 / 监控 / 工具站到工作台,一个界面全部打开"
|
||||||
|
action="添加第一个面板"
|
||||||
|
onAction={() => setAddOpen(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (mainTabs.length === 0) {
|
||||||
|
mainBody = (
|
||||||
|
<EmptyState
|
||||||
|
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />}
|
||||||
|
title="未打开面板"
|
||||||
|
hint="从左侧选择一个面板开始,或搜索后按 Enter 直达"
|
||||||
|
action="打开一个面板"
|
||||||
|
onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
mainBody = (
|
||||||
|
<ContentView
|
||||||
|
tabs={mainTabs}
|
||||||
|
activeKey={effectiveActiveKey}
|
||||||
|
refreshNonce={mainRefresh}
|
||||||
|
panelRenderKeys={panelRenderKeys}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar
|
||||||
|
menus={menus}
|
||||||
|
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');
|
||||||
|
}}
|
||||||
|
searchInputRef={searchRef}
|
||||||
|
onBackHome={() => { window.location.href = '/'; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={() => setActiveDrag(null)}
|
||||||
|
>
|
||||||
|
<Group
|
||||||
|
orientation="horizontal"
|
||||||
|
defaultLayout={defaultLayout}
|
||||||
|
onLayoutChanged={handleLayoutChanged}
|
||||||
|
groupRef={groupRef}
|
||||||
|
style={{ flex: 1, minHeight: 0, display: 'flex' }}
|
||||||
|
>
|
||||||
|
{/* 左侧活动栏 */}
|
||||||
|
<Panel id="activity-l" defaultSize={48} minSize={48} maxSize={48}>
|
||||||
|
<ActivityBar
|
||||||
|
side="left"
|
||||||
|
views={leftBarViews}
|
||||||
|
activeId={leftActive}
|
||||||
|
onActivate={(id) => handleActivateView(id, 'left')}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<ResizeSeparator disabled />
|
||||||
|
{/* 左侧侧边栏 */}
|
||||||
|
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
|
||||||
|
<SidebarPane
|
||||||
|
side="left"
|
||||||
|
views={leftPaneViews}
|
||||||
|
activeId={leftActive}
|
||||||
|
renderView={renderPaneView}
|
||||||
|
renderPlaceholder={() => renderPlaceholder('left')}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<ResizeSeparator />
|
||||||
|
{/* 主区:标签条 + 内容 + 底部终端 */}
|
||||||
|
<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}
|
||||||
|
activeKey={effectiveActiveKey}
|
||||||
|
onSelect={handleSelectTab}
|
||||||
|
onClose={handleCloseTab}
|
||||||
|
onAdd={() => setAddOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
|
||||||
|
{mainBody}
|
||||||
|
</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} onResize={onSidebarResize('right')}>
|
||||||
|
<SidebarPane
|
||||||
|
side="right"
|
||||||
|
views={rightPaneViews}
|
||||||
|
activeId={rightActive}
|
||||||
|
renderView={renderPaneView}
|
||||||
|
renderPlaceholder={() => renderPlaceholder('right')}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
<ResizeSeparator disabled />
|
||||||
|
{/* 右侧活动栏 */}
|
||||||
|
<Panel id="activity-r" defaultSize={48} minSize={48} maxSize={48}>
|
||||||
|
<ActivityBar
|
||||||
|
side="right"
|
||||||
|
views={rightBarViews}
|
||||||
|
activeId={rightActive}
|
||||||
|
onActivate={(id) => handleActivateView(id, 'right')}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* 拖拽浮层(纯展示,禁止渲染 useSortable 组件) */}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeDrag ? (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 1.25, py: 1, borderRadius: 2,
|
||||||
|
bgcolor: 'background.paper', border: 1, borderColor: 'primary.main',
|
||||||
|
boxShadow: (t) => t.shadows[6],
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1,
|
||||||
|
fontSize: 12.5, fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<activeDrag.Icon sx={{ fontSize: 16, color: 'primary.main' }} />
|
||||||
|
{activeDrag.label}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
<StatusBar left={`已打开 ${openPanels.length} 个面板${activePanel ? ` · ${activePanel.title}` : ''}`} />
|
||||||
|
|
||||||
|
<AddPanelDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
categories={categories}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
onSetMode={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 弹窗打开方式: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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
|
||||||
|
<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,752 @@
|
|||||||
|
import React, {
|
||||||
|
forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
|
||||||
|
} from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||||
|
import TerminalIcon from '@mui/icons-material/Terminal';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import Stack from '@mui/material/Stack';
|
||||||
|
import { useTheme } from '@mui/material/styles';
|
||||||
|
import { Terminal } from '@xterm/xterm';
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||||
|
import { terminalStatus, terminalAuth } from '../../../api/terminal.js';
|
||||||
|
import { showSnack } from '../../../admin/snack.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* TerminalPanel:底部终端面板(xterm.js v6)
|
||||||
|
*
|
||||||
|
* 安全门禁(后端 routes/terminal.js 契约):
|
||||||
|
* /api/terminal/status → hasPin ? /api/terminal/auth(pin) → 短 TTL token
|
||||||
|
* → ws(s)://host/ws/terminal?token=…(Root shell,admin + 终端密码双认证)
|
||||||
|
*
|
||||||
|
* - 最多 3 个会话(标签页切换,keep-alive:display 显隐不销毁)
|
||||||
|
* - 消息协议:输入/输出二进制;控制 JSON {type:'auth'|'resize'|'ping'|'bye'}
|
||||||
|
* - 断线指数退避重连 [250,500,1000,2000,4000,8000],恢复后 term.reset()
|
||||||
|
* - 关闭码:4001 token 过期→回 PIN 门;1013/503 会话满;1011 node-pty 不可用;
|
||||||
|
* 1000(exit)/4000(空闲) 为服务端主动终止→会话结束,需用户显式「重新连接」
|
||||||
|
* (不自动重生 root shell);仅 1006 等网络异常自动重连
|
||||||
|
* - CJK 必装 unicode11,字体栈 JetBrains Mono + Noto Sans Mono CJK SC
|
||||||
|
* - 明暗主题由 MUI MD3 palette 派生,随 data-theme 切换自动更新
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const MAX_SESSIONS = 3;
|
||||||
|
const BACKOFF = [250, 500, 1000, 2000, 4000, 8000];
|
||||||
|
const PING_MS = 30000;
|
||||||
|
const TOKEN_MARGIN_MS = 4 * 60 * 1000; // 5 分钟 TTL,提前 1 分钟视为过期
|
||||||
|
const FONT = '"JetBrains Mono","Noto Sans Mono CJK SC","Microsoft YaHei",monospace';
|
||||||
|
|
||||||
|
/* ---------- 颜色工具(由 MD3 palette 派生,不硬编码) ---------- */
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
let h = String(hex).replace('#', '');
|
||||||
|
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
||||||
|
if (!/^[0-9a-f]{6}$/i.test(h)) return [128, 128, 128];
|
||||||
|
const n = parseInt(h, 16);
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||||
|
}
|
||||||
|
function mixHex(a, b, w) {
|
||||||
|
const ca = hexToRgb(a);
|
||||||
|
const cb = hexToRgb(b);
|
||||||
|
return `#${ca.map((v, i) => Math.round(v + (cb[i] - v) * w).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xtermTheme(muiTheme) {
|
||||||
|
const p = muiTheme.palette;
|
||||||
|
const dark = p.mode === 'dark';
|
||||||
|
const bg = p.background.paper;
|
||||||
|
const black = mixHex(p.text.primary, bg, 0.6);
|
||||||
|
const brightBlack = mixHex(p.text.primary, bg, 0.2);
|
||||||
|
const base = [
|
||||||
|
black, // 0 黑
|
||||||
|
p.error.main, // 1 红
|
||||||
|
p.success.main, // 2 绿
|
||||||
|
p.warning.main, // 3 黄
|
||||||
|
p.info.main, // 4 蓝
|
||||||
|
p.tertiary.main, // 5 品红
|
||||||
|
mixHex(p.secondary.main, p.info.main, 0.5), // 6 青
|
||||||
|
p.text.primary, // 7 白
|
||||||
|
];
|
||||||
|
const bright = base.map((c) => mixHex(c, dark ? '#ffffff' : '#000000', dark ? 0.55 : 0.45));
|
||||||
|
const name = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
|
||||||
|
const out = {
|
||||||
|
background: bg,
|
||||||
|
foreground: p.text.primary,
|
||||||
|
cursor: p.primary.main,
|
||||||
|
cursorAccent: bg,
|
||||||
|
selectionBackground: mixHex(p.primary.main, bg, 0.72),
|
||||||
|
brightBlack,
|
||||||
|
};
|
||||||
|
name.forEach((n, i) => { out[n] = base[i]; out['bright' + n[0].toUpperCase() + n.slice(1)] = bright[i]; });
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- WebSocket URL ---------- */
|
||||||
|
// 认证 token 由 /api/terminal/auth 种 HttpOnly cookie(terminal_token,5 分钟),
|
||||||
|
// WS 握手同源自动携带,不再拼 ?token= 到 URL(避免落入 access log)。
|
||||||
|
function wsUrl() {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${window.location.host}/ws/terminal`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终端面板
|
||||||
|
* @param {{open: boolean}} props open=底部面板是否展开(展开才查状态;收起 keep-alive)
|
||||||
|
* @ref newSession() / lock()
|
||||||
|
*/
|
||||||
|
const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
|
||||||
|
const muiTheme = useTheme();
|
||||||
|
const themeObj = useMemo(() => xtermTheme(muiTheme), [muiTheme]);
|
||||||
|
|
||||||
|
// ---- 门禁状态:checking | needs-setup | locked | ready | error ----
|
||||||
|
const [gate, setGate] = useState('checking');
|
||||||
|
const [gateError, setGateError] = useState('');
|
||||||
|
const [pinInput, setPinInput] = useState('');
|
||||||
|
const [showPin, setShowPin] = useState(false);
|
||||||
|
const [pinBusy, setPinBusy] = useState(false);
|
||||||
|
|
||||||
|
// ---- 会话:{id,title,status} + 底层实例(term/ws 等) ----
|
||||||
|
const [sessions, setSessions] = useState([]);
|
||||||
|
const [activeId, setActiveId] = useState(null);
|
||||||
|
const [banner, setBanner] = useState(null); // {type:'warn'|'error', text}
|
||||||
|
|
||||||
|
const instRef = useRef({}); // id → {term,fit,ro,ws,backoffIdx,timers,autoReconnect,closed}
|
||||||
|
const tokenRef = useRef({ value: null, expiresAt: 0 });
|
||||||
|
const seqRef = useRef(0);
|
||||||
|
const stripRef = useRef(null); // 会话标签条(方向键 roving tabindex 用)
|
||||||
|
|
||||||
|
const patchStatus = useCallback((id, status) => {
|
||||||
|
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, status } : s)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---------- WS 连接层(一次性初始化,全走 ref,无陈旧闭包) ---------- */
|
||||||
|
const manager = useRef(null);
|
||||||
|
if (!manager.current) {
|
||||||
|
const M = {};
|
||||||
|
|
||||||
|
M.sendResize = (id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst || !inst.ws || inst.ws.readyState !== 1 || !inst.term) return;
|
||||||
|
try {
|
||||||
|
inst.ws.send(JSON.stringify({ type: 'resize', cols: inst.term.cols, rows: inst.term.rows }));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
M.handleControl = (id, text) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(text);
|
||||||
|
if (msg.type === 'bye') { /* server 主动结束,交由 close 处理 */ }
|
||||||
|
// 'pong' / 未知控制消息忽略
|
||||||
|
} catch { /* 非 JSON 文本忽略 */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
M.scheduleReconnect = (id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst || inst.closed) return;
|
||||||
|
const delay = BACKOFF[Math.min(inst.backoffIdx, BACKOFF.length - 1)];
|
||||||
|
inst.backoffIdx = Math.min(inst.backoffIdx + 1, BACKOFF.length - 1);
|
||||||
|
clearTimeout(inst.reconnectTimer);
|
||||||
|
inst.reconnectTimer = setTimeout(() => M.connectSession(id), delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
M.connectSession = async (id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst || inst.closed) return;
|
||||||
|
// 防双 WS 竞态:关闭旧连接并置空其回调(避免旧 onclose 触发多余重连)
|
||||||
|
try {
|
||||||
|
if (inst.ws) { inst.ws.onclose = null; inst.ws.onerror = null; try { inst.ws.close(); } catch {} }
|
||||||
|
} catch {}
|
||||||
|
inst.ws = null;
|
||||||
|
clearTimeout(inst.reconnectTimer);
|
||||||
|
clearInterval(inst.pingTimer);
|
||||||
|
inst.gen = (inst.gen || 0) + 1;
|
||||||
|
const gen = inst.gen;
|
||||||
|
patchStatus(id, 'connecting');
|
||||||
|
|
||||||
|
// 无有效 token → 回 PIN 门(4001 过期或从未认证)
|
||||||
|
const token = tokenRef.current.value;
|
||||||
|
if (!token) {
|
||||||
|
inst.autoReconnect = false;
|
||||||
|
setGate('locked');
|
||||||
|
setGateError('终端密码已过期,请重新输入');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先查状态:后端不可用 / 会话满 给明确提示,避免盲连
|
||||||
|
try {
|
||||||
|
const st = await terminalStatus();
|
||||||
|
if (st.sessions >= st.maxSessions) {
|
||||||
|
inst.autoReconnect = false;
|
||||||
|
patchStatus(id, 'error');
|
||||||
|
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!inst.closed && gen === inst.gen) {
|
||||||
|
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
|
||||||
|
patchStatus(id, 'reconnecting');
|
||||||
|
M.scheduleReconnect(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
try { ws = new WebSocket(wsUrl()); } catch { /* 构造失败走 onerror/onclose */ }
|
||||||
|
if (!ws) { M.scheduleReconnect(id); return; }
|
||||||
|
inst.ws = ws;
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (inst.closed || gen !== inst.gen) { try { ws.close(); } catch {} return; }
|
||||||
|
inst.backoffIdx = 0;
|
||||||
|
setBanner(null);
|
||||||
|
try { ws.send(JSON.stringify({ type: 'auth', token })); } catch {}
|
||||||
|
M.sendResize(id);
|
||||||
|
// 断线恢复后清屏,避免旧输出残留
|
||||||
|
try { inst.term?.reset(); } catch {}
|
||||||
|
patchStatus(id, 'connected');
|
||||||
|
inst.pingTimer = setInterval(() => {
|
||||||
|
if (inst.closed || gen !== inst.gen || ws.readyState !== 1) return;
|
||||||
|
try { ws.send(JSON.stringify({ type: 'ping' })); } catch {}
|
||||||
|
}, PING_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
if (inst.closed || gen !== inst.gen) return;
|
||||||
|
if (typeof ev.data === 'string') { M.handleControl(id, ev.data); return; }
|
||||||
|
try {
|
||||||
|
inst.term?.write(ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => { try { ws.close(); } catch {} };
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
if (inst.closed || gen !== inst.gen) return;
|
||||||
|
clearInterval(inst.pingTimer);
|
||||||
|
inst.ws = null;
|
||||||
|
// 关闭码语义(后端 routes/terminal.js 定义)
|
||||||
|
if (ev.code === 4001) { // token 无效/过期 → 回 PIN 门重新认证
|
||||||
|
tokenRef.current = { value: null, expiresAt: 0 };
|
||||||
|
inst.autoReconnect = false;
|
||||||
|
setGate('locked');
|
||||||
|
setGateError('终端密码已过期,请重新输入');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 1013 || ev.code === 503) { // 会话超限
|
||||||
|
inst.autoReconnect = false;
|
||||||
|
patchStatus(id, 'error');
|
||||||
|
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.code === 1011) { // pty 启动失败 / node-pty 未安装
|
||||||
|
inst.autoReconnect = false;
|
||||||
|
patchStatus(id, 'error');
|
||||||
|
setBanner({ type: 'error', text: '终端服务不可用:node-pty 未安装或启动失败' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 服务端主动结束会话:exit(1000) / 空闲超时(4000)
|
||||||
|
// 视为会话终止,不自动重生新 root shell;由用户显式「重新连接」
|
||||||
|
if (ev.code === 1000 || ev.code === 4000) {
|
||||||
|
inst.autoReconnect = false;
|
||||||
|
inst.endedReason = ev.code === 4000 ? 'idle' : 'exit';
|
||||||
|
patchStatus(id, 'ended');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 其余(1006 网络异常等)→ 指数退避自动重连
|
||||||
|
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
|
||||||
|
if (inst.autoReconnect) { patchStatus(id, 'reconnecting'); M.scheduleReconnect(id); }
|
||||||
|
else patchStatus(id, 'error');
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
manager.current = M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 门禁 ---------- */
|
||||||
|
const checkGate = useCallback(async () => {
|
||||||
|
setGate('checking');
|
||||||
|
setGateError('');
|
||||||
|
try {
|
||||||
|
const st = await terminalStatus();
|
||||||
|
if (tokenRef.current.value && Date.now() < tokenRef.current.expiresAt) { setGate('ready'); return; }
|
||||||
|
setGate(st.hasPin ? 'locked' : 'needs-setup');
|
||||||
|
} catch (e) {
|
||||||
|
setGate('error');
|
||||||
|
setGateError(e.message || '无法连接终端服务');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 首次展开面板才查状态(收起时 keep-alive,不重复请求)
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && gate === 'checking') checkGate();
|
||||||
|
}, [open, gate, checkGate]);
|
||||||
|
|
||||||
|
const submitPin = useCallback(async () => {
|
||||||
|
const pin = pinInput.trim();
|
||||||
|
if (!pin) { setGateError('请输入终端密码'); return; }
|
||||||
|
setPinBusy(true);
|
||||||
|
setGateError('');
|
||||||
|
try {
|
||||||
|
const res = await terminalAuth(pin);
|
||||||
|
tokenRef.current = { value: res.token, expiresAt: Date.now() + TOKEN_MARGIN_MS };
|
||||||
|
setPinInput('');
|
||||||
|
setShowPin(false);
|
||||||
|
setGate('ready');
|
||||||
|
// 有会话在等 token → 全部重新连接
|
||||||
|
Object.keys(instRef.current).forEach((id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setGateError(e.message || '认证失败');
|
||||||
|
setPinInput('');
|
||||||
|
} finally {
|
||||||
|
setPinBusy(false);
|
||||||
|
}
|
||||||
|
}, [pinInput]);
|
||||||
|
|
||||||
|
/* ---------- 会话生命周期 ---------- */
|
||||||
|
const newSession = useCallback(() => {
|
||||||
|
if (gate !== 'ready') {
|
||||||
|
showSnack('请先解锁终端', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const live = Object.keys(instRef.current).filter((k) => instRef.current[k] && !instRef.current[k].closed);
|
||||||
|
if (live.length >= MAX_SESSIONS) {
|
||||||
|
setBanner({ type: 'error', text: `终端会话数已达上限(最多 ${MAX_SESSIONS} 个)` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seqRef.current += 1;
|
||||||
|
const id = 't' + seqRef.current;
|
||||||
|
instRef.current[id] = {
|
||||||
|
id, term: null, fit: null, ro: null, ws: null,
|
||||||
|
backoffIdx: 0, reconnectTimer: null, pingTimer: null,
|
||||||
|
autoReconnect: true, closed: false, gen: 0,
|
||||||
|
};
|
||||||
|
setSessions((prev) => [...prev, { id, title: `终端 ${seqRef.current}`, status: 'connecting' }]);
|
||||||
|
setActiveId(id);
|
||||||
|
setBanner(null);
|
||||||
|
}, [gate]);
|
||||||
|
|
||||||
|
const closeSession = useCallback((id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst) return;
|
||||||
|
inst.closed = true;
|
||||||
|
clearTimeout(inst.reconnectTimer);
|
||||||
|
clearInterval(inst.pingTimer);
|
||||||
|
try { if (inst.ws && inst.ws.readyState === 1) inst.ws.send(JSON.stringify({ type: 'bye' })); } catch {}
|
||||||
|
try { inst.ws?.close(1000, 'bye'); } catch {}
|
||||||
|
try { inst.term?.dispose(); } catch {}
|
||||||
|
try { inst.ro?.disconnect(); } catch {}
|
||||||
|
instRef.current[id] = null;
|
||||||
|
// 状态更新不嵌套:先算剩余列表,再分别 set(避免 updater 内副作用)
|
||||||
|
const remaining = sessions.filter((s) => s.id !== id);
|
||||||
|
setSessions(remaining);
|
||||||
|
setActiveId((cur) => (cur === id ? (remaining[remaining.length - 1]?.id ?? null) : cur));
|
||||||
|
setBanner(null);
|
||||||
|
}, [sessions]);
|
||||||
|
|
||||||
|
const lockTerminal = useCallback(() => {
|
||||||
|
tokenRef.current = { value: null, expiresAt: 0 };
|
||||||
|
Object.keys(instRef.current).forEach((id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst) return;
|
||||||
|
inst.closed = true;
|
||||||
|
clearTimeout(inst.reconnectTimer);
|
||||||
|
clearInterval(inst.pingTimer);
|
||||||
|
try { inst.ws?.close(1000, 'bye'); } catch {}
|
||||||
|
try { inst.term?.dispose(); } catch {}
|
||||||
|
try { inst.ro?.disconnect(); } catch {}
|
||||||
|
instRef.current[id] = null;
|
||||||
|
});
|
||||||
|
setSessions([]);
|
||||||
|
setActiveId(null);
|
||||||
|
setBanner(null);
|
||||||
|
setGate('locked');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ newSession, lock: lockTerminal }), [newSession, lockTerminal]);
|
||||||
|
|
||||||
|
// 解锁后自动建首个会话
|
||||||
|
useEffect(() => {
|
||||||
|
if (gate === 'ready' && sessions.length === 0) newSession();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [gate]);
|
||||||
|
|
||||||
|
// 卸载时统一销毁(keep-alive 场景下不触发)
|
||||||
|
useEffect(() => () => {
|
||||||
|
Object.keys(instRef.current).forEach((id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst) return;
|
||||||
|
inst.closed = true;
|
||||||
|
clearTimeout(inst.reconnectTimer);
|
||||||
|
clearInterval(inst.pingTimer);
|
||||||
|
try { inst.ws?.close(); } catch {}
|
||||||
|
try { inst.term?.dispose(); } catch {}
|
||||||
|
try { inst.ro?.disconnect(); } catch {}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 主题随 muiTheme / data-theme 更新(后台主题静态,运行期一般不触发)
|
||||||
|
useEffect(() => {
|
||||||
|
Object.keys(instRef.current).forEach((id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (inst?.term) inst.term.options.theme = themeObj;
|
||||||
|
});
|
||||||
|
}, [themeObj]);
|
||||||
|
|
||||||
|
/** xterm 挂载点:会话创建后容器出现时调用;隐藏恢复时重新 fit */
|
||||||
|
const mountTerminal = useCallback((id, el) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst || !el) return;
|
||||||
|
if (inst.term) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let term;
|
||||||
|
let fit = null;
|
||||||
|
try {
|
||||||
|
term = new Terminal({
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: FONT,
|
||||||
|
lineHeight: 1.35,
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 3000,
|
||||||
|
theme: themeObj,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
try { ws.send(new TextEncoder().encode(d)); } catch {}
|
||||||
|
});
|
||||||
|
term.onResize(({ cols, rows }) => {
|
||||||
|
const ws = inst.ws;
|
||||||
|
if (ws && ws.readyState === 1) {
|
||||||
|
try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (el.clientWidth > 0 && el.clientHeight > 0) {
|
||||||
|
try { fit.fit(); manager.current.sendResize(id); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
inst.ro = ro;
|
||||||
|
// rAF fit 同样做尺寸守卫:容器零尺寸时跳过,避免 0 行列渲染成空白
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
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
|
||||||
|
}, [themeObj]);
|
||||||
|
|
||||||
|
/* ---------- 渲染 ---------- */
|
||||||
|
|
||||||
|
// PIN / 设置门禁
|
||||||
|
if (gate === 'checking' || gate === 'needs-setup' || gate === 'locked' || gate === 'error') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
|
||||||
|
{gate === 'checking' && (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5 }}>
|
||||||
|
<CircularProgress size={26} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>正在检查终端服务…</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{gate === 'error' && (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center' }}>
|
||||||
|
<ErrorIcon sx={{ fontSize: 38, color: 'error.main' }} />
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{gateError || '无法连接终端服务'}</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={checkGate}>重试</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{(gate === 'needs-setup' || gate === 'locked') && (
|
||||||
|
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitPin(); }} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3 }}>
|
||||||
|
<TerminalIcon sx={{ fontSize: 34, color: 'primary.main', mb: 0.5 }} />
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 15 }}>
|
||||||
|
{gate === 'needs-setup' ? '首次使用:设置终端密码' : '输入终端密码解锁'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', mb: 1 }}>
|
||||||
|
{gate === 'needs-setup'
|
||||||
|
? '终端为管理员 Root Shell,请设置专用密码(至少 8 位,与账号密码独立)'
|
||||||
|
: '终端密码与账号密码相互独立,仅管理员可访问'}
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
size="small"
|
||||||
|
type={showPin ? 'text' : 'password'}
|
||||||
|
label="终端密码"
|
||||||
|
value={pinInput}
|
||||||
|
onChange={(e) => { setPinInput(e.target.value); if (gateError) setGateError(''); }}
|
||||||
|
error={!!gateError}
|
||||||
|
helperText={gateError || ' '}
|
||||||
|
autoComplete={gate === 'needs-setup' ? 'new-password' : 'current-password'}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { maxLength: 128, 'aria-label': '终端密码' },
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" edge="end" aria-label={showPin ? '隐藏密码' : '显示密码'} onClick={() => setShowPin((v) => !v)}>
|
||||||
|
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ width: 300, maxWidth: '100%' }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
disabled={pinBusy || !pinInput}
|
||||||
|
startIcon={pinBusy ? <CircularProgress size={16} color="inherit" /> : <LockOpenIcon />}
|
||||||
|
sx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
{pinBusy ? '处理中…' : gate === 'needs-setup' ? '设置并连接' : '解锁终端'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ready:会话标签栏 + 终端
|
||||||
|
const live = sessions.length;
|
||||||
|
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
|
||||||
|
const moveSessionTab = (i) => {
|
||||||
|
const n = sessions.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const j = ((i % n) + n) % n;
|
||||||
|
setActiveId(sessions[j].id);
|
||||||
|
tabEls()[j]?.focus();
|
||||||
|
};
|
||||||
|
const handleTabKeyDown = (e, index) => {
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); moveSessionTab(index + 1); }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveSessionTab(index - 1); }
|
||||||
|
else if (e.key === 'Home') { e.preventDefault(); moveSessionTab(0); }
|
||||||
|
else if (e.key === 'End') { e.preventDefault(); moveSessionTab(sessions.length - 1); }
|
||||||
|
};
|
||||||
|
/** 会话结束后显式重连:新建 WS 连接 = 服务端 spawn 新 shell */
|
||||||
|
const reconnectSession = (id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (!inst) return;
|
||||||
|
inst.autoReconnect = true;
|
||||||
|
delete inst.endedReason;
|
||||||
|
manager.current.connectSession(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
|
||||||
|
{/* 会话标签栏 */}
|
||||||
|
<Box
|
||||||
|
ref={stripRef}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="终端会话"
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, height: 32, flexShrink: 0,
|
||||||
|
borderBottom: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{sessions.map((s, index) => {
|
||||||
|
const active = s.id === activeId;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={s.id}
|
||||||
|
id={`wb-term-tab-${s.id}`}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
aria-controls={`wb-term-panel-${s.id}`}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
onClick={() => setActiveId(s.id)}
|
||||||
|
onKeyDown={(e) => handleTabKeyDown(e, index)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
|
||||||
|
height: 24, borderRadius: 0.5, cursor: 'pointer', flexShrink: 0, outline: 'none',
|
||||||
|
bgcolor: active ? 'action.selected' : 'transparent',
|
||||||
|
color: active ? 'text.primary' : 'text.secondary',
|
||||||
|
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
|
||||||
|
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
|
||||||
|
bgcolor: s.status === 'connected' ? 'success.main'
|
||||||
|
: s.status === 'error' ? 'error.main'
|
||||||
|
: s.status === 'ended' ? 'text.disabled' : 'warning.main',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Typography sx={{ fontSize: 12, fontWeight: active ? 600 : 500 }}>{s.title}</Typography>
|
||||||
|
{live > 1 && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeSession(s.id); }}
|
||||||
|
aria-label={`关闭 ${s.title}`}
|
||||||
|
sx={{ width: 24, height: 24, borderRadius: 0.5, '& svg': { fontSize: 15 } }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{live < MAX_SESSIONS && (
|
||||||
|
<Tooltip title="新建终端会话">
|
||||||
|
<IconButton size="small" onClick={newSession} aria-label="新建终端会话" sx={{ ml: 0.25 }}>
|
||||||
|
<AddIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<Tooltip title="锁定终端(需重新输入密码)">
|
||||||
|
<IconButton size="small" onClick={lockTerminal} aria-label="锁定终端" sx={{ mr: 0.25 }}>
|
||||||
|
<LockIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 连接状态横幅 */}
|
||||||
|
{banner && (
|
||||||
|
<Box
|
||||||
|
role="status"
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5, flexShrink: 0,
|
||||||
|
bgcolor: banner.type === 'error' ? 'error.main' : 'warning.main',
|
||||||
|
color: banner.type === 'error' ? 'error.contrastText' : 'warning.contrastText',
|
||||||
|
borderBottom: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-hidden
|
||||||
|
sx={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, bgcolor: 'currentColor', opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ flex: 1, fontWeight: 500 }}>{banner.text}</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => Object.keys(instRef.current).forEach((id) => {
|
||||||
|
const inst = instRef.current[id];
|
||||||
|
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
|
||||||
|
})}
|
||||||
|
sx={{ minHeight: 24, px: 1, color: 'inherit' }}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
<IconButton size="small" onClick={() => setBanner(null)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
|
||||||
|
<CloseIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 终端区 */}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, position: 'relative', bgcolor: 'background.paper' }}>
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<Box
|
||||||
|
key={s.id}
|
||||||
|
id={`wb-term-panel-${s.id}`}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`wb-term-tab-${s.id}`}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', inset: 0,
|
||||||
|
display: activeId === s.id ? 'block' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
|
||||||
|
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(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
|
||||||
|
role="status"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 2,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.25,
|
||||||
|
bgcolor: 'background.paper', px: 3, textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TerminalIcon sx={{ fontSize: 28, color: 'text.disabled' }} />
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{instRef.current[s.id]?.endedReason === 'idle' ? '会话已空闲断开' : '终端会话已结束'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{instRef.current[s.id]?.endedReason === 'idle'
|
||||||
|
? '长时间无输入,连接被服务端关闭。点击「重新连接」可新建会话。'
|
||||||
|
: '输入 exit 退出或进程已结束。点击「重新连接」可新建会话。'}
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button size="small" variant="contained" startIcon={<RefreshIcon fontSize="small" />} onClick={() => reconnectSession(s.id)}>
|
||||||
|
重新连接
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="text" onClick={() => closeSession(s.id)}>
|
||||||
|
关闭会话
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{sessions.length === 0 && (
|
||||||
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
|
||||||
|
<TerminalIcon sx={{ fontSize: 30, color: 'text.disabled' }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>点击 + 新建终端会话</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default TerminalPanel;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* ActivityBar:VSCode 风格活动栏(宽 48px,竖排图标)
|
||||||
|
*
|
||||||
|
* - 左侧:主控视图(面板…);右侧:功能视图(记事本/密码箱…)
|
||||||
|
* - 活动项左侧 2.5px primary 指示条 + selected 底
|
||||||
|
* - 点击:未激活 → 激活并展开侧栏;已激活 → 收起侧栏(VSCode 语义)
|
||||||
|
* - 拖拽(@dnd-kit sortable):标签可拖到对侧活动栏 → 视图换边;
|
||||||
|
* 同侧可排序。父级 DndContext 负责 moveView / reorderView 落库。
|
||||||
|
* - 无障碍:role=toolbar,仅活动项 tabIndex=0(roving),
|
||||||
|
* ↑↓/Home/End 移动焦点、Enter/空格 激活;活动项 aria-controls 指向侧栏容器;
|
||||||
|
* 侧栏内由 aria-live 播报当前视图(见 SidebarPane)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
|
||||||
|
const {
|
||||||
|
attributes, listeners, setNodeRef, transform, transition, isDragging,
|
||||||
|
} = useSortable({ id: 'view:' + view.id });
|
||||||
|
|
||||||
|
const VIcon = view.Icon;
|
||||||
|
return (
|
||||||
|
<Tooltip title={view.label} placement="right">
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
data-activity-item
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
role="button"
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
aria-label={view.label}
|
||||||
|
aria-pressed={active}
|
||||||
|
aria-controls={active ? `wb-sidebar-${side}` : undefined}
|
||||||
|
onClick={() => onActivate(view.id)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActivate(view.id); }
|
||||||
|
else if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(index + 1); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(index - 1); }
|
||||||
|
else if (e.key === 'Home') { e.preventDefault(); moveFocus(0); }
|
||||||
|
else if (e.key === 'End') { e.preventDefault(); moveFocus(count - 1); }
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
position: 'relative', width: 48, height: 48, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: 'grab', touchAction: 'none', outline: 'none',
|
||||||
|
color: active ? 'primary.onContainer' : 'text.secondary',
|
||||||
|
bgcolor: active ? 'action.selected' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
|
||||||
|
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3, outlineStyle: 'solid' },
|
||||||
|
opacity: isDragging ? 0.35 : 1,
|
||||||
|
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: 0, top: 10, bottom: 10, width: 2.5,
|
||||||
|
bgcolor: 'primary.main', borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<VIcon sx={{ fontSize: 22 }} />
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityBar({
|
||||||
|
side, views, activeId, onActivate,
|
||||||
|
}) {
|
||||||
|
const right = side === 'right';
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
|
||||||
|
const barRef = useRef(null);
|
||||||
|
|
||||||
|
const itemEls = () => Array.from(barRef.current?.querySelectorAll('[data-activity-item]') || []);
|
||||||
|
const moveFocus = (i) => {
|
||||||
|
const n = views.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const j = ((i % n) + n) % n;
|
||||||
|
itemEls()[j]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={setNodeRef}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={right ? '右侧活动栏' : '左侧活动栏'}
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-controls={`wb-sidebar-${side}`}
|
||||||
|
sx={{
|
||||||
|
width: 48, height: '100%', flexShrink: 0,
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center',
|
||||||
|
bgcolor: isOver ? 'action.hover' : 'background.paper',
|
||||||
|
borderRight: right ? 0 : 1,
|
||||||
|
borderLeft: right ? 1 : 0,
|
||||||
|
borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SortableContext items={views.map((v) => 'view:' + v.id)} strategy={verticalListSortingStrategy}>
|
||||||
|
<Box ref={barRef} sx={{ display: 'flex', flexDirection: 'column', pt: 0.5 }}>
|
||||||
|
{views.map((v, index) => (
|
||||||
|
<ActivityTab
|
||||||
|
key={v.id}
|
||||||
|
view={v}
|
||||||
|
side={side}
|
||||||
|
index={index}
|
||||||
|
count={views.length}
|
||||||
|
moveFocus={moveFocus}
|
||||||
|
active={activeId === v.id}
|
||||||
|
onActivate={onActivate}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SortableContext>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import PanelFrame from './PanelFrame.jsx';
|
||||||
|
import { tabBtnId, tabPanelId } from './TabStrip.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* ContentView:主区内容分发(按标签 type)
|
||||||
|
*
|
||||||
|
* - 'panel' → PanelFrame(iframe,LRU 由 panelRenderKeys 控制挂载)
|
||||||
|
* - 其他 react 类型 → tab.render() 返回对应组件(记事本/密码箱/面板列表…)
|
||||||
|
* 仅激活标签可见,其余保持挂载(keep-alive 保留 iframe 与编辑状态)。
|
||||||
|
* 每个标签包一层 role=tabpanel,与 TabStrip 的 tab 通过 id 配对(ARIA)。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function ContentView({ tabs, activeKey, refreshNonce = 0, panelRenderKeys = null }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = tab.key === activeKey;
|
||||||
|
if (tab.type === 'panel') {
|
||||||
|
// LRU 裁剪:只渲染当前 + 最近使用的 iframe,其余卸载省内存
|
||||||
|
if (panelRenderKeys && !panelRenderKeys.has(tab.key)) return null;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={tab.key}
|
||||||
|
id={tabPanelId(tab.key)}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={tabBtnId(tab.key)}
|
||||||
|
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
<PanelFrame
|
||||||
|
panel={tab.panel}
|
||||||
|
active={active}
|
||||||
|
refreshNonce={refreshNonce}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={tab.key}
|
||||||
|
id={tabPanelId(tab.key)}
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={tabBtnId(tab.key)}
|
||||||
|
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
|
||||||
|
>
|
||||||
|
{tab.render ? tab.render() : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* MenuBar:VSCode 风格菜单栏(MUI Menu,配置数组驱动)
|
||||||
|
*
|
||||||
|
* menus = [{ label, items: [{ label, Icon?, action?, shortcut?, divider? }] }]
|
||||||
|
* - role=menubar / menuitem,顶层 roving tabindex + 方向键导航:
|
||||||
|
* ← → / Home / End 移动焦点,↓ / Enter / 空格 打开菜单
|
||||||
|
* - 菜单内部导航由 MUI Menu 自带(方向键 + Enter/Esc)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function MenuBar({ menus = [] }) {
|
||||||
|
const [openIdx, setOpenIdx] = useState(null);
|
||||||
|
const [anchorEl, setAnchorEl] = useState(null);
|
||||||
|
const [focusIdx, setFocusIdx] = useState(0); // roving tabindex 停靠点
|
||||||
|
const barRef = useRef(null);
|
||||||
|
|
||||||
|
const btnEls = () => Array.from(barRef.current?.querySelectorAll('[data-menu-btn]') || []);
|
||||||
|
const focusBtn = (i) => {
|
||||||
|
const n = menus.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const j = ((i % n) + n) % n;
|
||||||
|
setFocusIdx(j);
|
||||||
|
btnEls()[j]?.focus();
|
||||||
|
};
|
||||||
|
const openMenu = (i) => {
|
||||||
|
setFocusIdx(i);
|
||||||
|
setAnchorEl(btnEls()[i]);
|
||||||
|
setOpenIdx(i);
|
||||||
|
};
|
||||||
|
const close = () => { setOpenIdx(null); setAnchorEl(null); };
|
||||||
|
|
||||||
|
const handleTopKeyDown = (e, i) => {
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight': e.preventDefault(); focusBtn(i + 1); break;
|
||||||
|
case 'ArrowLeft': e.preventDefault(); focusBtn(i - 1); break;
|
||||||
|
case 'Home': e.preventDefault(); focusBtn(0); break;
|
||||||
|
case 'End': e.preventDefault(); focusBtn(menus.length - 1); break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(i); // MUI Menu 打开后自动聚焦首个菜单项
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={barRef}
|
||||||
|
component="nav"
|
||||||
|
role="menubar"
|
||||||
|
aria-label="主菜单"
|
||||||
|
sx={{ display: 'flex', alignItems: 'center' }}
|
||||||
|
>
|
||||||
|
{menus.map((menu, i) => (
|
||||||
|
<React.Fragment key={menu.label}>
|
||||||
|
<Button
|
||||||
|
data-menu-btn
|
||||||
|
role="menuitem"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={openIdx === i}
|
||||||
|
tabIndex={focusIdx === i ? 0 : -1}
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
setFocusIdx(i);
|
||||||
|
if (openIdx === i) { close(); return; }
|
||||||
|
setAnchorEl(e.currentTarget);
|
||||||
|
setOpenIdx(i);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => handleTopKeyDown(e, i)}
|
||||||
|
sx={{
|
||||||
|
minWidth: 0, px: 1, height: 30, borderRadius: 1,
|
||||||
|
fontSize: 13, color: openIdx === i ? 'primary.main' : 'text.primary',
|
||||||
|
'&:hover': { bgcolor: openIdx === i ? 'action.selected' : 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{menu.label}
|
||||||
|
<KeyboardArrowDownIcon sx={{ fontSize: 14, ml: 0.25, color: 'text.disabled' }} />
|
||||||
|
</Button>
|
||||||
|
<Menu
|
||||||
|
open={openIdx === i}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={close}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
|
MenuListProps={{ sx: { py: 0.5, minWidth: 200 } }}
|
||||||
|
>
|
||||||
|
{menu.items.map((item, j) => (
|
||||||
|
item.divider ? (
|
||||||
|
<Divider key={`d-${j}`} sx={{ my: 0.5 }} />
|
||||||
|
) : (
|
||||||
|
<MenuItem
|
||||||
|
key={item.label + j}
|
||||||
|
dense
|
||||||
|
onClick={() => { close(); item.action?.(); }}
|
||||||
|
>
|
||||||
|
{item.Icon && <item.Icon sx={{ fontSize: 16, mr: 1.25, color: 'text.secondary' }} />}
|
||||||
|
<span style={{ flex: 1 }}>{item.label}</span>
|
||||||
|
{item.shortcut && (
|
||||||
|
<Typography variant="caption" sx={{ ml: 3, color: 'text.disabled' }}>{item.shortcut}</Typography>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
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 DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import NoteAltIcon from '@mui/icons-material/NoteAlt';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||||
|
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import useNotes from '../hooks/useNotes.js';
|
||||||
|
import ConfirmDialog from '../../../admin/ConfirmDialog.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* NotesView:记事本视图(VSCode 侧边栏 / 标签页内容)
|
||||||
|
*
|
||||||
|
* 由 NoteEditor 裁剪:去掉浮窗拖拽 / fixed 侧栏 / 自带标题栏,
|
||||||
|
* 标题栏由父级 ToolWindow 提供。保留:
|
||||||
|
* 状态条(当前文件 + 保存状态)→ 编辑器(自动保存,Ctrl+S)
|
||||||
|
* → 文件管理器(新建/双击重命名/右键菜单/删除)
|
||||||
|
* 常驻挂载(keep-alive),切换视图不丢草稿。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 精简时间格式:今天 HH:mm / 今年 MM-DD / 往年 YYYY-MM-DD */
|
||||||
|
function fmtTime(ms) {
|
||||||
|
if (!ms) return '';
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = new Date();
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
if (d.toDateString() === now.toDateString()) return hm;
|
||||||
|
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`;
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建/重命名弹窗 */
|
||||||
|
function NameDialog({ open, title, initial, confirmText, exists, busy, onClose, onConfirm }) {
|
||||||
|
const [value, setValue] = useState(initial);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) { setValue(initial); setErr(''); }
|
||||||
|
}, [open, initial]);
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
let name = value.trim();
|
||||||
|
if (!name) { setErr('请输入文件名'); return; }
|
||||||
|
if (!name.toLowerCase().endsWith('.txt')) name += '.txt';
|
||||||
|
if (exists.includes(name.toLowerCase())) { setErr('同名笔记已存在'); return; }
|
||||||
|
onConfirm(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
size="small"
|
||||||
|
label="文件名"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => { setValue(e.target.value); setErr(''); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||||||
|
error={!!err}
|
||||||
|
helperText={err || '仅支持 .txt 文本文件'}
|
||||||
|
inputProps={{ style: { fontSize: 14 } }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button variant="contained" disabled={busy} onClick={submit}>{confirmText}</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotesView() {
|
||||||
|
const notes = useNotes();
|
||||||
|
const {
|
||||||
|
notes: list, loadError, refresh,
|
||||||
|
activeName, content, dirty, saving, busy,
|
||||||
|
open, updateContent, saveNow, create, rename, remove, nextUntitled,
|
||||||
|
} = notes;
|
||||||
|
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
const [renameTarget, setRenameTarget] = useState(null);
|
||||||
|
const [delTarget, setDelTarget] = useState(null);
|
||||||
|
const [ctx, setCtx] = useState(null); // { name, anchorEl }
|
||||||
|
|
||||||
|
const handleCreate = (name) => { setNewOpen(false); create(name); };
|
||||||
|
const handleRename = (name) => { setRenameTarget(null); rename(ctx?.name || renameTarget, name); };
|
||||||
|
const handleDelete = async () => {
|
||||||
|
const name = delTarget;
|
||||||
|
setDelTarget(null);
|
||||||
|
await remove(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = saving ? '保存中…' : dirty ? '未保存' : '已保存';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section" aria-label="记事本" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 状态条 */}
|
||||||
|
<Box sx={{ px: 1.5, py: 0.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default', borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||||
|
<Typography variant="caption" noWrap sx={{ flex: 1, color: 'text.secondary' }} title={activeName || ''}>
|
||||||
|
{activeName || '未打开笔记'}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ flexShrink: 0, color: saving ? 'text.secondary' : dirty ? 'warning.main' : 'success.main', fontWeight: 500 }}>
|
||||||
|
{statusText}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 编辑器(上半) */}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', p: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
variant="outlined"
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => updateContent(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveNow(); }
|
||||||
|
}}
|
||||||
|
disabled={!activeName || busy}
|
||||||
|
placeholder={activeName ? '开始输入…(自动保存,Ctrl+S 立即保存)' : '从下方选择或新建一篇笔记'}
|
||||||
|
inputProps={{ 'aria-label': '笔记内容', style: { fontSize: 13, lineHeight: 1.6 } }}
|
||||||
|
sx={{
|
||||||
|
flex: 1, minHeight: 0,
|
||||||
|
'& .MuiInputBase-root': { height: '100%', alignItems: 'flex-start', borderRadius: 1 },
|
||||||
|
'& .MuiInputBase-inputMultiline': { height: '100% !important', overflow: 'auto !important', boxSizing: 'border-box' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 文件管理器(下半) */}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', borderTop: 1, borderColor: 'divider' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.25, py: 0.5, flexShrink: 0 }}>
|
||||||
|
<Typography sx={{ flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}>
|
||||||
|
笔记({list ? list.length : 0})
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="新建笔记">
|
||||||
|
<IconButton size="small" onClick={() => setNewOpen(true)} aria-label="新建笔记"><AddIcon fontSize="small" /></IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
|
{list === null && !loadError ? (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>
|
||||||
|
) : loadError ? (
|
||||||
|
<Box sx={{ px: 2, py: 2, textAlign: 'center' }}>
|
||||||
|
<ErrorIcon sx={{ fontSize: 24, color: 'error.main', mb: 0.5 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{loadError}</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={refresh}>重试</Button>
|
||||||
|
</Box>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
|
||||||
|
<NoteAltIcon sx={{ fontSize: 28, color: 'text.disabled', mb: 0.5 }} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>暂无笔记,点击上方 + 新建</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List dense disablePadding>
|
||||||
|
{list.map((n) => {
|
||||||
|
const active = n.name === activeName;
|
||||||
|
return (
|
||||||
|
<ListItemButton
|
||||||
|
key={n.name}
|
||||||
|
onClick={() => open(n.name)}
|
||||||
|
onDoubleClick={() => setRenameTarget(n.name)}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtx({ name: n.name, anchorEl: e.currentTarget }); }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// F2 重命名(标准文件管理器约定,避免与 Enter 打开冲突)
|
||||||
|
if (e.key === 'F2') { e.preventDefault(); setRenameTarget(n.name); }
|
||||||
|
}}
|
||||||
|
aria-label={`打开笔记 ${n.name}`}
|
||||||
|
sx={{
|
||||||
|
minHeight: 40, borderRadius: 0, m: 0, px: 0,
|
||||||
|
bgcolor: active ? 'primary.container' : 'transparent',
|
||||||
|
color: active ? 'primary.onContainer' : 'text.primary',
|
||||||
|
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
|
||||||
|
'&:hover .nb-del': { opacity: 1 },
|
||||||
|
'&:focus-visible .nb-del': { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NoteAltIcon fontSize="small" sx={{ mx: 1, flexShrink: 0, opacity: 0.7 }} />
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0, py: 0.5 }}>
|
||||||
|
<Typography noWrap sx={{ fontSize: 12.5, fontWeight: active ? 600 : 500 }}>{n.name}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
|
||||||
|
{fmtTime(n.mtime)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<IconButton
|
||||||
|
className="nb-del"
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDelTarget(n.name); }}
|
||||||
|
aria-label={`删除 ${n.name}`}
|
||||||
|
sx={{ opacity: 0, width: 28, height: 28, mr: 0.5, transition: 'opacity .12s' }}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 右键菜单 */}
|
||||||
|
<Menu
|
||||||
|
open={!!ctx}
|
||||||
|
anchorEl={ctx?.anchorEl || null}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<MenuItem dense onClick={() => { if (ctx) setRenameTarget(ctx.name); setCtx(null); }}>
|
||||||
|
<EditOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />重命名
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem dense onClick={() => { if (ctx) setDelTarget(ctx.name); setCtx(null); }}>
|
||||||
|
<DeleteOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />删除
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<NameDialog
|
||||||
|
open={newOpen}
|
||||||
|
title="新建笔记"
|
||||||
|
initial={nextUntitled}
|
||||||
|
confirmText="创建"
|
||||||
|
exists={(list || []).map((n) => n.name.toLowerCase())}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setNewOpen(false)}
|
||||||
|
onConfirm={handleCreate}
|
||||||
|
/>
|
||||||
|
<NameDialog
|
||||||
|
open={!!renameTarget}
|
||||||
|
title="重命名笔记"
|
||||||
|
initial={renameTarget || ''}
|
||||||
|
confirmText="保存"
|
||||||
|
exists={(list || []).map((n) => n.name.toLowerCase()).filter((n) => n !== renameTarget?.toLowerCase())}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setRenameTarget(null)}
|
||||||
|
onConfirm={handleRename}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!delTarget}
|
||||||
|
title="删除笔记"
|
||||||
|
message={`确定要删除 "${delTarget || ''}" 吗?此操作不可恢复。`}
|
||||||
|
onClose={() => setDelTarget(null)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
confirmText="删除"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: 'background.paper', 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: 'transparent' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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,79 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 面板图标: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;
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 首字母 + 确定性颜色
|
||||||
|
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: size / 4, flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
bgcolor: bg, color: fg,
|
||||||
|
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</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,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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,272 @@
|
|||||||
|
import React, { Fragment, useMemo, 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';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* PanelsView:面板列表视图(VSCode 侧边栏「面板」内容)
|
||||||
|
*
|
||||||
|
* - 固定区(pin)+ admin_links 按 category 分组
|
||||||
|
* - 当前项:左侧 2px primary 指示条 + primary-container 选中底
|
||||||
|
* - 分组折叠(持久化)、右键切换打开方式、底部 + 添加
|
||||||
|
* - 搜索时展示扁平结果列表
|
||||||
|
* - 宽度/折叠由外部 react-resizable-panels 布局管理,本组件不再自持
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
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 PanelsView({
|
||||||
|
loading = false, error = '', onRetry,
|
||||||
|
links = [], filtered = [], search = '',
|
||||||
|
activeId = null, pinned = [], onSelect, onPin,
|
||||||
|
groups = {}, onToggleGroup,
|
||||||
|
modes = {}, onSetMode,
|
||||||
|
onAdd,
|
||||||
|
}) {
|
||||||
|
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(String(p.id)));
|
||||||
|
|
||||||
|
const renderItem = (p) => {
|
||||||
|
const selected = String(p.id) === String(activeId);
|
||||||
|
const isPinned = pinned.includes(String(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 (
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 右键菜单:切换打开方式 */}
|
||||||
|
<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>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* SidebarPane:VSCode 风格侧边栏容器(一侧一个)
|
||||||
|
*
|
||||||
|
* - 该侧全部 sidebar 模式视图**常驻挂载**(display 显隐切换),
|
||||||
|
* 切换视图/切标签页不丢编辑状态(记事本草稿、密码箱解锁态)
|
||||||
|
* - 每个视图由 ToolWindow 提供标题栏;内容按 activeView 显示
|
||||||
|
* - 视图处于 tab 模式时该视图不在侧栏 → 显示占位 + 「移回侧边栏」
|
||||||
|
* - 无障碍:容器 id=wb-sidebar-{side} 供活动栏 aria-controls;
|
||||||
|
* aria-live 播报当前视图,配合活动栏焦点提示
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function SidebarPane({ side, views, activeId, renderView, renderPlaceholder }) {
|
||||||
|
const right = side === 'right';
|
||||||
|
const activeView = views.find((v) => v.id === activeId);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="aside"
|
||||||
|
id={`wb-sidebar-${side}`}
|
||||||
|
aria-label={right ? '右侧侧边栏' : '左侧侧边栏'}
|
||||||
|
sx={{
|
||||||
|
height: '100%', minWidth: 0, overflow: 'hidden', position: 'relative',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 活动栏激活后的屏幕阅读器播报(视觉隐藏) */}
|
||||||
|
<Box
|
||||||
|
aria-live="polite"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', width: 1, height: 1, overflow: 'hidden',
|
||||||
|
clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', m: -1, p: 0, border: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{activeView ? `当前视图:${activeView.label}` : '侧边栏为空'}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{views.map((v) => {
|
||||||
|
const active = activeId === v.id;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={v.id}
|
||||||
|
sx={{
|
||||||
|
flex: 1, minHeight: 0, display: active ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderView(v.id)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{views.length === 0 && (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 3, textAlign: 'center' }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
|
||||||
|
从另一侧活动栏拖一个功能标签到这里
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{views.length > 0 && !views.some((v) => v.id === activeId) && renderPlaceholder && (
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3, textAlign: 'center' }}>
|
||||||
|
{renderPlaceholder()}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* StatusBar:底部状态栏(可选,VSCode 风格)
|
||||||
|
* 左:当前上下文;右:主机:端口 + 版本
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function StatusBar({ left, right = null }) {
|
||||||
|
const host = typeof window !== 'undefined' ? window.location.host : '';
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="status"
|
||||||
|
sx={{
|
||||||
|
height: 24, flexShrink: 0, display: 'flex', alignItems: 'center',
|
||||||
|
px: 1.5, gap: 1.5, bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||||
|
fontSize: 11, borderTop: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-hidden
|
||||||
|
sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: 'success.main', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography noWrap sx={{ fontSize: 11, fontWeight: 500, flex: 1, minWidth: 0 }}>{left}</Typography>
|
||||||
|
<Typography noWrap sx={{ fontSize: 11, opacity: 0.85 }}>{right || host}</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* TabStrip:主区标签页条(VSCode 风格,浏览器标签交互)
|
||||||
|
*
|
||||||
|
* - tabs:{ key, title, icon?, closable? };iframe 面板与
|
||||||
|
* tab 模式视图共用一套标签(由 Workbench 拼装)
|
||||||
|
* - 键盘:← → 切换、Home/End 首尾(roving tabindex)
|
||||||
|
* - ARIA:每个 tab 与 ContentView 的 tabpanel 通过 id 配对
|
||||||
|
* (tabBtnId / tabPanelId,冒号等字符已做无害化)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** tab 按钮元素 id:tab.key 可能含 ':',替换为下划线保证合法 */
|
||||||
|
export function tabBtnId(key) {
|
||||||
|
return 'wb-tab-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 对应 tabpanel 元素 id */
|
||||||
|
export function tabPanelId(key) {
|
||||||
|
return 'wb-panel-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TabStrip({ tabs, activeKey, onSelect, onClose, onAdd }) {
|
||||||
|
const stripRef = useRef(null);
|
||||||
|
|
||||||
|
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
|
||||||
|
const moveTo = (i) => {
|
||||||
|
const n = tabs.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const j = ((i % n) + n) % n;
|
||||||
|
onSelect(tabs[j].key);
|
||||||
|
tabEls()[j]?.focus();
|
||||||
|
};
|
||||||
|
const handleKeyDown = (e, index) => {
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(index + 1); }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(index - 1); }
|
||||||
|
else if (e.key === 'Home') { e.preventDefault(); moveTo(0); }
|
||||||
|
else if (e.key === 'End') { e.preventDefault(); moveTo(tabs.length - 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={stripRef}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="已打开的面板与视图"
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.5,
|
||||||
|
height: 40, flexShrink: 0, bgcolor: 'background.paper',
|
||||||
|
borderBottom: 1, borderColor: 'divider',
|
||||||
|
overflowX: 'auto', overflowY: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tabs.map((tab, index) => {
|
||||||
|
const active = tab.key === activeKey;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={tab.key}
|
||||||
|
id={tabBtnId(tab.key)}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
aria-label={tab.title}
|
||||||
|
aria-controls={tabPanelId(tab.key)}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
onClick={() => onSelect(tab.key)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
|
||||||
|
height: 28, borderRadius: 1, cursor: 'pointer', flexShrink: 0, outline: 'none',
|
||||||
|
bgcolor: active ? 'primary.container' : 'transparent',
|
||||||
|
color: active ? 'primary.onContainer' : 'text.secondary',
|
||||||
|
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
|
||||||
|
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<Typography
|
||||||
|
noWrap
|
||||||
|
sx={{
|
||||||
|
fontSize: 12, fontWeight: active ? 600 : 500, maxWidth: 140,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.title}
|
||||||
|
</Typography>
|
||||||
|
{tab.closable && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClose(tab.key); }}
|
||||||
|
aria-label={`关闭 ${tab.title}`}
|
||||||
|
sx={{ width: 20, height: 20, borderRadius: 0.5, '& svg': { fontSize: 14 } }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{onAdd && (
|
||||||
|
<Tooltip title="添加面板">
|
||||||
|
<IconButton size="small" onClick={onAdd} sx={{ ml: 0.25, flexShrink: 0 }} aria-label="添加面板">
|
||||||
|
<AddIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||||
|
import TabIcon from '@mui/icons-material/Tab';
|
||||||
|
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* ToolWindow:通用可停靠面板(侧边栏 / 标签页双模式 + 浮动占位)
|
||||||
|
*
|
||||||
|
* - 标题栏:图标 + 标题 + 模式切换(侧边栏/标签页)+ 浮动占位按钮
|
||||||
|
* (P2 未实现,点击只触发 onFloat 提示)+ 关闭按钮 + 自定义 actions
|
||||||
|
* - 纯展示组件,无内部状态;模式/关闭行为由父级注入
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, closeTitle = '关闭', actions = null, children }) {
|
||||||
|
const modeBtn = (m, label, MIcon) => (
|
||||||
|
<Tooltip key={m} title={label}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => onSetMode?.(m)}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={mode === m}
|
||||||
|
sx={{
|
||||||
|
width: 26, height: 26, borderRadius: 1,
|
||||||
|
color: mode === m ? 'primary.main' : 'text.secondary',
|
||||||
|
bgcolor: mode === m ? 'action.selected' : 'transparent',
|
||||||
|
'&:hover': { bgcolor: mode === m ? 'action.selected' : 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.25,
|
||||||
|
px: 1.25, height: 44, flexShrink: 0,
|
||||||
|
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Typography noWrap sx={{ flex: 1, minWidth: 0, ml: 0.25, fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
{modeBtn(VIEW_MODE_SIDEBAR, '侧边栏模式', ViewSidebarIcon)}
|
||||||
|
{modeBtn(VIEW_MODE_TAB, '标签页模式', TabIcon)}
|
||||||
|
{onFloat && (
|
||||||
|
<Tooltip title="浮动模式(即将上线)">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onFloat}
|
||||||
|
aria-label="浮动模式"
|
||||||
|
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
<OpenInFullIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{actions}
|
||||||
|
{onClose && (
|
||||||
|
<Tooltip title={closeTitle}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={closeTitle}
|
||||||
|
sx={{ width: 26, height: 26, borderRadius: 1 }}
|
||||||
|
>
|
||||||
|
<CloseIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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 { useTheme } from '@mui/material/styles';
|
||||||
|
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 OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import MenuBar from './MenuBar.jsx';
|
||||||
|
import PanelIcon from './PanelIcon.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Toolbar:VSCode 风格顶栏(高度 56px)
|
||||||
|
*
|
||||||
|
* Rain Work(渐变字标) | 菜单栏 | ← → ↻ | 地址栏(自适应填满) | 搜索 | 返回
|
||||||
|
* - 地址栏仅展示当前面板 URL(嵌入地址优先),只读、可点开新标签;
|
||||||
|
* flex:1 填满顶栏剩余空间,左侧内容变宽时自然缩减
|
||||||
|
* - 搜索框:面板名/URL/分组,Enter 直达(逻辑由 Workbench 注入)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function Toolbar({
|
||||||
|
menus = [],
|
||||||
|
canBack = false, canForward = false, onBack, onForward, onRefresh,
|
||||||
|
currentPanel = null, onOpenExternal,
|
||||||
|
search = '', onSearchChange, onSearchEnter,
|
||||||
|
searchInputRef = null, onBackHome,
|
||||||
|
}) {
|
||||||
|
const muiTheme = useTheme();
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const inputRef = searchInputRef || searchRef;
|
||||||
|
|
||||||
|
const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : '';
|
||||||
|
const logoGradient = `linear-gradient(135deg, ${muiTheme.palette.primary.main} 0%, ${muiTheme.palette.tertiary.main} 100%)`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
height: 56, flexShrink: 0, display: 'flex', alignItems: 'center',
|
||||||
|
gap: 0.5, px: 1, zIndex: 10,
|
||||||
|
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 品牌:纯文字 Logo,MD3 渐变字重 */}
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
aria-label="Rain Work"
|
||||||
|
sx={{
|
||||||
|
fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
|
||||||
|
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
|
||||||
|
WebkitTextFillColor: 'transparent', color: 'transparent',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rain Work
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<MenuBar menus={menus} />
|
||||||
|
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||||
|
|
||||||
|
{/* 导航:后退 / 前进 / 刷新 */}
|
||||||
|
<Tooltip title="后退">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* 地址栏(只读,flex:1 自适应填满顶栏剩余空间;无 maxWidth 上限,
|
||||||
|
左侧菜单/导航变宽时自然缩减,顶栏始终占满) */}
|
||||||
|
<Box
|
||||||
|
role="textbox"
|
||||||
|
aria-label="当前面板地址"
|
||||||
|
sx={{
|
||||||
|
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' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={16} />}
|
||||||
|
<InputBase
|
||||||
|
readOnly
|
||||||
|
fullWidth
|
||||||
|
value={currentUrl}
|
||||||
|
placeholder="未打开面板"
|
||||||
|
inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 12.5 } }}
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
/>
|
||||||
|
{currentPanel && (
|
||||||
|
<Tooltip title="在新标签页打开原站">
|
||||||
|
<IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站">
|
||||||
|
<OpenInNewIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 搜索 */}
|
||||||
|
<Box sx={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<SearchIcon
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
fontSize: 16, color: 'text.secondary', pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputBase
|
||||||
|
ref={inputRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter?.(); inputRef.current?.blur(); } }}
|
||||||
|
placeholder="搜索面板"
|
||||||
|
inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 12.5 } }}
|
||||||
|
sx={{
|
||||||
|
height: 34, width: { xs: 100, sm: 150 }, px: 0.5,
|
||||||
|
bgcolor: 'background.default', border: 1, borderColor: 'divider',
|
||||||
|
borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 返回前台 */}
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={onBackHome}
|
||||||
|
startIcon={<HomeIcon sx={{ fontSize: 17 }} />}
|
||||||
|
sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.25 }, flexShrink: 0, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }}
|
||||||
|
>
|
||||||
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, fontSize: 13 }}>返回</Box>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
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 CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
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';
|
||||||
|
import { showSnack } from '../../../admin/snack.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* VaultView:密码箱视图(VSCode 侧边栏 / 标签页内容)
|
||||||
|
*
|
||||||
|
* 由 VaultDrawer 裁剪:去掉 MUI Drawer 壳与自带标题栏/关闭按钮
|
||||||
|
* (由父级 ToolWindow 提供),锁定按钮并入解锁后的搜索行。
|
||||||
|
* 标题栏之外全部逻辑保留:
|
||||||
|
* loading → noPin(引导设置)| locked(PIN 输入)→ unlocked(搜索+条目)
|
||||||
|
* 「锁定」走 lock API;切换视图不锁定,方便取用。
|
||||||
|
* @param {{open: boolean}} open 视图是否处于激活(激活时重新同步服务端状态)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 剪贴板写入:优先 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 }) {
|
||||||
|
// 防注入:仅放行 http(s)/mailto,其余协议降级为纯文本
|
||||||
|
const safeHref = href && /^(https?|mailto):/i.test(href) ? href : null;
|
||||||
|
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>
|
||||||
|
{safeHref ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="a"
|
||||||
|
href={safeHref}
|
||||||
|
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 视图是否激活(激活时重新同步服务端会话状态)
|
||||||
|
*/
|
||||||
|
export default function VaultView({ open = false }) {
|
||||||
|
const vault = useVault({ open });
|
||||||
|
|
||||||
|
// PIN 输入屏的本地状态
|
||||||
|
const [pinInput, setPinInput] = useState('');
|
||||||
|
const [pinError, setPinError] = useState('');
|
||||||
|
const [showPin, setShowPin] = useState(false);
|
||||||
|
// 展开中的条目 id(重新打开时清空)
|
||||||
|
const [expandedId, setExpandedId] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPinInput('');
|
||||||
|
setPinError('');
|
||||||
|
setShowPin(false);
|
||||||
|
setExpandedId(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submitUnlock = useCallback(async () => {
|
||||||
|
const pin = pinInput.trim();
|
||||||
|
if (!pin) { setPinError('请输入 PIN 码'); return; }
|
||||||
|
setPinError('');
|
||||||
|
try {
|
||||||
|
await vault.unlock(pin);
|
||||||
|
setPinInput('');
|
||||||
|
setShowPin(false);
|
||||||
|
showSnack('已解锁');
|
||||||
|
} catch (e) {
|
||||||
|
setPinError(e.message || '解锁失败');
|
||||||
|
setPinInput('');
|
||||||
|
}
|
||||||
|
}, [pinInput, vault]);
|
||||||
|
|
||||||
|
const handleLock = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await vault.lock();
|
||||||
|
setExpandedId(null);
|
||||||
|
showSnack('已锁定');
|
||||||
|
} catch (e) {
|
||||||
|
showSnack(e.message || '锁定失败', 'error');
|
||||||
|
}
|
||||||
|
}, [vault]);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(async (text, label) => {
|
||||||
|
const ok = await copyText(text);
|
||||||
|
if (ok) showSnack(`${label} 已复制`);
|
||||||
|
else showSnack('复制失败', 'error');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* ---------- 各状态下的主体 ---------- */
|
||||||
|
|
||||||
|
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>
|
||||||
|
{/* 搜索 + 锁定(标题栏由 ToolWindow 提供,锁定并入此行) */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
|
||||||
|
<TextField
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Tooltip title="锁定密码库">
|
||||||
|
<span>
|
||||||
|
<IconButton aria-label="锁定密码库" onClick={handleLock} disabled={vault.locking} size="small">
|
||||||
|
<LockIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<Box component="section" aria-label="密码箱" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', px: 1.5, py: 1.5 }}>
|
||||||
|
{renderBody()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import * as notesApi from '../../../api/notes.js';
|
||||||
|
import { showSnack } from '../../../admin/snack.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* useNotes:工作台「记事本」状态管理
|
||||||
|
*
|
||||||
|
* - 列表加载/错误态、当前编辑文件(activeName)、草稿内容 + 服务端快照
|
||||||
|
* - 自动保存:输入防抖 900ms;Ctrl+S 立即保存(dirty = 草稿 ≠ 快照)
|
||||||
|
* - 新建 / 重命名(新文件 + 删旧文件)/ 删除,全程复用 notes API
|
||||||
|
* - 会话保持(sidebar↔tab 模式切换/切视图会重挂载组件):
|
||||||
|
* · 草稿存模块级 sessionCache,重挂载瞬间恢复,光标上下文不丢
|
||||||
|
* · activeName 持久化到 localStorage(workbench.notesActive),跨刷新恢复上次文件
|
||||||
|
* · 监听 window 'workbench:notes-save-request'(Workbench 切换模式前派发),
|
||||||
|
* 立即 flush 未落盘的 <900ms 草稿
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const AUTOSAVE_MS = 900;
|
||||||
|
const NOTES_ACTIVE_KEY = 'workbench.notesActive';
|
||||||
|
|
||||||
|
/** 模块级草稿缓存:组件重挂载后直接恢复,不依赖服务端往返 */
|
||||||
|
const sessionCache = { name: null, text: '' };
|
||||||
|
|
||||||
|
export default function useNotes() {
|
||||||
|
const [notes, setNotes] = useState(null); // null = 加载中
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [activeName, setActiveName] = useState(() => {
|
||||||
|
try { return localStorage.getItem(NOTES_ACTIVE_KEY) || null; } catch { return null; }
|
||||||
|
});
|
||||||
|
// 草稿与快照初始化取自缓存(若缓存有同名文件),避免模式切换丢内容
|
||||||
|
const [content, setContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
|
||||||
|
const [savedContent, setSavedContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
|
||||||
|
const [saving, setSaving] = useState(false); // 保存请求进行中
|
||||||
|
const [busy, setBusy] = useState(false); // 打开/新建/重命名进行中
|
||||||
|
|
||||||
|
const draftRef = useRef({ name: null, text: '' }); // 最新草稿(供防抖回调读取)
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
const restoredRef = useRef(false); // 恢复会话只执行一次
|
||||||
|
|
||||||
|
// ---- 列表加载(静默刷新:保留现有列表,避免自动保存时闪烁) ----
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setLoadError('');
|
||||||
|
return notesApi.listNotes()
|
||||||
|
.then((ls) => {
|
||||||
|
const arr = Array.isArray(ls) ? ls : [];
|
||||||
|
setNotes(arr);
|
||||||
|
setActiveName((prev) => (prev && arr.some((n) => n.name === prev) ? prev : null));
|
||||||
|
})
|
||||||
|
.catch((e) => { setLoadError(e.message || '加载失败'); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
||||||
|
if (restoredRef.current) return;
|
||||||
|
if (activeName === null) { restoredRef.current = true; return; }
|
||||||
|
if (sessionCache.name === activeName) {
|
||||||
|
restoredRef.current = true;
|
||||||
|
saveContent(activeName, sessionCache.text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoredRef.current = true;
|
||||||
|
open(activeName);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- activeName 持久化(模式切换/刷新后恢复上次文件) ----
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (activeName) localStorage.setItem(NOTES_ACTIVE_KEY, activeName);
|
||||||
|
else localStorage.removeItem(NOTES_ACTIVE_KEY);
|
||||||
|
} catch { /* 隐私模式忽略 */ }
|
||||||
|
}, [activeName]);
|
||||||
|
|
||||||
|
// ---- 模式切换前的显式保存:Workbench 派发 workbench:notes-save-request ----
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
const d = draftRef.current;
|
||||||
|
if (d.name) saveContent(d.name, d.text);
|
||||||
|
};
|
||||||
|
window.addEventListener('workbench:notes-save-request', handler);
|
||||||
|
return () => window.removeEventListener('workbench:notes-save-request', handler);
|
||||||
|
}, [saveContent]);
|
||||||
|
|
||||||
|
// 当前文件被外部删除/失效时清空编辑器
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeName === null) {
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
draftRef.current = { name: null, text: '' };
|
||||||
|
}
|
||||||
|
}, [activeName]);
|
||||||
|
|
||||||
|
// ---- 打开文件 ----
|
||||||
|
const open = useCallback((name) => {
|
||||||
|
setBusy(true);
|
||||||
|
return notesApi.getNote(name)
|
||||||
|
.then((res) => {
|
||||||
|
const text = res.content || '';
|
||||||
|
setActiveName(name);
|
||||||
|
setContent(text);
|
||||||
|
setSavedContent(text);
|
||||||
|
draftRef.current = { name, text };
|
||||||
|
sessionCache.name = name;
|
||||||
|
sessionCache.text = text;
|
||||||
|
})
|
||||||
|
.catch((e) => showSnack(e.message, 'error'))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 编辑(防抖自动保存 + 实时写缓存) ----
|
||||||
|
const updateContent = useCallback((text) => {
|
||||||
|
setContent(text);
|
||||||
|
draftRef.current = { name: activeName, text };
|
||||||
|
sessionCache.name = activeName;
|
||||||
|
sessionCache.text = text;
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
const d = draftRef.current;
|
||||||
|
if (d.name) saveContent(d.name, d.text);
|
||||||
|
}, AUTOSAVE_MS);
|
||||||
|
}, [activeName, saveContent]);
|
||||||
|
|
||||||
|
// ---- 立即保存(Ctrl+S / 切换文件前 / save-request) ----
|
||||||
|
const saveNow = useCallback(() => {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
const d = draftRef.current;
|
||||||
|
if (d.name) return saveContent(d.name, d.text);
|
||||||
|
return Promise.resolve();
|
||||||
|
}, [saveContent]);
|
||||||
|
|
||||||
|
// ---- 新建 ----
|
||||||
|
const create = useCallback((name) => {
|
||||||
|
setBusy(true);
|
||||||
|
return notesApi.createNote(name, '')
|
||||||
|
.then((res) => {
|
||||||
|
setActiveName(res.name);
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
draftRef.current = { name: res.name, text: '' };
|
||||||
|
sessionCache.name = res.name;
|
||||||
|
sessionCache.text = '';
|
||||||
|
return refresh();
|
||||||
|
})
|
||||||
|
.catch((e) => showSnack(e.message, 'error'))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
// ---- 重命名(新文件写入旧内容 + 删旧文件) ----
|
||||||
|
const rename = useCallback(async (oldName, newName) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
// 若正在编辑该文件,用草稿内容;否则先读服务端内容
|
||||||
|
let text = draftRef.current.text;
|
||||||
|
if (activeName !== oldName) {
|
||||||
|
const res = await notesApi.getNote(oldName);
|
||||||
|
text = res.content || '';
|
||||||
|
}
|
||||||
|
await notesApi.createNote(newName, text);
|
||||||
|
await notesApi.deleteNote(oldName);
|
||||||
|
if (activeName === oldName) {
|
||||||
|
setActiveName(newName);
|
||||||
|
setSavedContent(text);
|
||||||
|
draftRef.current = { name: newName, text };
|
||||||
|
sessionCache.name = newName;
|
||||||
|
sessionCache.text = text;
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
showSnack('重命名成功');
|
||||||
|
} catch (e) {
|
||||||
|
showSnack(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [activeName, refresh]);
|
||||||
|
|
||||||
|
// ---- 删除 ----
|
||||||
|
const remove = useCallback(async (name) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await notesApi.deleteNote(name);
|
||||||
|
if (activeName === name) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
setActiveName(null);
|
||||||
|
sessionCache.name = null;
|
||||||
|
sessionCache.text = '';
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
showSnack(e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [activeName, refresh]);
|
||||||
|
|
||||||
|
// ---- 新建默认名:untitled-N.txt(避开已有名) ----
|
||||||
|
const nextUntitled = useMemo(() => {
|
||||||
|
const names = new Set((notes || []).map((n) => n.name.toLowerCase()));
|
||||||
|
let i = 1;
|
||||||
|
while (names.has(`untitled-${i}.txt`)) i += 1;
|
||||||
|
return `untitled-${i}.txt`;
|
||||||
|
}, [notes]);
|
||||||
|
|
||||||
|
const dirty = activeName !== null && content !== savedContent;
|
||||||
|
|
||||||
|
return {
|
||||||
|
notes, loadError, refresh,
|
||||||
|
activeName, content, dirty, saving, busy,
|
||||||
|
open, updateContent, saveNow, create, rename, remove, nextUntitled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { listAdminLinks } from '../../../api/adminLinks.js';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 工作台面板状态(Lane A 核心 hook)
|
||||||
|
*
|
||||||
|
* 管理:面板列表(admin_links)、打开集合、当前面板、历史栈(◀▶)、
|
||||||
|
* 固定(pin)、分组折叠、打开方式、侧边栏折叠、搜索。
|
||||||
|
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStorage(key: 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 } 历史栈
|
||||||
|
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('');
|
||||||
|
// 初始化即清洗(治愈已污染的 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);
|
||||||
|
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(() => {
|
||||||
|
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('');
|
||||||
|
|
||||||
|
// 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.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)));
|
||||||
|
// 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.map(String).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) => {
|
||||||
|
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;
|
||||||
|
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 (activeId === 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) => {
|
||||||
|
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
|
||||||
|
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) => {
|
||||||
|
id = String(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, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||||
|
import NoteAltIcon from '@mui/icons-material/NoteAlt';
|
||||||
|
import KeyIcon from '@mui/icons-material/Key';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* useWorkbenchLayout:VSCode 风格工作台布局状态(Lane A 新增)
|
||||||
|
*
|
||||||
|
* - 视图注册表 VIEWS:左侧主控(面板)+ 右侧功能区(记事本/密码箱)
|
||||||
|
* - order:{ left: [viewId], right: [viewId] } —— 视图在左右活动栏的归属
|
||||||
|
* (dnd-kit 拖拽换边后持久化,key: workbench.viewOrder)
|
||||||
|
* - active:{ left: viewId, right: viewId } —— 每侧当前显示的视图
|
||||||
|
* - mode:{ [viewId]: 'sidebar' | 'tab' | 'float' } —— 展示模式
|
||||||
|
* (sidebar 默认;tab 进主区标签页;float 为 P2 占位,本期不实现)
|
||||||
|
* - terminalOpen:底部终端面板是否展开
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/** 视图注册表:id → 元信息(新增视图只需在此登记 + Workbench 提供渲染) */
|
||||||
|
export const VIEWS = {
|
||||||
|
panels: { id: 'panels', label: '面板', Icon: WidgetsIcon, hint: '面板工作台' },
|
||||||
|
notes: { id: 'notes', label: '记事本', Icon: NoteAltIcon, hint: '笔记与文件' },
|
||||||
|
vault: { id: 'vault', label: '密码箱', Icon: KeyIcon, hint: '密码管理' },
|
||||||
|
};
|
||||||
|
export const VIEW_IDS = Object.keys(VIEWS);
|
||||||
|
|
||||||
|
export const VIEW_MODE_SIDEBAR = 'sidebar';
|
||||||
|
export const VIEW_MODE_TAB = 'tab';
|
||||||
|
export const VIEW_MODE_FLOAT = 'float'; // P2:本期仅占位
|
||||||
|
|
||||||
|
const STORE = {
|
||||||
|
order: 'workbench.viewOrder', // { left: [], right: [] }
|
||||||
|
active: 'workbench.viewActive', // { left: id, right: id }
|
||||||
|
mode: 'workbench.viewMode', // { id: 'sidebar'|'tab' }
|
||||||
|
terminal: 'workbench.terminalOpen',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_ORDER = { left: ['panels'], right: ['notes', 'vault'] };
|
||||||
|
const DEFAULT_ACTIVE = { left: 'panels', right: 'notes' };
|
||||||
|
const DEFAULT_MODE = { panels: VIEW_MODE_SIDEBAR, notes: VIEW_MODE_SIDEBAR, vault: VIEW_MODE_SIDEBAR };
|
||||||
|
|
||||||
|
function read(key, fallback) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw === null ? fallback : JSON.parse(raw);
|
||||||
|
} catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key, value) {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
|
||||||
|
function sanitizeOrder(saved) {
|
||||||
|
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
|
||||||
|
const seen = new Set();
|
||||||
|
const clean = (arr) => {
|
||||||
|
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 };
|
||||||
|
if (saved && typeof saved === 'object') {
|
||||||
|
VIEW_IDS.forEach((id) => {
|
||||||
|
if (saved[id] === VIEW_MODE_SIDEBAR || saved[id] === VIEW_MODE_TAB) out[id] = saved[id];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useWorkbenchLayout() {
|
||||||
|
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
|
||||||
|
const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
|
||||||
|
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
|
||||||
|
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
|
||||||
|
|
||||||
|
useEffect(() => write(STORE.order, order), [order]);
|
||||||
|
useEffect(() => write(STORE.active, active), [active]);
|
||||||
|
useEffect(() => write(STORE.mode, mode), [mode]);
|
||||||
|
useEffect(() => write(STORE.terminal, terminalOpen), [terminalOpen]);
|
||||||
|
|
||||||
|
/** 视图 id → 所在侧 */
|
||||||
|
const sideOf = useMemo(() => {
|
||||||
|
const m = {};
|
||||||
|
order.left.forEach((id) => { m[id] = 'left'; });
|
||||||
|
order.right.forEach((id) => { m[id] = 'right'; });
|
||||||
|
return m;
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
const viewsOf = useCallback((side) => [...order[side]], [order]);
|
||||||
|
|
||||||
|
/** 每侧有效激活视图(active 指向已移走的视图时回退到第一个) */
|
||||||
|
const activeViewOf = useCallback(
|
||||||
|
(side) => (order[side].includes(active[side]) ? active[side] : order[side][0] || null),
|
||||||
|
[order, active]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 激活某视图(无论在哪侧) */
|
||||||
|
const activateView = useCallback((id) => {
|
||||||
|
const side = sideOf[id];
|
||||||
|
if (!side) return;
|
||||||
|
setActive((prev) => ({ ...prev, [side]: id }));
|
||||||
|
}, [sideOf]);
|
||||||
|
|
||||||
|
const setViewMode = useCallback((id, m) => {
|
||||||
|
if (!VIEW_IDS.includes(id)) return;
|
||||||
|
setMode((prev) => ({ ...prev, [id]: m }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 拖拽换边:视图从一侧移到另一侧,并激活目标侧 */
|
||||||
|
const moveView = useCallback((id, toSide) => {
|
||||||
|
if (toSide !== 'left' && toSide !== 'right') return;
|
||||||
|
setOrder((prev) => {
|
||||||
|
const fromSide = (['left', 'right']).find((s) => prev[s].includes(id));
|
||||||
|
if (!fromSide || fromSide === toSide) return prev;
|
||||||
|
const next = {
|
||||||
|
left: [...prev.left.filter((x) => x !== id)],
|
||||||
|
right: [...prev.right.filter((x) => x !== id)],
|
||||||
|
};
|
||||||
|
next[toSide].push(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setActive((prev) => ({ ...prev, [toSide]: id }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 同侧排序(dnd-kit arrayMove 语义) */
|
||||||
|
const reorderView = useCallback((id, overId) => {
|
||||||
|
setOrder((prev) => {
|
||||||
|
const side = (['left', 'right']).find((s) => prev[s].includes(id));
|
||||||
|
const overSide = (['left', 'right']).find((s) => prev[s].includes(overId));
|
||||||
|
if (!side || side !== overSide) return prev;
|
||||||
|
const list = prev[side];
|
||||||
|
const from = list.indexOf(id);
|
||||||
|
const to = list.indexOf(overId);
|
||||||
|
if (from < 0 || to < 0 || from === to) return prev;
|
||||||
|
const next = [...list];
|
||||||
|
next.splice(from, 1);
|
||||||
|
next.splice(to, 0, id);
|
||||||
|
return { ...prev, [side]: next };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTerminal = useCallback(() => setTerminalOpen((v) => !v), []);
|
||||||
|
const openTerminal = useCallback(() => setTerminalOpen(true), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
order, sideOf, viewsOf,
|
||||||
|
active, activeViewOf, activateView,
|
||||||
|
mode, setViewMode,
|
||||||
|
moveView, reorderView,
|
||||||
|
terminalOpen, toggleTerminal, openTerminal,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 工作台模块导出(frontend/src/tools/workbench)
|
||||||
|
* 便于未来升级 / 替换 / 独立打包
|
||||||
|
*
|
||||||
|
* 组件命名与文件名的兼容说明:
|
||||||
|
* - Sidebar.jsx 导出 PanelsView(面板列表视图,VSCode 侧栏内容)
|
||||||
|
* - NoteEditor.jsx 导出 NotesView(记事本视图)
|
||||||
|
* - VaultDrawer.jsx 导出 VaultView(密码箱视图)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export { default as Workbench } from './Workbench.jsx';
|
||||||
|
export { default as Toolbar } from './components/Toolbar.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 PanelsView } from './components/Sidebar.jsx';
|
||||||
|
export { default as NotesView } from './components/NoteEditor.jsx';
|
||||||
|
export { default as VaultView } from './components/VaultDrawer.jsx';
|
||||||
|
export { default as ActivityBar } from './components/ActivityBar.jsx';
|
||||||
|
export { default as SidebarPane } from './components/SidebarPane.jsx';
|
||||||
|
export { default as ToolWindow } from './components/ToolWindow.jsx';
|
||||||
|
export { default as TabStrip } from './components/TabStrip.jsx';
|
||||||
|
export { default as ContentView } from './components/ContentView.jsx';
|
||||||
|
// 终端面板临时下线:组件归档到 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';
|
||||||
|
export { default as useWorkbenchLayout } from './hooks/useWorkbenchLayout.js';
|
||||||
|
export { VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB, VIEW_MODE_FLOAT } from './hooks/useWorkbenchLayout.js';
|
||||||
|
export {
|
||||||
|
default as usePanels,
|
||||||
|
OPEN_MODE_EMBED,
|
||||||
|
OPEN_MODE_TAB,
|
||||||
|
OPEN_MODE_MODAL,
|
||||||
|
} from './hooks/usePanels.js';
|
||||||
Generated
+143
-8
@@ -1,17 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.3.1",
|
"@mui/icons-material": "^9.3.1",
|
||||||
"@mui/material": "^9.3.1",
|
"@mui/material": "^9.3.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -22,10 +27,13 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2"
|
"react-resizable-panels": "^4.12.2",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
@@ -371,6 +379,59 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emotion/babel-plugin": {
|
"node_modules/@emotion/babel-plugin": {
|
||||||
"version": "11.13.5",
|
"version": "11.13.5",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz",
|
||||||
@@ -506,7 +567,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/utils": {
|
"node_modules/@emotion/utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
|
||||||
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -1149,6 +1210,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/addon-unicode11": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -1320,7 +1402,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
"engines": [
|
"engines": [
|
||||||
@@ -1480,7 +1562,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -1557,7 +1639,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -2557,7 +2639,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
@@ -2618,6 +2700,22 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-pty": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-pty/node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||||
@@ -2885,6 +2983,16 @@
|
|||||||
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
"integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-resizable-panels": {
|
||||||
|
"version": "4.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.12.2.tgz",
|
||||||
|
"integrity": "sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.18.2",
|
"version": "7.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
|
||||||
@@ -3359,6 +3467,12 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
@@ -3542,6 +3656,27 @@
|
|||||||
"node": "^22.14.0 || >=24.0.0"
|
"node": "^22.14.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||||
|
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
+10
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.0.3",
|
"version": "2.2.0",
|
||||||
"description": "链接聚合管理平台",
|
"description": "链接聚合管理平台",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,10 +11,15 @@
|
|||||||
"cli": "node cli.js"
|
"cli": "node cli.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@mui/icons-material": "^9.3.1",
|
"@mui/icons-material": "^9.3.1",
|
||||||
"@mui/material": "^9.3.1",
|
"@mui/material": "^9.3.1",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-unicode11": "^0.9.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
@@ -25,10 +30,13 @@
|
|||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"multer": "^2.2.0",
|
"multer": "^2.2.0",
|
||||||
|
"node-pty": "^1.0.0",
|
||||||
"nodemailer": "^9.0.1",
|
"nodemailer": "^9.0.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-router-dom": "^7.18.2"
|
"react-resizable-panels": "^4.12.2",
|
||||||
|
"react-router-dom": "^7.18.2",
|
||||||
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.5",
|
"@vitejs/plugin-react": "^6.0.5",
|
||||||
|
|||||||
+236
-17
@@ -68,8 +68,10 @@ html { scroll-behavior: smooth; }
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after {
|
*, *::before, *::after {
|
||||||
animation-duration: 0.01ms !important;
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
transition-duration: 0.01ms !important;
|
transition-duration: 0.01ms !important;
|
||||||
}
|
}
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -188,6 +190,27 @@ button:active, .btn:active {
|
|||||||
filter: brightness(0.9);
|
filter: brightness(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 键盘焦点可见(B1):所有可交互元素 focus-visible 时显示焦点环,补偿上方 outline: none */
|
||||||
|
button:focus-visible, .btn:focus-visible, a:focus-visible, [tabindex]:focus-visible {
|
||||||
|
outline: 2px solid var(--md-ref-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 屏幕阅读器专用(B6):只读不出、不占布局的辅助标题 */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
clip-path: inset(50%);
|
||||||
|
white-space: nowrap;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-filled {
|
.btn-filled {
|
||||||
background: var(--md-ref-primary);
|
background: var(--md-ref-primary);
|
||||||
color: var(--md-ref-on-primary);
|
color: var(--md-ref-on-primary);
|
||||||
@@ -522,6 +545,10 @@ table tr:hover td {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
|
/* div/span → button 后补偿:不被 button 全局样式的固定高度/字体/对齐影响 */
|
||||||
|
font-family: inherit;
|
||||||
|
height: auto;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip.active {
|
.chip.active {
|
||||||
@@ -531,14 +558,41 @@ table tr:hover td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chip:hover {
|
.chip:hover {
|
||||||
|
background: var(--md-ref-surface-variant);
|
||||||
border-color: var(--md-ref-outline);
|
border-color: var(--md-ref-outline);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 选中态优先级:hover 不覆盖 active(保持 primary-container 底) */
|
||||||
|
.chip.active:hover {
|
||||||
|
background: var(--md-ref-primary-container);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.chip:active {
|
.chip:active {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 装饰性静态徽标(替代内联样式):不可点、紧凑尺寸;
|
||||||
|
static=描边中性,tonal=次级色容器(均无交互态) */
|
||||||
|
.chip.chip-static {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--md-ref-outline-variant);
|
||||||
|
color: var(--md-ref-on-surface-variant);
|
||||||
|
cursor: default;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip.chip-tonal {
|
||||||
|
background: var(--md-ref-secondary-container);
|
||||||
|
color: var(--md-ref-on-secondary-container);
|
||||||
|
border-color: transparent;
|
||||||
|
cursor: default;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes chipPop {
|
@keyframes chipPop {
|
||||||
from { transform: scale(0.92); }
|
from { transform: scale(0.92); }
|
||||||
50% { transform: scale(1.06); }
|
50% { transform: scale(1.06); }
|
||||||
@@ -979,6 +1033,17 @@ table tr:hover td {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
/* div/span → button 后补偿:保持块级填充、左对齐与自然高度 */
|
||||||
|
font-family: inherit;
|
||||||
|
min-height: 40px;
|
||||||
|
height: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: inherit;
|
||||||
|
/* B2 div→button 后补偿:清除 UA 默认浅色按钮底/描边;同时基础态保留
|
||||||
|
主题化底色(surface-container)而非透明——否则深色模式下非选中按钮
|
||||||
|
与页面暗底融为一体,点击切换分类后"背景消失"(沿用 .chip 的既有模式) */
|
||||||
|
background: var(--md-ref-surface-container);
|
||||||
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.forum-cat-item:hover { background: var(--md-ref-surface-variant); }
|
.forum-cat-item:hover { background: var(--md-ref-surface-variant); }
|
||||||
@@ -1135,10 +1200,25 @@ table tr:hover td {
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 密码卡片的外层定位容器:复制按钮与卡片同级(避免 button 嵌套 button),
|
||||||
|
用绝对定位贴在卡片右上角 */
|
||||||
|
.password-card-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.password-card {
|
.password-card {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
/* div/span → button 后补偿:卡片保留块级布局、继承文字色与左对齐 */
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
height: auto;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-card .pw-title {
|
.password-card .pw-title {
|
||||||
@@ -1192,6 +1272,12 @@ table tr:hover td {
|
|||||||
color: var(--md-ref-primary);
|
color: var(--md-ref-primary);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
/* span → button 后补偿:保持小图标按钮外观(不被 button 全局样式撑高) */
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: auto;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.password-detail .pw-field .pw-copy:hover { background: var(--md-ref-primary-container); }
|
.password-detail .pw-field .pw-copy:hover { background: var(--md-ref-primary-container); }
|
||||||
@@ -1266,7 +1352,7 @@ table tr:hover td {
|
|||||||
|
|
||||||
.nav-version {
|
.nav-version {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--md-ref-outline);
|
color: var(--md-ref-on-surface-variant);
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
@@ -1444,6 +1530,7 @@ table tr:hover td {
|
|||||||
/* ===== Markdown toggle ===== */
|
/* ===== Markdown toggle ===== */
|
||||||
.toggle-group { display: flex; gap: 8px; margin-bottom: 16px; }
|
.toggle-group { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||||
.toggle-btn { padding: 6px 16px; border-radius: 100px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--md-ref-outline-variant); background: transparent; color: var(--md-ref-on-surface-variant); }
|
.toggle-btn { padding: 6px 16px; border-radius: 100px; font-size: 13px; font-weight: 500; cursor: pointer; border: 1px solid var(--md-ref-outline-variant); background: transparent; color: var(--md-ref-on-surface-variant); }
|
||||||
|
.toggle-btn:hover { background: var(--md-ref-surface-variant); }
|
||||||
.toggle-btn.active { background: var(--md-ref-primary-container); color: var(--md-ref-on-primary-container); border-color: transparent; }
|
.toggle-btn.active { background: var(--md-ref-primary-container); color: var(--md-ref-on-primary-container); border-color: transparent; }
|
||||||
|
|
||||||
/* ===== Captcha ===== */
|
/* ===== Captcha ===== */
|
||||||
@@ -1587,9 +1674,28 @@ body.has-wallpaper .footer-classic {
|
|||||||
background: rgba(28, 27, 31, 0.75);
|
background: rgba(28, 27, 31, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Forum sidebar glass with wallpaper */
|
/* 壁纸下详情页正文卡 / 目录卡加强:阅读面需要更高不透明度(其余卡片由
|
||||||
.has-wallpaper .forum-sidebar .forum-cat-item {
|
body.has-wallpaper 的 --glass-bg 变量统一加强) */
|
||||||
backdrop-filter: blur(4px);
|
body.has-wallpaper .blog-article,
|
||||||
|
body.has-wallpaper .article-toc-side {
|
||||||
|
background: rgba(255, 251, 254, 0.78);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body.has-wallpaper .blog-article,
|
||||||
|
[data-theme="dark"] body.has-wallpaper .article-toc-side {
|
||||||
|
background: rgba(28, 27, 31, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 壁纸下论坛侧栏玻璃底(B9):仿 .blog-article 的半透明 + 模糊 + 圆角,
|
||||||
|
保证壁纸下分类文字可读(深色主题用深色底) */
|
||||||
|
.has-wallpaper .forum-sidebar {
|
||||||
|
background: rgba(255, 251, 254, 0.5);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .has-wallpaper .forum-sidebar {
|
||||||
|
background: rgba(28, 27, 31, 0.55);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blog waterfall cards with wallpaper */
|
/* Blog waterfall cards with wallpaper */
|
||||||
@@ -1630,7 +1736,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.blog-waterfall .blog-card { padding: 16px; }
|
.blog-waterfall .blog-card { padding: 16px; }
|
||||||
.nav-tab { font-size: 13px; padding: 4px 10px; }
|
.nav-tab { font-size: 13px; padding: 8px 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Homepage Layout ===== */
|
/* ===== Homepage Layout ===== */
|
||||||
@@ -1791,6 +1897,18 @@ body.has-wallpaper .footer-classic {
|
|||||||
line-height: 0;
|
line-height: 0;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
/* 折叠触发按钮:div → button 后补偿,保持 48×48 圆角矩形图标样式 */
|
||||||
|
.music-embed-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--md-ref-on-surface-variant);
|
||||||
|
}
|
||||||
.music-embed.right { right: 24px; }
|
.music-embed.right { right: 24px; }
|
||||||
.music-embed.left { left: 24px; }
|
.music-embed.left { left: 24px; }
|
||||||
.music-embed iframe {
|
.music-embed iframe {
|
||||||
@@ -1869,6 +1987,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
.footer-classic .fc-version {
|
.footer-classic .fc-version {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: var(--md-ref-on-surface-variant);
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1923,7 +2042,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--md-ref-outline);
|
color: var(--md-ref-on-surface-variant);
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.fc-col .col-links { display: flex; flex-direction: column; gap: 8px; }
|
.fc-col .col-links { display: flex; flex-direction: column; gap: 8px; }
|
||||||
@@ -2076,8 +2195,8 @@ body.has-wallpaper .footer-classic {
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
.search-clear {
|
.search-clear {
|
||||||
width: 28px;
|
width: 40px;
|
||||||
height: 28px;
|
height: 40px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -2131,7 +2250,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
.sri-meta { font-size: 12px; color: var(--md-ref-outline); display: flex; gap: 6px; flex-wrap: wrap; }
|
.sri-meta { font-size: 12px; color: var(--md-ref-on-surface-variant); display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
.sri-meta .sep { color: var(--md-ref-outline-variant); }
|
.sri-meta .sep { color: var(--md-ref-outline-variant); }
|
||||||
.sri-tags { color: var(--md-ref-primary); }
|
.sri-tags { color: var(--md-ref-primary); }
|
||||||
|
|
||||||
@@ -2170,7 +2289,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
}
|
}
|
||||||
.article-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
.article-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
.article-tag-chip { text-decoration: none; transition: all 0.2s; }
|
.article-tag-chip { text-decoration: none; transition: all 0.2s; }
|
||||||
.article-tag-chip:hover { border-color: var(--md-ref-primary); background: var(--md-ref-primary-container); }
|
.article-tag-chip:hover { border-color: var(--md-ref-primary); background: var(--md-ref-surface-variant); }
|
||||||
.article-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
.article-actions { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||||||
.like-btn {
|
.like-btn {
|
||||||
height: 34px;
|
height: 34px;
|
||||||
@@ -2187,12 +2306,12 @@ body.has-wallpaper .footer-classic {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1);
|
transition: all 0.25s cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||||
}
|
}
|
||||||
.like-btn:hover { border-color: #d32f2f; color: #d32f2f; background: rgba(211, 47, 47, 0.06); }
|
.like-btn:hover { border-color: var(--md-ref-error); color: var(--md-ref-error); background: color-mix(in srgb, var(--md-ref-error) 10%, transparent); }
|
||||||
.like-btn .material-icons { font-size: 18px; }
|
.like-btn .material-icons { font-size: 18px; }
|
||||||
.like-btn.liked {
|
.like-btn.liked {
|
||||||
border-color: #d32f2f;
|
border-color: var(--md-ref-error);
|
||||||
color: #d32f2f;
|
color: var(--md-ref-error);
|
||||||
background: rgba(211, 47, 47, 0.1);
|
background: color-mix(in srgb, var(--md-ref-error) 10%, transparent);
|
||||||
}
|
}
|
||||||
.like-btn:active { transform: scale(0.94); }
|
.like-btn:active { transform: scale(0.94); }
|
||||||
|
|
||||||
@@ -2277,7 +2396,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
border-color: var(--md-ref-primary);
|
border-color: var(--md-ref-primary);
|
||||||
box-shadow: 0 4px 16px var(--md-shadow);
|
box-shadow: 0 4px 16px var(--md-shadow);
|
||||||
}
|
}
|
||||||
.pn-label { font-size: 12px; font-weight: 500; color: var(--md-ref-outline); }
|
.pn-label { font-size: 12px; font-weight: 500; color: var(--md-ref-on-surface-variant); }
|
||||||
.pn-title {
|
.pn-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -2312,7 +2431,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
}
|
}
|
||||||
.reply-btn {
|
.reply-btn {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
height: 28px;
|
height: 40px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--md-ref-on-surface-variant);
|
color: var(--md-ref-on-surface-variant);
|
||||||
@@ -2374,7 +2493,7 @@ body.has-wallpaper .footer-classic {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.archive-item:hover .archive-item-title { color: var(--md-ref-primary); }
|
.archive-item:hover .archive-item-title { color: var(--md-ref-primary); }
|
||||||
.archive-item-date { font-size: 12px; color: var(--md-ref-outline); flex-shrink: 0; }
|
.archive-item-date { font-size: 12px; color: var(--md-ref-on-surface-variant); flex-shrink: 0; }
|
||||||
|
|
||||||
/* ===== 博客增强响应式 ===== */
|
/* ===== 博客增强响应式 ===== */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
@@ -2595,3 +2714,103 @@ body.has-wallpaper .footer-classic {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Markdown Indigo 定制(.md-body)
|
||||||
|
—— 只做文本级渲染;除代码块(okaidia 暗底)外全部背景透明,
|
||||||
|
由卡片磨砂玻璃接管;颜色全部走主题 CSS 变量,
|
||||||
|
浅/深色随 data-theme 自动切换。
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
/* 正文基调:透明背景,文字色走主题变量 */
|
||||||
|
.md-body {
|
||||||
|
color: var(--md-ref-on-surface);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 标题:Indigo 排版(字重 500 / 行高 1.3),h1/h2 带左侧竖线 + 下边框 */
|
||||||
|
.md-body h1, .md-body h2, .md-body h3, .md-body h4, .md-body h5, .md-body h6 {
|
||||||
|
color: var(--md-ref-on-surface);
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 1.5em 0 .6em;
|
||||||
|
}
|
||||||
|
.md-body h1, .md-body h2 {
|
||||||
|
padding: .25em 0 .25em .75em;
|
||||||
|
border-left: 6px solid var(--md-ref-primary);
|
||||||
|
border-bottom: 1px solid var(--md-ref-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加粗 / 删除线:不加 Indigo 的「」引号,仅保留字重;删除线走主题灰 */
|
||||||
|
.md-body strong { font-weight: 700; }
|
||||||
|
.md-body del, .md-body s { color: var(--md-ref-outline); }
|
||||||
|
|
||||||
|
/* 链接:主题主色 + Indigo 下边框式下划线 */
|
||||||
|
.md-body a {
|
||||||
|
color: var(--md-ref-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--md-ref-primary) 40%, transparent);
|
||||||
|
}
|
||||||
|
.md-body a:hover { border-bottom-color: var(--md-ref-primary); }
|
||||||
|
|
||||||
|
/* 行内代码:主题容器色背景(可换肤),透明背景不出现 */
|
||||||
|
.md-body code {
|
||||||
|
background: var(--md-ref-surface-container);
|
||||||
|
color: var(--md-ref-on-surface);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: .9em;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 引用块:Indigo 左粗线(主题主色)+ 主题浅底 */
|
||||||
|
.md-body blockquote {
|
||||||
|
margin: 1em 0;
|
||||||
|
padding: .5em 1em;
|
||||||
|
border-left: 4px solid var(--md-ref-primary);
|
||||||
|
background: var(--md-ref-surface-container-low);
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
color: color-mix(in srgb, var(--md-ref-primary) 70%, var(--md-ref-on-surface));
|
||||||
|
}
|
||||||
|
.md-body blockquote > :last-child { margin-bottom: 0; }
|
||||||
|
.md-body blockquote > :first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* 代码块:唯一保留背景的元素 —— okaidia 暗色主题 */
|
||||||
|
.md-body pre {
|
||||||
|
background: #272822;
|
||||||
|
color: #f8f8f2;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1em 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-shadow: 0 1px rgba(0, 0, 0, .3);
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
.md-body pre code {
|
||||||
|
background: none;
|
||||||
|
color: #f8f8f2;
|
||||||
|
padding: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
text-shadow: 0 1px rgba(0, 0, 0, .3);
|
||||||
|
}
|
||||||
|
.md-body pre::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, .18); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* okaidia 语法高亮(token 出现时生效,如后续接入 highlight.js/prism) */
|
||||||
|
.md-body .token.cdata, .md-body .token.comment, .md-body .token.doctype, .md-body .token.prolog { color: #8292a2; }
|
||||||
|
.md-body .token.punctuation { color: #f8f8f2; }
|
||||||
|
.md-body .token.namespace { opacity: .7; }
|
||||||
|
.md-body .token.constant, .md-body .token.deleted, .md-body .token.property, .md-body .token.symbol, .md-body .token.tag { color: #f92672; }
|
||||||
|
.md-body .token.boolean, .md-body .token.number { color: #ae81ff; }
|
||||||
|
.md-body .token.attr-name, .md-body .token.builtin, .md-body .token.char, .md-body .token.inserted, .md-body .token.selector, .md-body .token.string { color: #a6e22e; }
|
||||||
|
.md-body .token.entity, .md-body .token.operator, .md-body .token.url, .md-body .token.variable { color: #f8f8f2; }
|
||||||
|
.md-body .token.atrule, .md-body .token.attr-value, .md-body .token.class-name, .md-body .token.function { color: #e6db74; }
|
||||||
|
.md-body .token.keyword { color: #66d9ef; }
|
||||||
|
.md-body .token.important, .md-body .token.regex { color: #fd971f; }
|
||||||
|
.md-body .token.bold, .md-body .token.important { font-weight: 700; }
|
||||||
|
.md-body .token.italic { font-style: italic; }
|
||||||
|
|
||||||
|
/* 深色模式:文本色跟随 on-surface 浅色变体;代码块保持 okaidia 暗底不变 */
|
||||||
|
[data-theme="dark"] .md-body { color: var(--md-ref-on-surface); }
|
||||||
|
[data-theme="dark"] .md-body blockquote { background: var(--md-ref-surface-container); }
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { authMiddleware, adminOnly } = require('../middleware/auth');
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 工作台「记事本」API(仅管理员)
|
||||||
|
*
|
||||||
|
* 存储:实体 .txt 文件,目录 frontend/src/tools/workbench/workspace/
|
||||||
|
* (.gitignore 已排除,不入库)
|
||||||
|
*
|
||||||
|
* GET /api/notes → 文件列表(按修改时间倒序)
|
||||||
|
* GET /api/notes/:filename → 读取文件内容 { name, content, size, mtime }
|
||||||
|
* POST /api/notes → 创建 { filename, content }(同名 409)
|
||||||
|
* PUT /api/notes/:filename → 更新 { content }(不存在 404)
|
||||||
|
* DELETE /api/notes/:filename → 删除
|
||||||
|
*
|
||||||
|
* 安全:文件名只允许 .txt、拒绝路径穿越(/、\、..、隐藏文件)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const WORKSPACE_DIR = path.join(__dirname, '..', 'frontend', 'src', 'tools', 'workbench', 'workspace');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
fs.mkdirSync(WORKSPACE_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件名安全校验:返回规范化文件名(自动补 .txt),非法返回 null */
|
||||||
|
function sanitizeFilename(raw) {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
let name = raw.trim();
|
||||||
|
if (!name || name.length > 120) return null;
|
||||||
|
if (name.includes('/') || name.includes('\\') || name.includes('\0')) return null;
|
||||||
|
if (name === '.' || name === '..' || name.startsWith('.')) return null;
|
||||||
|
if (!name.toLowerCase().endsWith('.txt')) name += '.txt';
|
||||||
|
// 兜底:解析后必须仍位于 workspace 目录内,杜绝路径穿越
|
||||||
|
const full = path.join(WORKSPACE_DIR, name);
|
||||||
|
if (!full.startsWith(WORKSPACE_DIR + path.sep)) return null;
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出所有 .txt 笔记,按修改时间倒序 */
|
||||||
|
function listNotes() {
|
||||||
|
ensureDir();
|
||||||
|
return fs.readdirSync(WORKSPACE_DIR, { withFileTypes: true })
|
||||||
|
.filter((d) => d.isFile() && d.name.toLowerCase().endsWith('.txt'))
|
||||||
|
.map((d) => {
|
||||||
|
const st = fs.statSync(path.join(WORKSPACE_DIR, d.name));
|
||||||
|
return { name: d.name, size: st.size, mtime: st.mtimeMs };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表
|
||||||
|
router.get('/', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(listNotes());
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Notes list error:', e.message);
|
||||||
|
res.status(500).json({ error: '读取笔记列表失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 读取内容
|
||||||
|
router.get('/:filename', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const name = sanitizeFilename(req.params.filename);
|
||||||
|
if (!name) return res.status(400).json({ error: '文件名不合法' });
|
||||||
|
try {
|
||||||
|
const full = path.join(WORKSPACE_DIR, name);
|
||||||
|
if (!fs.existsSync(full)) return res.status(404).json({ error: '笔记不存在' });
|
||||||
|
const content = fs.readFileSync(full, 'utf8');
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
res.json({ name, content, size: st.size, mtime: st.mtimeMs });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Note read error:', e.message);
|
||||||
|
res.status(500).json({ error: '读取笔记失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建
|
||||||
|
router.post('/', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const name = sanitizeFilename(req.body && req.body.filename);
|
||||||
|
if (!name) return res.status(400).json({ error: '文件名不合法' });
|
||||||
|
const content = typeof req.body.content === 'string' ? req.body.content : '';
|
||||||
|
try {
|
||||||
|
ensureDir();
|
||||||
|
const full = path.join(WORKSPACE_DIR, name);
|
||||||
|
if (fs.existsSync(full)) return res.status(409).json({ error: '同名笔记已存在' });
|
||||||
|
fs.writeFileSync(full, content, 'utf8');
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
res.json({ name, size: st.size, mtime: st.mtimeMs });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Note create error:', e.message);
|
||||||
|
res.status(500).json({ error: '创建笔记失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新内容
|
||||||
|
router.put('/:filename', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const name = sanitizeFilename(req.params.filename);
|
||||||
|
if (!name) return res.status(400).json({ error: '文件名不合法' });
|
||||||
|
const content = typeof req.body.content === 'string' ? req.body.content : '';
|
||||||
|
try {
|
||||||
|
const full = path.join(WORKSPACE_DIR, name);
|
||||||
|
if (!fs.existsSync(full)) return res.status(404).json({ error: '笔记不存在' });
|
||||||
|
fs.writeFileSync(full, content, 'utf8');
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
res.json({ name, size: st.size, mtime: st.mtimeMs });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Note update error:', e.message);
|
||||||
|
res.status(500).json({ error: '保存笔记失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
router.delete('/:filename', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
const name = sanitizeFilename(req.params.filename);
|
||||||
|
if (!name) return res.status(400).json({ error: '文件名不合法' });
|
||||||
|
try {
|
||||||
|
const full = path.join(WORKSPACE_DIR, name);
|
||||||
|
if (!fs.existsSync(full)) return res.status(404).json({ error: '笔记不存在' });
|
||||||
|
fs.unlinkSync(full);
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Note delete error:', e.message);
|
||||||
|
res.status(500).json({ error: '删除笔记失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+16
-1
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
// Web 终端后端 —— 安全敏感模块:终端 = RCE 级攻击面。
|
||||||
|
// 安全措施:
|
||||||
|
// 1. 专用终端密码二次确认:PBKDF2-SHA256 600k(与密码箱 routes/passwords.js 同参数,异步版不阻塞事件循环);
|
||||||
|
// 首次调用(未设置密码)即完成设置(needs_setup 流程),强度要求 8 位以上
|
||||||
|
// 2. 短 TTL token(5 分钟):jwt 带 terminal:true 声明,普通登录 token 不可直接连终端;
|
||||||
|
// 认证成功后种 HttpOnly SameSite=Strict 短 TTL cookie(terminal_token),WS upgrade 优先取 cookie,
|
||||||
|
// ?token= query 仅为旧前端回退(不推荐,会落 access log);带失败限流 + 请求级限流防 PBKDF2 DoS
|
||||||
|
// 3. WS handshake Origin 校验(须与站点域名一致;仅 NODE_ENV=development 时放行 localhost)——纵深防御,非认证边界
|
||||||
|
// 4. 并发会话上限(MAX_SESSIONS=3):handleUpgrade 内同步预占槽位,杜绝升级回调前的竞态突破
|
||||||
|
// 5. pty.spawn 使用参数数组传参(防命令注入);kill 时连进程组一起终止(forkpty 子进程为 session leader,
|
||||||
|
// 负 PID 即杀整组),杜绝 & 后台任务变孤儿进程
|
||||||
|
// 6. 审计:会话开始/结束/密码失败等写审计日志(console.log 带时间戳 + audit_logs 表);
|
||||||
|
// 拒绝类事件(Origin/token/超限)降频落审计防日志洪泛;audit_logs 按 90 天保留策略惰性清理
|
||||||
|
// 7. 空闲超时(15 分钟无输入)自动断开
|
||||||
|
// 8. ws close/error → pty.kill() + 进程组清理 + 监听器/计时器全量销毁
|
||||||
|
// 流控:pty 输出缓冲超限时 pause(仅在 onData 暂停),恢复检查放在 ws 消息处理 + 兜底轮询
|
||||||
|
//
|
||||||
|
// 部署注意:
|
||||||
|
// - node-pty 是原生模块,需要服务器编译环境(build-essential / python3),
|
||||||
|
// 安装失败时先 rm -rf node_modules 再 npm install(项目已有此重装先例)。
|
||||||
|
// - sudoers 配置(把 rainweb 换成实际运行用户):
|
||||||
|
// ⚠️ 实测 NOPASSWD: /usr/bin/sudo -i 写法匹配不到(sudo 匹配规则时 -i 会被规范化
|
||||||
|
// 为实际执行的 login shell 命令,精确命令规则失效),必须放宽为:
|
||||||
|
// echo "rainweb ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/rainweb-terminal
|
||||||
|
// sudo chmod 440 /etc/sudoers.d/rainweb-terminal && sudo visudo -c
|
||||||
|
// 验证:以运行用户执行 `sudo -n -i id` 应直接输出 root 身份且不询问密码。
|
||||||
|
// 注意:NOPASSWD: ALL 等同把运行用户提权为免密 root,务必确认运行用户无交互登录面。
|
||||||
|
// - 后端若置于反向代理之后并希望审计 IP 取真实来源 IP,需设置环境变量 TRUST_PROXY=1
|
||||||
|
// (否则一律用 socket 直连地址,避免伪造 X-Forwarded-For)。
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const { WebSocketServer, WebSocket } = require('ws');
|
||||||
|
const db = require('../db');
|
||||||
|
const { authMiddleware, adminOnly, SECRET } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// ---------- 常量 ----------
|
||||||
|
const TOKEN_TTL = '5m'; // 短 TTL token 有效期
|
||||||
|
const TOKEN_COOKIE = 'terminal_token'; // HttpOnly 短 TTL cookie 名
|
||||||
|
const TOKEN_COOKIE_MAX_AGE = 300; // cookie 有效期(秒),与 TOKEN_TTL 一致
|
||||||
|
const MAX_SESSIONS = 3; // 最大并发终端会话数
|
||||||
|
const IDLE_TIMEOUT = 15 * 60 * 1000; // 空闲超时:15 分钟无输入自动断开
|
||||||
|
const PIN_MIN_LEN = 8; // 终端=root shell,PIN 强度要求 8 位以上
|
||||||
|
const PBKDF2_ITERATIONS = 600000; // 与 routes/passwords.js 一致(OWASP 2023 建议)
|
||||||
|
const MAX_PTY_BUFFER = 1024 * 1024; // pty 输出缓冲上限(超过则暂停 pty 做流控)
|
||||||
|
const WS_MAX_PAYLOAD = 1024 * 1024; // ws 单帧消息上限,超限立即断开
|
||||||
|
const RESUME_POLL_MS = 2000; // 流控恢复兜底轮询间隔
|
||||||
|
const WS_CLOSE_BAD_TOKEN = 4001; // token 无效
|
||||||
|
const WS_CLOSE_OVERLOAD = 1013; // 会话数超限
|
||||||
|
const WS_CLOSE_IDLE = 4000; // 空闲超时
|
||||||
|
const WS_CLOSE_BAD_MSG = 4002; // 非法消息
|
||||||
|
|
||||||
|
// PIN 失败限流(与 passwords.js unlock 同模式):5 分钟 3 次失败锁定 5 分钟
|
||||||
|
const authFails = new Map();
|
||||||
|
const FAIL_LIMIT = 3;
|
||||||
|
const FAIL_WINDOW = 5 * 60 * 1000;
|
||||||
|
const LOCK_DURATION = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// /auth 请求级限流:防止异步 PBKDF2 被并发滥用造成 CPU DoS
|
||||||
|
// (失败锁定只在校验之后生效,无法挡住每请求一次的 600k 迭代计算)
|
||||||
|
const authRateLimit = rateLimit({
|
||||||
|
windowMs: 5 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: '尝试次数过多,请稍后再试' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 拒绝类事件审计降频:同一来源 IP 在窗口内最多记一条(防日志洪泛)
|
||||||
|
const REJECT_AUDIT_INTERVAL = 10 * 1000;
|
||||||
|
const rejectAudit = new Map(); // ip -> lastTs
|
||||||
|
|
||||||
|
// 并发终端会话集合(计数与统一清理)
|
||||||
|
const activeSessions = new Set();
|
||||||
|
|
||||||
|
// node-pty 懒加载:未安装时 REST 接口(status/auth)仍可用,仅 WS 会话被拒绝
|
||||||
|
let ptyModule = null;
|
||||||
|
try { ptyModule = require('node-pty'); } catch (e) { /* 未安装:连接时提示 */ }
|
||||||
|
|
||||||
|
// 审计写入计数:每 50 条惰性清理一次过期记录(保留策略,见 db.js)
|
||||||
|
let auditWriteCount = 0;
|
||||||
|
|
||||||
|
// ---------- 审计 ----------
|
||||||
|
// ctx: { user, ip }(user 为 JWT payload)
|
||||||
|
function audit(action, detail, ctx) {
|
||||||
|
const userId = ctx && ctx.user ? ctx.user.id : 0;
|
||||||
|
const username = ctx && ctx.user ? ctx.user.username : '';
|
||||||
|
const ip = ctx && ctx.ip ? ctx.ip : '';
|
||||||
|
console.log(`[${new Date().toISOString()}] [TERMINAL] ${action} user=${username}(${userId}) ip=${ip} ${detail}`);
|
||||||
|
// 审计落库(audit_logs 表由 db.js initTables 建表;db.run 内部已容错)
|
||||||
|
db.run('INSERT INTO audit_logs (action, user_id, username, detail, ip) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[action, userId, username, detail, ip]);
|
||||||
|
// 保留策略:每 50 条写入惰性清理 90 天前的记录(created_at 为 UTC datetime,与建表一致)
|
||||||
|
if (++auditWriteCount % 50 === 0) {
|
||||||
|
db.run("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端真实 IP:仅当 TRUST_PROXY=1 时信任 X-Forwarded-For(否则客户端可任意伪造该头)
|
||||||
|
function getClientIp(request) {
|
||||||
|
if (process.env.TRUST_PROXY === '1' && request.headers['x-forwarded-for']) {
|
||||||
|
return String(request.headers['x-forwarded-for']).split(',')[0].trim() || '';
|
||||||
|
}
|
||||||
|
return (request.socket.remoteAddress || '').toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拒绝事件降频审计:同 IP 10 秒内只记一条
|
||||||
|
function auditRejectOnce(ip, action, detail) {
|
||||||
|
const now = Date.now();
|
||||||
|
if ((rejectAudit.get(ip) || 0) > now - REJECT_AUDIT_INTERVAL) return;
|
||||||
|
rejectAudit.set(ip, now);
|
||||||
|
audit(action, detail, { ip });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- PIN(终端专用密码)----------
|
||||||
|
const PIN_SALT_KEY = 'terminal_pin_salt';
|
||||||
|
const PIN_ITER_KEY = 'terminal_pin_iter';
|
||||||
|
const PIN_HASH_KEY = 'terminal_pin_hash';
|
||||||
|
|
||||||
|
function getPinRecord() {
|
||||||
|
const salt = db.getSetting(PIN_SALT_KEY);
|
||||||
|
const hash = db.getSetting(PIN_HASH_KEY);
|
||||||
|
if (!salt || !hash) return null;
|
||||||
|
const iter = parseInt(db.getSetting(PIN_ITER_KEY), 10) || PBKDF2_ITERATIONS;
|
||||||
|
return { salt: Buffer.from(salt, 'hex'), hash: Buffer.from(hash, 'hex'), iter };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步 PBKDF2:不阻塞事件循环(600k 迭代约几十毫秒,同步版会卡住所有请求)
|
||||||
|
function derivePinHash(pin, salt, iter) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(pin, salt, iter, 32, 'sha256', (err, key) => {
|
||||||
|
if (err) reject(err); else resolve(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPin(pin) {
|
||||||
|
const salt = crypto.randomBytes(16);
|
||||||
|
const hash = await derivePinHash(pin, salt, PBKDF2_ITERATIONS);
|
||||||
|
db.setSetting(PIN_SALT_KEY, salt.toString('hex'));
|
||||||
|
db.setSetting(PIN_ITER_KEY, String(PBKDF2_ITERATIONS));
|
||||||
|
db.setSetting(PIN_HASH_KEY, hash.toString('hex'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPin(pin) {
|
||||||
|
const rec = getPinRecord();
|
||||||
|
if (!rec) return false;
|
||||||
|
const derived = await derivePinHash(pin, rec.salt, rec.iter);
|
||||||
|
// 常数时间比较,防时序侧信道
|
||||||
|
return crypto.timingSafeEqual(derived, rec.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- PIN 失败限流 ----------
|
||||||
|
function isLocked() {
|
||||||
|
const rec = authFails.get('global');
|
||||||
|
return !!(rec && rec.lockedUntil && Date.now() < rec.lockedUntil);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordFail() {
|
||||||
|
const now = Date.now();
|
||||||
|
let rec = authFails.get('global');
|
||||||
|
if (!rec || now - rec.firstTs > FAIL_WINDOW) rec = { count: 0, firstTs: now, lockedUntil: 0 };
|
||||||
|
rec.count += 1;
|
||||||
|
if (rec.count >= FAIL_LIMIT) { rec.lockedUntil = now + LOCK_DURATION; rec.count = 0; }
|
||||||
|
authFails.set('global', rec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 短 TTL token(参考 proxy.js)----------
|
||||||
|
function issueToken(user) {
|
||||||
|
// terminal:true 声明:只有本接口签发的 token 才能连接终端,普通登录 token 无效
|
||||||
|
return jwt.sign(
|
||||||
|
{ id: user.id, username: user.username, role: user.role, terminal: true },
|
||||||
|
SECRET,
|
||||||
|
{ expiresIn: TOKEN_TTL }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
if (!token || typeof token !== 'string') return null;
|
||||||
|
try {
|
||||||
|
const payload = jwt.verify(token, SECRET);
|
||||||
|
if (!payload.terminal) return null; // 必须为终端专用 token
|
||||||
|
if (payload.role !== 'admin') return null; // 防篡改
|
||||||
|
const u = db.get('SELECT role FROM users WHERE id = ?', [payload.id]);
|
||||||
|
if (!u || u.role !== 'admin') return null; // 复查角色(用户被删/降权立即失效)
|
||||||
|
return payload;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 认证成功 → 种 HttpOnly 短 TTL cookie:WS 握手(同源 GET /ws/terminal)自动携带,
|
||||||
|
// root shell 凭证不再进 URL / access log。Path 精确限定 /ws/terminal,HttpOnly 防 XSS 读取,
|
||||||
|
// SameSite=Strict 防跨站发送。Secure 按 req.secure(HTTPS 连接)条件附加。
|
||||||
|
function setTerminalCookie(res, token) {
|
||||||
|
res.setHeader('Set-Cookie',
|
||||||
|
`terminal_token=${token}; Path=/ws/terminal; HttpOnly; SameSite=Strict; Max-Age=${TOKEN_COOKIE_MAX_AGE}` +
|
||||||
|
(res.req && res.req.secure ? '; Secure' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 极简 Cookie 解析(upgrade 事件不经 Express,无 cookie-parser)
|
||||||
|
function parseCookies(header) {
|
||||||
|
const out = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of String(header).split(';')) {
|
||||||
|
const idx = part.indexOf('=');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const name = part.slice(0, idx).trim();
|
||||||
|
const value = part.slice(idx + 1).trim();
|
||||||
|
if (name) { try { out[name] = decodeURIComponent(value); } catch { out[name] = value; } }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- REST API ----------
|
||||||
|
// GET /api/terminal/status —— 是否已设置密码 + 当前会话数
|
||||||
|
router.get('/status', authMiddleware, adminOnly, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
hasPin: !!getPinRecord(),
|
||||||
|
sessions: activeSessions.size,
|
||||||
|
maxSessions: MAX_SESSIONS,
|
||||||
|
idleTimeoutMs: IDLE_TIMEOUT,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/terminal/auth —— 设置/校验终端专用密码,通过后发放 5 分钟短 TTL token
|
||||||
|
// 并种 HttpOnly cookie(terminal_token)。
|
||||||
|
// 首次(未设置密码)调用即完成设置(前端按 /status 的 hasPin 决定显示设置表单或密码输入框),
|
||||||
|
// 响应带 setup:true 标记。响应仍含 token 字段(保持 API 契约),前端无需再从 URL 传递。
|
||||||
|
router.post('/auth', authRateLimit, authMiddleware, adminOnly, async (req, res) => {
|
||||||
|
const pin = req.body && req.body.pin;
|
||||||
|
if (!pin || typeof pin !== 'string') return res.status(400).json({ error: '请输入终端密码' });
|
||||||
|
if (pin.length > 128) return res.status(400).json({ error: '终端密码过长' });
|
||||||
|
if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
|
||||||
|
|
||||||
|
const rec = getPinRecord();
|
||||||
|
const ctx = { user: req.user, ip: getClientIp(req) };
|
||||||
|
|
||||||
|
if (!rec) {
|
||||||
|
// 首次设置:无密码 → 设置并发放 token
|
||||||
|
if (pin.length < PIN_MIN_LEN) {
|
||||||
|
return res.status(400).json({ error: `终端密码至少 ${PIN_MIN_LEN} 位` });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setPin(pin);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(500).json({ error: '密码处理失败' });
|
||||||
|
}
|
||||||
|
authFails.delete('global');
|
||||||
|
audit('PIN_SETUP', '终端专用密码已设置', ctx);
|
||||||
|
const token = issueToken(req.user);
|
||||||
|
setTerminalCookie(res, token);
|
||||||
|
return res.json({
|
||||||
|
needs_setup: false,
|
||||||
|
setup: true,
|
||||||
|
token,
|
||||||
|
expiresIn: TOKEN_TTL,
|
||||||
|
message: '终端密码已设置',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let pinOk = false;
|
||||||
|
try { pinOk = await verifyPin(pin); } catch { pinOk = false; }
|
||||||
|
if (!pinOk) {
|
||||||
|
recordFail();
|
||||||
|
audit('PIN_FAIL', '终端密码校验失败', ctx);
|
||||||
|
if (isLocked()) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
|
||||||
|
return res.status(401).json({ error: '终端密码错误' });
|
||||||
|
}
|
||||||
|
|
||||||
|
authFails.delete('global');
|
||||||
|
audit('PIN_OK', '终端密码校验通过,发放短 TTL token', ctx);
|
||||||
|
const token = issueToken(req.user);
|
||||||
|
setTerminalCookie(res, token);
|
||||||
|
res.json({ token, expiresIn: TOKEN_TTL });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- WS /ws/terminal ----------
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
|
||||||
|
// Origin 校验:必须与站点域名一致(site_url 或请求 Host 头)。
|
||||||
|
// localhost 豁免仅 NODE_ENV=development 时生效(生产环境非浏览器客户端无法借 localhost 绕过)。
|
||||||
|
// 注意:这是纵深防御,不是认证边界——真正把关的是 token/cookie 校验。
|
||||||
|
function originAllowed(request) {
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) return false;
|
||||||
|
try {
|
||||||
|
const o = new URL(origin);
|
||||||
|
const allowed = [];
|
||||||
|
const siteUrl = db.getSetting('site_url');
|
||||||
|
if (siteUrl) { try { allowed.push(new URL(siteUrl).host); } catch {} }
|
||||||
|
if (request.headers.host) allowed.push(request.headers.host);
|
||||||
|
if (isDev && ['127.0.0.1', 'localhost', '::1'].includes(o.hostname)) return true;
|
||||||
|
return allowed.includes(o.host);
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// pty 安全:参数数组传参(防命令注入),禁止字符串拼接;sudo -i 获得 root shell。
|
||||||
|
// cwd 必须用运行用户可访问的目录:直接给 '/root' 时 spawn 不抛错,
|
||||||
|
// 而是 shell 启动后 chdir(2) 失败(Permission denied)——sudo -i 本身会把
|
||||||
|
// 工作目录与 HOME 切到 /root,所以这里给运行用户当前目录即可。
|
||||||
|
function spawnPty(cols, rows) {
|
||||||
|
if (!ptyModule) return null; // node-pty 未安装
|
||||||
|
const opts = { name: 'xterm-color', cols, rows, cwd: process.cwd() };
|
||||||
|
return ptyModule.spawn('sudo', ['-i'], opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 终止 pty:先杀 shell,再连进程组一起杀(forkpty 子进程是 session leader,pgid == pid,
|
||||||
|
// 负 PID 即杀整组),500ms 后兜底 SIGKILL,杜绝 `&` 后台任务变孤儿进程。
|
||||||
|
function killPty(pty) {
|
||||||
|
if (!pty) return;
|
||||||
|
const pid = pty.pid;
|
||||||
|
try { pty.kill(); } catch {} // 标准接口:向 shell 发 SIGKILL
|
||||||
|
if (pid && Number.isInteger(pid)) {
|
||||||
|
try { process.kill(-pid, 'SIGTERM'); } catch {}
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
try { process.kill(-pid, 'SIGKILL'); } catch {}
|
||||||
|
}, 500);
|
||||||
|
if (t.unref) t.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WS 服务:noServer 模式,由 server.js 的 http server 'upgrade' 事件转交;
|
||||||
|
// maxPayload 限制单帧大小,超限由 ws 自动关闭(1009)。
|
||||||
|
const wss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
|
||||||
|
|
||||||
|
// server.js 调用:返回 true 表示已接管该 upgrade 请求(含拒绝),false 表示非终端路径
|
||||||
|
function handleUpgrade(request, socket, head) {
|
||||||
|
let pathname = null;
|
||||||
|
try { pathname = new URL(request.url, 'http://localhost').pathname; } catch {}
|
||||||
|
if (pathname !== '/ws/terminal') return false;
|
||||||
|
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
|
||||||
|
// 1) Origin 校验(纵深防御;拒绝事件降频落审计)
|
||||||
|
if (!originAllowed(request)) {
|
||||||
|
auditRejectOnce(ip, 'WS_ORIGIN_REJECT', 'Origin 校验失败');
|
||||||
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 2) token 校验:优先 HttpOnly cookie(推荐),?token= query 仅作旧前端回退(不推荐,落 access log)
|
||||||
|
const cookies = parseCookies(request.headers.cookie);
|
||||||
|
let token = cookies[TOKEN_COOKIE];
|
||||||
|
if (!token) {
|
||||||
|
try { token = new URL(request.url, 'http://localhost').searchParams.get('token'); } catch {}
|
||||||
|
}
|
||||||
|
const payload = verifyToken(token);
|
||||||
|
if (!payload) {
|
||||||
|
auditRejectOnce(ip, 'WS_TOKEN_REJECT', 'token 无效或过期');
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 3) 并发会话上限:同步预占槽位再检查(升级回调前执行,杜绝并发竞态突破上限)
|
||||||
|
const slot = { pending: true };
|
||||||
|
activeSessions.add(slot);
|
||||||
|
if (activeSessions.size > MAX_SESSIONS) {
|
||||||
|
activeSessions.delete(slot);
|
||||||
|
auditRejectOnce(ip, 'WS_OVERLOAD', '并发会话数超限被拒绝');
|
||||||
|
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
|
||||||
|
socket.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 升级中途失败(socket 断开等)时释放预占槽位,防止泄漏
|
||||||
|
const releaseSlot = () => { activeSessions.delete(slot); };
|
||||||
|
socket.once('close', releaseSlot);
|
||||||
|
socket.once('error', releaseSlot);
|
||||||
|
|
||||||
|
// 4) 升级并触发连接处理(槽位随会话移交)
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
socket.removeListener('close', releaseSlot);
|
||||||
|
socket.removeListener('error', releaseSlot);
|
||||||
|
if (ws.readyState !== WebSocket.OPEN) { // 升级异常:释放槽位
|
||||||
|
activeSessions.delete(slot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.emit('connection', ws, request, payload, slot);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', (ws, request, payload, slot) => {
|
||||||
|
// 复用 handleUpgrade 预占的槽位(已在 activeSessions 中,幂等 add),
|
||||||
|
// 升级失败路径已由 releaseSlot/readyState 检查负责释放。
|
||||||
|
const session = slot || {};
|
||||||
|
session.id = session.id || crypto.randomBytes(4).toString('hex');
|
||||||
|
session.startedAt = session.startedAt || Date.now();
|
||||||
|
activeSessions.add(session);
|
||||||
|
|
||||||
|
let ptyProc = null;
|
||||||
|
let idleTimer = null;
|
||||||
|
let resumeTimer = null;
|
||||||
|
let closed = false;
|
||||||
|
const ctx = { user: payload, ip: getClientIp(request) };
|
||||||
|
|
||||||
|
audit('SESSION_START', `终端会话 ${session.id} 建立`, ctx);
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
clearInterval(resumeTimer);
|
||||||
|
// 销毁 pty:kill() + 进程组清理(含后台任务),事件回调为一次性注册随对象失效
|
||||||
|
killPty(ptyProc);
|
||||||
|
ptyProc = null;
|
||||||
|
activeSessions.delete(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleIdle() {
|
||||||
|
clearTimeout(idleTimer);
|
||||||
|
idleTimer = setTimeout(() => {
|
||||||
|
if (closed) return;
|
||||||
|
audit('SESSION_IDLE_TIMEOUT', `终端会话 ${session.id} 空闲 ${IDLE_TIMEOUT / 60000} 分钟无输入,自动断开`, ctx);
|
||||||
|
try { ws.close(WS_CLOSE_IDLE, 'idle timeout'); } catch {}
|
||||||
|
cleanup();
|
||||||
|
}, IDLE_TIMEOUT);
|
||||||
|
if (idleTimer.unref) idleTimer.unref(); // 不阻止进程退出
|
||||||
|
}
|
||||||
|
|
||||||
|
// 流控恢复:onData 暂停后不再触发回调,恢复检查必须在消息路径/轮询中执行
|
||||||
|
function maybeResume() {
|
||||||
|
if (closed || !session._paused || !ptyProc) return;
|
||||||
|
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < MAX_PTY_BUFFER / 2) {
|
||||||
|
session._paused = false;
|
||||||
|
try { ptyProc.resume(); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('message', (data, isBinary) => {
|
||||||
|
if (closed) return;
|
||||||
|
maybeResume(); // 任意帧到达即尝试恢复(resize/输入/控制消息均可)
|
||||||
|
// 二进制帧 = pty 输入(触发空闲计时刷新)
|
||||||
|
if (isBinary) {
|
||||||
|
if (!ptyProc) return;
|
||||||
|
scheduleIdle();
|
||||||
|
try { ptyProc.write(data); } catch {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 文本帧 = JSON 控制消息
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(data.toString('utf8')); } catch {
|
||||||
|
try { ws.close(WS_CLOSE_BAD_MSG, 'invalid json'); } catch {}
|
||||||
|
cleanup(); return;
|
||||||
|
}
|
||||||
|
if (!msg || typeof msg.type !== 'string') {
|
||||||
|
try { ws.close(WS_CLOSE_BAD_MSG, 'bad message'); } catch {}
|
||||||
|
cleanup(); return;
|
||||||
|
}
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'auth':
|
||||||
|
// 连接后可选再发 token 二次认证;无效立即断开(4001)
|
||||||
|
if (!verifyToken(msg.token)) {
|
||||||
|
audit('SESSION_AUTH_FAIL', `终端会话 ${session.id} 二次认证失败`, ctx);
|
||||||
|
try { ws.close(WS_CLOSE_BAD_TOKEN, 'bad token'); } catch {}
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'resize': {
|
||||||
|
const cols = Math.min(Math.max(parseInt(msg.cols, 10) || 80, 2), 500);
|
||||||
|
const rows = Math.min(Math.max(parseInt(msg.rows, 10) || 24, 2), 200);
|
||||||
|
if (ptyProc) { try { ptyProc.resize(cols, rows); } catch {} }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ping':
|
||||||
|
try { ws.send(JSON.stringify({ type: 'pong' })); } catch {}
|
||||||
|
break;
|
||||||
|
case 'bye':
|
||||||
|
audit('SESSION_BYE', `终端会话 ${session.id} 主动退出`, ctx);
|
||||||
|
try { ws.close(1000, 'bye'); } catch {}
|
||||||
|
cleanup();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break; // 未知控制消息忽略
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (err) => {
|
||||||
|
audit('SESSION_ERROR', `终端会话 ${session.id} WebSocket 错误: ${err.message}`, ctx);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
audit('SESSION_END', `终端会话 ${session.id} 连接关闭`, ctx);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动 pty(sudo -i → root shell)
|
||||||
|
try {
|
||||||
|
ptyProc = spawnPty(80, 24);
|
||||||
|
} catch (e) {
|
||||||
|
audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} pty 启动失败: ${e.message}`, ctx);
|
||||||
|
try { ws.close(1011, 'pty spawn failed'); } catch {}
|
||||||
|
cleanup(); return;
|
||||||
|
}
|
||||||
|
if (!ptyProc) {
|
||||||
|
audit('SESSION_SPAWN_FAIL', `终端会话 ${session.id} 拒绝:node-pty 未安装或无法加载`, ctx);
|
||||||
|
try { ws.close(1011, 'node-pty not installed'); } catch {}
|
||||||
|
cleanup(); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底恢复轮询:即使客户端不再发任何帧,也能在缓冲排空后恢复 pty
|
||||||
|
resumeTimer = setInterval(() => maybeResume(), RESUME_POLL_MS);
|
||||||
|
if (resumeTimer.unref) resumeTimer.unref();
|
||||||
|
|
||||||
|
// pty 输出 → ws 二进制帧(binaryType=arraybuffer)
|
||||||
|
ptyProc.onData((data) => {
|
||||||
|
if (closed || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
// 仅做暂停判断(数据流动时天然触发);恢复由 maybeResume(消息/轮询)负责
|
||||||
|
if (!session._paused && ws.bufferedAmount > MAX_PTY_BUFFER) {
|
||||||
|
session._paused = true;
|
||||||
|
try { ptyProc.pause(); } catch {}
|
||||||
|
}
|
||||||
|
try { ws.send(data, { binary: true }); } catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
ptyProc.onExit(({ exitCode, signal }) => {
|
||||||
|
audit('SESSION_EXIT', `终端会话 ${session.id} 进程退出 code=${exitCode} signal=${signal}`, ctx);
|
||||||
|
cleanup();
|
||||||
|
if (ws.readyState === WebSocket.OPEN) { try { ws.close(1000, 'pty exited'); } catch {} }
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleIdle();
|
||||||
|
audit('SESSION_READY', `终端会话 ${session.id} pty 就绪`, ctx);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.handleUpgrade = handleUpgrade;
|
||||||
@@ -32,7 +32,9 @@ const uploadRoutes = require('./routes/upload');
|
|||||||
const setupRoutes = require('./routes/setup');
|
const setupRoutes = require('./routes/setup');
|
||||||
const proxyRoutes = require('./routes/proxy');
|
const proxyRoutes = require('./routes/proxy');
|
||||||
const importRoutes = require('./routes/import');
|
const importRoutes = require('./routes/import');
|
||||||
|
const noteRoutes = require('./routes/notes');
|
||||||
const feedRoutes = require('./routes/feed');
|
const feedRoutes = require('./routes/feed');
|
||||||
|
const terminalRoutes = require('./routes/terminal');
|
||||||
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
|
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -111,6 +113,8 @@ app.use('/api/upload', uploadRoutes);
|
|||||||
app.use('/api/setup', setupRoutes);
|
app.use('/api/setup', setupRoutes);
|
||||||
app.use('/api/proxy', proxyRoutes);
|
app.use('/api/proxy', proxyRoutes);
|
||||||
app.use('/api/import', importRoutes);
|
app.use('/api/import', importRoutes);
|
||||||
|
app.use('/api/notes', noteRoutes);
|
||||||
|
app.use('/api/terminal', terminalRoutes);
|
||||||
|
|
||||||
// Version & Update
|
// Version & Update
|
||||||
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
|
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
|
||||||
@@ -186,9 +190,17 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await getDb();
|
await getDb();
|
||||||
app.listen(PORT, '0.0.0.0', () => {
|
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`RainWeb running on port ${PORT}`);
|
console.log(`RainWeb running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
// Web 终端 WS 升级:upgrade 事件不经 Express 中间件栈,
|
||||||
|
// 在此提前接管 /ws/terminal(避开 SPA catch-all),其余升级请求直接关闭。
|
||||||
|
// 安全校验(Origin/token/会话上限)均在 routes/terminal.js handleUpgrade 内完成。
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
if (!terminalRoutes.handleUpgrade(request, socket, head)) {
|
||||||
|
socket.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to start:', err.message);
|
console.error('Failed to start:', err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ module.exports = defineConfig({
|
|||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:3101',
|
'/api': 'http://localhost:3101',
|
||||||
'/uploads': 'http://localhost:3101',
|
'/uploads': 'http://localhost:3101',
|
||||||
|
// Web 终端 WS:/ws/terminal 必须走 WebSocket 代理,否则 dev 下解锁后
|
||||||
|
// 连接永远失败 → 终端空转白屏(生产由 server.js 的 upgrade 事件处理)
|
||||||
|
'/ws': { target: 'ws://localhost:3101', ws: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user