Compare commits
28
Commits
v2.0.3
..
f2740afe50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2740afe50 | ||
|
|
d9d2ad3f7d | ||
|
|
0ae8815f33 | ||
|
|
9400f76c7e | ||
|
|
03d5c97315 | ||
|
|
2cb9592d56 | ||
|
|
8a08932d78 | ||
|
|
b152a2a9c9 | ||
|
|
4af71185d5 | ||
|
|
6d60b10c14 | ||
|
|
cf4c7d4ca4 | ||
|
|
5acc461e88 | ||
|
|
391cd1bdae | ||
|
|
5b166ef0e2 | ||
|
|
4f89c63b28 | ||
|
|
7bfeffaaa0 | ||
|
|
aff5764b9b | ||
|
|
bb830f779c | ||
|
|
63d329db5f | ||
|
|
e83a4e6ee1 | ||
|
|
0153f770f6 | ||
|
|
789a2d9b33 | ||
|
|
5a878c1aab | ||
|
|
5e4cdf26b9 | ||
|
|
2faace4810 | ||
|
|
45002b809a | ||
|
|
1b62bf99ce | ||
|
|
6fafa6eb00 |
@@ -8,3 +8,4 @@ server.pid
|
|||||||
releases/
|
releases/
|
||||||
public/dist/
|
public/dist/
|
||||||
backups/
|
backups/
|
||||||
|
workspace/
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ function seedDefaults() {
|
|||||||
smtp_from_name: 'RainWeb',
|
smtp_from_name: 'RainWeb',
|
||||||
theme_wallpaper: '',
|
theme_wallpaper: '',
|
||||||
theme_wallpaper_scale: 'cover',
|
theme_wallpaper_scale: 'cover',
|
||||||
|
theme_wallpaper_enabled: '1',
|
||||||
nav_style: 'default',
|
nav_style: 'default',
|
||||||
card_style: 'default',
|
card_style: 'default',
|
||||||
glass_blur: '20',
|
glass_blur: '20',
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function ThemeSettings() {
|
|||||||
primary_color: '#6750a4',
|
primary_color: '#6750a4',
|
||||||
theme_wallpaper: '',
|
theme_wallpaper: '',
|
||||||
theme_wallpaper_scale: 'cover',
|
theme_wallpaper_scale: 'cover',
|
||||||
|
theme_wallpaper_enabled: true,
|
||||||
nav_style: 'default',
|
nav_style: 'default',
|
||||||
card_style: 'default',
|
card_style: 'default',
|
||||||
glass_blur: '20',
|
glass_blur: '20',
|
||||||
@@ -34,6 +35,7 @@ export default function ThemeSettings() {
|
|||||||
primary_color: s.primary_color || '#6750a4',
|
primary_color: s.primary_color || '#6750a4',
|
||||||
theme_wallpaper: s.theme_wallpaper || '',
|
theme_wallpaper: s.theme_wallpaper || '',
|
||||||
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
|
||||||
|
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
|
||||||
nav_style: s.nav_style || 'default',
|
nav_style: s.nav_style || 'default',
|
||||||
card_style: s.card_style || 'default',
|
card_style: s.card_style || 'default',
|
||||||
glass_blur: s.glass_blur || '20',
|
glass_blur: s.glass_blur || '20',
|
||||||
@@ -65,6 +67,7 @@ export default function ThemeSettings() {
|
|||||||
primary_color: form.primary_color,
|
primary_color: form.primary_color,
|
||||||
theme_wallpaper: form.theme_wallpaper.trim(),
|
theme_wallpaper: form.theme_wallpaper.trim(),
|
||||||
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
theme_wallpaper_scale: form.theme_wallpaper_scale,
|
||||||
|
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
|
||||||
theme_force_dark: form.theme_force_dark ? '1' : '0',
|
theme_force_dark: form.theme_force_dark ? '1' : '0',
|
||||||
nav_style: form.nav_style,
|
nav_style: form.nav_style,
|
||||||
card_style: form.card_style,
|
card_style: form.card_style,
|
||||||
@@ -92,6 +95,11 @@ export default function ThemeSettings() {
|
|||||||
|
|
||||||
<Paper sx={{ p: 3, mb: 2 }}>
|
<Paper sx={{ p: 3, mb: 2 }}>
|
||||||
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
<Typography variant="subtitle1" sx={{ mb: 1 }}>壁纸背景</Typography>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.theme_wallpaper_enabled} onChange={(e) => setForm((p) => ({ ...p, theme_wallpaper_enabled: e.target.checked }))} />}
|
||||||
|
label="启用壁纸背景"
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
|
||||||
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
|
||||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function Layout() {
|
|||||||
const wallpaper = settings.theme_wallpaper || '';
|
const wallpaper = settings.theme_wallpaper || '';
|
||||||
const scale = settings.theme_wallpaper_scale || 'cover';
|
const scale = settings.theme_wallpaper_scale || 'cover';
|
||||||
|
|
||||||
if (wallpaper) {
|
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
|
||||||
body.classList.add('has-wallpaper');
|
body.classList.add('has-wallpaper');
|
||||||
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
|
||||||
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ export function renderContent(content, useMarkdown) {
|
|||||||
|
|
||||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
||||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
|
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
|
||||||
return DOMPurify.sanitize(html);
|
html = DOMPurify.sanitize(html);
|
||||||
|
|
||||||
|
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||||
|
// 不在渲染后 DOM 上赋 id——React 重渲可能替换 DOM 导致 id 丢失)
|
||||||
|
let h2Index = 0;
|
||||||
|
html = html.replace(/<h2(?![^>]*\bid=)/gi, () => `<h2 id="toc-${h2Index++}"`);
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
|
export default function MarkdownRenderer({ content = '', useMarkdown = true }) {
|
||||||
|
|||||||
@@ -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">
|
||||||
{/* 工具条:搜索框 + 归档 */}
|
{/* 工具条:搜索框 + 归档 */}
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export default function BlogDetail() {
|
|||||||
const [prevnext, setPrevnext] = useState(null);
|
const [prevnext, setPrevnext] = useState(null);
|
||||||
const [like, setLike] = useState({ liked: false, count: 0 });
|
const [like, setLike] = useState({ liked: false, count: 0 });
|
||||||
const [toc, setToc] = useState([]);
|
const [toc, setToc] = useState([]);
|
||||||
const [tocOpen, setTocOpen] = useState(true);
|
|
||||||
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
const [replyTo, setReplyTo] = useState(null); // {id, name}
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -67,20 +66,22 @@ export default function BlogDetail() {
|
|||||||
}
|
}
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
// 目录:从已渲染的 .md-body 提取 h2/h3 并打锚点 id
|
|
||||||
useEffect(() => {
|
|
||||||
if (!post) return;
|
|
||||||
const body = document.querySelector('.blog-article .md-body');
|
|
||||||
if (!body) { setToc([]); return; }
|
|
||||||
const items = [...body.querySelectorAll('h2, h3')].map((h, i) => {
|
|
||||||
if (!h.id) h.id = 'toc-' + i;
|
|
||||||
return { id: h.id, text: (h.textContent || '').trim(), level: h.tagName === 'H2' ? 2 : 3 };
|
|
||||||
});
|
|
||||||
setToc(items);
|
|
||||||
}, [post]);
|
|
||||||
|
|
||||||
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
|
||||||
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
const wordCount = useMemo(() => (post ? countWords(post.content) : 0), [post]);
|
||||||
|
|
||||||
|
// 目录:从已渲染的 .md-body 提取 h2(仅一级章节)打锚点;短文章(<500 字)不提取
|
||||||
|
useEffect(() => {
|
||||||
|
if (!post) return;
|
||||||
|
if (wordCount < 500) { setToc([]); return; }
|
||||||
|
const body = document.querySelector('.blog-article .md-body');
|
||||||
|
if (!body) { setToc([]); return; }
|
||||||
|
const items = [...body.querySelectorAll('h2')].map((h, i) => {
|
||||||
|
if (!h.id) h.id = 'toc-' + i;
|
||||||
|
return { id: h.id, text: (h.textContent || '').trim(), level: 2 };
|
||||||
|
});
|
||||||
|
setToc(items);
|
||||||
|
}, [post, wordCount]);
|
||||||
|
|
||||||
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
const tags = useMemo(() => String(post?.tags || '').split(',').map((t) => t.trim()).filter(Boolean), [post]);
|
||||||
|
|
||||||
// ── 点赞(乐观更新)──
|
// ── 点赞(乐观更新)──
|
||||||
@@ -161,7 +162,8 @@ export default function BlogDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="blog-article">
|
<div className="article-layout">
|
||||||
|
<div className="blog-article article-main">
|
||||||
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
|
||||||
</Link>
|
</Link>
|
||||||
@@ -185,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>
|
||||||
@@ -194,36 +196,6 @@ export default function BlogDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 目录(正文顶部,可折叠) */}
|
|
||||||
{toc.length > 0 && (
|
|
||||||
<div className="toc-block">
|
|
||||||
<button className="toc-toggle" onClick={() => setTocOpen((v) => !v)}>
|
|
||||||
<span className="material-icons" style={{ fontSize: 17 }}>format_list_bulleted</span>
|
|
||||||
目录
|
|
||||||
<span className="toc-count">{toc.length}</span>
|
|
||||||
<span className={'material-icons toc-arrow' + (tocOpen ? ' open' : '')} style={{ fontSize: 18 }}>expand_more</span>
|
|
||||||
</button>
|
|
||||||
{tocOpen && (
|
|
||||||
<ul className="toc-list">
|
|
||||||
{toc.map((t) => (
|
|
||||||
<li key={t.id} className={'toc-item toc-lv' + t.level}>
|
|
||||||
<a
|
|
||||||
href={'#' + t.id}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const el = document.getElementById(t.id);
|
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t.text}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
|
||||||
|
|
||||||
{/* 上一篇 / 下一篇 */}
|
{/* 上一篇 / 下一篇 */}
|
||||||
@@ -253,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>
|
||||||
@@ -295,5 +267,32 @@ export default function BlogDetail() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 侧边目录(仅 h2 章节;短文章 <500 字不显示) */}
|
||||||
|
{toc.length > 0 && (
|
||||||
|
<aside className="article-toc-side">
|
||||||
|
<div className="toc-title">
|
||||||
|
<span className="material-icons" style={{ fontSize: 16 }}>format_list_bulleted</span>
|
||||||
|
目录 <span className="toc-count">{toc.length}</span>
|
||||||
|
</div>
|
||||||
|
<ul className="toc-list">
|
||||||
|
{toc.map((t) => (
|
||||||
|
<li key={t.id} className="toc-item">
|
||||||
|
<a
|
||||||
|
href={'#' + t.id}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = document.getElementById(t.id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +250,23 @@ 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 (
|
return (
|
||||||
|
<>
|
||||||
|
{/* PIN 屏(设置 或 解锁)——全屏覆盖页,非弹窗 */}
|
||||||
|
{phase === 'setup' || phase === 'locked' ? (
|
||||||
<div className="pin-overlay">
|
<div className="pin-overlay">
|
||||||
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
|
||||||
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
<div style={{ fontSize: 18, fontWeight: 500 }}>
|
||||||
@@ -282,11 +291,8 @@ export default function Passwords() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
) : (
|
||||||
}
|
/* 已解锁:密码箱 */
|
||||||
|
|
||||||
// 已解锁:密码箱
|
|
||||||
return (
|
|
||||||
<div>
|
<div>
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<h2>已保存的密码</h2>
|
<h2>已保存的密码</h2>
|
||||||
@@ -313,14 +319,18 @@ export default function Passwords() {
|
|||||||
{entries && entries.length > 0 && (
|
{entries && entries.length > 0 && (
|
||||||
<div className="password-grid">
|
<div className="password-grid">
|
||||||
{entries.map((p) => (
|
{entries.map((p) => (
|
||||||
<div key={p.id} className="card password-card" onClick={() => setDetailId(p.id)}>
|
<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-title">{p.title}</div>
|
||||||
<div className="pw-username">{p.username || '无用户名'}</div>
|
<div className="pw-username">{p.username || '无用户名'}</div>
|
||||||
|
</button>
|
||||||
<div className="pw-actions">
|
<div className="pw-actions">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
className="btn-icon"
|
className="btn-icon"
|
||||||
style={{ width: 32, height: 32, fontSize: 16 }}
|
style={{ width: 40, height: 40, fontSize: 16 }}
|
||||||
title="复制密码"
|
title="复制密码"
|
||||||
|
aria-label={`复制 ${p.title} 的密码`}
|
||||||
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
|
||||||
>
|
>
|
||||||
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
|
||||||
@@ -330,13 +340,23 @@ export default function Passwords() {
|
|||||||
))}
|
))}
|
||||||
</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,409 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import { getToken } from '../../api/client.js';
|
||||||
|
import { me } from '../../api/auth.js';
|
||||||
|
import usePanels from './hooks/usePanels.js';
|
||||||
|
import Toolbar from './components/Toolbar.jsx';
|
||||||
|
import Sidebar from './components/Sidebar.jsx';
|
||||||
|
import PanelFrame from './components/PanelFrame.jsx';
|
||||||
|
import AddPanelDialog from './components/AddPanelDialog.jsx';
|
||||||
|
import VaultDrawer from './components/VaultDrawer.jsx';
|
||||||
|
import NoteEditor from './components/NoteEditor.jsx';
|
||||||
|
import PanelIcon from './components/PanelIcon.jsx';
|
||||||
|
import SnackHost, { showSnack } from '../../admin/snack.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Workbench:面板工作台页面入口(路由 /admin/workbench)
|
||||||
|
* - 鉴权守卫:无 token → /login.html;非 admin → 403 提示返回
|
||||||
|
* - 组装 Toolbar + Sidebar + PanelFrame 区 + AddPanelDialog
|
||||||
|
* - 挂载 VaultDrawer(Lane B 提供),LRU 保留最多 3 个活跃 iframe
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
function AdminGuard({ children }) {
|
||||||
|
const [state, setState] = useState('checking'); // checking | denied | ok
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) { window.location.href = '/login.html'; return; }
|
||||||
|
me()
|
||||||
|
.then((u) => {
|
||||||
|
if (u.role !== 'admin') setState('denied');
|
||||||
|
else setState('ok');
|
||||||
|
})
|
||||||
|
.catch(() => { window.location.href = '/login.html'; });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (state === 'checking') {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
|
||||||
|
<CircularProgress />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === 'denied') {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
height: '100vh', display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', gap: 2, px: 3, textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LockIcon sx={{ fontSize: 48, color: 'error.main' }} />
|
||||||
|
<Typography variant="h6">需要管理员权限</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
|
||||||
|
面板工作台仅对管理员开放。如需使用,请使用管理员账号登录,或返回前台。
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button variant="outlined" startIcon={<HomeIcon />} href="/">返回前台</Button>
|
||||||
|
<Button variant="contained" href="/login.html">重新登录</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开面板标签条:切换 + 关闭(浏览器标签风格,支持方向键/Home/End 键盘导航) */
|
||||||
|
function TabStrip({ openPanels, activeId, onSelect, onClose, onAdd }) {
|
||||||
|
const stripRef = useRef(null);
|
||||||
|
|
||||||
|
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
|
||||||
|
const moveTo = (i) => {
|
||||||
|
const n = openPanels.length;
|
||||||
|
if (n === 0) return;
|
||||||
|
const j = ((i % n) + n) % n;
|
||||||
|
onSelect(openPanels[j].id);
|
||||||
|
tabEls()[j]?.focus();
|
||||||
|
};
|
||||||
|
const handleKeyDown = (e, index) => {
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(index + 1); }
|
||||||
|
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(index - 1); }
|
||||||
|
else if (e.key === 'Home') { e.preventDefault(); moveTo(0); }
|
||||||
|
else if (e.key === 'End') { e.preventDefault(); moveTo(openPanels.length - 1); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
ref={stripRef}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="已打开的面板"
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.5,
|
||||||
|
height: 40, flexShrink: 0, bgcolor: 'background.paper',
|
||||||
|
borderBottom: 1, borderColor: 'divider', overflowX: 'auto', overflowY: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{openPanels.map((p, index) => {
|
||||||
|
const active = String(p.id) === String(activeId);
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
tabIndex={active ? 0 : -1}
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
|
||||||
|
height: 28, borderRadius: 1, cursor: 'pointer', flexShrink: 0, outline: 'none',
|
||||||
|
bgcolor: active ? 'primary.container' : 'transparent',
|
||||||
|
color: active ? 'primary.onContainer' : 'text.secondary',
|
||||||
|
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
|
||||||
|
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PanelIcon url={p.embed_url || p.url} title={p.title} size={14} />
|
||||||
|
<Typography
|
||||||
|
sx={{
|
||||||
|
fontSize: 12, fontWeight: active ? 600 : 500, maxWidth: 120,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.title}
|
||||||
|
</Typography>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onClose(p.id); }}
|
||||||
|
aria-label={`关闭面板 ${p.title}`}
|
||||||
|
sx={{ width: 20, height: 20, borderRadius: 0.5, '& svg': { fontSize: 14 } }}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Tooltip title="添加面板">
|
||||||
|
<IconButton size="small" onClick={onAdd} sx={{ ml: 0.25, flexShrink: 0 }} aria-label="添加面板">
|
||||||
|
<AddIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, title, hint, action, onAction }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>{title}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{hint}</Typography>
|
||||||
|
</Box>
|
||||||
|
{action && <Button variant="contained" startIcon={<AddIcon />} onClick={onAction}>{action}</Button>}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WorkbenchContent() {
|
||||||
|
const panels = usePanels();
|
||||||
|
const {
|
||||||
|
links, loadError, reload, panelsById, renderedIds, openPanels,
|
||||||
|
activeId, activePanel, modalPanel, closeModal,
|
||||||
|
canBack, canForward, goBack, goForward,
|
||||||
|
openPanel, closePanel, togglePin, toggleGroup, setMode,
|
||||||
|
pinned, groups, collapsed, setCollapsed,
|
||||||
|
search, setSearch, filteredPanels, openFirstMatch,
|
||||||
|
} = panels;
|
||||||
|
|
||||||
|
// 主面板与弹窗面板各自独立的刷新信号(弹窗刷新不连带主面板)
|
||||||
|
const [mainRefresh, setMainRefresh] = useState(0);
|
||||||
|
const [modalRefresh, setModalRefresh] = useState(0);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [vaultOpen, setVaultOpen] = useState(false);
|
||||||
|
// 记事本:noteOpen 开关,noteMode 'float'(右下角浮窗) / 'fixed'(右侧固定栏)
|
||||||
|
const [noteOpen, setNoteOpen] = useState(false);
|
||||||
|
const [noteMode, setNoteMode] = useState('float');
|
||||||
|
const toggleNoteMode = () => setNoteMode((m) => (m === 'float' ? 'fixed' : 'float'));
|
||||||
|
|
||||||
|
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
|
||||||
|
|
||||||
|
const handleOpenExternal = () => {
|
||||||
|
if (activePanel) window.open(activePanel.embed_url || activePanel.url, '_blank', 'noopener');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaved = (panel) => {
|
||||||
|
showSnack(`已添加「${panel.title}」`);
|
||||||
|
setAddOpen(false);
|
||||||
|
reload().then(() => openPanel(panel.id, panel));
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (() => {
|
||||||
|
if (links === null && !loadError) {
|
||||||
|
// 加载态
|
||||||
|
return (
|
||||||
|
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<CircularProgress size={36} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (loadError) {
|
||||||
|
// 错误态
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />}
|
||||||
|
title="面板列表加载失败"
|
||||||
|
hint={loadError}
|
||||||
|
action="重试"
|
||||||
|
onAction={reload}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (links.length === 0) {
|
||||||
|
// 空态:无面板引导添加
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />}
|
||||||
|
title="还没有任何面板"
|
||||||
|
hint="添加常用后台 / 监控 / 工具站到工作台,一个界面全部打开"
|
||||||
|
action="添加第一个面板"
|
||||||
|
onAction={() => setAddOpen(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!activePanel) {
|
||||||
|
// 已打开的面板都被关闭
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />}
|
||||||
|
title="未打开面板"
|
||||||
|
hint="从左侧选择一个面板开始,或搜索后按 Enter 直达"
|
||||||
|
action="打开一个面板"
|
||||||
|
onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 正常态:LRU 渲染(当前 + 最近 2 个)
|
||||||
|
return renderedIds.map((id) => {
|
||||||
|
const panel = panelsById[id];
|
||||||
|
if (!panel) return null;
|
||||||
|
return (
|
||||||
|
<PanelFrame
|
||||||
|
key={id}
|
||||||
|
panel={panel}
|
||||||
|
active={String(id) === String(activeId)}
|
||||||
|
refreshNonce={mainRefresh}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toolbar
|
||||||
|
canBack={canBack}
|
||||||
|
canForward={canForward}
|
||||||
|
onBack={goBack}
|
||||||
|
onForward={goForward}
|
||||||
|
onRefresh={() => { if (activePanel) setMainRefresh((n) => n + 1); }}
|
||||||
|
currentPanel={activePanel}
|
||||||
|
onOpenExternal={handleOpenExternal}
|
||||||
|
search={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
onSearchEnter={() => {
|
||||||
|
if (!openFirstMatch() && search.trim()) showSnack('未找到匹配的面板', 'info');
|
||||||
|
}}
|
||||||
|
onAdd={() => setAddOpen(true)}
|
||||||
|
onOpenVault={() => setVaultOpen(true)}
|
||||||
|
notesOpen={noteOpen}
|
||||||
|
onOpenNotes={() => setNoteOpen(true)}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleSidebar={() => setCollapsed((c) => !c)}
|
||||||
|
onBackHome={() => { window.location.href = '/'; }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flex: 1, minHeight: 0, width: '100%' }}>
|
||||||
|
<Sidebar
|
||||||
|
loading={links === null && !loadError}
|
||||||
|
error={loadError}
|
||||||
|
onRetry={reload}
|
||||||
|
links={links || []}
|
||||||
|
filtered={filteredPanels}
|
||||||
|
search={search}
|
||||||
|
activeId={activeId}
|
||||||
|
pinned={pinned}
|
||||||
|
onSelect={openPanel}
|
||||||
|
onPin={togglePin}
|
||||||
|
groups={groups}
|
||||||
|
onToggleGroup={toggleGroup}
|
||||||
|
modes={panels.modes}
|
||||||
|
onSetMode={setMode}
|
||||||
|
onAdd={() => setAddOpen(true)}
|
||||||
|
collapsed={collapsed}
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
component="main"
|
||||||
|
sx={{
|
||||||
|
flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column',
|
||||||
|
bgcolor: 'background.default', overflow: 'hidden',
|
||||||
|
// 记事本固定态:右侧占 280px,主内容区推挤
|
||||||
|
mr: noteOpen && noteMode === 'fixed' ? '280px' : 0,
|
||||||
|
transition: 'margin-right .2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{openPanels.length > 0 && (
|
||||||
|
<TabStrip
|
||||||
|
openPanels={openPanels}
|
||||||
|
activeId={activeId}
|
||||||
|
onSelect={openPanel}
|
||||||
|
onClose={closePanel}
|
||||||
|
onAdd={() => setAddOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative' }}>
|
||||||
|
{content}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<AddPanelDialog
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
categories={categories}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
onSetMode={setMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 密码库抽屉(Lane B 提供完整实现,此处仅挂载) */}
|
||||||
|
<VaultDrawer open={vaultOpen} onClose={() => setVaultOpen(false)} />
|
||||||
|
|
||||||
|
{/* 记事本:float=右下角拖拽浮窗 / fixed=右侧固定栏;保持挂载以保留编辑状态 */}
|
||||||
|
{noteOpen && (
|
||||||
|
<NoteEditor mode={noteMode} onToggleMode={toggleNoteMode} onClose={() => setNoteOpen(false)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 弹窗打开方式:Dialog 内嵌 PanelFrame */}
|
||||||
|
{modalPanel && (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
fullWidth
|
||||||
|
maxWidth="lg"
|
||||||
|
onClose={closeModal}
|
||||||
|
sx={{ '& .MuiDialog-paper': { height: '85vh', maxHeight: '85vh' } }}
|
||||||
|
>
|
||||||
|
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, pr: 1 }}>
|
||||||
|
<PanelIcon url={modalPanel.embed_url || modalPanel.url} title={modalPanel.title} size={22} />
|
||||||
|
<Typography noWrap sx={{ flex: 1, fontSize: 16, fontWeight: 500 }}>
|
||||||
|
{modalPanel.title}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="在新标签页打开">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => window.open(modalPanel.embed_url || modalPanel.url, '_blank', 'noopener')}
|
||||||
|
>
|
||||||
|
<OpenInNewIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="刷新">
|
||||||
|
<IconButton size="small" onClick={() => setModalRefresh((n) => n + 1)}>
|
||||||
|
<RefreshIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<IconButton size="small" onClick={closeModal} aria-label="关闭弹窗">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent sx={{ p: 0, display: 'flex', flex: 1, minHeight: 0 }}>
|
||||||
|
<PanelFrame panel={modalPanel} active refreshNonce={modalRefresh} />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SnackHost />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Workbench() {
|
||||||
|
return (
|
||||||
|
<AdminGuard>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden',
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<WorkbenchContent />
|
||||||
|
</Box>
|
||||||
|
</AdminGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import Dialog from '@mui/material/Dialog';
|
||||||
|
import DialogTitle from '@mui/material/DialogTitle';
|
||||||
|
import DialogContent from '@mui/material/DialogContent';
|
||||||
|
import DialogActions from '@mui/material/DialogActions';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import ToggleButton from '@mui/material/ToggleButton';
|
||||||
|
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
|
||||||
|
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||||
|
import Switch from '@mui/material/Switch';
|
||||||
|
import Autocomplete from '@mui/material/Autocomplete';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import WebAssetIcon from '@mui/icons-material/WebAsset';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||||
|
import { createAdminLink } from '../../../api/adminLinks.js';
|
||||||
|
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* AddPanelDialog:添加面板
|
||||||
|
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式(内嵌/新标签/弹窗)
|
||||||
|
* + 嵌入 URL + 代理开关;保存到 admin_links(复用现有 API)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const MODE_META = [
|
||||||
|
{ mode: OPEN_MODE_EMBED, label: '内嵌', Icon: WebAssetIcon },
|
||||||
|
{ mode: OPEN_MODE_TAB, label: '新标签', Icon: OpenInNewIcon },
|
||||||
|
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeUrl(raw) {
|
||||||
|
const s = (raw || '').trim();
|
||||||
|
if (!s) return '';
|
||||||
|
return /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : 'https://' + s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddPanelDialog({ open, onClose, categories = [], onSaved, onSetMode }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
|
||||||
|
use_proxy: false, embed_url: '',
|
||||||
|
});
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const host = useMemo(() => {
|
||||||
|
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
|
||||||
|
}, [form.url]);
|
||||||
|
|
||||||
|
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
const title = form.title.trim();
|
||||||
|
const url = normalizeUrl(form.url);
|
||||||
|
if (!title) { setErr('请输入面板标题'); return; }
|
||||||
|
if (!url) { setErr('请输入面板链接'); return; }
|
||||||
|
try {
|
||||||
|
const panel = await createAdminLink({
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
embed_url: form.embed_url.trim(),
|
||||||
|
category: form.category.trim() || '默认',
|
||||||
|
use_proxy: form.use_proxy ? 1 : 0,
|
||||||
|
description: '',
|
||||||
|
icon: '',
|
||||||
|
sort_order: 0,
|
||||||
|
version: '',
|
||||||
|
});
|
||||||
|
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
|
||||||
|
setErr('');
|
||||||
|
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
|
||||||
|
onSaved(panel);
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message || '保存失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>添加面板</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="标题 *"
|
||||||
|
value={form.title}
|
||||||
|
onChange={set('title')}
|
||||||
|
margin="normal"
|
||||||
|
placeholder="例如:服务器监控"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="链接 URL *"
|
||||||
|
value={form.url}
|
||||||
|
onChange={set('url')}
|
||||||
|
margin="normal"
|
||||||
|
placeholder="example.com 或 https://…"
|
||||||
|
error={!!err && !form.url.trim()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* favicon 实时预览 */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1, mb: 0.5 }}>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
width: 32, height: 32, borderRadius: 1.5, border: 1, borderColor: 'divider',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
bgcolor: 'background.paper', overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{host ? (
|
||||||
|
<img
|
||||||
|
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||||
|
alt=""
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
loading="lazy"
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
onError={(e) => { e.target.style.visibility = 'hidden'; }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WebAssetIcon sx={{ fontSize: 18, color: 'text.disabled' }} />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
{host ? `站点:${host}` : '输入链接后预览站点图标'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
freeSolo
|
||||||
|
fullWidth
|
||||||
|
value={form.category}
|
||||||
|
onChange={(e, v) => setForm((p) => ({ ...p, category: v || '' }))}
|
||||||
|
onInputChange={(e, v) => setForm((p) => ({ ...p, category: v }))}
|
||||||
|
options={categories}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField {...params} label="分组" margin="normal" placeholder="默认" />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography variant="caption" sx={{ display: 'block', mt: 1, mb: 0.5, color: 'text.secondary' }}>
|
||||||
|
打开方式
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
exclusive
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
value={form.openMode}
|
||||||
|
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, openMode: v })); }}
|
||||||
|
aria-label="打开方式"
|
||||||
|
>
|
||||||
|
{MODE_META.map(({ mode, label, Icon }) => (
|
||||||
|
<ToggleButton key={mode} value={mode} sx={{ py: 0.75 }}>
|
||||||
|
<Icon fontSize="small" sx={{ mr: 0.5 }} />{label}
|
||||||
|
</ToggleButton>
|
||||||
|
))}
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
|
||||||
|
{form.openMode === OPEN_MODE_EMBED && (
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={form.use_proxy}
|
||||||
|
onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="通过代理嵌入(绕过 X-Frame-Options 限制)"
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="嵌入 URL(可选,iframe 专用)"
|
||||||
|
value={form.embed_url}
|
||||||
|
onChange={set('embed_url')}
|
||||||
|
margin="dense"
|
||||||
|
placeholder="留空则使用上面的链接"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{err && (
|
||||||
|
<Typography variant="body2" sx={{ mt: 1.5, color: 'error.main' }}>{err}</Typography>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>取消</Button>
|
||||||
|
<Button variant="contained" onClick={save}>添加</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import AppBar from '@mui/material/AppBar';
|
||||||
|
import MuiToolbar from '@mui/material/Toolbar';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
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 PushPinIcon from '@mui/icons-material/PushPin';
|
||||||
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* NoteEditor:工作台「记事本」
|
||||||
|
*
|
||||||
|
* 两种形态(由 mode 切换,组件保持挂载不丢编辑状态):
|
||||||
|
* - float:右下角可拖拽浮窗(320×400),标题栏拖动
|
||||||
|
* - fixed:右侧固定侧边栏(280px,position:fixed top:56),
|
||||||
|
* 主内容区由 Workbench 加 margin-right 推挤
|
||||||
|
*
|
||||||
|
* 布局:顶部工具栏(标题 + 📌 固定/浮窗 + ✕ 关闭)→
|
||||||
|
* 状态条(当前文件 + 保存状态)→ 上半编辑器(textarea 自动保存)
|
||||||
|
* → 下半文件管理器(列表 + 新建/双击重命名/右键菜单/删除)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const FLOAT_W = 320;
|
||||||
|
const FLOAT_H = 400;
|
||||||
|
|
||||||
|
/* 精简时间格式:今天 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('');
|
||||||
|
|
||||||
|
// 每次打开时重置为 initial
|
||||||
|
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 NoteEditor({ mode = 'float', onToggleMode, onClose }) {
|
||||||
|
const notes = useNotes();
|
||||||
|
const {
|
||||||
|
notes: list, loadError, refresh,
|
||||||
|
activeName, content, dirty, saving, busy,
|
||||||
|
open, updateContent, saveNow, create, rename, remove, nextUntitled,
|
||||||
|
} = notes;
|
||||||
|
|
||||||
|
// ── 浮窗拖拽(标题栏) ──
|
||||||
|
const [pos, setPos] = useState(() => ({
|
||||||
|
x: Math.max(0, (typeof window !== 'undefined' ? window.innerWidth : 1200) - FLOAT_W - 16),
|
||||||
|
y: Math.max(0, (typeof window !== 'undefined' ? window.innerHeight : 800) - FLOAT_H - 72),
|
||||||
|
}));
|
||||||
|
const dragRef = useRef(null);
|
||||||
|
const startDrag = (e) => {
|
||||||
|
if (mode !== 'float') return;
|
||||||
|
if (e.target.closest('button')) return; // 不拦截按钮
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startY = e.clientY;
|
||||||
|
const base = pos;
|
||||||
|
dragRef.current = (ev) => {
|
||||||
|
const x = Math.min(Math.max(0, base.x + ev.clientX - startX), window.innerWidth - FLOAT_W);
|
||||||
|
const y = Math.min(Math.max(0, base.y + ev.clientY - startY), window.innerHeight - 56);
|
||||||
|
setPos({ x, y });
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', dragRef.current);
|
||||||
|
window.addEventListener('mouseup', endDrag);
|
||||||
|
};
|
||||||
|
const endDrag = () => {
|
||||||
|
window.removeEventListener('mousemove', dragRef.current);
|
||||||
|
window.removeEventListener('mouseup', endDrag);
|
||||||
|
dragRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 弹窗/菜单状态 ──
|
||||||
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
|
const [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 ? '未保存' : '已保存';
|
||||||
|
|
||||||
|
// ── 头部工具栏(共享) ──
|
||||||
|
const header = (
|
||||||
|
<AppBar position="static" elevation={0}
|
||||||
|
sx={{
|
||||||
|
flexShrink: 0, cursor: mode === 'float' ? 'grab' : 'default',
|
||||||
|
'&:active': { cursor: mode === 'float' ? 'grabbing' : 'default' },
|
||||||
|
borderRadius: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MuiToolbar variant="dense" sx={{ minHeight: 44, px: 1, gap: 0.25, cursor: 'inherit' }} onMouseDown={startDrag}>
|
||||||
|
<NoteAltIcon sx={{ fontSize: 18, color: 'primary.main' }} />
|
||||||
|
<Typography sx={{ flex: 1, fontSize: 14, fontWeight: 600, ml: 0.5 }}>记事本</Typography>
|
||||||
|
<Tooltip title={mode === 'float' ? '固定到右侧' : '收回为浮窗'}>
|
||||||
|
<IconButton size="small" onMouseDown={(e) => e.stopPropagation()} onClick={onToggleMode} aria-label="固定/浮窗切换">
|
||||||
|
{mode === 'float' ? <PushPinOutlinedIcon fontSize="small" /> : <PushPinIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="关闭记事本">
|
||||||
|
<IconButton size="small" onMouseDown={(e) => e.stopPropagation()} onClick={onClose} aria-label="关闭记事本">
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</MuiToolbar>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 状态条(当前文件 + 保存状态) ──
|
||||||
|
const statusBar = (
|
||||||
|
<Box sx={{ px: 1.5, py: 0.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default', borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 编辑器(上半) ──
|
||||||
|
const editor = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 文件管理器(下半) ──
|
||||||
|
const fileManager = (
|
||||||
|
<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 (
|
||||||
|
<Box
|
||||||
|
key={n.name}
|
||||||
|
onClick={() => open(n.name)}
|
||||||
|
onDoubleClick={() => setRenameTarget(n.name)}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtx({ name: n.name, anchorEl: e.currentTarget }); }}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', cursor: 'pointer', minHeight: 40,
|
||||||
|
bgcolor: active ? 'primary.container' : 'transparent',
|
||||||
|
color: active ? 'primary.onContainer' : 'text.primary',
|
||||||
|
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
|
||||||
|
'&:hover .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: 26, height: 26, mr: 0.5, transition: 'opacity .12s' }}
|
||||||
|
>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── 右键菜单 ──
|
||||||
|
const ctxMenu = (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const existsList = (list || []).map((n) => n.name.toLowerCase());
|
||||||
|
|
||||||
|
const rootSx = mode === 'float'
|
||||||
|
? {
|
||||||
|
position: 'fixed', left: pos.x, top: pos.y,
|
||||||
|
width: FLOAT_W, height: FLOAT_H, zIndex: 1240, // 低于 MUI Dialog(1300),不遮挡弹窗
|
||||||
|
borderRadius: 2, overflow: 'hidden',
|
||||||
|
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
|
||||||
|
boxShadow: (t) => t.shadows[8], display: 'flex', flexDirection: 'column',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
position: 'fixed', top: 56, right: 0, bottom: 0,
|
||||||
|
width: 280, zIndex: 1100,
|
||||||
|
bgcolor: 'background.paper', borderLeft: 1, borderColor: 'divider',
|
||||||
|
display: 'flex', flexDirection: 'column',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box component="section" aria-label="记事本" sx={rootSx}>
|
||||||
|
{header}
|
||||||
|
{statusBar}
|
||||||
|
{editor}
|
||||||
|
{fileManager}
|
||||||
|
{ctxMenu}
|
||||||
|
|
||||||
|
<NameDialog
|
||||||
|
open={newOpen}
|
||||||
|
title="新建笔记"
|
||||||
|
initial={nextUntitled}
|
||||||
|
confirmText="创建"
|
||||||
|
exists={existsList}
|
||||||
|
busy={busy}
|
||||||
|
onClose={() => setNewOpen(false)}
|
||||||
|
onConfirm={handleCreate}
|
||||||
|
/>
|
||||||
|
<NameDialog
|
||||||
|
open={!!renameTarget}
|
||||||
|
title="重命名笔记"
|
||||||
|
initial={renameTarget || ''}
|
||||||
|
confirmText="保存"
|
||||||
|
exists={existsList.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: '#ffffff', flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mounted && (
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title={panel.title || '面板'}
|
||||||
|
src={src}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
sandbox={sandbox}
|
||||||
|
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: '#fff' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'loading' && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
|
||||||
|
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5,
|
||||||
|
bgcolor: 'background.default',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={32} />
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||||
|
正在加载 {panel.title || '面板'}…
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'timeout' && (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
|
||||||
|
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
|
||||||
|
bgcolor: 'background.default', px: 3, textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ErrorIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>面板加载超时</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
|
||||||
|
目标站点可能拒绝嵌入(X-Frame-Options),或响应过慢。
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
startIcon={<OpenInNewIcon fontSize="small" />}
|
||||||
|
href={targetUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
在新标签页打开
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
startIcon={<RefreshIcon fontSize="small" />}
|
||||||
|
onClick={handleRetry}
|
||||||
|
>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载成功后的非阻塞提示条:空白页可能被 X-Frame-Options 拒绝 */}
|
||||||
|
{showHint && status === 'loaded' && !needsProxy && (
|
||||||
|
<Box
|
||||||
|
role="status"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: '50%', transform: 'translateX(-50%)', bottom: 16,
|
||||||
|
zIndex: 3, maxWidth: '92%', display: 'flex', alignItems: 'center', gap: 0.5,
|
||||||
|
px: 1.5, py: 0.5, borderRadius: 2,
|
||||||
|
bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||||
|
border: 1, borderColor: 'divider', boxShadow: (t) => t.shadows[3],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" sx={{ fontWeight: 500 }}>
|
||||||
|
若页面空白,可能被 X-Frame-Options 拒绝
|
||||||
|
</Typography>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
component="a"
|
||||||
|
href={targetUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
sx={{ minHeight: 28, px: 1, color: 'primary.onContainer', fontWeight: 700 }}
|
||||||
|
>
|
||||||
|
在新标签打开
|
||||||
|
</Button>
|
||||||
|
<IconButton size="small" onClick={() => setShowHint(false)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import LanguageIcon from '@mui/icons-material/Language';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 面板图标:Google favicon 服务 + 失败回退(首字母块 / 地球图标)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
function hostOf(url) {
|
||||||
|
try {
|
||||||
|
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
|
||||||
|
return new URL(u).hostname;
|
||||||
|
} catch { return ''; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const host = hostOf(url);
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
width: size, height: size, borderRadius: '50%', flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||||
|
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
|
||||||
|
...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(title[0] || '?').toUpperCase()}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
width: size, height: size, borderRadius: size / 4, overflow: 'hidden',
|
||||||
|
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
bgcolor: 'background.paper', ...sx,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error ? (
|
||||||
|
<LanguageIcon sx={{ fontSize: size * 0.7, color: 'text.disabled' }} />
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
|
||||||
|
alt=""
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
loading="lazy"
|
||||||
|
onError={() => setError(true)}
|
||||||
|
style={{ display: 'block' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import React, { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Menu from '@mui/material/Menu';
|
||||||
|
import MenuItem from '@mui/material/MenuItem';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import Skeleton from '@mui/material/Skeleton';
|
||||||
|
import StarIcon from '@mui/icons-material/Star';
|
||||||
|
import StarBorderIcon from '@mui/icons-material/StarBorder';
|
||||||
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import WebAssetIcon from '@mui/icons-material/WebAsset';
|
||||||
|
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
|
||||||
|
import WidgetsIcon from '@mui/icons-material/Widgets';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import PanelIcon from './PanelIcon.jsx';
|
||||||
|
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Sidebar:左侧面板列表(默认 240px,可拖拽 180-360px 并记忆)
|
||||||
|
* - 固定区(pin,localStorage)+ admin_links 按 category 分组
|
||||||
|
* - 当前项:左侧 2px primary 指示条 + primary-container 选中底
|
||||||
|
* - 分组折叠(持久化)、右键切换打开方式、底部 + 添加
|
||||||
|
* - 搜索时展示扁平结果列表
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const WIDTH_KEY = 'workbench.sidebarWidth';
|
||||||
|
const MODE_META = [
|
||||||
|
{ mode: OPEN_MODE_EMBED, label: '内嵌打开', Icon: WebAssetIcon },
|
||||||
|
{ mode: OPEN_MODE_TAB, label: '新标签打开', Icon: OpenInNewIcon },
|
||||||
|
{ mode: OPEN_MODE_MODAL, label: '弹窗打开', Icon: OpenInFullIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Sidebar({
|
||||||
|
loading = false, error = '', onRetry,
|
||||||
|
links = [], filtered = [], search = '',
|
||||||
|
activeId = null, pinned = [], onSelect, onPin,
|
||||||
|
groups = {}, onToggleGroup,
|
||||||
|
modes = {}, onSetMode,
|
||||||
|
onAdd, collapsed = false,
|
||||||
|
}) {
|
||||||
|
const [width, setWidth] = useState(() => {
|
||||||
|
const w = Number(localStorage.getItem(WIDTH_KEY));
|
||||||
|
return w >= 180 && w <= 360 ? w : 240;
|
||||||
|
});
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const dragRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try { localStorage.setItem(WIDTH_KEY, String(width)); } catch { /* ignore */ }
|
||||||
|
}, [width]);
|
||||||
|
|
||||||
|
const handleDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const startX = e.clientX;
|
||||||
|
const startW = width;
|
||||||
|
setDragging(true);
|
||||||
|
dragRef.current = (ev) => {
|
||||||
|
const w = Math.min(360, Math.max(180, startW + ev.clientX - startX));
|
||||||
|
setWidth(w);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', dragRef.current);
|
||||||
|
window.addEventListener('mouseup', handleUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUp = () => {
|
||||||
|
window.removeEventListener('mousemove', dragRef.current);
|
||||||
|
window.removeEventListener('mouseup', handleUp);
|
||||||
|
dragRef.current = null;
|
||||||
|
setDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 右键菜单:切换打开方式
|
||||||
|
const [ctx, setCtx] = useState(null); // { id, anchorEl }
|
||||||
|
|
||||||
|
// 分组
|
||||||
|
const groupOrder = useMemo(() => {
|
||||||
|
const order = []; const seen = new Set();
|
||||||
|
links.forEach((p) => {
|
||||||
|
const c = p.category || '默认';
|
||||||
|
if (!seen.has(c)) { seen.add(c); order.push(c); }
|
||||||
|
});
|
||||||
|
return order;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const byGroup = useMemo(() => {
|
||||||
|
const m = {};
|
||||||
|
links.forEach((p) => {
|
||||||
|
const c = p.category || '默认';
|
||||||
|
(m[c] = m[c] || []).push(p);
|
||||||
|
});
|
||||||
|
return m;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const pinnedPanels = links.filter((p) => pinned.includes(p.id));
|
||||||
|
|
||||||
|
const renderItem = (p) => {
|
||||||
|
const selected = String(p.id) === String(activeId);
|
||||||
|
const isPinned = pinned.includes(p.id);
|
||||||
|
const mode = modes[p.id] || OPEN_MODE_EMBED;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
key={p.id}
|
||||||
|
sx={{
|
||||||
|
display: 'flex', alignItems: 'center', position: 'relative',
|
||||||
|
'&:hover .wb-pin': { opacity: 1 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemButton
|
||||||
|
selected={selected}
|
||||||
|
onClick={() => onSelect(p.id)}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); setCtx({ id: p.id, anchorEl: e.currentTarget }); }}
|
||||||
|
aria-label={`打开面板 ${p.title}`}
|
||||||
|
sx={{
|
||||||
|
flex: 1, minHeight: 40, borderRadius: 0, m: 0, pl: 2, pr: 4,
|
||||||
|
position: 'relative',
|
||||||
|
'&.Mui-selected': {
|
||||||
|
bgcolor: 'primary.container', color: 'primary.onContainer',
|
||||||
|
'&:hover': { bgcolor: 'primary.container' },
|
||||||
|
},
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<Box
|
||||||
|
aria-hidden
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: 0, top: '22%', bottom: '22%',
|
||||||
|
width: 2, bgcolor: 'primary.main', borderRadius: 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<PanelIcon url={p.embed_url || p.url} title={p.title} size={20} sx={{ mr: 1 }} />
|
||||||
|
<ListItemText
|
||||||
|
primary={p.title}
|
||||||
|
primaryTypographyProps={{ fontSize: 13, fontWeight: selected ? 600 : 500, noWrap: true }}
|
||||||
|
sx={{ my: 0, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
{mode !== OPEN_MODE_EMBED && (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={MODE_META.find((m) => m.mode === mode)?.label}
|
||||||
|
sx={{ mr: 0.5, fontSize: 10, color: 'text.disabled', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
{mode === OPEN_MODE_TAB ? '新标签' : '弹窗'}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ListItemButton>
|
||||||
|
<IconButton
|
||||||
|
className="wb-pin"
|
||||||
|
size="small"
|
||||||
|
onClick={() => onPin(p.id)}
|
||||||
|
aria-label={isPinned ? `取消固定 ${p.title}` : `固定 ${p.title}`}
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', right: 4, opacity: isPinned ? 1 : 0,
|
||||||
|
transition: 'opacity .12s ease', color: isPinned ? 'warning.main' : 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPinned ? <StarIcon fontSize="small" /> : <StarBorderIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionLabel = (text) => (
|
||||||
|
<Typography
|
||||||
|
component="div"
|
||||||
|
sx={{
|
||||||
|
px: 2, pt: 1.25, pb: 0.5, fontSize: 11, fontWeight: 700,
|
||||||
|
letterSpacing: 0.6, color: 'text.secondary',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
|
||||||
|
const listBody = (() => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 2, pt: 1 }}>
|
||||||
|
{[0, 1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Skeleton key={i} variant="rounded" height={36} sx={{ my: 1, bgcolor: 'action.hover' }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
|
||||||
|
<ErrorIcon sx={{ fontSize: 32, color: 'error.main', mb: 1 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>面板加载失败</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={onRetry}>重试</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (links.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
|
||||||
|
<WidgetsIcon sx={{ fontSize: 32, color: 'text.disabled', mb: 1 }} />
|
||||||
|
<Typography variant="body2" sx={{ color: 'text.secondary' }}>还没有面板,点击下方按钮添加</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (search.trim()) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{sectionLabel(`搜索结果(${filtered.length})`)}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<Typography variant="body2" sx={{ px: 2, py: 2, color: 'text.secondary' }}>无匹配面板</Typography>
|
||||||
|
) : (
|
||||||
|
<List dense disablePadding>{filtered.map(renderItem)}</List>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{pinnedPanels.length > 0 && (
|
||||||
|
<>
|
||||||
|
{sectionLabel('固定')}
|
||||||
|
<List dense disablePadding>{pinnedPanels.map(renderItem)}</List>
|
||||||
|
<Divider sx={{ mx: 2, my: 0.5 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{groupOrder.map((name) => {
|
||||||
|
const collapsedGroup = !!groups[name];
|
||||||
|
const items = byGroup[name] || [];
|
||||||
|
return (
|
||||||
|
<Fragment key={name}>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={() => onToggleGroup(name)}
|
||||||
|
aria-expanded={!collapsedGroup}
|
||||||
|
sx={{
|
||||||
|
minHeight: 36, borderRadius: 0, m: 0, px: 1.5,
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon sx={{ minWidth: 30 }}>
|
||||||
|
<FolderOpenIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={name}
|
||||||
|
primaryTypographyProps={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: 'text.secondary' }}
|
||||||
|
sx={{ my: 0 }}
|
||||||
|
/>
|
||||||
|
<Typography variant="caption" sx={{ mr: 0.5, color: 'text.disabled' }}>{items.length}</Typography>
|
||||||
|
{collapsedGroup
|
||||||
|
? <ExpandMoreIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
||||||
|
: <ExpandLessIcon fontSize="small" sx={{ color: 'text.secondary' }} />}
|
||||||
|
</ListItemButton>
|
||||||
|
<Collapse in={!collapsedGroup} unmountOnExit timeout={150}>
|
||||||
|
<List dense disablePadding>{items.map(renderItem)}</List>
|
||||||
|
</Collapse>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<Box
|
||||||
|
component="aside"
|
||||||
|
aria-label="面板侧边栏"
|
||||||
|
sx={{
|
||||||
|
position: 'relative', flexShrink: 0, width: collapsed ? 0 : width,
|
||||||
|
minWidth: 0, display: 'flex', flexDirection: 'column',
|
||||||
|
bgcolor: 'background.paper', borderRight: 1, borderColor: 'divider',
|
||||||
|
overflow: 'hidden', transition: dragging ? 'none' : 'width .2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1, flexShrink: 0 }}>
|
||||||
|
<WidgetsIcon sx={{ fontSize: 18, color: 'primary.main' }} />
|
||||||
|
<Typography sx={{ fontSize: 13, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}>
|
||||||
|
面板工作台
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Divider sx={{ flexShrink: 0 }} />
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
|
||||||
|
{listBody}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Divider sx={{ flexShrink: 0 }} />
|
||||||
|
<Box sx={{ p: 1, flexShrink: 0 }}>
|
||||||
|
<Button fullWidth size="small" startIcon={<AddIcon />} onClick={onAdd}>添加面板</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{!collapsed && (
|
||||||
|
<Box
|
||||||
|
onMouseDown={handleDown}
|
||||||
|
onDoubleClick={() => setWidth(240)}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
title="拖拽调整宽度"
|
||||||
|
sx={{
|
||||||
|
width: 5, flexShrink: 0, alignSelf: 'stretch', cursor: 'col-resize',
|
||||||
|
bgcolor: 'divider', opacity: dragging ? 1 : 0.45,
|
||||||
|
transition: 'background-color .12s ease', zIndex: 5,
|
||||||
|
'&:hover': { bgcolor: 'primary.main', opacity: 0.7 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 右键菜单:切换打开方式 */}
|
||||||
|
<Menu
|
||||||
|
open={!!ctx}
|
||||||
|
anchorEl={ctx?.anchorEl || null}
|
||||||
|
onClose={() => setCtx(null)}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||||
|
>
|
||||||
|
{MODE_META.map(({ mode, label, Icon }) => (
|
||||||
|
<MenuItem
|
||||||
|
key={mode}
|
||||||
|
selected={(modes[ctx?.id] || OPEN_MODE_EMBED) === mode}
|
||||||
|
onClick={() => {
|
||||||
|
if (ctx) { onSetMode(ctx.id, mode); onSelect(ctx.id); }
|
||||||
|
setCtx(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
|
||||||
|
{label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import InputBase from '@mui/material/InputBase';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||||
|
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import KeyIcon from '@mui/icons-material/Key';
|
||||||
|
import NoteAltIcon from '@mui/icons-material/NoteAlt';
|
||||||
|
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
|
||||||
|
import HomeIcon from '@mui/icons-material/Home';
|
||||||
|
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||||
|
import PanelIcon from './PanelIcon.jsx';
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Toolbar:浏览器式顶栏(高度 56px,surface-container 底)
|
||||||
|
* ◀ 后退 / ▶ 前进(历史栈)· ↻ 刷新 · 只读地址栏 · 🔍 搜索
|
||||||
|
* + 添加 · 🔑 密码库 · 侧边栏折叠 · 返回前台
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export default function Toolbar({
|
||||||
|
canBack = false, canForward = false, onBack, onForward, onRefresh,
|
||||||
|
currentPanel = null, onOpenExternal,
|
||||||
|
search = '', onSearchChange, onSearchEnter,
|
||||||
|
onAdd, onOpenVault, notesOpen = false, onOpenNotes,
|
||||||
|
collapsed = false, onToggleSidebar, onBackHome,
|
||||||
|
}) {
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="header"
|
||||||
|
sx={{
|
||||||
|
height: 56, flexShrink: 0, display: 'flex', alignItems: 'center',
|
||||||
|
gap: 0.5, px: { xs: 0.5, sm: 1 }, zIndex: 10,
|
||||||
|
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="后退">
|
||||||
|
<span><IconButton size="small" onClick={onBack} disabled={!canBack} aria-label="后退"><ArrowBackIcon fontSize="small" /></IconButton></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="前进">
|
||||||
|
<span><IconButton size="small" onClick={onForward} disabled={!canForward} aria-label="前进"><ArrowForwardIcon fontSize="small" /></IconButton></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="刷新当前面板">
|
||||||
|
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* 只读地址栏 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 1,
|
||||||
|
mx: { xs: 0.5, sm: 1.5 }, height: 36, px: 1.5,
|
||||||
|
bgcolor: 'background.default', border: 1, borderColor: 'divider',
|
||||||
|
borderRadius: 1.5, '&:focus-within': { borderColor: 'primary.main' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={18} />}
|
||||||
|
<InputBase
|
||||||
|
readOnly
|
||||||
|
fullWidth
|
||||||
|
value={currentUrl}
|
||||||
|
placeholder="未选择面板"
|
||||||
|
inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 13 } }}
|
||||||
|
sx={{ color: 'text.secondary' }}
|
||||||
|
/>
|
||||||
|
{currentPanel && (
|
||||||
|
<Tooltip title="在新标签页打开原站">
|
||||||
|
<IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站">
|
||||||
|
<OpenInNewIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
{/* 搜索(面板名/URL/分组,Enter 直达) */}
|
||||||
|
<Box sx={{ position: 'relative' }}>
|
||||||
|
<SearchIcon
|
||||||
|
sx={{
|
||||||
|
position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
fontSize: 17, color: 'text.secondary', pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<InputBase
|
||||||
|
ref={searchRef}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter(); searchRef.current?.blur(); } }}
|
||||||
|
placeholder="搜索面板"
|
||||||
|
inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 13 } }}
|
||||||
|
sx={{
|
||||||
|
height: 36, width: { xs: 96, sm: 150 }, px: 0.5,
|
||||||
|
bgcolor: 'background.default', border: 1, borderColor: 'divider',
|
||||||
|
borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Tooltip title="添加面板">
|
||||||
|
<span><IconButton size="small" onClick={onAdd} aria-label="添加面板"><AddIcon fontSize="small" /></IconButton></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="记事本">
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={onOpenNotes}
|
||||||
|
aria-label="记事本"
|
||||||
|
color={notesOpen ? 'primary' : 'default'}
|
||||||
|
>
|
||||||
|
<NoteAltIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="密码库">
|
||||||
|
<span><IconButton size="small" onClick={onOpenVault} aria-label="密码库"><KeyIcon fontSize="small" /></IconButton></span>
|
||||||
|
</Tooltip>
|
||||||
|
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||||
|
<Tooltip title={collapsed ? '展开侧边栏' : '收起侧边栏'}>
|
||||||
|
<span><IconButton size="small" onClick={onToggleSidebar} aria-label="切换侧边栏"><ViewSidebarIcon fontSize="small" /></IconButton></span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
color="inherit"
|
||||||
|
onClick={onBackHome}
|
||||||
|
startIcon={<HomeIcon sx={{ fontSize: 18 }} />}
|
||||||
|
sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.5 }, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }}
|
||||||
|
>
|
||||||
|
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>返回前台</Box>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import Drawer from '@mui/material/Drawer';
|
||||||
|
import Box from '@mui/material/Box';
|
||||||
|
import Typography from '@mui/material/Typography';
|
||||||
|
import IconButton from '@mui/material/IconButton';
|
||||||
|
import Button from '@mui/material/Button';
|
||||||
|
import TextField from '@mui/material/TextField';
|
||||||
|
import InputAdornment from '@mui/material/InputAdornment';
|
||||||
|
import List from '@mui/material/List';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
|
import ListItemButton from '@mui/material/ListItemButton';
|
||||||
|
import ListItemIcon from '@mui/material/ListItemIcon';
|
||||||
|
import ListItemText from '@mui/material/ListItemText';
|
||||||
|
import Collapse from '@mui/material/Collapse';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
|
import Snackbar from '@mui/material/Snackbar';
|
||||||
|
import Alert from '@mui/material/Alert';
|
||||||
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import LockIcon from '@mui/icons-material/Lock';
|
||||||
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
|
import KeyIcon from '@mui/icons-material/Key';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
|
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import useVault from '../hooks/useVault.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工作台 · 密码库抽屉(Lane B,只读)。
|
||||||
|
*
|
||||||
|
* 交互流程:
|
||||||
|
* loading →(pinStatus)→ noPin(引导去 /passwords.html)| locked(PIN 输入)
|
||||||
|
* → unlock → unlocked(搜索 + 条目列表,点击条目展开详情逐字段复制)
|
||||||
|
* 「锁定」按钮走 lock API;关闭抽屉不锁定,方便切面板取用。
|
||||||
|
*
|
||||||
|
* 接口:{ open, onClose, title? }(title 可选,默认「密码库」,兼容占位版约定)。
|
||||||
|
* 自包含:不依赖主工作台的任何代码,只依赖 useVault hook 与 api/passwords.js。
|
||||||
|
* 深浅色随后台 MD3 主题(createMD3Theme)自动适配。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 剪贴板写入:优先 Async Clipboard,失败时降级 execCommand(非安全上下文/LAN 部署) */
|
||||||
|
async function copyText(text) {
|
||||||
|
if (!text) return false;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.setAttribute('readonly', '');
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.left = '-9999px';
|
||||||
|
ta.style.top = '0';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch { return false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 展开详情中的单字段行:标签 + 值(网址可点开)+ 复制按钮 */
|
||||||
|
function DetailField({ label, value, href, secret, onCopy }) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.75, minWidth: 0 }}>
|
||||||
|
<Typography variant="caption" sx={{ color: 'text.secondary', width: 40, flexShrink: 0 }}>{label}</Typography>
|
||||||
|
{href ? (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
component="a"
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title={value}
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1, minWidth: 0, color: 'primary.main',
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
title={value}
|
||||||
|
sx={{
|
||||||
|
flexGrow: 1, minWidth: 0,
|
||||||
|
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
fontFamily: secret ? 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' : undefined,
|
||||||
|
letterSpacing: secret ? '0.04em' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value || '—'}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{onCopy ? (
|
||||||
|
<IconButton size="small" aria-label={`复制${label}`} onClick={onCopy} sx={{ flexShrink: 0 }}>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 15 }} />
|
||||||
|
</IconButton>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条密码:行内标题 + 用户名 + 复制密码,点击展开详情 */
|
||||||
|
function VaultEntryRow({ entry, expanded, onToggle, onCopy }) {
|
||||||
|
return (
|
||||||
|
<ListItem disablePadding sx={{ display: 'block' }}>
|
||||||
|
<ListItemButton
|
||||||
|
onClick={onToggle}
|
||||||
|
selected={expanded}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
sx={{ pr: 1 }}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<KeyIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={entry.title}
|
||||||
|
secondary={entry.username || '无用户名'}
|
||||||
|
primaryTypographyProps={{ noWrap: true, fontWeight: 500 }}
|
||||||
|
secondaryTypographyProps={{ noWrap: true }}
|
||||||
|
/>
|
||||||
|
<Tooltip title="复制密码">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`复制 ${entry.title} 的密码`}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onCopy(entry.password, '密码'); }}
|
||||||
|
>
|
||||||
|
<ContentCopyIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<ExpandMoreIcon
|
||||||
|
fontSize="small"
|
||||||
|
sx={{
|
||||||
|
color: 'text.secondary', ml: 0.25,
|
||||||
|
transform: expanded ? 'rotate(180deg)' : 'none',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
<Collapse in={expanded} unmountOnExit>
|
||||||
|
<Box sx={{ px: 2, py: 1, mx: 1, mb: 1, bgcolor: 'action.hover', borderRadius: 2 }}>
|
||||||
|
<DetailField
|
||||||
|
label="用户名"
|
||||||
|
value={entry.username}
|
||||||
|
onCopy={entry.username ? () => onCopy(entry.username, '用户名') : undefined}
|
||||||
|
/>
|
||||||
|
<DetailField
|
||||||
|
label="密码"
|
||||||
|
value={entry.password}
|
||||||
|
secret
|
||||||
|
onCopy={entry.password ? () => onCopy(entry.password, '密码') : undefined}
|
||||||
|
/>
|
||||||
|
{entry.url ? (
|
||||||
|
<DetailField label="网址" value={entry.url} href={entry.url} onCopy={() => onCopy(entry.url, '网址')} />
|
||||||
|
) : null}
|
||||||
|
{entry.notes ? <DetailField label="备注" value={entry.notes} /> : null}
|
||||||
|
</Box>
|
||||||
|
</Collapse>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码库抽屉
|
||||||
|
* @param {boolean} open 是否打开(每次打开会重新同步服务端会话状态)
|
||||||
|
* @param {() => void} onClose 关闭回调(仅关闭,不锁定)
|
||||||
|
* @param {string} [title] 抽屉标题,默认「密码库」
|
||||||
|
*/
|
||||||
|
export default function VaultDrawer({ open, onClose, title = '密码库' }) {
|
||||||
|
const vault = useVault({ open });
|
||||||
|
|
||||||
|
// PIN 输入屏的本地状态
|
||||||
|
const [pinInput, setPinInput] = useState('');
|
||||||
|
const [pinError, setPinError] = useState('');
|
||||||
|
const [showPin, setShowPin] = useState(false);
|
||||||
|
|
||||||
|
// 展开中的条目 id(每次打开清空)
|
||||||
|
const [expandedId, setExpandedId] = useState(null);
|
||||||
|
// 每次打开自增,作为内容 key:保证重新打开时 autoFocus 生效
|
||||||
|
const [openCount, setOpenCount] = useState(0);
|
||||||
|
|
||||||
|
const [snack, setSnack] = useState({ open: false, msg: '', severity: 'success' });
|
||||||
|
const notify = useCallback((msg, severity = 'success') => {
|
||||||
|
setSnack({ open: true, msg, severity });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPinInput('');
|
||||||
|
setPinError('');
|
||||||
|
setShowPin(false);
|
||||||
|
setExpandedId(null);
|
||||||
|
setOpenCount((c) => c + 1);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const submitUnlock = useCallback(async () => {
|
||||||
|
const pin = pinInput.trim();
|
||||||
|
if (!pin) { setPinError('请输入 PIN 码'); return; }
|
||||||
|
setPinError('');
|
||||||
|
try {
|
||||||
|
await vault.unlock(pin);
|
||||||
|
setPinInput('');
|
||||||
|
setShowPin(false);
|
||||||
|
notify('已解锁');
|
||||||
|
} catch (e) {
|
||||||
|
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'
|
||||||
|
setPinError(e.message || '解锁失败');
|
||||||
|
setPinInput('');
|
||||||
|
}
|
||||||
|
}, [pinInput, vault, notify]);
|
||||||
|
|
||||||
|
const handleLock = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await vault.lock();
|
||||||
|
setExpandedId(null);
|
||||||
|
notify('已锁定');
|
||||||
|
} catch (e) {
|
||||||
|
notify(e.message || '锁定失败', 'error');
|
||||||
|
}
|
||||||
|
}, [vault, notify]);
|
||||||
|
|
||||||
|
const copyToClipboard = useCallback(async (text, label) => {
|
||||||
|
const ok = await copyText(text);
|
||||||
|
if (ok) notify(`${label} 已复制`);
|
||||||
|
else notify('复制失败', 'error');
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
/* ---------- 各状态下的主体 ---------- */
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
switch (vault.status) {
|
||||||
|
case 'loading':
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 2 }}>
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
<Typography variant="body2" color="text.secondary">正在检查密码库状态…</Typography>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
|
||||||
|
<ErrorIcon color="error" sx={{ fontSize: 40 }} />
|
||||||
|
<Typography variant="body2" color="text.secondary">{vault.statusError}</Typography>
|
||||||
|
<Button variant="outlined" onClick={vault.checkStatus} sx={{ mt: 1 }}>重试</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'noPin':
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
|
||||||
|
<LockOpenIcon sx={{ fontSize: 44, color: 'text.disabled' }} />
|
||||||
|
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>未设置 PIN</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">请先到密码库页设置 PIN 后再使用</Typography>
|
||||||
|
<Button variant="contained" component="a" href="/passwords.html" sx={{ mt: 1 }}>前往密码库页</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'locked':
|
||||||
|
return (
|
||||||
|
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitUnlock(); }} sx={{ mt: 1 }}>
|
||||||
|
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 500 }}>输入 PIN 码解锁</Typography>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
type={showPin ? 'text' : 'password'}
|
||||||
|
label="PIN 码"
|
||||||
|
value={pinInput}
|
||||||
|
onChange={(e) => { setPinInput(e.target.value); if (pinError) setPinError(''); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') submitUnlock(); }}
|
||||||
|
error={!!pinError}
|
||||||
|
helperText={pinError || ' '}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { maxLength: 20, 'aria-label': 'PIN 码' },
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
edge="end"
|
||||||
|
aria-label={showPin ? '隐藏 PIN' : '显示 PIN'}
|
||||||
|
onClick={() => setShowPin((v) => !v)}
|
||||||
|
>
|
||||||
|
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
fullWidth
|
||||||
|
disabled={vault.unlocking || !pinInput}
|
||||||
|
onClick={submitUnlock}
|
||||||
|
startIcon={vault.unlocking ? <CircularProgress size={18} color="inherit" /> : <LockOpenIcon />}
|
||||||
|
sx={{ mt: 2 }}
|
||||||
|
>
|
||||||
|
{vault.unlocking ? '解锁中…' : '解锁'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'unlocked':
|
||||||
|
default: {
|
||||||
|
const { entries, entriesError, filteredEntries, search, setSearch } = vault;
|
||||||
|
const empty = filteredEntries.length === 0;
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
placeholder="搜索标题或用户名"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { 'aria-label': '搜索密码' },
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon fontSize="small" sx={{ color: 'text.secondary' }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: search ? (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<IconButton size="small" edge="end" aria-label="清空搜索" onClick={() => setSearch('')}>
|
||||||
|
<CloseIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</InputAdornment>
|
||||||
|
) : null,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ mb: 1 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{entries === null && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||||
|
<CircularProgress size={26} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entriesError && (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, py: 3, textAlign: 'center' }}>
|
||||||
|
<ErrorIcon color="error" />
|
||||||
|
<Typography variant="body2" color="text.secondary">加载失败:{entriesError}</Typography>
|
||||||
|
<Button size="small" variant="outlined" onClick={vault.loadEntries}>重试</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries && !entriesError && empty && (
|
||||||
|
<Box sx={{ textAlign: 'center', py: 4 }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
{search ? '无匹配的密码记录' : '暂无密码记录'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries && !entriesError && !empty && (
|
||||||
|
<List disablePadding>
|
||||||
|
{filteredEntries.map((e) => (
|
||||||
|
<VaultEntryRow
|
||||||
|
key={e.id}
|
||||||
|
entry={e}
|
||||||
|
expanded={expandedId === e.id}
|
||||||
|
onToggle={() => setExpandedId(expandedId === e.id ? null : e.id)}
|
||||||
|
onCopy={copyToClipboard}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
sx={{
|
||||||
|
'& .MuiDrawer-paper': {
|
||||||
|
width: 360,
|
||||||
|
maxWidth: '100vw',
|
||||||
|
borderRight: 'none',
|
||||||
|
borderLeft: (t) => `1px solid ${t.palette.divider}`,
|
||||||
|
boxShadow: (t) => t.shadows[8],
|
||||||
|
backgroundImage: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
key={openCount}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="vault-drawer-title"
|
||||||
|
sx={{ width: 360, maxWidth: '100vw', height: '100%', display: 'flex', flexDirection: 'column' }}
|
||||||
|
>
|
||||||
|
{/* 头部:标题 + 锁定 + 关闭 */}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', px: 2, py: 1.5 }}>
|
||||||
|
<KeyIcon sx={{ color: 'primary.main', mr: 0.5 }} />
|
||||||
|
<Typography variant="h6" id="vault-drawer-title" sx={{ flexGrow: 1, fontSize: 18 }}>{title}</Typography>
|
||||||
|
{vault.status === 'unlocked' && (
|
||||||
|
<Tooltip title="锁定密码库">
|
||||||
|
<span>
|
||||||
|
<IconButton aria-label="锁定密码库" onClick={handleLock} disabled={vault.locking} size="small">
|
||||||
|
<LockIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="关闭">
|
||||||
|
<IconButton aria-label="关闭密码库" onClick={onClose} edge="end">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
<Divider />
|
||||||
|
|
||||||
|
{/* 主体 */}
|
||||||
|
<Box sx={{ flexGrow: 1, overflowY: 'auto', px: 2, py: 2 }}>
|
||||||
|
{renderBody()}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Snackbar
|
||||||
|
open={snack.open}
|
||||||
|
autoHideDuration={2200}
|
||||||
|
onClose={() => setSnack((s) => ({ ...s, open: false }))}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||||
|
>
|
||||||
|
<Alert
|
||||||
|
severity={snack.severity}
|
||||||
|
variant="filled"
|
||||||
|
onClose={() => setSnack((s) => ({ ...s, open: false }))}
|
||||||
|
>
|
||||||
|
{snack.msg}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
const AUTOSAVE_MS = 900;
|
||||||
|
|
||||||
|
export default function useNotes() {
|
||||||
|
const [notes, setNotes] = useState(null); // null = 加载中
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [activeName, setActiveName] = useState(null); // 当前编辑的笔记名
|
||||||
|
const [content, setContent] = useState(''); // 编辑器草稿
|
||||||
|
const [savedContent, setSavedContent] = useState(''); // 服务端快照
|
||||||
|
const [saving, setSaving] = useState(false); // 保存请求进行中
|
||||||
|
const [busy, setBusy] = useState(false); // 打开/新建/重命名进行中
|
||||||
|
|
||||||
|
const draftRef = useRef({ name: null, text: '' }); // 最新草稿(供防抖回调读取)
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
|
||||||
|
// ---- 列表加载(静默刷新:保留现有列表,避免自动保存时闪烁) ----
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 当前文件被外部删除/失效时清空编辑器
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeName === null) {
|
||||||
|
setContent('');
|
||||||
|
setSavedContent('');
|
||||||
|
draftRef.current = { name: null, text: '' };
|
||||||
|
}
|
||||||
|
}, [activeName]);
|
||||||
|
|
||||||
|
// ---- 保存(PUT) ----
|
||||||
|
const saveContent = useCallback((name, text) => {
|
||||||
|
setSaving(true);
|
||||||
|
return notesApi.updateNote(name, text)
|
||||||
|
.then((res) => {
|
||||||
|
setSavedContent(text);
|
||||||
|
setNotes((prev) => (prev
|
||||||
|
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
|
||||||
|
: prev));
|
||||||
|
})
|
||||||
|
.catch((e) => showSnack(e.message, 'error'))
|
||||||
|
.finally(() => setSaving(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 打开文件 ----
|
||||||
|
const open = useCallback((name) => {
|
||||||
|
setBusy(true);
|
||||||
|
return notesApi.getNote(name)
|
||||||
|
.then((res) => {
|
||||||
|
const text = res.content || '';
|
||||||
|
setActiveName(name);
|
||||||
|
setContent(text);
|
||||||
|
setSavedContent(text);
|
||||||
|
draftRef.current = { name, text };
|
||||||
|
})
|
||||||
|
.catch((e) => showSnack(e.message, 'error'))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ---- 编辑(防抖自动保存) ----
|
||||||
|
const updateContent = useCallback((text) => {
|
||||||
|
setContent(text);
|
||||||
|
draftRef.current = { name: activeName, 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 / 切换文件前) ----
|
||||||
|
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: '' };
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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,239 @@
|
|||||||
|
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 } 历史栈
|
||||||
|
collapsed: 'workbench.sidebarCollapsed',
|
||||||
|
groups: 'workbench.collapsedGroups', // { 分组名: true } 折叠
|
||||||
|
pinned: 'workbench.pinned', // 固定面板 id 数组
|
||||||
|
modes: 'workbench.openModes', // { id: 'embed'|'tab'|'modal' }
|
||||||
|
};
|
||||||
|
|
||||||
|
function read(key, fallback) {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
return raw === null ? fallback : JSON.parse(raw);
|
||||||
|
} catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(key, value) {
|
||||||
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式等场景忽略 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function usePanels() {
|
||||||
|
const [links, setLinks] = useState(null); // null = 加载中
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [openIds, setOpenIds] = useState(() => read(STORE.open, []));
|
||||||
|
const [activeId, setActiveId] = useState(() => read(STORE.active, null));
|
||||||
|
const [history, setHistory] = useState(() => {
|
||||||
|
const h = read(STORE.history, null);
|
||||||
|
return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 };
|
||||||
|
});
|
||||||
|
const [collapsed, setCollapsed] = useState(() => {
|
||||||
|
const stored = read(STORE.collapsed, null);
|
||||||
|
return stored === null ? window.innerWidth < 768 : !!stored; // 移动端默认收起
|
||||||
|
});
|
||||||
|
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
|
||||||
|
const [pinned, setPinned] = useState(() => read(STORE.pinned, []));
|
||||||
|
const [modes, setModes] = useState(() => read(STORE.modes, {}));
|
||||||
|
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
// LRU 引用(内存态,不需要持久化):面板 id -> 最近使用时间戳
|
||||||
|
const recency = useRef({});
|
||||||
|
|
||||||
|
// ---- 持久化 ----
|
||||||
|
useEffect(() => write(STORE.open, openIds), [openIds]);
|
||||||
|
useEffect(() => write(STORE.active, activeId), [activeId]);
|
||||||
|
useEffect(() => write(STORE.history, history), [history]);
|
||||||
|
useEffect(() => write(STORE.collapsed, collapsed), [collapsed]);
|
||||||
|
useEffect(() => write(STORE.groups, groups), [groups]);
|
||||||
|
useEffect(() => write(STORE.pinned, pinned), [pinned]);
|
||||||
|
useEffect(() => write(STORE.modes, modes), [modes]);
|
||||||
|
|
||||||
|
// ---- 加载面板列表(reconcile 全部用函数式更新,消除陈旧闭包竞态) ----
|
||||||
|
const reload = useCallback(() => {
|
||||||
|
setLoadError('');
|
||||||
|
setLinks(null);
|
||||||
|
return listAdminLinks()
|
||||||
|
.then((ls) => {
|
||||||
|
const arr = Array.isArray(ls) ? ls : [];
|
||||||
|
setLinks(arr);
|
||||||
|
const valid = new Set(arr.map((p) => String(p.id)));
|
||||||
|
setOpenIds((prev) => prev.filter((id) => valid.has(id)));
|
||||||
|
setPinned((prev) => prev.filter((id) => valid.has(id)));
|
||||||
|
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
|
||||||
|
setHistory((prev) => {
|
||||||
|
const ids = prev.ids.filter((id) => valid.has(id));
|
||||||
|
return { ids, index: Math.min(prev.index, ids.length - 1) };
|
||||||
|
});
|
||||||
|
return arr;
|
||||||
|
})
|
||||||
|
.catch((e) => { setLoadError(e.message || '加载失败'); });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { reload(); }, [reload]);
|
||||||
|
|
||||||
|
// activeId 兜底(派生逻辑):重载后当前面板已被删除时,
|
||||||
|
// 从历史栈 / 打开集合取最近一个,避免指向不存在的面板
|
||||||
|
useEffect(() => {
|
||||||
|
if (!links) return;
|
||||||
|
const valid = new Set(links.map((p) => String(p.id)));
|
||||||
|
if (activeId && valid.has(String(activeId))) return;
|
||||||
|
const fallback = (history.index >= 0 ? history.ids[history.index] : null)
|
||||||
|
?? openIds[0]
|
||||||
|
?? null;
|
||||||
|
setActiveId(fallback);
|
||||||
|
}, [links, activeId, history, openIds]);
|
||||||
|
|
||||||
|
const touch = useCallback((id) => { recency.current[id] = Date.now(); }, []);
|
||||||
|
|
||||||
|
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
|
||||||
|
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
|
||||||
|
const openPanel = useCallback((id, panelOverride) => {
|
||||||
|
const panel = panelOverride || (links && links.find((p) => String(p.id) === String(id)));
|
||||||
|
if (!panel) return false;
|
||||||
|
touch(id);
|
||||||
|
const mode = modes[id] || OPEN_MODE_EMBED;
|
||||||
|
if (mode === OPEN_MODE_TAB) {
|
||||||
|
window.open(panel.embed_url || panel.url, '_blank', 'noopener');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
|
||||||
|
if (String(activeId) === String(id)) return false;
|
||||||
|
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
|
||||||
|
setHistory((prev) => {
|
||||||
|
const ids = prev.ids.slice(0, prev.index + 1);
|
||||||
|
if (ids[ids.length - 1] === id) return prev;
|
||||||
|
ids.push(id);
|
||||||
|
return { ids, index: ids.length - 1 };
|
||||||
|
});
|
||||||
|
setActiveId(id);
|
||||||
|
return true;
|
||||||
|
}, [activeId, links, modes, touch]);
|
||||||
|
|
||||||
|
// ---- 历史栈:◀ 后退 / ▶ 前进 ----
|
||||||
|
const goBack = useCallback(() => {
|
||||||
|
if (history.index <= 0) return;
|
||||||
|
const index = history.index - 1;
|
||||||
|
const id = history.ids[index];
|
||||||
|
if (id == null) return;
|
||||||
|
setHistory({ ...history, index });
|
||||||
|
setActiveId(id);
|
||||||
|
touch(id);
|
||||||
|
}, [history, touch]);
|
||||||
|
|
||||||
|
const goForward = useCallback(() => {
|
||||||
|
if (history.index >= history.ids.length - 1) return;
|
||||||
|
const index = history.index + 1;
|
||||||
|
const id = history.ids[index];
|
||||||
|
if (id == null) return;
|
||||||
|
setHistory({ ...history, index });
|
||||||
|
setActiveId(id);
|
||||||
|
touch(id);
|
||||||
|
}, [history, touch]);
|
||||||
|
|
||||||
|
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
|
||||||
|
const closePanel = useCallback((id) => {
|
||||||
|
const nextOpen = openIds.filter((x) => x !== id);
|
||||||
|
setOpenIds(nextOpen);
|
||||||
|
delete recency.current[id];
|
||||||
|
const histIds = history.ids.filter((x) => x !== id);
|
||||||
|
let index = history.index;
|
||||||
|
const removedAt = history.ids.indexOf(id);
|
||||||
|
if (removedAt !== -1 && removedAt < index) index -= 1;
|
||||||
|
if (index > histIds.length - 1) index = histIds.length - 1;
|
||||||
|
setHistory({ ids: histIds, index: index < 0 ? -1 : index });
|
||||||
|
const nextActive = histIds[index] ?? nextOpen[0] ?? null;
|
||||||
|
if (nextActive) setActiveId(nextActive); else setActiveId(null);
|
||||||
|
}, [history, openIds]);
|
||||||
|
|
||||||
|
const togglePin = useCallback((id) => {
|
||||||
|
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleGroup = useCallback((name) => {
|
||||||
|
setGroups((prev) => ({ ...prev, [name]: !prev[name] }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setMode = useCallback((id, mode) => {
|
||||||
|
setModes((prev) => ({ ...prev, [id]: mode }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeModal = useCallback(() => setModalId(null), []);
|
||||||
|
|
||||||
|
// ---- LRU 渲染集:当前面板 + 最近用过的 2 个(最多 3 个活跃 iframe) ----
|
||||||
|
const renderedIds = useMemo(() => {
|
||||||
|
const active = activeId ? String(activeId) : null;
|
||||||
|
if (!active) return [];
|
||||||
|
const others = openIds.filter((id) => String(id) !== active);
|
||||||
|
const sorted = others
|
||||||
|
.map((id) => ({ id, ts: recency.current[id] || 0 }))
|
||||||
|
.sort((a, b) => b.ts - a.ts)
|
||||||
|
.map((x) => x.id);
|
||||||
|
return [active, ...sorted.slice(0, 2)];
|
||||||
|
}, [activeId, openIds]);
|
||||||
|
|
||||||
|
const panelsById = useMemo(() => {
|
||||||
|
const m = {};
|
||||||
|
(links || []).forEach((p) => { m[p.id] = p; });
|
||||||
|
return m;
|
||||||
|
}, [links]);
|
||||||
|
|
||||||
|
const activePanel = activeId ? (panelsById[activeId] || null) : null;
|
||||||
|
const modalPanel = modalId ? (panelsById[modalId] || null) : null;
|
||||||
|
const openPanels = openIds.map((id) => panelsById[id]).filter(Boolean);
|
||||||
|
|
||||||
|
// ---- 搜索过滤(面板名 / URL / 分组) ----
|
||||||
|
const filteredPanels = useMemo(() => {
|
||||||
|
if (!search.trim()) return links || [];
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
return (links || []).filter((p) =>
|
||||||
|
String(p.title || '').toLowerCase().includes(q)
|
||||||
|
|| String(p.url || '').toLowerCase().includes(q)
|
||||||
|
|| String(p.embed_url || '').toLowerCase().includes(q)
|
||||||
|
|| String(p.category || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [links, search]);
|
||||||
|
|
||||||
|
// 搜索框 Enter:直达第一个匹配面板(空搜索不触发)
|
||||||
|
const openFirstMatch = useCallback(() => {
|
||||||
|
if (!search.trim()) return false;
|
||||||
|
const first = filteredPanels[0];
|
||||||
|
if (first) { openPanel(first.id); setSearch(''); return true; }
|
||||||
|
return false;
|
||||||
|
}, [filteredPanels, openPanel, search]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 数据
|
||||||
|
links, loadError, reload, panelsById,
|
||||||
|
// 打开集合
|
||||||
|
openIds, openCount: openIds.length, openPanels, renderedIds,
|
||||||
|
activeId, activePanel, modalId, modalPanel, closeModal,
|
||||||
|
// 历史栈
|
||||||
|
history,
|
||||||
|
canBack: history.index > 0,
|
||||||
|
canForward: history.index >= 0 && history.index < history.ids.length - 1,
|
||||||
|
goBack, goForward,
|
||||||
|
// 操作
|
||||||
|
openPanel, closePanel, togglePin, toggleGroup, setMode,
|
||||||
|
// 侧边栏
|
||||||
|
pinned, groups, collapsed, setCollapsed, modes,
|
||||||
|
// 搜索
|
||||||
|
search, setSearch, filteredPanels, openFirstMatch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import * as pwApi from '../../../api/passwords.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码库抽屉的状态管理(Lane B)。
|
||||||
|
*
|
||||||
|
* 状态机 status:
|
||||||
|
* loading —— 正在检查 PIN 状态(pinStatus)
|
||||||
|
* error —— 检查失败(可重试)
|
||||||
|
* noPin —— 未设置 PIN(引导去密码库页)
|
||||||
|
* locked —— 已设 PIN 但未解锁(PIN 输入屏)
|
||||||
|
* unlocked —— 已解锁(搜索 + 条目列表)
|
||||||
|
*
|
||||||
|
* 约定:关闭抽屉不锁定,只有 lock() 才锁定——方便切面板取用。
|
||||||
|
* 会话在服务端内存中滑动过期(1 小时),每次打开抽屉会重新同步一次状态。
|
||||||
|
*/
|
||||||
|
export default function useVault({ open } = {}) {
|
||||||
|
const [status, setStatus] = useState('loading');
|
||||||
|
const [statusError, setStatusError] = useState('');
|
||||||
|
const [entries, setEntries] = useState(null); // null = 条目加载中
|
||||||
|
const [entriesError, setEntriesError] = useState('');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [unlocking, setUnlocking] = useState(false);
|
||||||
|
const [locking, setLocking] = useState(false);
|
||||||
|
|
||||||
|
/** 拉取条目列表(解锁后),失败进入错误态并保留重试按钮 */
|
||||||
|
const loadEntries = useCallback(async () => {
|
||||||
|
setEntries(null);
|
||||||
|
setEntriesError('');
|
||||||
|
try {
|
||||||
|
const list = await pwApi.listEntries();
|
||||||
|
setEntries(list || []);
|
||||||
|
} catch (e) {
|
||||||
|
setEntriesError(e.message || '加载失败');
|
||||||
|
setEntries([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 同步服务端 PIN / 会话状态 */
|
||||||
|
const checkStatus = useCallback(async () => {
|
||||||
|
setStatus('loading');
|
||||||
|
setStatusError('');
|
||||||
|
try {
|
||||||
|
const s = await pwApi.pinStatus();
|
||||||
|
if (!s.hasPin) setStatus('noPin');
|
||||||
|
else if (s.unlocked) { setStatus('unlocked'); loadEntries(); }
|
||||||
|
else setStatus('locked');
|
||||||
|
} catch (e) {
|
||||||
|
setStatusError(e.message || '检查密码库状态失败');
|
||||||
|
setStatus('error');
|
||||||
|
}
|
||||||
|
}, [loadEntries]);
|
||||||
|
|
||||||
|
// 每次抽屉打开(含重新打开)都同步一次状态;
|
||||||
|
// 若父组件保持挂载仅切换 open,也在这里触发。
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) checkStatus();
|
||||||
|
}, [open, checkStatus]);
|
||||||
|
|
||||||
|
/** 解锁:成功则进入条目列表;失败抛出后端错误(401 PIN 错误 / 429 次数过多) */
|
||||||
|
const unlock = useCallback(async (pin) => {
|
||||||
|
setUnlocking(true);
|
||||||
|
try {
|
||||||
|
await pwApi.unlock(pin);
|
||||||
|
setStatus('unlocked');
|
||||||
|
setSearch('');
|
||||||
|
await loadEntries();
|
||||||
|
} finally {
|
||||||
|
setUnlocking(false);
|
||||||
|
}
|
||||||
|
}, [loadEntries]);
|
||||||
|
|
||||||
|
/** 锁定:清空本地条目与搜索,回到 PIN 输入屏 */
|
||||||
|
const lock = useCallback(async () => {
|
||||||
|
setLocking(true);
|
||||||
|
try {
|
||||||
|
await pwApi.lock();
|
||||||
|
setStatus('locked');
|
||||||
|
setEntries(null);
|
||||||
|
setEntriesError('');
|
||||||
|
setSearch('');
|
||||||
|
} finally {
|
||||||
|
setLocking(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 按标题 / 用户名过滤(不区分大小写) */
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
const base = entries || [];
|
||||||
|
if (!q) return base;
|
||||||
|
return base.filter((e) =>
|
||||||
|
(e.title || '').toLowerCase().includes(q) ||
|
||||||
|
(e.username || '').toLowerCase().includes(q));
|
||||||
|
}, [entries, search]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status, statusError,
|
||||||
|
entries, entriesError,
|
||||||
|
search, setSearch, filteredEntries,
|
||||||
|
unlocking, locking,
|
||||||
|
checkStatus, loadEntries, unlock, lock,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 工作台模块导出(frontend/src/tools/workbench)
|
||||||
|
* 便于未来升级 / 替换 / 独立打包
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
export { default as Workbench } from './Workbench.jsx';
|
||||||
|
export { default as Toolbar } from './components/Toolbar.jsx';
|
||||||
|
export { default as Sidebar } from './components/Sidebar.jsx';
|
||||||
|
export { default as PanelFrame } from './components/PanelFrame.jsx';
|
||||||
|
export { default as AddPanelDialog } from './components/AddPanelDialog.jsx';
|
||||||
|
export { default as PanelIcon } from './components/PanelIcon.jsx';
|
||||||
|
export { default as VaultDrawer } from './components/VaultDrawer.jsx';
|
||||||
|
export { default as NoteEditor } from './components/NoteEditor.jsx';
|
||||||
|
export { default as useNotes } from './hooks/useNotes.js';
|
||||||
|
export {
|
||||||
|
default as usePanels,
|
||||||
|
OPEN_MODE_EMBED,
|
||||||
|
OPEN_MODE_TAB,
|
||||||
|
OPEN_MODE_MODAL,
|
||||||
|
} from './hooks/usePanels.js';
|
||||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.0.3",
|
"version": "2.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.0.3",
|
"version": "2.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
@@ -506,7 +506,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@emotion/utils": {
|
"node_modules/@emotion/utils": {
|
||||||
"version": "2.0.3",
|
"version": "2.1.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"
|
||||||
@@ -1320,7 +1320,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "2.0.3",
|
"version": "2.1.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 +1480,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.3",
|
"version": "2.1.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 +1557,7 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.3",
|
"version": "2.1.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 +2557,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.3",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "rainweb-links",
|
"name": "rainweb-links",
|
||||||
"version": "2.0.3",
|
"version": "2.1.0",
|
||||||
"description": "链接聚合管理平台",
|
"description": "链接聚合管理平台",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+392
-29
@@ -188,6 +188,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 +543,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 +556,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); }
|
||||||
@@ -779,13 +831,13 @@ table tr:hover td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Login Page ===== */
|
/* ===== Login Page ===== */
|
||||||
.login-page {
|
.login-page, .register-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
background: var(--md-ref-background);
|
background: transparent; /* 完全透明,透出壁纸 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
@@ -979,6 +1031,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 +1198,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 +1270,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 +1350,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 +1528,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 ===== */
|
||||||
@@ -1505,22 +1590,23 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
.has-wallpaper .forum-post-card,
|
.has-wallpaper .forum-post-card,
|
||||||
.has-wallpaper .blog-card,
|
.has-wallpaper .blog-card,
|
||||||
.has-wallpaper .panel-card,
|
.has-wallpaper .panel-card,
|
||||||
.has-wallpaper .password-card {
|
.has-wallpaper .password-card,
|
||||||
background: var(--wallpaper-overlay, rgba(0,0,0,0.25));
|
.has-wallpaper .link-card {
|
||||||
|
background: var(--glass-bg, rgba(255,251,254,0.75));
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-wallpaper .nav-bar {
|
.has-wallpaper .nav-bar {
|
||||||
background: var(--wallpaper-overlay, rgba(0,0,0,0.2));
|
background: var(--glass-bg, rgba(255,251,254,0.8));
|
||||||
backdrop-filter: blur(16px);
|
backdrop-filter: blur(16px);
|
||||||
-webkit-backdrop-filter: blur(16px);
|
-webkit-backdrop-filter: blur(16px);
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.08);
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.has-wallpaper .dialog {
|
.has-wallpaper .dialog {
|
||||||
background: var(--wallpaper-overlay, rgba(0,0,0,0.4));
|
background: var(--glass-bg-heavy, rgba(255,251,254,0.9));
|
||||||
backdrop-filter: blur(24px);
|
backdrop-filter: blur(24px);
|
||||||
-webkit-backdrop-filter: blur(24px);
|
-webkit-backdrop-filter: blur(24px);
|
||||||
}
|
}
|
||||||
@@ -1528,14 +1614,27 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
.has-wallpaper input,
|
.has-wallpaper input,
|
||||||
.has-wallpaper select,
|
.has-wallpaper select,
|
||||||
.has-wallpaper textarea {
|
.has-wallpaper textarea {
|
||||||
background: var(--wallpaper-overlay, rgba(0,0,0,0.15));
|
background: var(--glass-bg, rgba(255,251,254,0.6));
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .has-wallpaper::before {
|
[data-theme="dark"] .has-wallpaper::before {
|
||||||
background: var(--wallpaper-overlay, rgba(0,0,0,0.4));
|
background: var(--wallpaper-overlay, rgba(0,0,0,0.4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 壁纸开启时整体提高玻璃不透明度(内容可读性):
|
||||||
|
--glass-bg / --glass-bg-heavy 的所有使用者(玻璃卡/导航/表格/弹窗)自动加强;
|
||||||
|
无壁纸时保持原有半透明质感不受影响 */
|
||||||
|
body.has-wallpaper {
|
||||||
|
--glass-bg: rgba(255,251,254,0.82);
|
||||||
|
--glass-bg-heavy: rgba(255,251,254,0.92);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body.has-wallpaper {
|
||||||
|
--glass-bg: rgba(28,27,31,0.74);
|
||||||
|
--glass-bg-heavy: rgba(28,27,31,0.84);
|
||||||
|
}
|
||||||
|
|
||||||
/* Glass card style */
|
/* Glass card style */
|
||||||
.card.glass-card,
|
.card.glass-card,
|
||||||
.glass-card {
|
.glass-card {
|
||||||
@@ -1559,9 +1658,42 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
-webkit-backdrop-filter: blur(8px);
|
-webkit-backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Forum sidebar glass with wallpaper */
|
/* 壁纸下内容区统一磨砂背景(可读性核心):
|
||||||
.has-wallpaper .forum-sidebar .forum-cat-item {
|
用 .page::before 实现而不是直接在 .page 上 backdrop-filter——
|
||||||
backdrop-filter: blur(4px);
|
直接加会使 .page 成为 position:fixed 后代的包含块,
|
||||||
|
破坏详情页悬浮目录(.article-toc-side)的视口定位。
|
||||||
|
半透明 + 模糊给文字稳固底色,壁纸在面板边缘仍清晰可见 */
|
||||||
|
|
||||||
|
/* 壁纸下页脚同源加强(页脚在 .page 之外,单独覆盖) */
|
||||||
|
body.has-wallpaper .footer-classic {
|
||||||
|
background: rgba(255, 251, 254, 0.82);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] body.has-wallpaper .footer-classic {
|
||||||
|
background: rgba(28, 27, 31, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 壁纸下详情页正文卡 / 目录卡加强:阅读面需要更高不透明度(其余卡片由
|
||||||
|
body.has-wallpaper 的 --glass-bg 变量统一加强) */
|
||||||
|
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 */
|
||||||
@@ -1602,7 +1734,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
}
|
}
|
||||||
@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 ===== */
|
||||||
@@ -1763,6 +1895,18 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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 {
|
||||||
@@ -1802,7 +1946,13 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 22px 28px 28px;
|
padding: 22px 28px 28px;
|
||||||
border-top: 1px solid var(--md-ref-outline-variant);
|
border-top: 1px solid var(--md-ref-outline-variant);
|
||||||
background: var(--md-ref-background);
|
/* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */
|
||||||
|
background: rgba(255, 251, 254, 0.55);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .footer-classic {
|
||||||
|
background: rgba(28, 27, 31, 0.6);
|
||||||
}
|
}
|
||||||
.footer-classic .fc-left {
|
.footer-classic .fc-left {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1835,14 +1985,21 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 分栏导航 ---- */
|
/* ---- 分栏导航 ---- */
|
||||||
.footer-columns {
|
.footer-columns {
|
||||||
background: var(--md-ref-background);
|
/* 磨砂玻璃:半透明底 + 模糊,壁纸下增强可读性 */
|
||||||
|
background: rgba(255, 251, 254, 0.55);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
border-top: 1px solid var(--md-ref-outline-variant);
|
border-top: 1px solid var(--md-ref-outline-variant);
|
||||||
}
|
}
|
||||||
|
[data-theme="dark"] .footer-columns {
|
||||||
|
background: rgba(28, 27, 31, 0.6);
|
||||||
|
}
|
||||||
.footer-columns .fc-grid {
|
.footer-columns .fc-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 2fr 1fr 1fr;
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
@@ -1883,7 +2040,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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; }
|
||||||
@@ -2036,8 +2193,8 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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;
|
||||||
@@ -2091,7 +2248,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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); }
|
||||||
|
|
||||||
@@ -2130,7 +2287,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
}
|
}
|
||||||
.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;
|
||||||
@@ -2147,12 +2304,12 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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); }
|
||||||
|
|
||||||
@@ -2237,7 +2394,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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;
|
||||||
@@ -2272,7 +2429,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
}
|
}
|
||||||
.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);
|
||||||
@@ -2334,7 +2491,7 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
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) {
|
||||||
@@ -2449,3 +2606,209 @@ button.glass-card, .btn.glass-card, .nav-tab.glass-card, .chip.glass-card {
|
|||||||
0%, 55% { left: -60%; }
|
0%, 55% { left: -60%; }
|
||||||
100% { left: 130%; }
|
100% { left: 130%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 博客文章详情页壁纸下磨砂背景(增强可读性) ===== */
|
||||||
|
.blog-article {
|
||||||
|
background: rgba(255, 251, 254, 0.5);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .blog-article {
|
||||||
|
background: rgba(28, 27, 31, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 文章详情页布局:正文卡片(居中固定宽)+ 右侧悬浮目录 =====
|
||||||
|
* 注意:.blog-article 带 backdrop-filter,会成为 position:fixed 后代的包含块,
|
||||||
|
* 因此目录卡片必须放在 .article-layout 层(磨砂卡片的兄弟),
|
||||||
|
* 其 fixed 才相对视口定位。正文宽 720 居中,目录左缘 = 50% + 388px(360+28)。 */
|
||||||
|
.article-layout {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.article-main {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 720px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.article-toc-side {
|
||||||
|
width: 220px;
|
||||||
|
/* 独立悬浮卡片:相对视口 fixed,锚定在正文右缘 + 28px 处,
|
||||||
|
不占文档流、不挤压正文,任何视口下都不会与阅读区重叠 */
|
||||||
|
position: fixed;
|
||||||
|
top: 88px;
|
||||||
|
left: calc(50% + 388px); /* 正文半宽 360px + 间距 28px */
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgba(255, 251, 254, 0.55);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--md-ref-outline-variant);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
box-shadow: 0 4px 20px var(--md-shadow);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .article-toc-side {
|
||||||
|
background: rgba(28, 27, 31, 0.6);
|
||||||
|
}
|
||||||
|
.article-toc-side .toc-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: var(--md-ref-on-surface);
|
||||||
|
}
|
||||||
|
.article-toc-side .toc-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
.article-toc-side .toc-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.article-toc-side .toc-item a {
|
||||||
|
display: block;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--md-ref-on-surface-variant);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.article-toc-side .toc-item a:hover {
|
||||||
|
background: var(--md-ref-surface-container-high);
|
||||||
|
color: var(--md-ref-primary);
|
||||||
|
}
|
||||||
|
/* 小屏隐藏:视口容不下「正文 720 + 目录 220 + 间距 28 + 边距」时收起,
|
||||||
|
内容区不受影响(目录 fixed 不占文档流) */
|
||||||
|
@media (max-width: 1299px) {
|
||||||
|
.article-toc-side { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 文章 meta 间距(作者 · 时间 · 阅读 · 字数) ===== */
|
||||||
|
.article-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--md-ref-on-surface-variant);
|
||||||
|
}
|
||||||
|
.article-meta .meta-stat {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
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);
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@ const router = express.Router();
|
|||||||
|
|
||||||
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
|
const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
|
||||||
'recaptcha_site_key','turnstile_site_key',
|
'recaptcha_site_key','turnstile_site_key',
|
||||||
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style',
|
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style',
|
||||||
'glass_blur','glass_opacity','theme_force_dark',
|
'glass_blur','glass_opacity','theme_force_dark',
|
||||||
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
||||||
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
||||||
@@ -15,7 +15,7 @@ const PUBLIC_KEYS = ['site_name','site_description','site_url','primary_color',
|
|||||||
|
|
||||||
const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key',
|
const ALL_KEYS = ['site_name','site_description','site_url','primary_color','recaptcha_site_key','turnstile_site_key',
|
||||||
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
|
'smtp_host','smtp_port','smtp_user','smtp_from_email','smtp_from_name',
|
||||||
'theme_wallpaper','theme_wallpaper_scale','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
|
'theme_wallpaper','theme_wallpaper_scale','theme_wallpaper_enabled','nav_style','card_style','glass_blur','glass_opacity','theme_force_dark',
|
||||||
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
'captcha_type','captcha_login','captcha_register','captcha_forum',
|
||||||
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
'homepage_avatar','homepage_bio','homepage_content','blog_show_sidebar',
|
||||||
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
'site_favicon','homepage_contacts','music_embed_enabled','music_embed_code','music_embed_position','music_embed_autohide','music_embed_idle_timeout',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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 { blogSSR, forumSSR, sitemapXml } = require('./ssr');
|
const { blogSSR, forumSSR, sitemapXml } = require('./ssr');
|
||||||
|
|
||||||
@@ -111,6 +112,7 @@ 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);
|
||||||
|
|
||||||
// Version & Update
|
// Version & Update
|
||||||
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
|
const version = require('fs').readFileSync('./VERSION', 'utf8').trim();
|
||||||
|
|||||||
Reference in New Issue
Block a user