89 Commits
Author SHA1 Message Date
miaomiao 4c67bf269a chore: bump version 2.3.0 2026-08-10 19:44:21 +08:00
miaomiao ed40a040e3 feat: RainID 配置入后台站点设置(secret 双通道可写不可读)+ site_url 域名 2026-08-10 19:34:45 +08:00
miaomiao 4200826120 feat: RainID 单点登录接入(ROPC 密码登录 + 授权码 PKCE + 影子账号 + 注册跳转 + 登出联动) 2026-08-10 18:20:42 +08:00
miaomiao 6ce6438854 feat: 代理内网白名单(proxy_allowed_hosts CIDR)+ fix: Cloudflare 验证码首次加载卡死/登录注册失败后验证码状态重置 2026-08-09 03:48:19 +08:00
miaomiao 0def382511 chore: bump version 2.2.0 2026-08-08 16:50:15 +08:00
miaomiao 636d13407f chore: 终端前端入口临时下线归档(archive/,后端保留,后续恢复) 2026-08-08 16:31:04 +08:00
miaomiao f9abb877e7 fix: 面板id类型统一(重复标签/关不掉/星标/reload)+ 终端解锁白屏(dev WS代理+xterm守卫)+ 图标首字母降级 2026-08-08 16:19:40 +08:00
miaomiao c947e62364 fix: 右侧侧边栏程序化控制(setLayout 绕开 pivot 刚性邻居)+ 折叠箭头常驻可见 2026-08-08 04:57:20 +08:00
miaomiao ded6fdb753 fix: 右侧侧边栏打不开(RRP 亚像素卡死→状态阈值+resize 默认宽度)+ 折叠箭头显眼化 2026-08-08 04:29:54 +08:00
miaomiao 9c34374b95 feat: 工作区自动恢复(主区激活标签持久化)+ 侧边栏折叠箭头 + X 关闭整栏 2026-08-08 04:18:16 +08:00
miaomiao 8a3e213e40 fix: 工作台布局占满/侧边栏刷新空白/地址栏填满/Logo 加大(RRP block 容器+order 校验 bug) 2026-08-08 04:03:31 +08:00
miaomiao 2de07710da fix: 工作台布局高度修复(Fragment→Box 100vh flex column) 2026-08-08 03:51:45 +08:00
miaomiao e16130bf8f fix: 记事本 hook TDZ 白屏(saveContent 声明后移,依赖数组渲染期求值报错) 2026-08-08 03:30:29 +08:00
miaomiao 2b748fc48e fix: 工作台审计修复(终端流控/exit重生/无障碍/ARIA/cookie token/安全纵深)+ 前后端联调整合 2026-08-08 03:14:51 +08:00
miaomiao 717ebc44f4 feat: 工作台 VSCode 风格改造 + Web 终端(双侧栏/活动栏/xterm/dnd-kit/resizable-panels/终端 API) 2026-08-08 02:31:42 +08:00
miaomiao f2740afe50 fix: 密码箱弹窗改回前台 dialog-overlay 风格(深浅色适配,保留顶层渲染修复) 2026-08-08 00:36:08 +08:00
miaomiao d9d2ad3f7d fix: 密码箱设置 PIN 弹窗无反应(弹窗仅渲染于已解锁分支 → MUI Dialog 提升顶层渲染) 2026-08-07 23:27:49 +08:00
miaomiao 0ae8815f33 feat: 工作台记事本(浮窗↔固定侧边栏双形态/自动保存/文件管理器/实体文件存储)
- routes/notes.js CRUD + 文件名安全校验 + 实体 .txt 存储 workspace/
- NoteEditor 浮窗(可拖拽)↔固定(280px)切换,📌 工具栏入口
- 编辑器 900ms 防抖自动保存 + Ctrl+S + dirty 指示
- 文件管理器:新建/重命名/删除/空态/错误态
- server 挂载 /api/notes,gitignore workspace/
2026-08-07 22:38:10 +08:00
miaomiao 9400f76c7e fix: 工作台审查修复 - 短 TTL proxy token/沙箱剥离/加载提示/独立刷新/键盘导航/懒加载分包 2026-08-07 21:57:56 +08:00
miaomiao 03d5c97315 feat: 面板工作台模块(tools/workbench)- 浏览器式侧边栏/工具栏/iframe 管理/密码库抽屉
- Workbench 独立页(/admin/workbench,adminOnly 守卫,全屏布局,MUI 风格)
- 工具栏:历史栈/刷新/地址栏/搜索/添加/密码库/侧栏折叠/返回
- 侧边栏:分组+固定区+折叠+拖拽调宽+打开方式(内嵌/新标签/弹窗)
- PanelFrame:懒加载/保留切换/LRU(3)/15s 超时失败回退/proxy 模式
- VaultDrawer:PIN 解锁/搜索/详情复制/锁定(复用 passwords API)
- 后台顶栏「工作台」入口 + 路由注册;localStorage 持久化
2026-08-07 21:43:06 +08:00
miaomiao 2cb9592d56 v2.1.0: 版本号更新 2026-08-07 20:09:06 +08:00
miaomiao 8a08932d78 fix: 控件三态统一(forum-cat-item/chip/toggle 对齐 surface-container→surface-variant→primary-container 体系) 2026-08-07 20:04:44 +08:00
miaomiao b152a2a9c9 fix: 论坛分类按钮点击后深色背景丢失(基础态 surface-container 主题化底色) 2026-08-07 19:56:14 +08:00
miaomiao 4af71185d5 feat: Markdown Indigo 定制主题(文本级渲染/背景透明走磨砂玻璃/主题色变量/深浅色/okaidia 代码块) 2026-08-07 19:50:29 +08:00
miaomiao 6d60b10c14 fix: 论坛板块按钮深色模式白底(button UA 默认样清除,基础态透明) 2026-08-07 19:24:58 +08:00
miaomiao cf4c7d4ca4 fix: UI/无障碍审查修复(焦点可见/键盘可达/dialog 语义/对比度/触控目标等 B1-B10) 2026-08-07 16:13:01 +08:00
miaomiao 5acc461e88 fix: 壁纸下玻璃表面统一抬升可读性(--glass-bg 0.82/0.74,逐表面应用,壁纸间隙可见) 2026-08-07 16:00:30 +08:00
miaomiao 391cd1bdae fix: 删除壁纸下 .page 遮罩(保留卡片/页脚磨砂) 2026-08-07 15:48:24 +08:00
miaomiao 5b166ef0e2 fix: 壁纸下内容区统一磨砂背景(可读性修复,浅深色自适应) 2026-08-07 15:26:03 +08:00
miaomiao 4f89c63b28 feat: 壁纸开关(后台主题设置可启用/禁用壁纸) 2026-08-07 15:24:09 +08:00
miaomiao 7bfeffaaa0 feat: 目录侧边独立卡片(正文 720 居中不重叠)+ 锚点根治(HTML 生成时注入 h2 id) 2026-08-07 04:50:34 +08:00
miaomiao aff5764b9b fix: 目录悬浮定位修正(≥1500px 才显示,避免与正文重叠) 2026-08-07 04:32:09 +08:00
miaomiao bb830f779c feat: 目录独立悬浮卡片(右侧留白区,正文不缩宽;<1280px 隐藏) 2026-08-07 04:30:52 +08:00
miaomiao 63d329db5f fix: 修复详情页 TDZ 白屏(wordCount 重复声明清理) 2026-08-07 04:22:42 +08:00
miaomiao e83a4e6ee1 feat: 详情页侧边目录(仅 h2/500 字阈值)+ meta 间距修正 2026-08-07 04:19:18 +08:00
miaomiao 0153f770f6 revert: 移除博客列表页磨砂背景(保留详情页) 2026-08-07 04:16:09 +08:00
miaomiao 789a2d9b33 feat: 博客文章详情页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:14:29 +08:00
miaomiao 5a878c1aab feat: 博客页磨砂玻璃背景(壁纸下可读性) 2026-08-07 04:13:50 +08:00
miaomiao 5e4cdf26b9 feat: 页脚三样式统一磨砂玻璃(半透明+blur,浅深色自适应) 2026-08-07 04:12:43 +08:00
miaomiao 2faace4810 fix: 登录/注册页改回完全透明(透壁纸) 2026-08-07 04:08:01 +08:00
miaomiao 45002b809a feat: 登录/注册页磨砂玻璃背景(半透明+blur,浅深色自适应) 2026-08-07 04:06:34 +08:00
miaomiao 1b62bf99ce fix: 登录/注册页去掉深色遮罩背景(透出壁纸,保留居中) 2026-08-07 04:05:45 +08:00
miaomiao 6fafa6eb00 fix: 注册弹窗与登录一致(flex 居中+背景遮罩);页脚背景透明(壁纸透出) 2026-08-07 04:00:04 +08:00
miaomiao 6649eba777 v2.0.3: 版本号更新 2026-08-07 03:24:05 +08:00
miaomiao 6aedc5f632 fix: 页脚分隔点去文本化(sep 纯发光圆点,去掉 JSX 里的 '·' 字符) 2026-08-07 03:22:56 +08:00
miaomiao ed82908a0d fix: 版权文本去掉多余分隔点(保留发光圆点分隔) 2026-08-07 03:18:00 +08:00
miaomiao 274291f8fe feat: 页脚微调 - 发光分隔点 + Powered 改中文带 Gitea 链接(RainWeb Engine) 2026-08-07 03:17:00 +08:00
miaomiao 588df32306 feat: 炫酷 Powered by 徽章(流光渐变/描边/扫光动画) 2026-08-07 03:14:27 +08:00
miaomiao 3c98084909 feat: 炫酷版权提示 - 霓虹发光胶囊(呼吸光晕/渐变文字/模糊底,浅深色自适应) 2026-08-07 03:11:18 +08:00
miaomiao 1c0339bae1 docs: 移除页脚 HTML 支持提示文案(后台即管理员无需说明) 2026-08-07 03:06:31 +08:00
miaomiao 651bbbb22e docs: 页脚 HTML 提示文案澄清(效果所有人可见,仅管理员可编辑) 2026-08-07 03:05:52 +08:00
miaomiao 36be795af5 fix: 前台页脚落于视口外(.page min-height 撑满视口剩余,滚动才见页脚) 2026-08-07 02:32:28 +08:00
miaomiao 632d19f49e feat: 后台评论管理 + 评论设置开关 + 技术债清理
- 后台评论管理页(待审核 Tab + 通过/拒绝),侧栏入口
- 站点设置加评论审核模式/评论邮件通知开关
- Material Icons 自托管(本地 woff2)+ ssr 图标修复
- /admin* 路由收紧为显式两条
- 删除死代码 routes/links.js
- 密码箱改 PIN 增加数据丢失警告确认
2026-08-07 02:14:07 +08:00
miaomiao 3cf560f932 feat: 博客功能前台 - 搜索/标签云/标签页/归档/详情增强/嵌套评论
- Blog 列表: 搜索框 + 标签云 + 归档入口
- 新路由: /tag/:name、/archive.html
- 详情: views/字数/标签 chips/点赞(乐观更新)/前后篇/可折叠 TOC
- 嵌套评论任意层级递归渲染 + 回复预填; Write 加 tags 输入
2026-08-07 02:06:01 +08:00
miaomiao ad8883cc61 feat: 博客功能后端 - 搜索/RSS/标签/归档/views/点赞/评论增强/备份
- migrateSchema v2: tags/views/parent_id/status 列 + post_likes 表
- 接口: search/tags/tag/archive/prevnext/点赞三连/评论审核三连/feed.xml
- 评论: 嵌套回复/审核模式(comment_moderate)/限流/邮件通知(comment_notify)
- cli backup 命令 + status 显示最近备份;backups/ 入 gitignore
- ssr blogSSR 阅读量计数;settings 白名单补新键
2026-08-07 01:57:01 +08:00
miaomiao f8d495b128 feat: 页脚文案支持 HTML/CSS 渲染(仅管理员字段,self-XSS 与音乐嵌入同级) 2026-08-07 01:47:12 +08:00
miaomiao c2ca0f2327 feat: 页脚三样式(经典/分栏/玻璃)+ 后台样式编辑卡 + 博客页首屏修复
- Footer.jsx 按 footer_style 渲染三变体(含 Powered by RainnyaWeb + 版权)
- 后台站点设置页脚卡:样式选择 + 按样式显示可编辑字段(分栏导航固定不可编辑)
- db 默认值 footer_style/copyright/powered/desc;settings 白名单接入
- 博客页移动端内容优先排列(首屏可见标题)
2026-08-07 01:44:12 +08:00
miaomiao 249ed8cd2c feat: 后台 MUI 主题 MD3 化(与前台同源 token/大圆角/surface 边框)+ 深色模式修复 2026-08-07 00:27:00 +08:00
miaomiao a858439e19 fix: 首页侧栏强制显示(blog_show_sidebar 开关仅影响博客页);v2.0.0 版本号 2026-08-07 00:11:15 +08:00
miaomiao 535e4b8fcd v2.0.0: 大版本号更新(v1 原生前端 → v2 React 前后台) 2026-08-06 23:59:48 +08:00
miaomiao 75d102d6ee docs: AGENTS.md 更新至 v2 现状(assets 挂载/无更新接口/新路由顺序等) 2026-08-06 23:56:34 +08:00
miaomiao f0b642362a fix: /assets 挂载构建产物(vite base='/' 与 dist/assets 路径不匹配导致 MIME text/html 白屏) 2026-08-06 23:55:03 +08:00
miaomiao 45f0dc9beb fix: serveIndex 加 no-store 缓存头(防止旧 index.html 缓存导致 assets 404 白屏) 2026-08-06 23:28:52 +08:00
miaomiao b29fbcdcfc fix: 面板嵌入 proxy 模式支持 query token(iframe 无法带 header) 2026-08-06 23:20:25 +08:00
miaomiao 20deb09dbd P6: 集成上线 - dist 切换/后台路由/Setup+Embed/v1 退役/文档更新
- server.js: serveIndex 改读构建产物 dist/index.html(占位符替换保留);新增 /admin* → dist/admin.html;/forum/manage/:id 改 302 重定向
- Setup 初始化向导三步迁移;Embed 面板嵌入迁移(proxy 代理)
- v1 前端全部退役(12 html + public/js/ 12 文件 + deploy 脚本)
- README/AGENTS.md 更新为 v2 架构(React+Vite、better-sqlite3、构建流程)
2026-08-06 23:16:18 +08:00
miaomiao c71f4f0ed6 P5: 后台独立应用(MUI)- 13 模块/布局/守卫/复用前台基建 2026-08-06 23:10:09 +08:00
miaomiao f7dd4163b2 P4: 审查收尾 - 第三方验证码闭环/导航指向/验证码统一校验 2026-08-06 23:01:52 +08:00
miaomiao 72295ded4d P4: 密码箱迁移(PIN/CRUD/生成器/安全加固) 2026-08-06 22:51:17 +08:00
miaomiao f4a5276fb8 P4: 登录/注册/个人中心迁移(验证码 proof/邮箱验证/头像/改密) 2026-08-06 22:48:48 +08:00
miaomiao be6f644154 P4: 论坛迁移(列表/发帖/详情回复/板块管理) 2026-08-06 22:45:31 +08:00
miaomiao bdd3a068af P4: 首页/博客/写文章迁移(列表/详情/评论/编辑/上传) 2026-08-06 22:42:25 +08:00
miaomiao 990f6f40d0 P4: 基础设施 - api 层/布局壳/公共组件(Markdown/Captcha/音乐嵌入) 2026-08-06 22:38:21 +08:00
miaomiao 1bed2dda5f P3: 前端工程化搭建 - Vite + React 多入口脚手架
- vite.config.js 双入口(前台 index + 后台 admin),产物输出 public/dist
- 前台 App 布局壳 + 11 条路由占位;api client/theme/utils 共享层
- index.html 保留 ${site_name} 等占位符与 style.css 引用(UI 100% 还原基础)
- dev 代理 3101,构建产物 gitignored
2026-08-06 22:29:03 +08:00
miaomiao b319584012 chore: 换轨遗留清理 - Node>=22 文档/sql.js 卸载/cli 并发写警告 2026-08-06 22:24:00 +08:00
miaomiao c4c0115ba6 P2: 数据层换轨 better-sqlite3(sql.js → 原生 SQLite)
- db.js: getDb 同步化 + new Database(DB_PATH) 直接打开;run/get/all 重写为 prepare API;删除 saveDb 全量导出;22 处 DDL 改 exec
- migrateSchema 填实 version 1(user_settings.pin_iter),passwords.js 删除私有 ensureSchema
- routes/import.js: better-sqlite3 只读打开上传文件(PRAGMA table_info + all() 对象行),finally 关闭
- 数据零迁移:现有 data/rainweb.db 直接打开,播种数据完整,CRUD/SSR/cli 全通
2026-08-06 22:19:57 +08:00
miaomiao f6407a84f7 fix: 审查修复 - setup 死锁/验证码前后端对接/SPA 侧 XSS
- setup /complete 改 setup_complete 门禁(修复新装死锁),新密码禁止等于默认
- 验证码 proof 全链路贯通:前端 captcha.js/api/login/register/forum 传 captcha_proof;consumeProof 一次性消费 + jti 防同秒碰撞;删除死端点与死代码
- render.js 引入 DOMPurify 净化 markdown(SPA 侧存储型 XSS)
- email.js SMTP 检查提前;avatar 强正则白名单;register 验证码 crypto.randomInt
2026-08-06 22:11:43 +08:00
miaomiao 2ac286a4f8 P1: 后端安全加固 - 上传白名单/SSR净化/限流/密码箱/鉴权收敛/数据层统一
- 上传扩展名白名单 + MIME 校验;/uploads 加 nosniff + CSP sandbox 防护头
- ssr.js 全插值转义 + DOMPurify 净化 + JSON-LD JSON.stringify 序列化(消除存储型 XSS)
- 登录/发帖限流(express-rate-limit);验证码 proof token 体系(5 分钟 JWT + 防重放)
- 密码箱:PBKDF2 600k(pin_iter 列兼容旧密文)、解锁 3 次/5 分钟锁定、滑动过期会话、PIN >= 6 位
- adminOnly 查库复查;草稿仅 admin/作者可见;错误信息收敛;avatar 站内路径白名单;验证码 crypto.randomInt
- cli.js 删除重复 sql.js 封装统一到 db.js(data/rainweb.db 单路径);db.js 新增 migrateSchema 迁移框架
2026-08-06 21:32:06 +08:00
miaomiao 91cd009899 P0: 删除 Web 更新链路,修复 setup/proxy/email 安全漏洞
- 删除 /api/update/check 与 /api/update/run(无鉴权 RCE 面 + .git 覆盖风险)
- cli.js upgrade 仅保留 git pull 分支,删除 GitHub zip 下载分支
- admin 前端移除更新按钮与 checkUpdate/runUpdate
- JWT_SECRET 启动时随机生成写入 .env.json,删除硬编码回退
- /api/setup/complete 加 adminOnly;/setup/status 移除 default_password 泄露
- /api/proxy/fetch 加 adminOnly + 内网地址拦截(SSRF)
- send-verify 验证码不再回显,改 crypto.randomInt,修复 pending 重建 bug
2026-08-06 21:22:04 +08:00
miaomiao 4d0ad9da68 docs: 添加 AGENTS.md 与 v2 重构计划文档 2026-08-06 21:22:04 +08:00
miaomiao e7f8b3f173 v1.4.2: PJAX router, auto-hide music embed, update script fix
- PJAX navigation: nav clicks fetch & replace <main> seamlessly
- router.js dynamically loads page-specific JS (blog/forum/admin)
- Music embed: global on/off toggle, hover-based auto-collapse
- Update script restored to download-based (no git dependency)
- All PJAX pages have #musicEmbed container (index, blog, forum, admin, passwords)
- Version bump to 1.4.2
2026-07-02 02:22:09 +08:00
miaomiao a30a628772 fix: add musicEmbed container to admin.html for PJAX persistence 2026-07-02 02:09:32 +08:00
miaomiao cd290ff237 PJAX router + music embed with auto-hide
- router.js: intercept nav clicks, fetch page via AJAX, replace <main> content
- Blog/Forum/Admin/Homepage init functions extracted for PJAX re-init
- music-embed.js: hover-based auto-hide (leave → 10s timer → collapse)
- Settings: music_embed_code, position, pages, autohide, idle_timeout
- New keys replace old homepage_music_embed/position (auto-migrated)
2026-07-02 02:00:49 +08:00
miaomiao f7f30af569 v1.4.1: theme color refactor, UI restructuring, markdown improvements 2026-07-01 23:46:01 +08:00
miaomiao 4465130e60 fix: update download URL follows redirects, use codeload directly 2026-07-01 20:05:28 +08:00
miaomiao bd0c0442ab feat: add public settings endpoint and update settings retrieval in frontend 2026-07-01 19:41:06 +08:00
shanshuilala b80c5a1277 v1.3.1 2026-06-21 18:07:03 +08:00
shanshuilala 73e145df07 v1.1.1: captcha refactor, Turnstile support, SEO, forum boards, icon fixes 2026-06-21 18:05:05 +08:00
shanshuilala 6ffbf19aff Refactor forum.js for improved post and category handling; add sub-category support and enhance captcha verification in login and registration; implement update check and run functionality in server.js; improve captcha generation with noise and difficulty adjustments. 2026-06-21 00:54:49 +08:00
shanshuilala 2e6eeadfc1 feat: 增加数据库导入功能,支持从旧版 RainWeb 导入数据,优化验证码处理逻辑 2026-06-20 21:57:04 +08:00
147 changed files with 18746 additions and 3426 deletions
Vendored
BIN
View File
Binary file not shown.
+3
View File
@@ -6,3 +6,6 @@ uploads/*
.env.json .env.json
server.pid server.pid
releases/ releases/
public/dist/
backups/
workspace/
+61
View File
@@ -0,0 +1,61 @@
# AGENTS.md
RainWeb 个人云平台(博客 + 论坛 + 密码管理器 + 管理面板聚合)。Node.js 22+(推荐 22 LTS)、CommonJS、Express 4、better-sqlite3(原生 SQLite)。前端 **React + Vite + React Router**(前台原生样式 Material Design 3,后台 MUI 独立应用)。**无测试、无 lint**——前端改动进 `frontend/src/`,构建产物 `public/dist/`gitignored)由 server 静态服务,部署必须 `npm run build`
## 常用命令
```bash
npm start # 生产:node server.jsserve 构建产物 + /api
npm run dev # 开发:vite dev server5173/api 与 /uploads 代理到 3101
npm run build # 构建 React 前端 → public/dist/index.html + admin.html + assets
npm run preview # 预览构建产物
node cli.js <cmd> # status | start | stop | restart | port [N] | password [pwd] | config | captcha | upgrade
```
- 端口:`.env.json`gitignored`{"port": N}` 或环境变量 `PORT`,默认 3001vite 代理目标硬编码 3101vite.config.js),后端端口改了就同步改。
- 验证方式:后端改动启动后 `curl` 接口;前端改动 `npm run dev` 热更新,或 `npm run build` 后刷新验证(需强刷一次,见下)。
- **部署后浏览器必须拿新 HTML**:`serveIndex` 响应带 `Cache-Control: no-store`;构建产物 assets 文件名带 hash,经 `/assets` 挂载长期缓存(immutable)。若用户白屏且 console 报 "MIME type text/html",先查是否 `npm run build` 缺失/旧产物。
- 首次启动自动建库播种:管理员 `admin / admin123`,并写入示例博文/论坛帖子;`/setup.html` 初始化向导(`POST /api/setup/complete``setup_complete` 门禁,非 adminOnly——新装无 token)。
## 数据层(db.js
- better-sqlite3 是原生 SQLite`data/rainweb.db` 单文件持久化,写操作经事务落盘。CLI 与 server 并发写库会等待(busy_timeout 5s)而非互相覆盖,但建议不要同时执行写操作。
- 建表在 `initTables()`;列迁移用 try/catch 包裹 `ALTER TABLE ... ADD COLUMN`(幂等、静默失败)确保列存在。**新增列必须沿用此模式**,否则旧库会崩。版本化迁移在 `migrateSchema()``PRAGMA user_version` 记录 schema 版本,migrations 数组按 version 升序执行(`current < m.version` 才运行并推进版本号)——**新迁移写进 migrations 数组,不要散落 try/catch**。
- 站点设置存 `site_settings`key/value),通过 `db.getSetting` / `setSetting` 读写。
- **cli.js 与 server.js 统一使用 db.js 的 `data/rainweb.db`**;旧版 `data.db` 已由 server.js 首次启动时迁移为 `data/rainweb.db`(旧文件改名 `data.db.bak`)。
## 路由(server.js
- 集中挂载所有 `/api/*`auth、admin-links、announcements、forum、blog、passwords、settings、email、profile、captcha、upload、setup、proxy、import)。**新路由必须在此挂载**——后面有 SPA catch-all:非 `/api/` 一律回 dist/index.html。
- **挂载顺序关键**`serveIndex('/')``/assets`dist/assetsimmutable)→ static(public) → `/uploads``/api/*``/admin*`dist/admin.html)→ SSR 区(/blog/:id 等)→ catch-all。新路由注意别被 catch-all 吞掉。
- `middleware/auth.js`Bearer JWT`SECRET` = `process.env.JWT_SECRET`**无硬编码回退**——server.js 启动时若 `.env.json``jwt_secret` 则随机生成写入并设置环境变量);`adminOnly` 会查库复查角色(用户被删/降权立即失效)。
- `routes/setup.js``/complete``setup_complete` 门禁('1' 后 403),新密码禁止等于默认 `admin123`
- `routes/proxy.js`:面板嵌入代理——支持 `Authorization` header 或 `?token=` queryiframe 无法带 header);有内网地址拦截(SSRF)。
- 验证码:内置 SVG + reCAPTCHA/Turnstile 第三方,服务端统一 `resolveCaptcha` 校验(builtin 走 proof JWT,第三方走 siteverify)。
## 前端(React + Vite
- 源码在 `frontend/``src/main.jsx`(前台入口)、`src/App.jsx`(前台布局 Layout + 路由表)、`src/pages/`(前台页面)、`src/components/`Layout/MarkdownRenderer/CaptchaModal/MusicEmbed/BlogSidebar)、`src/admin/`**后台独立应用** MUImain.jsx + AdminLayout + pages/)、`src/api/`(按模块封装的 fetch 层,`client.js` 管 token)、`src/lib/utils.js``src/theme.jsx`
- 前后台是**两个独立入口**(Vite 多入口),仅通过整页跳转连接:前台导航「管理后台」=`<a href="/admin">`(后台路由 basename `/admin`)。
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html``/forum.html` 等),SPA fallback 已支持,链接不用改。
- `frontend/index.html``${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
- 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css``[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx`
- 构建:`npm run build``public/dist/`gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
- 登录态:token 存 localStoragekey `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js``notifyAuthChange`)。
## SSR 与 SEO
- `ssr.js``/blog/:id``/forum/:id``/sitemap.xml`robots.txt 的域名取自 `site_url` 设置)。SSR 页引用 `/css/style.css`public/css 保留,勿删)。
- 已发布博文(`published=1`)才会 SSR 渲染,未发布返回 404。
## 版本与更新
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
- **Web 更新接口已移除**(P0 删除 `/api/update/check``/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`git pull(本地 origingit.rainnya.asia 镜像)+ npm install + 重启。
- 上传附件在 `uploads/`gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
## 约定
- UI 文案、代码注释、commit message 均为中文,保持一致。
- 不要提交:`node_modules/``data/*``uploads/*``.env.json``server.pid``releases/``public/dist/`
+33 -38
View File
@@ -1,6 +1,5 @@
# 🌧️ RainWeb - 个人云管理平台 # 🌧️ RainWeb - 个人云管理平台
[![GitHub](https://img.shields.io/badge/GitHub-Xianyunah%2Frainwebblog-blue?logo=github)](https://github.com/Xianyunah/rainwebblog)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。 一体化个人云平台,集成博客、论坛、密码管理器、管理后台聚合等功能,Material Design 3 风格,支持深色/浅色切换。
@@ -25,7 +24,7 @@
### 环境要求 ### 环境要求
- **Node.js** 16.x 或更高版本(推荐 20.x LTS - **Node.js** 22 或更高版本(推荐 22 LTS
- **npm** 随 Node.js 安装 - **npm** 随 Node.js 安装
### 下载安装 ### 下载安装
@@ -45,14 +44,7 @@
unzip rainweb-source.zip -d rainweb unzip rainweb-source.zip -d rainweb
cd rainweb cd rainweb
npm install npm install
npm start npm run build
```
**从 GitHub 安装:**
```bash
git clone https://github.com/Xianyunah/rainwebblog.git
cd rainwebblog
npm install
npm start npm start
``` ```
@@ -65,7 +57,8 @@ npm start
- **启动文件**: `server.js` - **启动文件**: `server.js`
- **端口**: `3001` - **端口**: `3001`
3. 提交后宝塔自动 `npm install` 并启动 3. 提交后宝塔自动 `npm install` 并启动
4. 如需域名访问,配置 Nginx 反向代理: 4. 若未自动构建,手动执行 `npm run build`(构建 React 前端到 `public/dist/`
5. 如需域名访问,配置 Nginx 反向代理:
```nginx ```nginx
server { server {
@@ -90,7 +83,7 @@ server {
```bash ```bash
# 使用 Node.js 官方镜像 # 使用 Node.js 官方镜像
docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:20 bash -c " docker run -d -p 3001:3001 -v $(pwd)/data:/app/data node:22 bash -c "
cd /app && npm install && node server.js cd /app && npm install && node server.js
" "
``` ```
@@ -126,8 +119,8 @@ npm run cli -- <command>
## 技术栈 ## 技术栈
- **后端**: Node.js + Express - **后端**: Node.js + Express
- **数据库**: SQLite (sql.js) - **数据库**: SQLite (better-sqlite3)
- **前端**: 原生 HTML/CSS/JS + Material Design 3 - **前端**: React + Vite + React Router(前台原生样式 / 后台 MUI)
- **加密**: AES-256-GCM, PBKDF2, bcrypt - **加密**: AES-256-GCM, PBKDF2, bcrypt
- **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2 - **验证码**: SVG 扭曲文字 / Google reCAPTCHA V2
- **文件上传**: multer - **文件上传**: multer
@@ -139,6 +132,7 @@ npm run cli -- <command>
``` ```
rainweb/ rainweb/
├── server.js # 主入口 ├── server.js # 主入口
├── vite.config.js # Vite 构建配置
├── cli.js # CLI 工具 ├── cli.js # CLI 工具
├── db.js # 数据库层 ├── db.js # 数据库层
├── middleware/ ├── middleware/
@@ -156,29 +150,20 @@ rainweb/
│ ├── setup.js # 初始化向导 │ ├── setup.js # 初始化向导
│ ├── announcements.js # 公告 │ ├── announcements.js # 公告
│ └── profile.js # 个人资料 │ └── profile.js # 个人资料
├── frontend/ # React 前端源码(Vite 根目录)
│ ├── index.html # 前台入口(含 ${site_name} 占位符)
│ ├── admin.html # 后台入口
│ └── src/
│ ├── main.jsx # 前台入口
│ ├── App.jsx # 前台布局 + 路由表
│ ├── admin/ # 后台入口(MUI)
│ ├── api/ # fetch 封装
│ ├── lib/ # 工具函数
│ ├── theme.jsx # 主题切换(data-theme
│ └── pages/ # 前台页面
├── public/ ├── public/
│ ├── index.html # 博客首页 │ ├── dist/ # Vite 构建产物(npm run build 生成,serveIndex 读取)
│ ├── login.html # 登录
│ ├── register.html # 注册
│ ├── admin.html # 管理后台
│ ├── forum.html # 论坛
│ ├── blog.html # 博客页
│ ├── passwords.html # 密码管理器
│ ├── embed.html # 网页嵌入
│ ├── profile.html # 个人中心
│ ├── setup.html # 初始化向导
│ ├── css/style.css # 全局样式 + 主题 │ ├── css/style.css # 全局样式 + 主题
│ ├── js/
│ │ ├── api.js # API 封装
│ │ ├── nav.js # 导航栏 + 主题
│ │ ├── captcha.js # 验证码前端
│ │ ├── theme.js # 深浅色切换
│ │ ├── main.js # 博客首页
│ │ ├── admin.js # 管理后台
│ │ ├── forum.js # 论坛
│ │ ├── blog.js # 博客
│ │ ├── passwords.js # 密码管理器
│ │ └── ... # 其他页面逻辑
│ └── wallpaper/ # 上传的壁纸 │ └── wallpaper/ # 上传的壁纸
└── package.json └── package.json
``` ```
@@ -211,11 +196,20 @@ rainweb/
## 开发 ## 开发
```bash ```bash
# 开发模式启动 # 开发模式Vite dev server,端口 5173/api 与 /uploads 代理到本地后端 3101
node server.js npm run dev
# 构建生产产物到 public/dist/
npm run build
# 预览构建产物
npm run preview
# 生产运行(Express 服务 public/ 与构建产物)
npm start
``` ```
项目不依赖构建工具,直接修改 `public/` 目录下的文件即可 前端为 React + Vite,开发时前端改动由 Vite 热更新,无需刷新;生产部署必须先在服务器执行 `npm run build` 生成 `public/dist/`
## 升级 ## 升级
@@ -226,6 +220,7 @@ node cli.js upgrade
# 方法2: 手动 # 方法2: 手动
git pull git pull
npm install npm install
npm run build
node cli.js restart node cli.js restart
``` ```
+1 -1
View File
@@ -1 +1 @@
1.1.0 2.3.0
+93 -122
View File
@@ -6,49 +6,9 @@ const readline = require('readline');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const http = require('http'); const http = require('http');
const DB_PATH = path.join(__dirname, 'data.db');
const CONFIG_PATH = path.join(__dirname, '.env.json'); const CONFIG_PATH = path.join(__dirname, '.env.json');
const PKG = require('./package.json'); const PKG = require('./package.json');
const db = require('./db');
// Lazy-load db
let _db = null;
async function getDb() {
if (_db) return _db;
const initSqlJs = require('sql.js');
const SQL = await initSqlJs();
if (fs.existsSync(DB_PATH)) {
const buffer = fs.readFileSync(DB_PATH);
_db = new SQL.Database(buffer);
} else {
console.error('data.db not found. Run the server first.');
process.exit(1);
}
return _db;
}
function dbRun(sql, params = []) {
if (!_db) return;
_db.run(sql, params);
const r = _db.exec("SELECT last_insert_rowid()");
const data = _db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
return r && r[0] && r[0].values ? r[0].values[0][0] : 0;
}
function dbGet(sql, params = []) {
if (!_db) return null;
const stmt = _db.prepare(sql); stmt.bind(params);
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; }
stmt.free(); return null;
}
function dbAll(sql, params = []) {
if (!_db) return [];
const stmt = _db.prepare(sql); stmt.bind(params);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free(); return rows;
}
const HELP = ` const HELP = `
RainWeb CLI v${PKG.version} RainWeb CLI v${PKG.version}
@@ -63,6 +23,7 @@ Commands:
password [new-pass] Change admin password (leave empty for prompt) password [new-pass] Change admin password (leave empty for prompt)
captcha Interactive captcha rule configuration captcha Interactive captcha rule configuration
config Show all current settings config Show all current settings
backup Backup database to backups/ (readonly, safe to run while running)
upgrade Git pull + npm install + restart (one-click upgrade) upgrade Git pull + npm install + restart (one-click upgrade)
help Show this help help Show this help
@@ -71,6 +32,7 @@ Examples:
node cli.js port 8080 node cli.js port 8080
node cli.js password MyNewP@ss123 node cli.js password MyNewP@ss123
node cli.js captcha node cli.js captcha
node cli.js backup
node cli.js upgrade node cli.js upgrade
`; `;
@@ -86,6 +48,7 @@ async function main() {
case 'password': return cmdPassword(); case 'password': return cmdPassword();
case 'captcha': return cmdCaptcha(); case 'captcha': return cmdCaptcha();
case 'config': return cmdConfig(); case 'config': return cmdConfig();
case 'backup': return cmdBackup();
case 'upgrade': return cmdUpgrade(); case 'upgrade': return cmdUpgrade();
case 'help': case 'help':
default: default:
@@ -98,7 +61,11 @@ async function cmdStatus() {
console.log(`RainWeb v${PKG.version}`); console.log(`RainWeb v${PKG.version}`);
console.log(`Node.js: ${process.version}`); console.log(`Node.js: ${process.version}`);
console.log(`Platform: ${process.platform}`); console.log(`Platform: ${process.platform}`);
console.log(`Data DB: ${fs.existsSync(DB_PATH) ? fs.statSync(DB_PATH).size + ' bytes' : 'NOT FOUND'}`); console.log(`Data DB: ${fs.existsSync(path.join(__dirname, 'data', 'rainweb.db')) ? fs.statSync(path.join(__dirname, 'data', 'rainweb.db')).size + ' bytes' : 'NOT FOUND'}`);
// 最近一次备份时间
const latestBackup = getLatestBackup();
console.log('Last backup: ' + (latestBackup ? formatMtime(latestBackup.mtime) + ' (' + latestBackup.file + ')' : '从未备份'));
// Check if server is running // Check if server is running
try { try {
@@ -110,8 +77,8 @@ async function cmdStatus() {
// Show admin info // Show admin info
try { try {
const db = await getDb(); await db.getDb();
const admin = dbGet("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'"); const admin = db.get("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
if (admin) { if (admin) {
console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`); console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`);
} }
@@ -150,6 +117,7 @@ function cmdPort() {
// === Password === // === Password ===
async function cmdPassword() { async function cmdPassword() {
await warnIfServerRunning();
let newPass = process.argv[3]; let newPass = process.argv[3];
if (!newPass) { if (!newPass) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -163,44 +131,45 @@ async function cmdPassword() {
process.exit(1); process.exit(1);
} }
await getDb(); await db.getDb();
const admin = dbGet("SELECT id FROM users WHERE role = 'admin'"); const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
if (!admin) { console.error('No admin user found.'); process.exit(1); } if (!admin) { console.error('No admin user found.'); process.exit(1); }
const hash = bcrypt.hashSync(newPass, 10); const hash = bcrypt.hashSync(newPass, 10);
dbRun('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]); db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
console.log('Admin password updated successfully.'); console.log('Admin password updated successfully.');
} }
// === Captcha === // === Captcha ===
async function cmdCaptcha() { async function cmdCaptcha() {
await getDb(); await warnIfServerRunning();
await db.getDb();
const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const q = (q) => new Promise(resolve => rl.question(q, resolve)); const q = (q) => new Promise(resolve => rl.question(q, resolve));
console.log('=== Captcha Rule Configuration ===\n'); console.log('=== Captcha Rule Configuration ===\n');
console.log('Current settings:'); console.log('Current settings:');
['login','register','forum','failed'].forEach(k => { ['login','register','forum','failed'].forEach(k => {
const v = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'"); const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
console.log(` ${k}: ${v ? v.value : '0'}`); console.log(` ${k}: ${v ? v.value : '0'}`);
}); });
const type = dbGet("SELECT value FROM site_settings WHERE key = 'captcha_type'"); const type = db.get("SELECT value FROM site_settings WHERE key = 'captcha_type'");
console.log(` type: ${type ? type.value : 'builtin'}\n`); console.log(` type: ${type ? type.value : 'builtin'}\n`);
const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: '); const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: ');
if (typeAns) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]); if (typeAns) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
for (const scope of ['login', 'register', 'forum']) { for (const scope of ['login', 'register', 'forum']) {
const current = dbGet("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'"); const current = db.get("SELECT value FROM site_settings WHERE key='captcha_" + scope + "'");
const ans = await q(`Enable captcha for ${scope}? (y/n) [${current && current.value === '1' ? 'y' : 'n'}]: `); const ans = await q(`Enable captcha for ${scope}? (y/n) [${current && current.value === '1' ? 'y' : 'n'}]: `);
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']); db.run("UPDATE site_settings SET value=? WHERE key='captcha_" + scope + "'", [ans.toLowerCase() === 'y' ? '1' : '0']);
} }
const failAns = await q('Enable captcha after failed attempts? (y/n): '); const failAns = await q('Enable captcha after failed attempts? (y/n): ');
dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']); db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
if (failAns.toLowerCase() === 'y') { if (failAns.toLowerCase() === 'y') {
const threshold = await q('Failed attempts threshold (default 5): '); const threshold = await q('Failed attempts threshold (default 5): ');
if (threshold) dbRun("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]); if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
} }
rl.close(); rl.close();
@@ -209,8 +178,8 @@ async function cmdCaptcha() {
// === Config === // === Config ===
async function cmdConfig() { async function cmdConfig() {
await getDb(); await db.getDb();
const rows = dbAll("SELECT key, value FROM site_settings ORDER BY key"); const rows = db.all("SELECT key, value FROM site_settings ORDER BY key");
console.log('=== Site Configuration ===\n'); console.log('=== Site Configuration ===\n');
const secrets = ['smtp_pass', 'recaptcha_secret_key']; const secrets = ['smtp_pass', 'recaptcha_secret_key'];
rows.forEach(r => { rows.forEach(r => {
@@ -261,76 +230,70 @@ async function cmdRestart() {
await cmdStart(); await cmdStart();
} }
// === Upgrade === // === Backup ===
const REPO_URL = 'https://github.com/Xianyunah/rainwebblog.git'; async function cmdBackup() {
const REPO_ZIP = 'https://github.com/Xianyunah/rainwebblog/archive/refs/heads/main.zip'; const srcPath = path.join(__dirname, 'data', 'rainweb.db');
if (!fs.existsSync(srcPath)) {
console.error('数据库不存在:' + srcPath + '(首次启动 server 后才会创建)');
process.exit(1);
}
const backupDir = path.join(__dirname, 'backups');
fs.mkdirSync(backupDir, { recursive: true });
const now = new Date();
const destPath = path.join(backupDir,
`rainweb-${now.getFullYear()}${pad2(now.getMonth() + 1)}${pad2(now.getDate())}-${pad2(now.getHours())}${pad2(now.getMinutes())}${pad2(now.getSeconds())}.db`);
// 只读打开源库,不写数据,安全(server 运行中也可执行)
// better-sqlite3 v13src.backup(destPath) 接受目标文件路径(string),返回 Promise
const Database = require('better-sqlite3');
const src = new Database(srcPath, { readonly: true });
try {
await src.backup(destPath);
} finally {
src.close();
}
const size = fs.statSync(destPath).size;
console.log(`备份成功:${destPath}${size} bytes`);
}
// 读取 backups/ 目录下最新的备份文件(按 mtime),无备份返回 null
function getLatestBackup() {
const backupDir = path.join(__dirname, 'backups');
if (!fs.existsSync(backupDir)) return null;
let latest = null;
for (const f of fs.readdirSync(backupDir)) {
if (!f.startsWith('rainweb-') || !f.endsWith('.db')) continue;
const filePath = path.join(backupDir, f);
const mtime = fs.statSync(filePath).mtime;
if (!latest || mtime > latest.mtime) latest = { file: f, mtime };
}
return latest;
}
function pad2(n) { return String(n).padStart(2, '0'); }
function formatMtime(date) {
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
// === Upgrade ===
async function cmdUpgrade() { async function cmdUpgrade() {
console.log('=== RainWeb Upgrade ===\n'); console.log('=== RainWeb Upgrade ===\n');
const isGitRepo = fs.existsSync(path.join(__dirname, '.git'));
if (isGitRepo) { if (!fs.existsSync(path.join(__dirname, '.git'))) {
console.log('1. Pulling latest code via git...'); console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
try { process.exit(1);
execSync('git pull', { cwd: __dirname, stdio: 'inherit' }); }
} catch {
console.error('Git pull failed. Check for conflicts.');
process.exit(1);
}
} else {
console.log('1. Downloading latest release...');
try {
const tmpDir = path.join(__dirname, '.upgrade-tmp');
if (fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true });
fs.mkdirSync(tmpDir, { recursive: true });
// Download zip using fetch or curl console.log('1. Pulling latest code via git...');
const zipPath = path.join(tmpDir, 'rainweb.zip'); try {
try { execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
execSync(`curl -L "${REPO_ZIP}" -o "${zipPath}"`, { stdio: 'pipe' }); } catch {
} catch { console.error('Git pull failed. Check for conflicts.');
// Fallback to wget or node https process.exit(1);
execSync(`node -e "
const https = require('https');
const fs = require('fs');
const f = fs.createWriteStream('${zipPath.replace(/\\/g, '/')}');
https.get('${REPO_ZIP}', r => r.pipe(f));
"`, { stdio: 'pipe', timeout: 60000 });
}
// Extract (requires unzip or 7z)
const extractDir = path.join(tmpDir, 'extracted');
fs.mkdirSync(extractDir, { recursive: true });
execSync(`tar -xf "${zipPath}" -C "${extractDir}"`, { stdio: 'pipe' });
// Find the inner directory (github adds a prefix dir)
const inner = fs.readdirSync(extractDir).filter(f => fs.statSync(path.join(extractDir, f)).isDirectory())[0];
const srcDir = inner ? path.join(extractDir, inner) : extractDir;
// Copy files, excluding data.db and node_modules
const exclude = ['data.db', 'node_modules', '.env.json', 'server.pid', 'releases'];
const cpDir = (src, dest) => {
fs.readdirSync(src).forEach(f => {
if (exclude.includes(f)) return;
const s = path.join(src, f), d = path.join(dest, f);
if (fs.statSync(s).isDirectory()) {
if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true });
cpDir(s, d);
} else {
fs.copyFileSync(s, d);
}
});
};
cpDir(srcDir, __dirname);
// Cleanup
fs.rmSync(tmpDir, { recursive: true });
console.log(' Download & extract complete.');
} catch (e) {
console.error('Download failed:', e.message);
console.log(' Fallback: manually download from ' + REPO_URL);
process.exit(1);
}
} }
console.log('\n2. Installing dependencies...'); console.log('\n2. Installing dependencies...');
@@ -347,6 +310,14 @@ async function cmdUpgrade() {
} }
// === Helper === // === Helper ===
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
async function warnIfServerRunning() {
try {
await httpGet('http://localhost:' + (getConfigPort()));
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
} catch {}
}
function httpGet(url) { function httpGet(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); }) http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
BIN
View File
Binary file not shown.
+136 -53
View File
@@ -1,103 +1,109 @@
const initSqlJs = require('sql.js'); const Database = require('better-sqlite3');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const DB_PATH = path.join(__dirname, 'data', 'rainweb.db'); const DB_PATH = path.join(__dirname, 'data', 'rainweb.db');
const DATA_DIR = path.dirname(DB_PATH);
let db = null; let db = null;
async function getDb() { function getDb() {
if (db) return db; if (db) return db;
const SQL = await initSqlJs(); // Ensure data directory exists
if (fs.existsSync(DB_PATH)) { if (!fs.existsSync(DATA_DIR)) {
const buffer = fs.readFileSync(DB_PATH); fs.mkdirSync(DATA_DIR, { recursive: true });
db = new SQL.Database(buffer);
} else {
db = new SQL.Database();
} }
db = new Database(DB_PATH);
initTables(); initTables();
migrateSchema();
seedAdmin(); seedAdmin();
seedDefaults(); seedDefaults();
seedSampleData(); seedSampleData();
saveDb();
return db; return db;
} }
function saveDb() {
if (!db) return;
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
function initTables() { function initTables() {
db.run(`CREATE TABLE IF NOT EXISTS users ( db.exec(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0, password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '', role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`); created_at DATETIME DEFAULT (datetime('now')))`);
db.run(`CREATE TABLE IF NOT EXISTS pending_users ( db.exec(`CREATE TABLE IF NOT EXISTS pending_users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL, password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT (datetime('now')))`); created_at DATETIME DEFAULT (datetime('now')))`);
db.run(`CREATE TABLE IF NOT EXISTS admin_links ( db.exec(`CREATE TABLE IF NOT EXISTS admin_links (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '', url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0, icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '', use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`); created_at DATETIME DEFAULT (datetime('now')))`);
// Migration: add columns if missing // Migration: add columns if missing
try { db.run('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {} try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
try { db.run('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {} try { db.exec('ALTER TABLE admin_links ADD COLUMN version TEXT DEFAULT ""'); } catch {}
db.run(`CREATE TABLE IF NOT EXISTS announcements ( db.exec(`CREATE TABLE IF NOT EXISTS announcements (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '', id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT DEFAULT '',
content TEXT NOT NULL, active INTEGER DEFAULT 1, content TEXT NOT NULL, active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')))`); updated_at DATETIME DEFAULT (datetime('now')))`);
db.run(`CREATE TABLE IF NOT EXISTS forum_categories ( db.exec(`CREATE TABLE IF NOT EXISTS forum_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)`); description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0,
announcement TEXT DEFAULT '', sub_categories TEXT DEFAULT '')`);
try { db.exec('ALTER TABLE forum_categories ADD COLUMN announcement TEXT DEFAULT ""'); } catch {}
try { db.exec('ALTER TABLE forum_categories ADD COLUMN sub_categories TEXT DEFAULT ""'); } catch {}
db.run(`CREATE TABLE IF NOT EXISTS forum_posts ( db.exec(`CREATE TABLE IF NOT EXISTS forum_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL,
title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL, title TEXT NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
use_markdown INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1, sub_category TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (category_id) REFERENCES forum_categories(id), FOREIGN KEY (category_id) REFERENCES forum_categories(id),
FOREIGN KEY (author_id) REFERENCES users(id))`); FOREIGN KEY (author_id) REFERENCES users(id))`);
try { db.run('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {} try { db.exec('ALTER TABLE forum_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
try { db.exec('ALTER TABLE forum_posts ADD COLUMN sub_category TEXT DEFAULT ""'); } catch {}
try { db.exec('ALTER TABLE forum_posts ADD COLUMN tags TEXT DEFAULT ""'); } catch {}
db.run(`CREATE TABLE IF NOT EXISTS blog_comments ( db.exec(`CREATE TABLE IF NOT EXISTS blog_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
content TEXT NOT NULL, author_id INTEGER, content TEXT NOT NULL, author_id INTEGER,
author_name TEXT DEFAULT '', created_at DATETIME DEFAULT (datetime('now')), author_name TEXT DEFAULT '', parent_id INTEGER DEFAULT 0,
status TEXT DEFAULT 'approved',
created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (post_id) REFERENCES blog_posts(id))`); FOREIGN KEY (post_id) REFERENCES blog_posts(id))`);
db.run(`CREATE TABLE IF NOT EXISTS forum_replies ( db.exec(`CREATE TABLE IF NOT EXISTS forum_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, post_id INTEGER NOT NULL,
content TEXT NOT NULL, author_id INTEGER NOT NULL, content TEXT NOT NULL, author_id INTEGER NOT NULL,
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (post_id) REFERENCES forum_posts(id), FOREIGN KEY (post_id) REFERENCES forum_posts(id),
FOREIGN KEY (author_id) REFERENCES users(id))`); FOREIGN KEY (author_id) REFERENCES users(id))`);
db.run(`CREATE TABLE IF NOT EXISTS blog_posts ( db.exec(`CREATE TABLE IF NOT EXISTS blog_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL, content TEXT NOT NULL, excerpt TEXT DEFAULT '', author_id INTEGER NOT NULL,
published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1, published INTEGER DEFAULT 1, use_markdown INTEGER DEFAULT 1,
tags TEXT DEFAULT '', views INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (author_id) REFERENCES users(id))`); FOREIGN KEY (author_id) REFERENCES users(id))`);
db.run(`CREATE TABLE IF NOT EXISTS user_settings ( db.exec(`CREATE TABLE IF NOT EXISTS post_likes (
post_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT (datetime('now')),
PRIMARY KEY (post_id, user_id))`);
db.exec(`CREATE TABLE IF NOT EXISTS user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER UNIQUE NOT NULL,
pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '', pin_hash TEXT DEFAULT '', kdf_salt TEXT DEFAULT '',
FOREIGN KEY (user_id) REFERENCES users(id))`); FOREIGN KEY (user_id) REFERENCES users(id))`);
db.run(`CREATE TABLE IF NOT EXISTS password_entries ( db.exec(`CREATE TABLE IF NOT EXISTS password_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL,
title TEXT NOT NULL, username TEXT DEFAULT '', title TEXT NOT NULL, username TEXT DEFAULT '',
encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes TEXT DEFAULT '', encrypted_password TEXT NOT NULL, url TEXT DEFAULT '', notes TEXT DEFAULT '',
@@ -105,7 +111,7 @@ function initTables() {
updated_at DATETIME DEFAULT (datetime('now')), updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id))`); FOREIGN KEY (user_id) REFERENCES users(id))`);
db.run(`CREATE TABLE IF NOT EXISTS attachments ( db.exec(`CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL,
original_name TEXT NOT NULL, size INTEGER NOT NULL, original_name TEXT NOT NULL, size INTEGER NOT NULL,
mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL, mime_type TEXT DEFAULT '', user_id INTEGER NOT NULL,
@@ -113,15 +119,64 @@ function initTables() {
created_at DATETIME DEFAULT (datetime('now')), created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id))`); FOREIGN KEY (user_id) REFERENCES users(id))`);
db.run(`CREATE TABLE IF NOT EXISTS site_settings ( db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY, value TEXT DEFAULT '')`); key TEXT PRIMARY KEY, value TEXT DEFAULT '')`);
// 审计日志表(终端会话/密码失败等安全事件落库)
db.exec(`CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
user_id INTEGER DEFAULT 0,
username TEXT DEFAULT '',
detail TEXT DEFAULT '',
ip TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`);
db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)');
// 审计保留策略:启动时惰性清理 90 天前的记录,防止无限增长
//(写入路径上 terminal.js 每 50 条也会触发一次同规则清理)
db.exec("DELETE FROM audit_logs WHERE created_at < datetime('now','-90 day')");
// Indexes for performance // Indexes for performance
db.run('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)'); db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
db.run('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_forum_posts_category ON forum_posts(category_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_forum_replies_post ON forum_replies(post_id)');
db.run('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)'); db.exec('CREATE INDEX IF NOT EXISTS idx_password_user ON password_entries(user_id)');
db.exec('CREATE INDEX IF NOT EXISTS idx_attachments_ref ON attachments(ref_type, ref_id)');
// Legacy migrations for old database compatibility
try { db.exec("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''"); } catch {}
try { db.exec('ALTER TABLE users ADD COLUMN email_verified INTEGER DEFAULT 0'); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN avatar TEXT DEFAULT ''"); } catch {}
try { db.exec('ALTER TABLE blog_posts ADD COLUMN use_markdown INTEGER DEFAULT 1'); } catch {}
}
// 版本化迁移框架:PRAGMA user_version 记录当前 schema 版本,
// migrations 数组按 version 升序执行,仅当 current < m.version 时运行并推进版本号。
// 现有 try/catch ALTER 保留作为"确保列存在"的幂等层,未来新列迁移写入 migrations。
function migrateSchema() {
const current = db.pragma('user_version', { simple: true });
const migrations = [
// v1: 密码管理器 user_settings 增加 pin_iter 列(对应 routes/passwords.js 的 ensureSchema 逻辑,
// 默认 100000:存量用户保持旧迭代数,旧密文仍可解密)
{ version: 1, up: () => { try { db.exec("ALTER TABLE user_settings ADD COLUMN pin_iter INTEGER DEFAULT 100000"); } catch {} } },
// v2: 博客增强——博文标签/阅读量、评论嵌套与审核、点赞表
{ version: 2, up: () => {
try { db.exec("ALTER TABLE blog_posts ADD COLUMN tags TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE blog_posts ADD COLUMN views INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE blog_comments ADD COLUMN parent_id INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE blog_comments ADD COLUMN status TEXT DEFAULT 'approved'"); } catch {}
try { db.exec("CREATE TABLE IF NOT EXISTS post_likes (post_id INTEGER NOT NULL, user_id INTEGER NOT NULL, created_at DATETIME DEFAULT (datetime('now')), PRIMARY KEY (post_id, user_id))"); } catch {}
} },
// v3: RainID 单点登录——影子账号绑定键(sub)。部分唯一索引:空串(本地账号)不参与唯一约束
{ version: 3, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''"); } catch {}
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid ON users(rainid_user_id) WHERE rainid_user_id <> ''"); } catch {}
} },
];
for (const m of migrations) {
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
}
} }
function seedAdmin() { function seedAdmin() {
@@ -136,9 +191,12 @@ function seedDefaults() {
const defaults = { const defaults = {
site_name: 'RainWeb', site_name: 'RainWeb',
site_description: '个人云平台', site_description: '个人云平台',
site_url: '',
primary_color: '#6750a4', primary_color: '#6750a4',
recaptcha_site_key: '', recaptcha_site_key: '',
recaptcha_secret_key: '', recaptcha_secret_key: '',
turnstile_site_key: '',
turnstile_secret_key: '',
smtp_host: '', smtp_host: '',
smtp_port: '587', smtp_port: '587',
smtp_user: '', smtp_user: '',
@@ -147,6 +205,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',
@@ -155,6 +214,22 @@ function seedDefaults() {
captcha_register: '0', captcha_register: '0',
captcha_forum: '0', captcha_forum: '0',
captcha_type: 'builtin', captcha_type: 'builtin',
rainid_enabled: '0',
rainid_client_id: '',
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
rainid_register_redirect: '0',
site_favicon: 'data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🌧</text></svg>',
music_embed_enabled: '0',
music_embed_code: '',
music_embed_position: 'right',
music_embed_autohide: '0',
music_embed_idle_timeout: '10',
footer_style: 'classic',
footer_copyright: '<span class="copyright-glow">© 2026 <b>Rainnya Blog</b> All rights reserved</span>',
footer_powered: '<span class="powered-glow">由 <a href="https://git.rainnya.asia/miaomiao/rainblogweb" target="_blank" rel="noopener" style="color:#fff;text-decoration:underline;text-decoration-color:rgba(255,255,255,0.55);text-underline-offset:3px;">RainWeb Engine</a> 强力驱动</span>',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
}; };
for (const [k, v] of Object.entries(defaults)) { for (const [k, v] of Object.entries(defaults)) {
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) { if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
@@ -164,24 +239,32 @@ function seedDefaults() {
} }
function run(sql, params = []) { function run(sql, params = []) {
db.run(sql, params); try {
const r = db.exec("SELECT last_insert_rowid()"); const info = db.prepare(sql).run(...params);
const rowid = r && r[0] && r[0].values ? r[0].values[0][0] : 0; return Number(info.lastInsertRowid) || 0;
saveDb(); } catch (e) {
return rowid; console.error('SQL run error:', e.message, 'SQL:', sql.substring(0, 80));
return 0;
}
} }
function get(sql, params = []) { function get(sql, params = []) {
const stmt = db.prepare(sql); stmt.bind(params); try {
if (stmt.step()) { const row = stmt.getAsObject(); stmt.free(); return row; } const row = db.prepare(sql).get(...params);
stmt.free(); return null; return row || null;
} catch (e) {
console.error('SQL get error:', e.message, 'SQL:', sql.substring(0, 80));
return null;
}
} }
function all(sql, params = []) { function all(sql, params = []) {
const stmt = db.prepare(sql); stmt.bind(params); try {
const rows = []; return db.prepare(sql).all(...params);
while (stmt.step()) rows.push(stmt.getAsObject()); } catch (e) {
stmt.free(); return rows; console.error('SQL all error:', e.message, 'SQL:', sql.substring(0, 80));
return [];
}
} }
function getSetting(key) { function getSetting(key) {
@@ -208,8 +291,8 @@ function seedSampleData() {
if (adminUser) { if (adminUser) {
const aid = adminUser.id; const aid = adminUser.id;
// Forum posts // Forum posts
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid]); run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [1, '欢迎来到论坛', '这是论坛的第一篇帖子!欢迎大家交流讨论。', aid, '分享']);
run('INSERT INTO forum_posts (category_id, title, content, author_id) VALUES (?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid]); run('INSERT INTO forum_posts (category_id, title, content, author_id, sub_category) VALUES (?, ?, ?, ?, ?)', [2, '今天天气真不错', '大家今天过得怎么样?来聊聊吧!', aid, '讨论']);
// Blog posts // Blog posts
const blogMarkdown = `## 欢迎使用 RainWeb\n\nRainWeb 是一个多功能的个人云平台,集成了 **博客、论坛、密码管理器** 等功能。\n\n- 🎨 Material Design 3 风格\n- 🌓 深色/浅色主题切换\n- 📧 邮箱验证注册\n- 🔒 密码管理器 (AES-256-GCM 加密)\n\n### 快速开始\n\n1. 点击右上角「登录」使用默认账号 \`admin / admin123\`\n2. 在管理后台配置 SMTP 邮件和 reCAPTCHA\n3. 在「面板链接」中添加你的各个管理后台\n4. 发布你的第一篇博客文章!`; const blogMarkdown = `## 欢迎使用 RainWeb\n\nRainWeb 是一个多功能的个人云平台,集成了 **博客、论坛、密码管理器** 等功能。\n\n- 🎨 Material Design 3 风格\n- 🌓 深色/浅色主题切换\n- 📧 邮箱验证注册\n- 🔒 密码管理器 (AES-256-GCM 加密)\n\n### 快速开始\n\n1. 点击右上角「登录」使用默认账号 \`admin / admin123\`\n2. 在管理后台配置 SMTP 邮件和 reCAPTCHA\n3. 在「面板链接」中添加你的各个管理后台\n4. 发布你的第一篇博客文章!`;
-68
View File
@@ -1,68 +0,0 @@
@echo off
title RainWeb Deploy
set REPO_URL=https://github.com/Xianyunah/rainwebblog.git
set INSTALL_DIR=rainweb
echo ====================================
echo RainWeb - One-Click Deploy
echo Repo: %REPO_URL%
echo ====================================
echo.
REM Check Node.js
where node >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Node.js is not installed. Install from https://nodejs.org
pause
exit /b 1
)
echo [OK] Node.js:
node -v
REM Check git
where git >nul 2>&1
if %errorlevel% neq 0 (
echo [ERROR] Git is not installed.
pause
exit /b 1
)
REM Clone or pull
if exist "%INSTALL_DIR%\.git" (
echo [1/4] Updating existing installation...
cd "%INSTALL_DIR%"
git pull
) else (
echo [1/4] Cloning repository...
git clone "%REPO_URL%" "%INSTALL_DIR%"
cd "%INSTALL_DIR%"
)
REM Install dependencies
echo.
echo [2/4] Installing dependencies...
call npm install
if %errorlevel% neq 0 (
echo [ERROR] npm install failed.
pause
exit /b 1
)
echo [OK] Dependencies installed.
REM Create wallpaper folder
if not exist "public\wallpaper" mkdir "public\wallpaper"
REM Start
echo.
echo [3/4] Starting server...
echo.
echo ====================================
echo Open http://localhost:3001
echo Default admin: admin / admin123
echo ====================================
echo.
start "" http://localhost:3001
node server.js
pause
-62
View File
@@ -1,62 +0,0 @@
#!/bin/bash
set -e
REPO_URL="https://github.com/Xianyunah/rainwebblog.git"
INSTALL_DIR="${1:-rainweb}"
echo "===================================="
echo " RainWeb - One-Click Deploy"
echo " Repo: $REPO_URL"
echo "===================================="
echo ""
# Check Node.js
if ! command -v node &> /dev/null; then
echo "[ERROR] Node.js is not installed. Install from https://nodejs.org (LTS 20.x+)"
exit 1
fi
echo "[OK] Node.js: $(node -v)"
# Check git
if ! command -v git &> /dev/null; then
echo "[ERROR] Git is not installed."
exit 1
fi
# Clone or pull
if [ -d "$INSTALL_DIR/.git" ]; then
echo "[1/4] Updating existing installation..."
cd "$INSTALL_DIR"
git pull
else
echo "[1/4] Cloning repository..."
git clone "$REPO_URL" "$INSTALL_DIR"
cd "$INSTALL_DIR"
fi
# Install dependencies
echo ""
echo "[2/4] Installing dependencies..."
npm install --production
echo "[OK] Dependencies installed."
# Create wallpaper directory
mkdir -p public/wallpaper
# Start
echo ""
echo "[3/4] Starting server..."
echo ""
echo "===================================="
echo " Open http://localhost:3001"
echo " Default admin: admin / admin123"
echo "===================================="
echo ""
if command -v xdg-open &> /dev/null; then
xdg-open http://localhost:3001 2>/dev/null || true
elif command -v open &> /dev/null; then
open http://localhost:3001 2>/dev/null || true
fi
node server.js
+174
View File
@@ -0,0 +1,174 @@
# RainWeb 优化重构实施计划(P0 / P1 / P2)
> 依据:数据库优化师(better-sqlite3 选型调研)、后端架构师(架构评审)、应用安全工程师(安全审计)、前端路由专项深挖 四份报告合并。
> 数据库路线已定:**better-sqlite3 + Node 22 LTS**(放弃 sql.js 与 PostgreSQL)。
> 前端路由:用户确认问题很大 → 按「方案 A 止血 → 方案 B 协议收编」两步走(见 P1-14 / P2-19)。
> 本计划仅执行顺序与细节,**未开始任何代码改动**。
## 全局前提(每阶段执行前)
1. 备份:`cp -r data data.bak-<日期>``data/rainweb.db` 是唯一数据文件)
2. 每阶段一个 git commitcommit message 中文,如 `P0: 删除 Web 更新链路,修复鉴权漏洞`
3. 无测试环境,验证方式 = curl 接口 + 浏览器刷新 + `node cli.js` 命令
---
## P0 紧急止损(独立项,可并行,约半天)
### P0-1 删除 Web 更新链路(消除 R6 + .git 覆盖风险)
- `server.js`:删除 84-165 行 `/api/update/check` + `/api/update/run`,保留 `/api/version`81-82 行)
- `public/js/admin.js`:删除 checkUpdate/runUpdate 函数及更新按钮 UI(约 692-731 行区域)
- `cli.js``cmdUpgrade` 保留 git pull 分支(走本地 Gitea origin),删除非 git 仓库的 zip 下载分支(266-346 行区域)
- 理由:用户即上游,zip 覆盖式更新是错误范式;`/api/update/run` 无鉴权=RCE 面,且 exclude 未排除 `.git` 会冲掉 git 历史
- 风险:低。注意前端引用一并删除,避免 404
- 验证:`curl /api/update/check` → 404;admin 面板打开控制台无报错;`node cli.js upgrade` 走 git pull 分支正常
### P0-2 JWT_SECRET 随机化(消除 R4
- `middleware/auth.js`:改为 `process.env.JWT_SECRET` 必须存在(长度 ≥32),否则启动失败;删除硬编码回退
- `server.js` `start()`:首次启动若无 `JWT_SECRET`,用 `crypto.randomBytes(48).toString('hex')` 生成并写入 `.env.json`
- 风险:低;已签发旧 token 全部失效(自部署可接受)
- 验证:删 `.env.json` 重启 → 自动生成 secret;登录 → `curl /api/auth/me` 200
### P0-3 setup 接管漏洞(消除 R5
- `routes/setup.js``POST /complete``authMiddleware, adminOnly``GET /setup/status` 响应移除 `default_password` 字段(只保留 `setup_complete`
- 风险:低
- 验证:未登录 `curl -X POST /api/setup/complete` → 401;登录后正常
### P0-4 proxy SSRF 最小防护(消除 R3 的 SSRF 部分)
- `routes/proxy.js``GET /fetch``authMiddleware, adminOnly``new URL(target)` 后校验 hostname 非 `127.0.0.1`/`::1`/`localhost`/`10.`/`192.168.`/`172.16-31.`/`169.254.`/`fc00:`/`fe80:`
- 风险:低(本功能只服务于 admin_links 面板嵌入)
- 验证:未登录请求 → 401`/api/proxy/fetch?url=http://127.0.0.1:3001/` → 拒绝
### P0-5 邮件验证码回显与滥用(消除 R7)
- `routes/email.js``send-verify` 响应删除 `code` 回显;验证码生成改 `crypto.randomInt(10000000, 100000000)`;补回"删除 pending 后重建"的 bug(或废弃该接口由 register 内联重发)
- 风险:低
- 验证:调 send-verify → 响应无 code 字段;邮件仍能收到验证码
---
## P1 安全加固 + 数据层统一(依赖顺序:11 → 12,其余独立)
### P1-6 上传白名单 + /uploads 防护头(消除 R1
- `routes/upload.js``shaFileUpload` 内校验 `path.extname(...).toLowerCase()``ALLOWED_EXT = ['.png','.jpg','.jpeg','.gif','.webp','.pdf','.zip','.txt','.md']` 内,否则报"不支持的文件类型";图片额外校验 `file.mimetype``image/` 开头
- `server.js:63``/uploads` 静态服务加 `X-Content-Type-Options: nosniff` + `Content-Security-Policy: sandbox; default-src 'none'`;非白名单扩展名强制 `Content-Disposition: attachment`
- 风险:低;已有历史上传文件不受影响(仅新上传受限)
- 验证:登录后上传 `x.html` → 400;上传 png → 200;访问 `/uploads/<hash>.png` 响应头含 nosniff
### P1-7 内容净化双端统一(消除 R2)
- `ssr.js`:引入 `dompurify + jsdom`(服务端),`renderSSR``marked.parse` 结果过 `DOMPurify.sanitize``ssrPage``title/siteName/ogDesc` 全部 HTML 转义,JSON-LD 用 `JSON.stringify` 序列化
- `public/js/render.js`:引入 DOMPurify(本地化到 public/js/vendor/ 或 CDN + SRI),`marked.parse` 结果同样过 sanitize
- 风险:中(渲染路径改动,需走查博客/论坛/SEO 页)
- 验证:发帖内容含 `<img src=x onerror=alert(1)>` → SSR 页与 SPA 页均不弹窗、标签被剥;正常 markdown 渲染不变
### P1-8 登录限流 + 服务端验证码(消除 R8)
- 新增 `express-rate-limit` 依赖:`/api/auth/login` 15 分钟 10 次;`/api/email/send-verify` 60s/次
- `routes/auth.js`login/register 按 `captcha_login`/`captcha_register` 设置服务端校验 captcha token(与 captcha.js 的 verify 存储联动,token 与 username 绑定);`routes/forum.js` 发帖同理(`captcha_forum`
- 删除 `verifyCaptchaToken` 桩函数(auth.js:39-52
- 风险:中(验证码链路前后端要同步改)
- 验证:连错密码 11 次 → 429;开启 captcha_login 后登录需先过验证码
### P1-9 密码箱解锁限流 + KDF 加固(消除 M1)
- `routes/passwords.js``/unlock` 加失败计数(3 次/5 分钟,指数退避);解锁会话滑动过期替换一次性 setTimeout
- PBKDF2 迭代 100k → 600kOWASP 2023 建议);注意同步执行阻塞事件循环,可接受(单用户站)
- 风险:低
- 验证:连续输错 PIN 3 次 → 锁定提示;正确解锁正常;旧 PIN 密文在 KDF 迭代变更后需重设(set-pin 已存在)
### P1-10 鉴权与信息收敛(消除 M2-M7)
- `middleware/auth.js``adminOnly` 增加 `db.get('SELECT role FROM users WHERE id=?')` 复查(M2
- `routes/blog.js``?all=1``authMiddleware, adminOnly``GET /posts/:id` 未发布仅 admin/作者可见(M3)
- 错误信息收敛:`routes/forum.js:21,35``server.js:163``routes/email.js:54,93` 对外统一 `{ error: '操作失败' }`,详情仅 console.errorM4
- `routes/email.js:17``auth.js:93``profile.js:25``proxy.js:22``rejectUnauthorized:false` 收敛为配置项(默认关闭校验仅当 SMTP 自签时开启)(M5)
- `public/js/passwords.js:113``forum.js:76`:内联 onclick 字符串插值改 `data-id` + `addEventListener` 委托(M6
- **S3-3 escapeHtml 引号问题**`escapeHtml` 不转义 `'`/`"`,用于属性/JS 字符串上下文可注入断链(forum.js:76、passwords.js:113、admin.js:74/402-404)——新增 `escapeAttr`(全转义)用于属性上下文,或随 M6 的 data-id 改造一并消除;帖子标题/分类为他人可见,属存储型 XSS 面
- `routes/profile.js`avatar 仅接受 `/uploads/avatars/` 站内路径(M7
- 风险:中;验证:逐项 curl 测试(未登录 401/403、草稿对匿名 404、报错无堆栈详情)
### P1-11 cli.js 统一数据层(ora-2 P1-1
- `cli.js`:删除 9-51 行重复封装(DB_PATH/getDb/dbRun/dbGet/dbAll),改为 `const db = require('./db')``cmdStatus/cmdPassword/cmdCaptcha/cmdConfig` 改用 `db.get/db.all`;各命令 `await getDb()`
- `data.db` 旧路径彻底退役(server.js 启动迁移逻辑保留,把旧文件搬进 data/)
- 风险:中低;验证:备份后依次跑 `node cli.js status/config/password/captcha` 全部成功且落库
### P1-12 schema_version 迁移框架(ora-2 P1-2,依赖 P1-11
- `db.js``initTables()` 收敛——"确保列存在"函数(幂等补列,逻辑同现有 try/catch)+ `PRAGMA user_version` 记录迁移版本;未来迁移写成 `{ version, up() }` 数组顺序执行
- 风险:中;验证:旧库启动自动补列无报错;连续启动两次幂等
### P1-13 deploy 脚本处置(ora-2 P1-3
- 删除 `deploy.sh` + `deploy.bat`(指向 GitHub 旧仓库,对自托管用户无意义);README 相关段落同步清理
- 风险:低;验证:`ls` 确认删除;README 无残留引用
### P1-14 前端路由止血(方案 A,约 0.5 天)
> 专项深挖结论:PJAX 只替换 `<main>`、目标页 `<script>` 从不执行,而 5 个 PJAX 壳页各自加载不同共享脚本子集 → 从某些页面进入时目标页依赖缺失(结构性缺陷 S1-3)。已确认 bug:S1-1 首页空白、S1-2 个人中心空白、S1-3 脚本依赖矩阵、S2-1 监听器重复累积、S2-2 论坛详情后退进 SSR 页、S2-4 滚动不恢复、S2-5 登录态/设置变化后导航不刷新、S2-6 login/register 白跳。
- **A1 PJAX_PATHS 收敛**`router.js` 只保留 5 个有 `_pageConfig` 的页面(`/``/blog.html``/forum.html``/admin.html``/passwords.html`);移除 `login.html`/`register.html`/`profile.html`(整页导航约定,根治 S1-2、S2-6)
- **A2 统一共享脚本集**5 个 PJAX 壳页 html 全部加载完整共享集(marked + render + captcha + music-embed + theme + api + nav + router),消除 S1-3 全部矩阵缺口
- **A3 首页逻辑外部化**`public/index.html:54-96` 内联 HOMEPAGE 抽为 `public/js/homepage.js` 并注册进 `_pageConfig`(修 S1-1;顺带合并 S3-4 与 blog.js 的 loadSidebar 重复)
- **A4 `_loadScript` 判重**`router.js:91-99` 加已加载检测,修 S2-1 监听器累积
- **A5 popstate 分支 + 滚动**popstate 识别 `e.state.forumPostId` 直接还原论坛详情(修 S2-2 不再 reload 进 SSR 页);navigate 时 `scrollTo(0,0)`(修 S2-4
- **A6 导航重渲染钩子**loadPage 完成后调 `NAV.render?.()`(修 S2-5
- 风险:低(login/register/profile 当前本就走整页兜底,无回归面);A2 可能触发 S3-2 重复定义,但各文件实现相同无碍
- 验证:passwords.html 整页进入 → PJAX 依次进博客(markdown 正常)/论坛(发帖弹验证码、详情正常)/首页;论坛详情后退回 SPA 列表无 reload;循环 PJAX 5 次 forum.js 只加载一次
---
## P2 数据库换轨 + 工程卫生(依赖 P1-11/P1-12
### P2-15 better-sqlite3 迁移(lib-1 调研结论)
- **前置**Node 升 22 LTS(宝塔 Node 版本管理器 / nvm);README 与 Docker 示例同步(`node:20``node:22`
- 依赖:`npm install better-sqlite3`(v13,预编译随包,宝塔 glibc x64 免编译)
- `db.js``initSqlJs()` 异步初始化 → `new Database(DB_PATH)` 同步;`run/get/all` 重写:
- `run``db.prepare(sql).run(...params)` + `info.lastInsertRowid`
- `get``stmt.get(...params)``all``stmt.all(...params)`
- 删除 `saveDb()`/`db.export()`(SQLite 事务原生落盘);返回签名不变,40+ 业务调用点零改动
- `initTables()` 的 CREATE TABLE IF NOT EXISTS 与幂等补列原样保留(兼容旧库)
- `cli.js`:同套改写(P1-11 统一后只需改 db.js 一处)
- `routes/import.js`:上传文件先落临时路径再 `new Database(tmpPath)` 只读打开,`exec` 数组结果改对象结果,用后 close
- **数据迁移**:零迁移——现有 `data/rainweb.db` 是标准 SQLite 文件,直接打开
- 风险:中;验证:备份 data/ 后启动 → 管理后台 CRUD 走一遍 → `node cli.js status/password``sqlite3 data/rainweb.db "PRAGMA integrity_check;"`
### P2-16 死代码 + 前端重复清理
- 删除 `routes/links.js`(引用不存在的 links 表,挂载即崩)、`public/js/main.js`(无引用)、`public/js/passwords.js` 内重复的 `deleteFromDetail`
- S3-1`register.html:85``:92` 重复调用 `CAPTCHA.checkRequired('register')`,删一行
- S3-2`escapeHtml/closeDialog/openDialog/showSnackbar` 在 nav/render/blog/forum/admin/passwords/index/profile 各定义一份 → 收敛到 nav.js(或公共 util)单份定义;admin.js 同文件内 6 个函数重复定义(setNavStyle/setCardStyle/previewWallpaperUrl/removeWallpaper/loadWallpaperList/selectUploadedWallpaper/uploadWallpaperadmin.js:179/263 等)删后一份
- 风险:低;验证:全站走查控制台无 404、无重复定义报错
### P2-17 版本单源 + 文档同步
- `VERSION` 文件为唯一源(server.js:81 已读它);`cli.js` HELP 版本改读 VERSION`package.json` version 与 VERSION 同步
- `README.md`Node 要求改 `>= 22 LTS`;删除更新/部署章节中 GitHub 引用
- `AGENTS.md`:数据层章节重写(better-sqlite3、无 saveDb、单文件直写、cli.js 已统一)
- 风险:低;验证:`node cli.js help``/api/version` 显示一致
### P2-18 可选加固(H 系列,按需确认)
- H1`helmet()` + 收紧 CSPCDN 引入 marked 的 5 个页面加 SRI)——建议做
- H3:验证码 `Math.random()``crypto.randomInt`auth.js:80、email.js:63、profile.js:68)——建议做,改动极小
- H9bcrypt cost 10 → 12;注册用户名格式校验——建议做
- H2JWT 从 localStorage 改 HttpOnly cookie——改动大(前端 api.js 全量),**默认不做**,列入后续
- H4:首启随机管理员密码——与 setup 向导冲突,**默认不做**(P0-3 已封堵接管面)
### P2-19 前端路由协议收编(方案 B,约 1.5-2 天,依赖 P1-14
> 方案 A 修完当前全部可复现 bug;方案 B 是防复发投资——解决"下一次加页面还会不会踩坑"。
- **B1 单一注册表**`PAGES` 配置对象 `path → { pjax, title, scripts, global, init }``PJAX_PATHS`/`_pageConfig`/脚本依赖全部由它派生;命中且 `pjax:true` 才拦截,其余整页导航(无兜底分支)
- **B2 页面结构规范**:每个 PJAX 页 `<main data-page="blog">`router 读 `data-page` 查注册表,不再硬编码路径数组;共享依赖在注册表声明,`_loadScript` 按"已加载集合"增量补齐
- **B3 页面逻辑全外部化**HOMEPAGEP1-14 A3 已抽)、profile 逻辑(profile.html:43-180)抽为独立 js;内联 `<script>` 仅保留极少量数据初始化;统一 `window[global].init()` 协议
- **B4 pushState/popstate 统一由 router 管理**:页面不再自行 pushState 非标准 state;论坛/博客详情作为"子路由"由注册表定义,popstate 由 router 解析还原(根治 S2-2/S2-3
- **B5 生命周期钩子**:注册表条目可选 `destroy()`,音乐嵌入/定时器/事件解绑在此处理(根治 S2-1 结构性累积)
- 整页应用(login/register/write/embed/forum-manage/setup)标记 `pjax:false` 不参与 PJAX,保留内联脚本
- 风险:中(popstate 语义变化与音乐嵌入销毁/重建是重点回归面;11 个 html 全动,必须全导航路径走查)
- 验证:全导航路径走查(每页进出、后退/前进、刷新直链、登录/退出、音乐嵌入显示与自动隐藏);控制台零报错
- 明确不做(方案 C):引入构建链/框架——违反无构建约定,对单人无测试项目负收益
---
```
P0-1..P0-5 (独立,并行) ≈ 半天
P1-6..P1-10 (安全加固,独立并行)
P1-11 cli统一 → P1-12 schema_version ≈ 1-2 天
P1-13 / P1-14(路由止血,独立) ≈ 0.5 天
P2-15 better-sqlite3(依赖 P1-11/12)≈ 1 天
P2-16 / P2-17 / P2-18(独立)
P2-19 路由协议收编(依赖 P1-14 ≈ 1.5-2 天
```
> 若时间有限:P0 + P1 全部完成后项目即处于"安全 + 可正常使用"状态(路由 8 个 bug 全修);P2-19 是防复发投资,可最后做或延后。
## 交付节奏
每个 P 阶段结束:跑通验证清单 → git commit → 可随时暂停(数据文件备份在手,sql.js→better-sqlite3 前任何时刻可回滚)。
+159
View File
@@ -0,0 +1,159 @@
# RainWeb v2 全面升级计划
> v1(原生 HTML/CSS/JS + PJAX)→ v2React 前后台分离 + 后端改造)。用户确认的选型全部定案,本计划为唯一执行依据。
> 旧 `docs/refactor-plan.md` 的后端部分(P0/P1/P2 中安全与数据库项)并入本计划;其前端路由项(P1-14 方案 A、P2-19 方案 B**作废**——由 React Router 取代。
> 本计划未开始任何代码改动,待用户确认后按阶段执行。
## 0. 选型定案(已确认,不再变更)
| 项 | 决定 |
|---|---|
| 前端框架 | **React + Vite**TS 可选,见 §4.1 |
| 前台 UI | **不引 UI 库**,手写组件 + 复用 `public/css/style.css`**UI 100% 还原** |
| 后台 | **独立应用**(独立入口 `/admin`),引入 **MUI** |
| 工程组织 | **单 Vite 项目多入口**(前台 + 后台两个 HTML 入口) |
| SEO | **保留 ssr.js 双轨**/blog/:id、/forum/:id、sitemap.xml 不变) |
| 数据库 | **better-sqlite3 + Node 22 LTS**(替换 sql.js |
| 后端改造 | 原 P0 止损 + P1 安全加固照旧执行 |
## 1. 目标架构
```
浏览器
├── 前台 SPA/ React + React Router,构建产物 + style.css,占位符机制保留
│ └── 导航「管理后台」按钮 → 整页跳转 /admin(浏览器级导航,非 SPA 路由、非 iframe
├── 后台 SPA/admin React + MUI,完全独立入口/独立路由/独立登录态,外部面板嵌入保留
└── SEO 直链(/blog/:id 等) ssr.js 服务端渲染(不变)
Express 4server.js
├── /api/* 15 个路由模块(契约不变,前端重写以现有 API 为基)
├── 静态:构建产物 + uploads/ + public/wallpaper/
└── SPA fallback/ → 前台 index.html/admin* → 后台 index.html
better-sqlite3data/rainweb.db,零迁移直接打开)
```
**前后台关系**:两个独立 React 入口(单 Vite 多入口构建),互相之间仅通过整页跳转连接。前台无后台路由、后台无前台路由;登录态共用(同一 JWT,localStorage)。
## 2. v1 → v2 保留 / 废弃清单
**保留**:后端全部(routes/、middleware/、db.js 逻辑、ssr.js、cli.js 改造后)、`public/css/style.css`(搬入前台)、`public/wallpaper/``uploads/`、API 契约。
**废弃**`public/index.html` 等全部页面 HTML(改 React 组件)、`public/js/` 全部(api/nav/router/theme/captcha/render/music-embed/main/blog/forum/admin/passwords → 拆成 React 模块)、PJAX 机制(router.js)、`public/js/main.js` 等死代码。
**保留功能点(迁移时必须覆盖)**:博客瀑布流、论坛多分类/子分类/发帖回复、密码管理器(PIN + AES-256-GCM,见 §4.4)、管理面板聚合(iframe 嵌入 + proxy 代理)、壁纸/磨砂玻璃/深浅色主题、音乐嵌入(自动隐藏)、验证码(内置/recaptcha)、邮箱验证注册、公告、附件上传([image:]/[file:] 标签渲染)。
## 3. 阶段划分(每阶段验证 + commit,可随时暂停)
### Phase 0 后端止损(≈半天,独立)
- P0-1 删除 Web 更新链路(server.js /api/update/check + /api/update/run、admin.js 前端引用、cli.js zip 分支)→ 消除 RCE 面 + `.git` 覆盖风险
- P0-2 JWT_SECRET 启动生成随机值写入 `.env.json`(消除 R4 硬编码密钥)
- P0-3 /api/setup/complete 加 adminOnly/setup/status 移除 default_password(消除 R5 接管)
- P0-4 /api/proxy/fetch 加 adminOnly + 私网地址过滤(消除 R3 SSRF)
- P0-5 邮件验证码不回显 + 修复 pending 重建 bug(消除 R7
- 验证:curl 逐项(未登录 401、内网 URL 拒绝、响应无 code)
### Phase 1 后端安全加固(≈1-1.5 天)
- P1-1 上传白名单 + /uploads 防护头(R1
- P1-2 内容净化:ssr.js + 后端出口统一 DOMPurify/转义(R2 服务端侧;前端侧随 v2 组件实现)
- P1-3 登录/发帖限流 + 服务端验证码校验(R8express-rate-limit
- P1-4 密码箱解锁限流 + PBKDF2 600kM1
- P1-5 adminOnly 查库复查、草稿权限、错误信息收敛、rejectUnauthorized 收敛、avatar 白名单(M2-M7 中后端项)
- P1-6 cli.js 统一到 db.js(消除双封装 + data.db 路径分裂)
- P1-7 schema_version 迁移框架(幂等补列 + PRAGMA user_version
- 验证:curl 安全用例 + `node cli.js status/config/password` 正常
### Phase 2 数据层换轨 better-sqlite3(≈1 天,依赖 P1-6/P1-7
- 前置:Node 升 22 LTS(宝塔 Node 版本管理器);README/Docker 示例同步
- db.js`new Database(DB_PATH)` 同步初始化;run/get/all 重写(prepare + info.lastInsertRowid);删 saveDb/exportinitTables 保留幂等补列
- routes/import.js:上传文件落临时路径只读打开
- 数据零迁移:现有 data/rainweb.db 直接打开;`PRAGMA integrity_check` 预检
- 验证:备份 → 启动 → CRUD 走查 → cli 命令 → sqlite3 命令行直查
### Phase 3 前端工程化搭建(≈1 天)
- 项目根新建 `frontend/`Vite + React + React Router;多入口:`index.html`(前台)+ `admin.html`(后台))
- 共享层:api 客户端(封装现有 /api/*token 仍 localStorage,错误处理统一)、工具(escapeHtml/日期/分页)、主题(深浅色状态管理)
- `style.css` 迁入前台入口;`${site_name}` 等占位符保留在构建模板中(serveIndex 运行时替换机制不变)
- Vite 配置:构建产物输出到 `public/dist/``base: '/'`;多入口 rollupOptions
- package.json scripts`dev`vite)、`build`vite build)、`start`server.js
- 验证:dev server 起得来、两个入口都能打开、HMR 正常
### Phase 4 前台页面迁移(≈3-4 天,核心工作量)
按页面迁移为 React 组件,**视觉逐页对照还原**:
1. 布局壳(导航栏/主题切换/壁纸/磨砂玻璃)+ React Router 路由表(/、/blog、/forum、/passwords、/login、/register、/profile、/write、/forum-manage、/embed、/setup);导航栏「管理后台」按钮 = 整页跳转 `/admin``<a href="/admin">`,不走 SPA 路由)
2. 首页(瀑布流、侧栏头像/简介、最新文章)→ 原 HOMEPAGE 逻辑
3. 博客(列表/详情/编辑,markdown + [image:]/[file:] 渲染 → 组件化 DOMPurify 净化)
4. 论坛(分类/子分类/发帖/回复/详情)
5. 密码管理器(PIN 解锁、加密交互,见 §4.4)
6. 登录/注册(验证码、邮箱验证流程)
7. 个人中心(头像上传、资料编辑)
8. 音乐嵌入、公告、其他全局组件
- 验证:逐页对照 v1 截图/行为走查;无控制台报错
### Phase 5 后台独立应用(≈2 天)
- MUI 后台(/admin):完全独立入口与路由;入口方式 = 前台导航「管理后台」按钮整页跳转(Phase 4 已建);后台内可返回前台(跳转 /)
- 登录态守卫(未登录跳转 /login,JWT 与前台共用);布局(侧栏导航 + 顶栏)
- 页面:仪表盘、站点设置(含主题/壁纸/玻璃参数)、博客管理、论坛管理、用户管理、公告、面板链接(嵌入 + proxy 代理保留)、验证码/邮件配置、上传管理、密码箱(admin 视角)、更新检查入口移除(P0-1 已删)
- 原 admin.js 600 行的逻辑按 MUI 组件重写
- 验证:后台全功能走查 + 前台联动(改设置 → 前台生效)
### Phase 6 集成上线(≈1 天)
- server.js:静态服务指向构建产物;SPA fallback 分流(/admin* → 后台入口,其余 → 前台入口);serveIndex 占位符对构建产物生效
- 旧 public/*.html 与 public/js/ 全部退役删除(ssr.js 引用除外——核对 ssr.js 是否引用 style.css/占位符,保留所需)
- SEO 双轨验证:/blog/:id、/forum/:id、sitemap.xml、robots.txt 行为不变
- 部署流程更新:宝塔 = npm install → npm run build → npm start
- README/AGENTS.md 全量更新(v2 架构、命令、构建链说明)
## 4. 关键技术决策
### 4.1 TypeScript
默认**不用 TS**(v1 全 JS,单人无测试,减少迁移摩擦);如用户要 TS 可启用(React + Vite TS 模板),代价是迁移工作量 +20%。→ 计划默认 JS,待确认。
### 4.2 构建产物与占位符
Vite 构建产物 index.html 保留 `${site_name}`/`${site_description}`/`${site_favicon}` 文本,serveIndex 运行时替换机制不变(构建模板中写占位符即可)。后台入口同理。
### 4.3 路由与 fallback
- 前台:React Routerhistory 模式),server.js `app.get('*')` 回前台入口
- 后台:/admin 前缀路由,server.js 增加 `/admin*` → 后台入口(注意在 SPA catch-all 前)
- SSR 路由(/blog/:id、/forum/:id)优先级高于 SPA fallback(现状已如此,保持)
### 4.4 密码管理器加密
v1 为"服务端保管密钥"模型(PIN 明文到服务端派生)。v2 前台重写时**默认保持行为不变**(服务端派生 + PBKDF2 600kPhase 1 已加固)。真正 E2EWebCrypto 客户端派生)列为 v2 后续可选项,不在本次范围。
### 4.5 音乐嵌入
v1 的 music-embed(自动隐藏、位置、超时)迁移为 React 全局组件,行为参数从 site_settings 读取,逻辑照搬。
### 4.6 验证码
captcha.js 前端逻辑(内置 SVG 验证码 + recaptchaReact 化;服务端校验在 Phase 1 已接通(P1-3)。
## 5. 验证策略(无测试环境)
- 每阶段:curl API 用例 + 浏览器手动走查 + `node cli.js` 命令
- Phase 4/5:逐页对照 v1 行为清单(§2 保留功能点逐项打勾)
- 上线前:完整回归清单(登录/发帖/上传/密码箱/主题/面板嵌入/SEO 直链)
- 可选:引入 Vitest + React Testing Library 对共享层(api 客户端、渲染函数)补少量单测——默认不做,待确认
## 6. 风险与回滚
| 风险 | 缓解 |
|---|---|
| 前端重写回归(11 页功能面广) | 阶段化推进、§2 功能清单逐项走查;v1 代码保留到 Phase 6 验收通过再删 |
| 密码管理器迁移出错 | 加密逻辑独立模块化迁移,Phase 4 单独验收 |
| better-sqlite3 换轨 | 数据零迁移 + 备份 + PRAGMA 预检;失败可随时回滚 db.js(v1 代码在 git 历史) |
| 构建链引入部署变化 | Phase 6 单独验收部署流程;devvite dev+ prodbuild)双路径文档化 |
| SEO 回归 | ssr.js 双轨不变,Phase 6 专门验证直链 |
## 7. 工时总览
| 阶段 | 内容 | 工时 |
|---|---|---|
| Phase 0 | 后端止损 | 0.5 天 |
| Phase 1 | 后端安全加固 + cli 统一 + 迁移框架 | 1-1.5 天 |
| Phase 2 | better-sqlite3 换轨 | 1 天 |
| Phase 3 | 前端工程化搭建 | 1 天 |
| Phase 4 | 前台页面迁移 | 3-4 天 |
| Phase 5 | 后台独立应用(MUI | 2 天 |
| Phase 6 | 集成上线 + 文档 | 1 天 |
| **合计** | | **9.5-11.5 天** |
每阶段结束:验证清单通过 → git commit → 可暂停验收。
+83
View File
@@ -0,0 +1,83 @@
# 工作台模块项目计划(tools/workbench
> 独立完整项目流程:调研 ✓ → 设计规范 ✓ → 本计划 → 实现 → 审查 → 验证。
> 布局调研:lib-1VSCode 四层 / Dashy Workspace / Vivaldi Web Panels / Arc pinned / Homepage groups + iframe 技术建议)。
> 设计规范:frontend-pack/design skill8px 网格、语义色 token、层次/留白/焦点环/深浅色/加载空错误态)。
## 1. 模块定位
「面板工作台」:浏览器式多面板聚合界面(左侧侧边栏 + 顶部工具栏 + 右侧 iframe 内容区),叠加密码库快捷取用。仅管理员(adminOnly)可进入。
## 2. 技术方案
| 项 | 决定 |
|---|---|
| 风格 | **MUI**(与后台一致,MD3 主题复用 frontend/src/admin/theme.js |
| 路由 | 独立页面 `/workbench`React Router,挂管理后台路由树外或独立 BrowserRouter 区——用后台同一入口 admin 应用内加路由最简,basename /admin 下 `/admin/workbench`?——**决定:独立入口**(admin.html 已有),路由挂 `/admin/workbench`,与 Dashboard 等并列,顶栏入口按钮跳转;整页全屏布局不带 AdminLayout 侧栏(工作台自带侧边栏) |
| 代码位置 | `frontend/src/tools/workbench/`(独立工具区,页面+组件+密码库组件+工具函数全在这,方便后续升级) |
| 鉴权 | 入口守卫:无 token → 跳 /login.html;非 admin → 403 提示返回(复用后台守卫模式) |
| 数据 | admin_links API(复用 frontend/src/api/adminLinks.js+ passwords API(复用) |
| proxy | `/api/proxy/fetch?url=&token=`iframe query token,复用 Embed.jsx 的 needsProxy 逻辑) |
## 3. 文件结构
```
frontend/src/tools/workbench/
├── Workbench.jsx # 页面入口(路由挂载点 + 鉴权守卫 + 布局组装)
├── components/
│ ├── Toolbar.jsx # 顶部工具栏(◀▶↻ 地址栏 搜索 添加 密码库 侧栏开关 返回)
│ ├── Sidebar.jsx # 左侧边栏(固定区 + 分组列表 + 折叠 + 拖拽调宽可选)
│ ├── PanelFrame.jsx # iframe 内容区(加载态/超时/失败回退/LRU 保留)
│ ├── AddPanelDialog.jsx # 添加面板弹窗(URL/命名/分组/图标 + 打开方式)
│ └── VaultDrawer.jsx # 密码库抽屉(PIN 解锁/搜索/复制,MUI)
├── hooks/
│ ├── usePanels.js # 面板数据/打开集合/历史栈/持久化(localStorage)
│ └── useVault.js # 密码库解锁状态/条目加载(复用 passwords API
└── index.js # 模块导出(便于未来升级替换)
```
## 4. 功能清单
### 核心(必须)
- [ ] 鉴权守卫(adminOnly
- [ ] 工具栏:◀ 后退 / ▶ 前进(当前面板历史栈)、↻ 刷新、只读地址栏、🔍 搜索(面板名/URL/分组)、+添加、🔑 密码库、侧边栏折叠、返回前台
- [ ] 侧边栏:admin_links 按 category 分组 + 固定区(pin),点击切换,当前项左侧 2px primary 指示条 + surface-container 底(MUI 化:ListItemButton selected 态)
- [ ] iframe 区:懒加载(首次激活创建)、display:none 切换保留状态、**超时+load 双保险加载态**、失败(超时/空白启发式)显示「在新标签打开」回退按钮
- [ ] 打开方式:内嵌(默认)/ 新标签(target=_blank/ 弹窗(modal)——面板项右键或添加时选择,存面板设置
- [ ] 添加面板弹窗:URL + 标题 + 分组 + 图标(favicon 预览)+ 打开方式;保存到 admin_links(复用现有 API
- [ ] localStorage 持久化:打开面板集合、当前面板、历史栈、侧边栏折叠态
- [ ] 密码库抽屉:PIN 解锁(复用 /api/passwords/unlock,服务端会话互通)→ 条目列表(标题/用户名)+ 搜索 + 详情(密码明文)+ 一键复制(clipboard);未设 PIN 引导去 /passwords.html;只读取用不编辑
- [ ] 空态/加载态/错误态:无面板引导添加、加载骨架、加载失败重试
### 进阶(尽力)
- [ ] LRU iframe 保留(最多 3 个活跃,超出销毁最久未用,记录 URL 重建)
- [ ] 每面板定时刷新(可选 30s/5min/30min
- [ ] 侧边栏拖拽调宽 + 记忆
- [ ] 分组折叠状态持久化
## 5. 设计规范(design skill + 调研要点)
- 8px 网格间距;一屏内不超过 2 种圆角(MUI 默认 8px/20px pill
- 语义色 tokenMD3):工具栏/侧边栏 surface-container、选中 primary-container、hover surface-variant
- 选中态:左侧 2px primary 指示条(VSCode 模式)或 MUI selected 态,二选一统一
- 深色模式默认适配(后台已有 data-theme 机制)
- 焦点环(focus-visible)、语义 HTMLnav/main/aside/button)、触控目标 ≥40px
- 加载态用骨架/居中 spinner;空态带图标+CTA;错误态带重试
- iframe title 属性必须;allow 权限策略按面板
- 工具栏高度 48-56px、侧边栏默认 240px(可 180-360 拖拽)
## 6. 实施与质量流程
1. 派 designer 按本计划实现(一个会话,MUI + 复用后台主题/api)
2. ora 审查(鉴权/安全/iframe 处理/状态管理/无障碍)→ 修复
3. 验证:npm run build + 后台入口跳转 + admin 权限拦截 + 面板切换/proxy/密码库全链路冒烟
4. commit + 用户验收
## 7. 验收标准
- [ ] 非 admin 无法进入(无 token 跳登录、非 admin 403
- [ ] 面板分组/固定/切换/关闭/添加/持久化全部可用
- [ ] proxy 面板正常嵌入;X-Frame-Options 拒绝的面板有回退提示
- [ ] 密码库解锁→搜索→复制链路通(与服务端会话互通)
- [ ] 深浅色、320/768/1440 三档响应式正常
- [ ] build 通过、无 console 错误
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>管理后台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/admin/main.jsx"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${site_name}</title>
<meta name="description" content="${site_description}">
<meta property="og:type" content="website">
<link rel="icon" href="${site_favicon}">
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<div id="root"></div>
<div id="musicEmbed"></div>
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { ThemeProvider } from './theme.jsx';
import Layout from './components/Layout.jsx';
import Home from './pages/Home.jsx';
import Blog from './pages/Blog.jsx';
import BlogDetail from './pages/BlogDetail.jsx';
import Tag from './pages/Tag.jsx';
import Archive from './pages/Archive.jsx';
import Forum from './pages/Forum.jsx';
import ForumDetail from './pages/ForumDetail.jsx';
import Passwords from './pages/Passwords.jsx';
import Login from './pages/Login.jsx';
import Register from './pages/Register.jsx';
import Profile from './pages/Profile.jsx';
import Write from './pages/Write.jsx';
import ForumManage from './pages/ForumManage.jsx';
import Embed from './pages/Embed.jsx';
import Setup from './pages/Setup.jsx';
export default function App() {
return (
<ThemeProvider>
<Routes>
{/* 前台布局壳:导航 / 壁纸 / 页脚 / 音乐嵌入 / 验证码弹窗 */}
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/blog.html" element={<Blog />} />
<Route path="/blog/:id" element={<BlogDetail />} />
<Route path="/tag/:name" element={<Tag />} />
<Route path="/archive.html" element={<Archive />} />
<Route path="/forum.html" element={<Forum />} />
<Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/passwords.html" element={<Passwords />} />
<Route path="/login.html" element={<Login />} />
<Route path="/register.html" element={<Register />} />
<Route path="/profile.html" element={<Profile />} />
<Route path="/write.html" element={<Write />} />
<Route path="/forum-manage.html" element={<ForumManage />} />
<Route path="/embed.html" element={<Embed />} />
<Route path="/setup.html" element={<Setup />} />
</Route>
</Routes>
</ThemeProvider>
);
}
+132
View File
@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTheme as useMuiTheme } from '@mui/material/styles';
import useMediaQuery from '@mui/material/useMediaQuery';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import MenuIcon from '@mui/icons-material/Menu';
import HomeIcon from '@mui/icons-material/Home';
import GridViewIcon from '@mui/icons-material/GridView';
import LogoutIcon from '@mui/icons-material/Logout';
import DashboardIcon from '@mui/icons-material/Dashboard';
import SettingsIcon from '@mui/icons-material/Settings';
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser';
import PaletteIcon from '@mui/icons-material/Palette';
import EmailIcon from '@mui/icons-material/Email';
import ArticleIcon from '@mui/icons-material/Article';
import ForumIcon from '@mui/icons-material/Forum';
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
import PeopleIcon from '@mui/icons-material/People';
import CampaignIcon from '@mui/icons-material/Campaign';
import LinkIcon from '@mui/icons-material/Link';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { logout } from '../api/auth.js';
import SnackHost, { showSnack } from './snack.jsx';
const NAV_ITEMS = [
{ path: '/dashboard', label: '仪表盘', icon: <DashboardIcon /> },
{ path: '/settings', label: '站点设置', icon: <SettingsIcon /> },
{ path: '/captcha', label: '验证码', icon: <VerifiedUserIcon /> },
{ path: '/theme', label: '主题', icon: <PaletteIcon /> },
{ path: '/homepage', label: '首页设置', icon: <HomeIcon /> },
{ path: '/email', label: '邮件配置', icon: <EmailIcon /> },
{ path: '/blog', label: '博客管理', icon: <ArticleIcon /> },
{ path: '/comments', label: '评论管理', icon: <ChatBubbleOutlineOutlinedIcon /> },
{ path: '/forum', label: '论坛管理', icon: <ForumIcon /> },
{ path: '/users', label: '用户管理', icon: <PeopleIcon /> },
{ path: '/announcements', label: '公告管理', icon: <CampaignIcon /> },
{ path: '/links', label: '面板链接', icon: <LinkIcon /> },
{ path: '/uploads', label: '附件管理', icon: <AttachFileIcon /> },
{ path: '/import', label: '数据导入', icon: <UploadFileIcon /> },
];
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
const muiTheme = useMuiTheme();
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
const drawerContent = (
<List>
{NAV_ITEMS.map((item) => (
<ListItemButton
key={item.path}
selected={location.pathname === item.path}
onClick={() => { navigate(item.path); setMobileOpen(false); }}
>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
))}
</List>
);
const handleLogout = () => {
logout();
showSnack('已退出登录');
setTimeout(() => { window.location.href = '/login.html'; }, 600);
};
return (
<Box sx={{ display: 'flex' }}>
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
<Toolbar>
{isMobile && (
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 1 }}>
<MenuIcon />
</IconButton>
)}
<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="返回前台">
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
</Button>
<Button color="inherit" onClick={handleLogout}>
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
</Button>
</Toolbar>
</AppBar>
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
{isMobile ? (
<Drawer
variant="temporary"
open={mobileOpen}
onClose={() => setMobileOpen(false)}
ModalProps={{ keepMounted: true }}
sx={{ '& .MuiDrawer-paper': { width: 240 } }}
>
<Toolbar />
{drawerContent}
</Drawer>
) : (
<Drawer variant="permanent" sx={{ width: 240, '& .MuiDrawer-paper': { width: 240 } }}>
<Toolbar />
{drawerContent}
</Drawer>
)}
</Box>
<Box component="main" sx={{ flexGrow: 1, p: { xs: 2, md: 3 }, minWidth: 0 }}>
<Toolbar />
<Outlet />
</Box>
<SnackHost />
</Box>
);
}
+21
View File
@@ -0,0 +1,21 @@
import React 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 Typography from '@mui/material/Typography';
/** 通用确认弹窗 */
export default function ConfirmDialog({ open, title = '确认操作', message, onClose, onConfirm, confirmText = '确认', danger = true }) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent><Typography variant="body1">{message}</Typography></DialogContent>
<DialogActions>
<Button onClick={onClose}>取消</Button>
<Button color={danger ? 'error' : 'primary'} variant="contained" onClick={onConfirm}>{confirmText}</Button>
</DialogActions>
</Dialog>
);
}
+120
View File
@@ -0,0 +1,120 @@
import React, { lazy, Suspense, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { createMD3Theme } from './theme.js';
import './theme.css';
import { getToken } from '../api/client.js';
import { me } from '../api/auth.js';
import { getSettings } from '../api/settings.js';
import AdminLayout from './AdminLayout.jsx';
import Dashboard from './pages/Dashboard.jsx';
import Settings from './pages/Settings.jsx';
import CaptchaSettings from './pages/CaptchaSettings.jsx';
import ThemeSettings from './pages/ThemeSettings.jsx';
import Homepage from './pages/Homepage.jsx';
import EmailSettings from './pages/EmailSettings.jsx';
import BlogManage from './pages/BlogManage.jsx';
import CommentManage from './pages/CommentManage.jsx';
import ForumManage from './pages/ForumManage.jsx';
import Users from './pages/Users.jsx';
import Announcements from './pages/Announcements.jsx';
import Links from './pages/Links.jsx';
import Uploads from './pages/Uploads.jsx';
import ImportDb from './pages/ImportDb.jsx';
// 工作台独立分包:仅访问 /admin/workbench 时才加载(vite 自动 code-split
const Workbench = lazy(() => import('../tools/workbench/Workbench.jsx'));
// 生产:/admin/* 由后端 fallback 到 dist/admin.htmlPhase 6 需求),basename=/admin
// 开发:vite 多入口直接访问 /admin.htmlbasename 自适应为空。
const pathname = window.location.pathname;
const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/admin' : '';
function AdminApp() {
const [status, setStatus] = useState('checking'); // checking | ok
const [primary, setPrimary] = useState('#6750a4');
// 深色模式跟随前台偏好:整页跳转后 data-theme 属性不保留,
// 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|| localStorage.getItem('theme') === 'dark';
useEffect(() => {
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
}, [dark]);
useEffect(() => {
const token = getToken();
if (!token) { window.location.href = '/login.html'; return; }
me()
.then((u) => {
if (u.role !== 'admin') {
window.alert('需要管理员权限');
window.location.href = '/';
return;
}
setStatus('ok');
})
.catch(() => { window.location.href = '/login.html'; });
getSettings()
.then((s) => { if (s.primary_color) setPrimary(s.primary_color); })
.catch(() => {});
}, []);
if (status !== 'ok') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
}
const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary });
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter basename={basename}>
<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 index element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/captcha" element={<CaptchaSettings />} />
<Route path="/theme" element={<ThemeSettings />} />
<Route path="/homepage" element={<Homepage />} />
<Route path="/email" element={<EmailSettings />} />
<Route path="/blog" element={<BlogManage />} />
<Route path="/comments" element={<CommentManage />} />
<Route path="/forum" element={<ForumManage />} />
<Route path="/users" element={<Users />} />
<Route path="/announcements" element={<Announcements />} />
<Route path="/links" element={<Links />} />
<Route path="/uploads" element={<Uploads />} />
<Route path="/import" element={<ImportDb />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
</BrowserRouter>
</ThemeProvider>
);
}
createRoot(document.getElementById('root')).render(<AdminApp />);
+140
View File
@@ -0,0 +1,140 @@
import React, { useCallback, useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
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 TextField from '@mui/material/TextField';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { listAll, createAnnouncement, updateAnnouncement, deleteAnnouncement } from '../../api/announcements.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
const EMPTY = { title: '', content: '', active: true };
/** 公告管理:公告 CRUD(v2 新增模块,v1 无) */
export default function Announcements() {
const [list, setList] = useState(null);
const [dialog, setDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY);
const [confirm, setConfirm] = useState(null);
const load = useCallback(() => {
listAll()
.then((rows) => setList(rows || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (a) => {
setEditingId(a.id);
setForm({ title: a.title || '', content: a.content, active: !!a.active });
setDialog(true);
};
const save = async () => {
if (!form.content.trim()) { showSnack('内容不能为空', 'error'); return; }
const data = { title: form.title.trim(), content: form.content, active: form.active };
try {
if (editingId) await updateAnnouncement(editingId, data);
else await createAnnouncement(data);
showSnack('保存成功');
setDialog(false);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
const doDelete = async () => {
if (!confirm) return;
try {
await deleteAnnouncement(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">公告管理</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加公告</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>标题</TableCell>
<TableCell>内容</TableCell>
<TableCell>状态</TableCell>
<TableCell>时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{list === null ? (
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
) : list.length === 0 ? (
<TableRow><TableCell colSpan={5}>暂无公告</TableCell></TableRow>
) : list.map((a) => (
<TableRow key={a.id}>
<TableCell><Box sx={{ fontWeight: 600 }}>{a.title || '(无标题)'}</Box></TableCell>
<TableCell sx={{ maxWidth: 320, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'text.secondary' }}>{a.content}</TableCell>
<TableCell><Chip size="small" label={a.active ? '启用' : '停用'} color={a.active ? 'primary' : 'default'} variant={a.active ? 'filled' : 'outlined'} /></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{a.created_at}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<IconButton size="small" onClick={() => openEdit(a)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => setConfirm({ id: a.id, label: a.title || a.content })}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingId ? '编辑公告' : '添加公告'}</DialogTitle>
<DialogContent>
<TextField fullWidth label="标题" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
<TextField fullWidth label="内容 *" multiline rows={4} value={form.content} onChange={(e) => setForm((p) => ({ ...p, content: e.target.value }))} margin="normal" />
<FormControlLabel control={<Switch checked={form.active} onChange={(e) => setForm((p) => ({ ...p, active: e.target.checked }))} />} label="启用" />
</DialogContent>
<DialogActions>
<Button onClick={() => setDialog(false)}>取消</Button>
<Button variant="contained" onClick={save}>保存</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useCallback, useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Chip from '@mui/material/Chip';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import IconButton from '@mui/material/IconButton';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { listPosts, updatePost, deletePost } from '../../api/blog.js';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
export default function BlogManage() {
const [posts, setPosts] = useState(null);
const [showSidebar, setShowSidebar] = useState(true);
const [confirm, setConfirm] = useState(null); // { id, title }
const [deleting, setDeleting] = useState(false);
const load = useCallback(() => {
listPosts(true)
.then((ps) => setPosts(ps || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => {
load();
getSettings().then((s) => setShowSidebar(s.blog_show_sidebar !== '0')).catch(() => {});
}, [load]);
const saveSidebar = async () => {
try {
await saveSettings({ blog_show_sidebar: showSidebar ? '1' : '0' });
showSnack('已保存');
} catch (e) {
showSnack(e.message, 'error');
}
};
const togglePublish = async (p) => {
try {
await updatePost(p.id, {
title: p.title,
content: p.content,
excerpt: p.excerpt || '',
published: p.published ? 0 : 1,
use_markdown: p.use_markdown,
});
showSnack(p.published ? '已转为草稿' : '已发布');
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
const doDelete = async () => {
if (!confirm) return;
setDeleting(true);
try {
await deletePost(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setDeleting(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1, mb: 2 }}>
<Typography variant="h5">博客管理</Typography>
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
</Box>
<Paper sx={{ p: 2, mb: 2, maxWidth: 640 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FormControlLabel
control={<Switch checked={showSidebar} onChange={(e) => setShowSidebar(e.target.checked)} />}
label="博客页面左侧显示头像和简介"
/>
<Button variant="outlined" size="small" onClick={saveSidebar}>保存</Button>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>标题</TableCell>
<TableCell>状态</TableCell>
<TableCell>格式</TableCell>
<TableCell>时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{posts === null ? (
<TableRow><TableCell colSpan={5}>加载中...</TableCell></TableRow>
) : posts.length === 0 ? (
<TableRow><TableCell colSpan={5}>暂无文章</TableCell></TableRow>
) : posts.map((p) => (
<TableRow key={p.id}>
<TableCell><Box sx={{ fontWeight: 600 }}>{p.title}</Box></TableCell>
<TableCell>
<Chip
size="small"
label={p.published ? '已发布' : '草稿'}
color={p.published ? 'primary' : 'default'}
onClick={() => togglePublish(p)}
title="点击切换发布状态"
/>
</TableCell>
<TableCell><Chip size="small" label={p.use_markdown ? 'Markdown' : '纯文本'} variant="outlined" /></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{p.created_at}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<IconButton size="small" component="a" href={`/write.html?edit=${p.id}`} target="_blank" title="编辑(前台)"><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => setConfirm({ id: p.id, title: p.title })}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.title : ''}" 吗?`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText={deleting ? '删除中...' : '确认删除'}
/>
</Box>
);
}
@@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
/** 验证码设置:类型/登录注册发帖开关/reCAPTCHA+Turnstile keys(支持 builtin/recaptcha/turnstile/both */
export default function CaptchaSettings() {
const [form, setForm] = useState({
captcha_type: 'none',
captcha_login: false,
captcha_register: false,
captcha_forum: false,
recaptcha_site_key: '',
recaptcha_secret_key: '',
turnstile_site_key: '',
turnstile_secret_key: '',
});
const [saving, setSaving] = useState(false);
useEffect(() => {
getSettings()
.then((s) => setForm({
captcha_type: s.captcha_type || 'none',
captcha_login: s.captcha_login === '1',
captcha_register: s.captcha_register === '1',
captcha_forum: s.captcha_forum === '1',
recaptcha_site_key: s.recaptcha_site_key || '',
recaptcha_secret_key: s.recaptcha_secret_key || '',
turnstile_site_key: s.turnstile_site_key || '',
turnstile_secret_key: s.turnstile_secret_key || '',
}))
.catch((e) => showSnack(e.message, 'error'));
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
const save = async () => {
setSaving(true);
try {
await saveSettings({
captcha_type: form.captcha_type,
captcha_login: form.captcha_login ? '1' : '0',
captcha_register: form.captcha_register ? '1' : '0',
captcha_forum: form.captcha_forum ? '1' : '0',
recaptcha_site_key: form.recaptcha_site_key.trim(),
recaptcha_secret_key: form.recaptcha_secret_key.trim(),
turnstile_site_key: form.turnstile_site_key.trim(),
turnstile_secret_key: form.turnstile_secret_key.trim(),
});
showSnack('验证码设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
const showScope = form.captcha_type !== 'none';
const showRecaptcha = form.captcha_type === 'recaptcha' || form.captcha_type === 'both';
const showTurnstile = form.captcha_type === 'turnstile' || form.captcha_type === 'both';
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
<Paper sx={{ p: 3 }}>
<FormControl fullWidth margin="normal">
<InputLabel>验证码类型</InputLabel>
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
<MenuItem value="none">不使用验证码</MenuItem>
<MenuItem value="builtin">内置验证码扭曲文字</MenuItem>
<MenuItem value="recaptcha">Google reCAPTCHA V2</MenuItem>
<MenuItem value="turnstile">Cloudflare Turnstile</MenuItem>
<MenuItem value="both">两者都启用任一通过即可</MenuItem>
</Select>
</FormControl>
{showScope && (
<FormGroup>
<FormControlLabel control={<Switch checked={form.captcha_login} onChange={setSwitch('captcha_login')} />} label="登录验证" />
<FormControlLabel control={<Switch checked={form.captcha_register} onChange={setSwitch('captcha_register')} />} label="注册验证" />
<FormControlLabel control={<Switch checked={form.captcha_forum} onChange={setSwitch('captcha_forum')} />} label="发帖验证" />
</FormGroup>
)}
{showRecaptcha && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Google reCAPTCHA V2 配置</Typography>
<TextField fullWidth label="Site Key" value={form.recaptcha_site_key} onChange={set('recaptcha_site_key')} margin="normal" placeholder="6L..." />
<TextField fullWidth label="Secret Key" value={form.recaptcha_secret_key} onChange={set('recaptcha_secret_key')} margin="normal" placeholder="6L..." />
</Box>
)}
{showTurnstile && (
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle1">Cloudflare Turnstile 配置</Typography>
<TextField fullWidth label="Site Key" value={form.turnstile_site_key} onChange={set('turnstile_site_key')} margin="normal" placeholder="0x4AAAA..." />
<TextField fullWidth label="Secret Key" value={form.turnstile_secret_key} onChange={set('turnstile_secret_key')} margin="normal" placeholder="0x4AAAA..." />
</Box>
)}
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>保存验证码设置</Button>
</Box>
</Paper>
</Box>
);
}
+126
View File
@@ -0,0 +1,126 @@
import React, { useCallback, useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import Tabs from '@mui/material/Tabs';
import Tab from '@mui/material/Tab';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import RefreshIcon from '@mui/icons-material/Refresh';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import { listPendingComments, approveComment, rejectComment } from '../../api/blog.js';
import { showSnack } from '../snack.jsx';
/** 时间格式化:yyyy-mm-dd hh:mm */
function fmtTime(t) {
if (!t) return '';
return String(t).replace('T', ' ').slice(0, 16);
}
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
export default function CommentManage() {
const [tab, setTab] = useState(0);
const [pending, setPending] = useState([]);
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
setBusy(true);
listPendingComments()
.then((list) => setPending(list || []))
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setBusy(false));
}, []);
useEffect(() => { load(); }, [load]);
const act = async (id, fn, okMsg) => {
setBusy(true);
try {
await fn(id);
setPending((list) => list.filter((c) => c.id !== id));
showSnack(okMsg);
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h5">评论管理</Typography>
<IconButton onClick={load} title="刷新" disabled={busy}><RefreshIcon /></IconButton>
</Box>
<Paper sx={{ mb: 2 }}>
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
<Tab label="全部评论" />
</Tabs>
</Paper>
{tab === 0 ? (
pending.length === 0 ? (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ mb: 0.5 }}>暂无待审核评论</Typography>
<Typography variant="body2" color="text.secondary">
开启评论审核模式新评论会先进这里等待审核
</Typography>
</Paper>
) : (
<Paper>
<List disablePadding>
{pending.map((c) => (
<ListItem
key={c.id}
divider
alignItems="flex-start"
sx={{ flexDirection: 'column', alignItems: 'stretch', gap: 1, py: 2, px: { xs: 2, md: 3 } }}
>
<Box sx={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{c.author_name || '匿名'}
</Typography>
<Chip size="small" variant="outlined" label={`文章:${c.post_title || `#${c.post_id}`}`} />
<Typography variant="caption" color="text.secondary">{fmtTime(c.created_at)}</Typography>
</Box>
<Typography variant="body2" sx={{ color: 'text.primary', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{c.content}
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
variant="contained"
startIcon={<CheckIcon />}
disabled={busy}
onClick={() => act(c.id, approveComment, '已通过审核')}
>通过</Button>
<Button
size="small"
variant="outlined"
color="error"
startIcon={<CloseIcon />}
disabled={busy}
onClick={() => act(c.id, rejectComment, '已拒绝')}
>拒绝</Button>
</Box>
</ListItem>
))}
</List>
</Paper>
)
) : (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
<Typography variant="body2" color="text.secondary">
当前后端仅提供待审核评论的管理接口已通过 / 已拒绝评论可在对应文章页查看
</Typography>
</Paper>
)}
</Box>
);
}
+111
View File
@@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardActionArea from '@mui/material/CardActionArea';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Avatar from '@mui/material/Avatar';
import Chip from '@mui/material/Chip';
import { listAdminLinks } from '../../api/adminLinks.js';
import { listPosts as listBlogPosts } from '../../api/blog.js';
import { listPosts as listForumPosts } from '../../api/forum.js';
import { listUsers } from '../../api/auth.js';
import { listAttachments } from '../../api/upload.js';
/** 仪表盘:版本 + 状态概览 + 面板链接(分组卡片,迁移自 v1 loadPanels */
export default function Dashboard() {
const [version, setVersion] = useState('');
const [stats, setStats] = useState({ blog: 0, forum: 0, users: 0, attachments: 0 });
const [links, setLinks] = useState([]);
useEffect(() => {
fetch('/api/version')
.then((r) => r.json())
.then((v) => { if (v && v.version) setVersion(v.version); })
.catch(() => {});
Promise.allSettled([
listAdminLinks(), listBlogPosts(true), listForumPosts(), listUsers(), listAttachments(),
]).then(([l, b, f, u, a]) => {
setLinks((l.value) || []);
setStats({
blog: (b.value || []).length,
forum: (f.value || []).length,
users: (u.value || []).length,
attachments: (a.value || []).length,
});
});
}, []);
const statCards = [
{ label: '博客文章', value: stats.blog },
{ label: '论坛帖子', value: stats.forum },
{ label: '用户', value: stats.users },
{ label: '附件', value: stats.attachments },
];
// 面板链接按分类分组
const cats = {};
links.forEach((l) => { (cats[l.category || '默认'] = cats[l.category || '默认'] || []).push(l); });
const sortedCats = Object.keys(cats).sort();
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Typography variant="h5">仪表盘</Typography>
<Chip label={`RainWeb v${version}`} size="small" variant="outlined" />
</Box>
<Grid container spacing={2} sx={{ mb: 3 }}>
{statCards.map((s) => (
<Grid item xs={6} sm={3} key={s.label}>
<Card>
<CardContent>
<Typography variant="h4" sx={{ fontWeight: 600 }}>{s.value}</Typography>
<Typography variant="body2" color="text.secondary">{s.label}</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
<Typography variant="h6" sx={{ mb: 1 }}>面板</Typography>
{links.length === 0 ? (
<Typography variant="body2" color="text.secondary">暂无面板请先在"面板链接"中添加</Typography>
) : (
sortedCats.map((cat) => (
<Box key={cat} sx={{ mb: 2 }}>
<Typography variant="subtitle1" sx={{ color: 'text.secondary', fontWeight: 600, mb: 1 }}>{cat}</Typography>
<Grid container spacing={2}>
{cats[cat].map((l) => {
const embedUrl = l.embed_url || l.url;
const proxyParam = l.use_proxy ? '&proxy=1' : '';
const href = embedUrl
? `/embed.html?url=${encodeURIComponent(embedUrl)}&title=${encodeURIComponent(l.title)}${proxyParam}`
: l.url;
return (
<Grid item xs={12} sm={6} md={4} key={l.id}>
<Card>
<CardActionArea component="a" href={href} target={embedUrl ? undefined : '_blank'} rel="noopener">
<CardContent sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar sx={{ bgcolor: 'primary.main' }}>{l.icon ? l.icon.charAt(0).toUpperCase() : '🔗'}</Avatar>
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" noWrap>
{l.title}
{l.version ? <Typography component="span" variant="caption" color="text.secondary"> {l.version}</Typography> : null}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>{l.description || l.url}</Typography>
</Box>
</CardContent>
</CardActionArea>
</Card>
</Grid>
);
})}
</Grid>
</Box>
))
)}
</Box>
);
}
@@ -0,0 +1,88 @@
import React, { useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import { getSettings, saveSettings } from '../../api/settings.js';
import { testSmtp } from '../../api/email.js';
import { showSnack } from '../snack.jsx';
/** 邮件配置:SMTP + 发件人信息 + 测试发送(迁移自 v1 邮件卡片) */
export default function EmailSettings() {
const [form, setForm] = useState({
smtp_host: '', smtp_port: '587', smtp_user: '', smtp_pass: '',
smtp_from_email: '', smtp_from_name: 'RainWeb',
});
const [testEmail, setTestEmail] = useState('');
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
getSettings()
.then((s) => setForm({
smtp_host: s.smtp_host || '',
smtp_port: s.smtp_port || '587',
smtp_user: s.smtp_user || '',
smtp_pass: s.smtp_pass || '',
smtp_from_email: s.smtp_from_email || '',
smtp_from_name: s.smtp_from_name || 'RainWeb',
}))
.catch((e) => showSnack(e.message, 'error'));
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const save = async () => {
setSaving(true);
try {
await saveSettings({
smtp_host: form.smtp_host.trim(),
smtp_port: form.smtp_port,
smtp_user: form.smtp_user.trim(),
smtp_pass: form.smtp_pass,
smtp_from_email: form.smtp_from_email.trim(),
smtp_from_name: form.smtp_from_name.trim(),
});
showSnack('邮件配置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
const test = async () => {
if (!testEmail.trim()) { showSnack('请输入测试邮箱地址', 'error'); return; }
setTesting(true);
try {
await testSmtp(testEmail.trim());
showSnack('测试邮件已发送至 ' + testEmail.trim());
} catch (e) {
showSnack(e.message, 'error');
}
setTesting(false);
};
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>SMTP 服务器</Typography>
<TextField fullWidth label="SMTP 主机" value={form.smtp_host} onChange={set('smtp_host')} margin="normal" placeholder="smtp.example.com" />
<TextField fullWidth label="端口" type="number" value={form.smtp_port} onChange={set('smtp_port')} margin="normal" placeholder="587" />
<TextField fullWidth label="用户名" value={form.smtp_user} onChange={set('smtp_user')} margin="normal" />
<TextField fullWidth label="密码" type="password" value={form.smtp_pass} onChange={set('smtp_pass')} margin="normal" />
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>发件人信息</Typography>
<TextField fullWidth label="发件人邮箱" type="email" value={form.smtp_from_email} onChange={set('smtp_from_email')} margin="normal" placeholder="noreply@example.com" />
<TextField fullWidth label="发件人名称" value={form.smtp_from_name} onChange={set('smtp_from_name')} margin="normal" placeholder="RainWeb" />
</Paper>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-start', flexWrap: 'wrap' }}>
<Button variant="contained" onClick={save} disabled={saving}>保存邮件配置</Button>
<TextField size="small" label="测试接收邮箱" value={testEmail} onChange={(e) => setTestEmail(e.target.value)} placeholder="your@email.com" />
<Button variant="outlined" onClick={test} disabled={testing}>发送测试邮件</Button>
</Box>
</Box>
);
}
+155
View File
@@ -0,0 +1,155 @@
import React, { useCallback, useEffect, useState } from 'react';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
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 TextField from '@mui/material/TextField';
import { listCategories, createCategory, updateCategory, deleteCategory, listPosts } from '../../api/forum.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
/** 论坛管理:板块卡片(帖子数)+ 添加/编辑弹窗 + 删除(MUI 版,同前台 ForumManage API */
export default function ForumManage() {
const [cats, setCats] = useState([]);
const [counts, setCounts] = useState({});
const [dialog, setDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY);
const [confirm, setConfirm] = useState(null);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
listCategories()
.then((cs) => setCats(cs || []))
.catch((e) => showSnack(e.message, 'error'));
listPosts()
.then((ps) => {
const c = {};
(ps || []).forEach((p) => { c[p.category_id] = (c[p.category_id] || 0) + 1; });
setCounts(c);
})
.catch(() => {});
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (c) => {
setEditingId(c.id);
setForm({
name: c.name,
description: c.description || '',
announcement: c.announcement || '',
sub_categories: c.sub_categories || '',
sort_order: String(c.sort_order || 0),
});
setDialog(true);
};
const save = async () => {
if (!form.name.trim()) { showSnack('名称不能为空', 'error'); return; }
setSaving(true);
const data = {
name: form.name.trim(),
description: form.description.trim(),
announcement: form.announcement.trim(),
sub_categories: form.sub_categories.trim(),
sort_order: parseInt(form.sort_order, 10) || 0,
};
try {
if (editingId) await updateCategory(editingId, data);
else await createCategory(data);
showSnack('保存成功');
setDialog(false);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
const doDelete = async () => {
if (!confirm) return;
try {
await deleteCategory(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">论坛管理</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
</Box>
{cats.length === 0 ? (
<Typography variant="body2" color="text.secondary">暂无板块点击"添加板块"创建</Typography>
) : (
<Grid container spacing={2}>
{cats.map((c) => (
<Grid item xs={12} sm={6} md={4} key={c.id}>
<Card>
<CardContent sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>{c.name}</Typography>
<Chip size="small" label={`排序 ${c.sort_order}`} variant="outlined" />
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, mb: 1 }}>{c.description || '无描述'}</Typography>
{c.announcement && (
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>📢 {c.announcement}</Typography>
)}
<Typography variant="caption" color="text.secondary" sx={{ mb: 1 }}>{counts[c.id] || 0} 个帖子</Typography>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
<IconButton size="small" onClick={() => openEdit(c)} title="编辑"><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => setConfirm({ id: c.id, name: c.name })}><DeleteIcon fontSize="small" /></IconButton>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingId ? '编辑板块' : '添加板块'}</DialogTitle>
<DialogContent>
<TextField fullWidth label="名称 *" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} margin="normal" />
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
<TextField fullWidth label="板块公告" multiline rows={2} value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} margin="normal" />
<TextField fullWidth label="帖子分类(逗号分隔)" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} margin="normal" placeholder="例: 求助,分享,讨论,建议" />
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
</DialogContent>
<DialogActions>
<Button onClick={() => setDialog(false)}>取消</Button>
<Button variant="contained" onClick={save} disabled={saving}>保存</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}
+151
View File
@@ -0,0 +1,151 @@
import React, { useEffect, useRef, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { getSettings, saveSettings } from '../../api/settings.js';
import { uploadFile } from '../../api/upload.js';
import { showSnack } from '../snack.jsx';
/** 首页设置:个人信息(头像/简介/联系链接)/主页内容(Markdown)/音乐嵌入(迁移自 v1 首页卡片) */
export default function Homepage() {
const [form, setForm] = useState({
homepage_avatar: '',
homepage_bio: '',
homepage_content: '',
music_embed_enabled: false,
music_embed_code: '',
music_embed_position: 'right',
music_embed_autohide: false,
music_embed_idle_timeout: '10',
});
const [contacts, setContacts] = useState([{ icon: 'link', url: '', title: '' }]);
const [saving, setSaving] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
const fileRef = useRef(null);
useEffect(() => {
getSettings()
.then((s) => {
let parsed = [];
try { parsed = JSON.parse(s.homepage_contacts || '[]'); } catch { parsed = []; }
setForm({
homepage_avatar: s.homepage_avatar || '',
homepage_bio: s.homepage_bio || '',
homepage_content: s.homepage_content || '',
music_embed_enabled: s.music_embed_enabled === '1',
music_embed_code: s.music_embed_code || '',
music_embed_position: s.music_embed_position || 'right',
music_embed_autohide: s.music_embed_autohide === '1',
music_embed_idle_timeout: s.music_embed_idle_timeout || '10',
});
setContacts(parsed.length > 0 ? parsed : [{ icon: 'link', url: '', title: '' }]);
})
.catch((e) => showSnack(e.message, 'error'));
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const setSwitch = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked }));
const updateContact = (i, k) => (e) => {
const next = [...contacts];
next[i] = { ...next[i], [k]: e.target.value };
setContacts(next);
};
const addContact = () => {
if (contacts.length >= 5) { showSnack('最多 5 个联系链接', 'error'); return; }
setContacts([...contacts, { icon: 'link', url: '', title: '' }]);
};
const removeContact = (i) => setContacts(contacts.filter((_, idx) => idx !== i));
const handleUpload = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
try {
const data = await uploadFile(file);
setForm((p) => ({ ...p, homepage_content: p.homepage_content + '\n' + data.tag + '\n' }));
setUploadStatus('已插入: ' + data.tag);
} catch (err) {
showSnack(err.message, 'error');
}
};
const save = async () => {
const validContacts = contacts.filter((c) => c.url.trim());
setSaving(true);
try {
await saveSettings({
homepage_avatar: form.homepage_avatar.trim(),
homepage_bio: form.homepage_bio,
homepage_content: form.homepage_content,
homepage_contacts: JSON.stringify(validContacts),
music_embed_enabled: form.music_embed_enabled ? '1' : '0',
music_embed_code: form.music_embed_code.trim(),
music_embed_position: form.music_embed_position,
music_embed_autohide: form.music_embed_autohide ? '1' : '0',
music_embed_idle_timeout: form.music_embed_idle_timeout,
});
showSnack('首页设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
return (
<Box sx={{ maxWidth: 720 }}>
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>个人信息</Typography>
<TextField fullWidth label="头像 URL" type="url" value={form.homepage_avatar} onChange={set('homepage_avatar')} margin="normal" placeholder="https://example.com/avatar.jpg" />
<TextField fullWidth label="个人简介" multiline rows={2} value={form.homepage_bio} onChange={set('homepage_bio')} margin="normal" placeholder="一段简短的自我介绍" />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, mb: 1 }}>联系链接最多5个</Typography>
{contacts.map((c, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
<TextField size="small" label="图标名" value={c.icon} onChange={updateContact(i, 'icon')} sx={{ width: 110 }} />
<TextField size="small" label="URL" value={c.url} onChange={updateContact(i, 'url')} sx={{ flex: 1 }} placeholder="https://..." />
<TextField size="small" label="标题(选填)" value={c.title} onChange={updateContact(i, 'title')} sx={{ width: 120 }} />
<IconButton size="small" color="error" onClick={() => removeContact(i)} title="删除"><DeleteIcon fontSize="small" /></IconButton>
</Box>
))}
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>主页内容</Typography>
<TextField fullWidth label="正文 (Markdown)" multiline rows={10} value={form.homepage_content} onChange={set('homepage_content')} margin="normal" placeholder="支持 Markdown 语法和 [image:filename] 标签" />
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button variant="outlined" size="small" onClick={() => fileRef.current && fileRef.current.click()}>
上传附件
</Button>
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleUpload} />
{uploadStatus && <Typography variant="caption" color="text.secondary">{uploadStatus}</Typography>}
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>音乐嵌入</Typography>
<FormControlLabel control={<Switch checked={form.music_embed_enabled} onChange={setSwitch('music_embed_enabled')} />} label="所有页面显示音乐播放器" />
<TextField fullWidth label="嵌入代码" multiline rows={3} value={form.music_embed_code} onChange={set('music_embed_code')} margin="normal" placeholder="粘贴网易云音乐 iframe 代码" />
<TextField select label="显示位置" value={form.music_embed_position} onChange={set('music_embed_position')} margin="normal" sx={{ maxWidth: 200 }}>
<option value="right">右下角</option>
<option value="left">左下角</option>
</TextField>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<FormControlLabel control={<Switch checked={form.music_embed_autohide} onChange={setSwitch('music_embed_autohide')} />} label="无操作后缩小为图标" />
<TextField size="small" label="空闲超时(秒)" type="number" inputProps={{ min: 3, max: 120 }} value={form.music_embed_idle_timeout} onChange={set('music_embed_idle_timeout')} sx={{ width: 140 }} />
</Box>
</Paper>
<Button variant="contained" onClick={save} disabled={saving}>保存首页设置</Button>
</Box>
);
}
+87
View File
@@ -0,0 +1,87 @@
import React, { useRef, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import { getToken } from '../../api/client.js';
import { showSnack } from '../snack.jsx';
/** 数据导入:上传旧版 data.dbPOST /api/import/databaseFormData + Bearer */
export default function ImportDb() {
const [busy, setBusy] = useState(false);
const [result, setResult] = useState(null); // { total, details, errors } | { error }
const fileRef = useRef(null);
const doImport = async () => {
const file = fileRef.current && fileRef.current.files && fileRef.current.files[0];
if (!file) { showSnack('请选择 data.db 文件', 'error'); return; }
setBusy(true);
setResult(null);
const fd = new FormData();
fd.append('file', file);
try {
const token = getToken();
const res = await fetch('/api/import/database', {
method: 'POST',
headers: token ? { Authorization: 'Bearer ' + token } : {},
body: fd,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || '导入失败');
setResult(data);
showSnack('导入完成,共 ' + data.total + ' 条记录');
} catch (e) {
setResult({ error: e.message });
showSnack(e.message, 'error');
}
setBusy(false);
};
return (
<Box sx={{ maxWidth: 720 }}>
<Typography variant="h5" sx={{ mb: 2 }}>数据导入</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
上传旧版 RainWeb <code>data.db</code> 文件将数据导入当前数据库重复数据自动跳过
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Button variant="contained" component="label">
选择文件
<input type="file" hidden accept=".db" ref={fileRef} onChange={() => setResult(null)} />
</Button>
<Typography variant="body2" color="text.secondary">
{fileRef.current && fileRef.current.files && fileRef.current.files[0] ? fileRef.current.files[0].name : '未选择文件'}
</Typography>
</Box>
<Box sx={{ mt: 2 }}>
<Button variant="outlined" onClick={doImport} disabled={busy}>
{busy ? <CircularProgress size={18} sx={{ mr: 1 }} /> : null}
导入
</Button>
</Box>
{result && (
<Box sx={{ mt: 2 }}>
{result.error ? (
<Alert severity="error">导入失败: {result.error}</Alert>
) : (
<Alert severity="success">
<Box>导入完成 {result.total} 条记录</Box>
{Object.entries(result.details || {}).map(([table, count]) => (
<Box key={table} component="div" sx={{ fontSize: 13 }}>{table}: {count} </Box>
))}
{result.errors && result.errors.length > 0 && (
<Box component="div" sx={{ mt: 1, color: 'warning.main', fontSize: 13 }}>
警告:<br />
{result.errors.slice(0, 5).map((e, i) => <Box key={i}>{e}</Box>)}
</Box>
)}
</Alert>
)}
</Box>
)}
</Paper>
</Box>
);
}
+175
View File
@@ -0,0 +1,175 @@
import React, { useCallback, useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import AddIcon from '@mui/icons-material/Add';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
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 TextField from '@mui/material/TextField';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
const EMPTY = {
title: '', url: '', embed_url: '', use_proxy: false, description: '',
icon: '', category: '默认', version: '', sort_order: '0',
};
/** 面板链接管理:CRUD + 代理嵌入开关(迁移自 v1 面板链接卡片) */
export default function Links() {
const [links, setLinks] = useState(null);
const [dialog, setDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState(EMPTY);
const [confirm, setConfirm] = useState(null);
const load = useCallback(() => {
listAdminLinks()
.then((ls) => setLinks(ls || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (l) => {
setEditingId(l.id);
setForm({
title: l.title,
url: l.url,
embed_url: l.embed_url || '',
use_proxy: !!l.use_proxy,
description: l.description || '',
icon: l.icon || '',
category: l.category || '默认',
version: l.version || '',
sort_order: String(l.sort_order || 0),
});
setDialog(true);
};
const save = async () => {
if (!form.title.trim() || !form.url.trim()) { showSnack('标题和链接不能为空', 'error'); return; }
const data = {
title: form.title.trim(),
url: form.url.trim(),
embed_url: form.embed_url.trim(),
use_proxy: form.use_proxy ? 1 : 0,
description: form.description.trim(),
icon: form.icon.trim(),
category: form.category.trim() || '默认',
version: form.version.trim(),
sort_order: parseInt(form.sort_order, 10) || 0,
};
try {
if (editingId) await updateAdminLink(editingId, data);
else await createAdminLink(data);
showSnack('保存成功');
setDialog(false);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
const doDelete = async () => {
if (!confirm) return;
try {
await deleteAdminLink(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">面板链接管理</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加面板</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>排序</TableCell>
<TableCell>标题</TableCell>
<TableCell>版本</TableCell>
<TableCell>URL</TableCell>
<TableCell>嵌入URL</TableCell>
<TableCell>分类</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{links === null ? (
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
) : links.length === 0 ? (
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
) : links.map((l) => (
<TableRow key={l.id}>
<TableCell>{l.sort_order}</TableCell>
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{l.version || '-'}</TableCell>
<TableCell sx={{ maxWidth: 180 }}>
<Box component="a" href={l.url} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{l.url}</Box>
</TableCell>
<TableCell sx={{ maxWidth: 140, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{l.embed_url || '-'}</TableCell>
<TableCell><Chip size="small" label={l.category} variant="outlined" /></TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<IconButton size="small" onClick={() => openEdit(l)}><EditIcon fontSize="small" /></IconButton>
<IconButton size="small" color="error" onClick={() => setConfirm({ id: l.id, label: l.title })}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
<DialogContent>
<TextField fullWidth label="标题 *" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} margin="normal" />
<TextField fullWidth label="URL *" type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} margin="normal" placeholder="https://..." />
<TextField fullWidth label="嵌入 URLiframe嵌入用)" type="url" value={form.embed_url} onChange={(e) => setForm((p) => ({ ...p, embed_url: e.target.value }))} margin="normal" placeholder="留空则新标签页打开" />
<FormControlLabel control={<Switch checked={form.use_proxy} onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))} />} label="通过代理嵌入(绕过 X-Frame-Options 限制)" />
<TextField fullWidth label="描述" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} margin="normal" />
<TextField fullWidth label="图标 (Material图标名)" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="settings, dashboard, ..." />
<TextField fullWidth label="分类" value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} margin="normal" placeholder="默认" />
<TextField fullWidth label="版本号(可选)" value={form.version} onChange={(e) => setForm((p) => ({ ...p, version: e.target.value }))} margin="normal" placeholder="v2.1.0" />
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 160 }} />
</DialogContent>
<DialogActions>
<Button onClick={() => setDialog(false)}>取消</Button>
<Button variant="contained" onClick={save}>保存</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}
+244
View File
@@ -0,0 +1,244 @@
import React, { useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import FormControl from '@mui/material/FormControl';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormHelperText from '@mui/material/FormHelperText';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import Switch from '@mui/material/Switch';
import InputAdornment from '@mui/material/InputAdornment';
import IconButton from '@mui/material/IconButton';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
const FOOTER_STYLES = [
{ value: 'classic', label: '经典左右', desc: '左版权右版本,信息密度低,一眼扫过' },
{ value: 'columns', label: '分栏导航', desc: '品牌 + 固定导航栏目 + 底部版权条,内容型站点首选' },
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
];
/** 基本设置 + 页脚设置 + 面板代理 + RainID 单点登录(v2 */
export default function Settings() {
const [form, setForm] = useState({
site_name: '',
site_description: '',
site_url: '',
site_favicon: '',
footer_style: 'classic',
footer_copyright: '',
footer_powered: '',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
proxy_allowed_hosts: '',
rainid_enabled: '0',
rainid_client_id: '',
rainid_client_secret: '',
rainid_discovery_url: 'https://rainid.rainnya.asia/oauth',
rainid_register_redirect: '0',
});
const [saving, setSaving] = useState(false);
const [showSecret, setShowSecret] = useState(false);
useEffect(() => {
getSettings()
.then((s) => setForm({
site_name: s.site_name || '',
site_description: s.site_description || '',
site_url: s.site_url || '',
site_favicon: s.site_favicon || '',
footer_style: s.footer_style || 'classic',
footer_copyright: s.footer_copyright || '',
footer_powered: s.footer_powered || '',
footer_desc: s.footer_desc || '',
comment_moderate: s.comment_moderate === '1' ? '1' : '0',
comment_notify: s.comment_notify === '1' ? '1' : '0',
proxy_allowed_hosts: s.proxy_allowed_hosts || '',
rainid_enabled: s.rainid_enabled === '1' ? '1' : '0',
rainid_client_id: s.rainid_client_id || '',
// secret 后端不回读(ALLOWED_SET 可写不可读),始终为空,留空提交=不修改
rainid_client_secret: '',
rainid_discovery_url: s.rainid_discovery_url || 'https://rainid.rainnya.asia/oauth',
rainid_register_redirect: s.rainid_register_redirect === '1' ? '1' : '0',
}))
.catch((e) => showSnack(e.message, 'error'));
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const toggle = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.checked ? '1' : '0' }));
const save = async () => {
setSaving(true);
try {
const body = { ...form };
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
if (!body.rainid_client_secret) delete body.rainid_client_secret;
await saveSettings(body);
showSnack('设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
const footStyle = form.footer_style;
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
<Paper sx={{ p: 3 }}>
<TextField fullWidth label="网站名称" value={form.site_name} onChange={set('site_name')} margin="normal" placeholder="显示在标题和导航栏" />
<TextField fullWidth label="网站描述(SEO" value={form.site_description} onChange={set('site_description')} margin="normal" placeholder="搜索引擎结果中显示的描述" />
<TextField fullWidth label="网站域名" type="url" value={form.site_url} onChange={set('site_url')} margin="normal" placeholder="https://你的域名.com" />
<TextField fullWidth label="网站图标 URL" type="url" value={form.site_favicon} onChange={set('site_favicon')} margin="normal" placeholder="https://example.com/favicon.ico" />
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>评论设置</Typography>
<FormControlLabel
control={<Switch checked={form.comment_moderate === '1'} onChange={toggle('comment_moderate')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论审核模式</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后新评论需后台审核通过才显示</Box>
</Box>
)}
/>
<FormControlLabel
control={<Switch checked={form.comment_notify === '1'} onChange={toggle('comment_notify')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论邮件通知</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>新评论时给文章作者发邮件</Box>
</Box>
)}
/>
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
<FormControl component="fieldset" sx={{ mb: 1 }}>
<RadioGroup value={footStyle} onChange={set('footer_style')}>
{FOOTER_STYLES.map((o) => (
<FormControlLabel
key={o.value}
value={o.value}
control={<Radio />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>{o.label}</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>{o.desc}</Box>
</Box>
)}
sx={{ alignItems: 'flex-start' }}
/>
))}
</RadioGroup>
</FormControl>
{footStyle === 'columns' ? (
<>
<TextField fullWidth label="网站简介(页脚品牌区)" value={form.footer_desc} onChange={set('footer_desc')} margin="normal" placeholder="留空则自动使用网站描述" multiline minRows={2} />
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
<FormHelperText sx={{ mt: 1 }}>
导航栏目固定不可编辑首页 / 博客 / 论坛 / 管理后台 / 个人中心
</FormHelperText>
</>
) : (
<>
<TextField fullWidth label="版权文本" value={form.footer_copyright} onChange={set('footer_copyright')} margin="normal" placeholder="© 2026 Rainnya Blog. All rights reserved." />
<TextField fullWidth label="Powered by 文本" value={form.footer_powered} onChange={set('footer_powered')} margin="normal" placeholder="Powered by RainnyaWeb" />
</>
)}
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>面板代理</Typography>
<TextField
fullWidth
label="代理允许的内网地址(白名单)"
value={form.proxy_allowed_hosts}
onChange={set('proxy_allowed_hosts')}
margin="normal"
multiline
minRows={3}
placeholder={'每行或逗号分隔一个地址/网段,例如:\n192.168.0.0/16\n10.0.0.0/8\n192.168.3.1:8080'}
helperText="https 页面无法嵌入 http 内网面板,把可信内网地址/网段加入白名单后可经面板代理放行。支持单 IP、IPv4 CIDR(如 192.168.0.0/16)与主机名;默认拦截所有内网地址,请谨慎配置。"
/>
</Paper>
<Paper sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>RainID 单点登录</Typography>
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
通过 RainID 统一身份认证支持 ROPC 密码登录与授权码 SSO
</Typography>
<FormControlLabel
control={<Switch checked={form.rainid_enabled === '1'} onChange={toggle('rainid_enabled')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>启用 RainID 登录</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>登录页显示使用 RainID 登录密码登录自动转发 ROPC 校验</Box>
</Box>
)}
/>
<FormControlLabel
control={<Switch checked={form.rainid_register_redirect === '1'} onChange={toggle('rainid_register_redirect')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>注册跳转 RainID</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后注册页直接跳转 RainID 注册不再显示本地注册表单</Box>
</Box>
)}
/>
<TextField fullWidth label="Client ID" value={form.rainid_client_id} onChange={set('rainid_client_id')} margin="normal" placeholder="RainID 应用 Client ID(非机密)" />
<TextField
fullWidth
label="Client Secret(机密)"
type={showSecret ? 'text' : 'password'}
value={form.rainid_client_secret}
onChange={set('rainid_client_secret')}
margin="normal"
placeholder="••••••••••••"
autoComplete="new-password"
helperText="保存在服务端且不回读;留空提交则保持原值不变"
slotProps={{
htmlInput: { 'aria-label': 'RainID Client Secret' },
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
edge="end"
aria-label={showSecret ? '隐藏 Secret' : '显示 Secret'}
onClick={() => setShowSecret((v) => !v)}
>
{showSecret ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<TextField fullWidth label="Discovery URL" type="url" value={form.rainid_discovery_url} onChange={set('rainid_discovery_url')} margin="normal" placeholder="https://rainid.rainnya.asia/oauth" helperText="RainID OIDC 配置地址,默认即可" />
</Paper>
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={save} disabled={saving}>保存设置</Button>
</Box>
</Box>
);
}
+156
View File
@@ -0,0 +1,156 @@
import React, { useEffect, useRef, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Slider from '@mui/material/Slider';
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 { getSettings, saveSettings } from '../../api/settings.js';
import { uploadWallpaper } from '../../api/upload.js';
import { showSnack } from '../snack.jsx';
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
export default function ThemeSettings() {
const [form, setForm] = useState({
primary_color: '#6750a4',
theme_wallpaper: '',
theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: true,
nav_style: 'default',
card_style: 'default',
glass_blur: '20',
glass_opacity: '0.6',
theme_force_dark: false,
});
const [saving, setSaving] = useState(false);
const fileRef = useRef(null);
useEffect(() => {
getSettings()
.then((s) => setForm({
primary_color: s.primary_color || '#6750a4',
theme_wallpaper: s.theme_wallpaper || '',
theme_wallpaper_scale: s.theme_wallpaper_scale || 'cover',
theme_wallpaper_enabled: s.theme_wallpaper_enabled !== '0',
nav_style: s.nav_style || 'default',
card_style: s.card_style || 'default',
glass_blur: s.glass_blur || '20',
glass_opacity: s.glass_opacity || '0.6',
theme_force_dark: s.theme_force_dark === '1',
}))
.catch((e) => showSnack(e.message, 'error'));
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const handleWallpaperUpload = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
try {
const data = await uploadWallpaper(file);
setForm((p) => ({ ...p, theme_wallpaper: data.url }));
showSnack('壁纸已上传');
} catch (err) {
showSnack(err.message, 'error');
}
};
const save = async () => {
setSaving(true);
try {
await saveSettings({
primary_color: form.primary_color,
theme_wallpaper: form.theme_wallpaper.trim(),
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',
nav_style: form.nav_style,
card_style: form.card_style,
glass_blur: form.glass_blur,
glass_opacity: form.glass_opacity,
});
showSnack('主题设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>主题色</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<input type="color" value={form.primary_color} onChange={set('primary_color')} style={{ width: 48, height: 48, border: 'none', background: 'none', cursor: 'pointer' }} />
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" sx={{ width: 160 }} />
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<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 }}>
<Button variant="outlined" onClick={() => fileRef.current && fileRef.current.click()}>上传图片</Button>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleWallpaperUpload} />
<Typography variant="caption" color="text.secondary">上传后自动填入 URL</Typography>
</Box>
<TextField fullWidth label="图片 URL" type="url" value={form.theme_wallpaper} onChange={set('theme_wallpaper')} margin="normal" placeholder="https://example.com/wallpaper.jpg" />
{form.theme_wallpaper && (
<Box sx={{ mt: 1 }}>
<img src={form.theme_wallpaper} alt="wallpaper" style={{ width: '100%', maxHeight: 120, objectFit: 'cover', borderRadius: 8 }} />
</Box>
)}
<TextField fullWidth label="缩放方式" select value={form.theme_wallpaper_scale} onChange={set('theme_wallpaper_scale')} margin="normal" sx={{ maxWidth: 280 }}>
<option value="cover">cover - 覆盖填充</option>
<option value="contain">contain - 完整显示</option>
<option value="repeat">repeat - 平铺重复</option>
<option value="stretch">stretch - 拉伸填充</option>
</TextField>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>样式设置</Typography>
<Typography variant="body2" sx={{ mb: 0.5 }}>导航栏样式</Typography>
<ToggleButtonGroup exclusive value={form.nav_style} onChange={(e, v) => v && setForm((p) => ({ ...p, nav_style: v }))} size="small" sx={{ mb: 2 }}>
<ToggleButton value="default">默认</ToggleButton>
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
<ToggleButton value="capsule">胶囊</ToggleButton>
</ToggleButtonGroup>
<Typography variant="body2" sx={{ mb: 0.5 }}>卡片样式</Typography>
<ToggleButtonGroup exclusive value={form.card_style} onChange={(e, v) => v && setForm((p) => ({ ...p, card_style: v }))} size="small">
<ToggleButton value="default">默认</ToggleButton>
<ToggleButton value="glass">磨砂玻璃</ToggleButton>
</ToggleButtonGroup>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>玻璃效果</Typography>
<Typography variant="body2" color="text.secondary">模糊强度: {form.glass_blur}px</Typography>
<Slider min={5} max={40} value={parseInt(form.glass_blur, 10) || 20} onChange={(e, v) => setForm((p) => ({ ...p, glass_blur: String(v) }))} sx={{ maxWidth: 400 }} />
<Typography variant="body2" color="text.secondary">透明度: {form.glass_opacity}</Typography>
<Slider min={0.1} max={0.95} step={0.05} value={parseFloat(form.glass_opacity) || 0.6} onChange={(e, v) => setForm((p) => ({ ...p, glass_opacity: String(v) }))} sx={{ maxWidth: 400 }} />
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>深色模式</Typography>
<FormControlLabel
control={<Switch checked={form.theme_force_dark} onChange={(e) => setForm((p) => ({ ...p, theme_force_dark: e.target.checked }))} />}
label="强制深色模式(启用后用户无法切换为浅色)"
/>
</Paper>
<Button variant="contained" onClick={save} disabled={saving}>保存主题设置</Button>
</Box>
);
}
+93
View File
@@ -0,0 +1,93 @@
import React, { useCallback, useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import { listAttachments, deleteAttachment } from '../../api/upload.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 附件管理:列表/删除(迁移自 v1 附件卡片) */
export default function Uploads() {
const [list, setList] = useState(null);
const [confirm, setConfirm] = useState(null);
const load = useCallback(() => {
listAttachments()
.then((rows) => setList(rows || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
const doDelete = async () => {
if (!confirm) return;
try {
await deleteAttachment(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>附件管理</Typography>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>文件名</TableCell>
<TableCell>原始名</TableCell>
<TableCell>大小</TableCell>
<TableCell>类型</TableCell>
<TableCell>上传者</TableCell>
<TableCell>时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{list === null ? (
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
) : list.length === 0 ? (
<TableRow><TableCell colSpan={8}>暂无附件</TableCell></TableRow>
) : list.map((f) => (
<TableRow key={f.id}>
<TableCell>{f.id}</TableCell>
<TableCell sx={{ maxWidth: 160 }}>
<Box component="a" href={`/uploads/${f.filename}`} target="_blank" rel="noopener" sx={{ color: 'primary.main', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}>{f.filename}</Box>
</TableCell>
<TableCell sx={{ maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</TableCell>
<TableCell>{((f.size || 0) / 1024).toFixed(0)} KB</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.mime_type || '-'}</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>UID {f.user_id}</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{f.created_at}</TableCell>
<TableCell align="right">
<IconButton size="small" color="error" onClick={() => setConfirm({ id: f.id, label: f.filename })}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.label : ''}" 吗?`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}
+214
View File
@@ -0,0 +1,214 @@
import React, { useCallback, useEffect, useState } from 'react';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Box from '@mui/material/Box';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Chip from '@mui/material/Chip';
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 TextField from '@mui/material/TextField';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import { listUsers, deleteUser, resetUserPassword, setUserRole, registerByAdmin } from '../../api/auth.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 用户管理:列表/添加/改密/改权/删除(迁移自 v1 用户卡片) */
export default function Users() {
const [users, setUsers] = useState(null);
const [addOpen, setAddOpen] = useState(false);
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
const [pwDialog, setPwDialog] = useState(null); // { id, username }
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
const [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
const [confirm, setConfirm] = useState(null); // { id, username }
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
listUsers()
.then((us) => setUsers(us || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
const addUser = async () => {
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
setBusy(true);
try {
await registerByAdmin(addForm.username.trim(), addForm.password, addForm.role);
showSnack('用户已创建');
setAddOpen(false);
setAddForm({ username: '', password: '', role: 'user' });
load();
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const resetPw = async () => {
if (!pwDialog) return;
if (!pwForm.p1 || pwForm.p1.length < 6) { showSnack('密码至少6位', 'error'); return; }
if (pwForm.p1 !== pwForm.p2) { showSnack('两次密码不一致', 'error'); return; }
setBusy(true);
try {
await resetUserPassword(pwDialog.id, pwForm.p1);
showSnack('密码已重置');
setPwDialog(null);
setPwForm({ p1: '', p2: '' });
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const changeRole = async () => {
if (!roleDialog) return;
setBusy(true);
try {
await setUserRole(roleDialog.id, roleDialog.role);
showSnack('角色已更新');
setRoleDialog(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const doDelete = async () => {
if (!confirm) return;
setBusy(true);
try {
await deleteUser(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">用户管理</Typography>
<Button variant="contained" onClick={() => setAddOpen(true)}>添加用户</Button>
</Box>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>用户名</TableCell>
<TableCell>邮箱</TableCell>
<TableCell>验证</TableCell>
<TableCell>角色</TableCell>
<TableCell>注册时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{users === null ? (
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
) : users.length === 0 ? (
<TableRow><TableCell colSpan={7}>暂无用户</TableCell></TableRow>
) : users.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.id}</TableCell>
<TableCell><Box sx={{ fontWeight: 600 }}>{u.username}</Box></TableCell>
<TableCell sx={{ color: 'text.secondary' }}>{u.email || '-'}</TableCell>
<TableCell>
<Chip size="small" label={u.email_verified ? '已验证' : '未验证'} color={u.email_verified ? 'primary' : 'default'} variant={u.email_verified ? 'filled' : 'outlined'} />
</TableCell>
<TableCell>
<Chip size="small" label={u.role === 'admin' ? '管理员' : '用户'} color={u.role === 'admin' ? 'secondary' : 'default'} />
</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{u.created_at}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<Button size="small" onClick={() => { setPwDialog({ id: u.id, username: u.username }); setPwForm({ p1: '', p2: '' }); }}>改密</Button>
<Button size="small" onClick={() => setRoleDialog({ id: u.id, username: u.username, role: u.role })}>改权</Button>
<Button size="small" color="error" onClick={() => setConfirm({ id: u.id, username: u.username })}>删除</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{/* 添加用户 */}
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>添加用户</DialogTitle>
<DialogContent>
<TextField fullWidth label="用户名 *" value={addForm.username} onChange={(e) => setAddForm((p) => ({ ...p, username: e.target.value }))} margin="normal" />
<TextField fullWidth label="密码 *" type="password" value={addForm.password} onChange={(e) => setAddForm((p) => ({ ...p, password: e.target.value }))} margin="normal" />
<FormControl fullWidth margin="normal">
<InputLabel>角色</InputLabel>
<Select value={addForm.role} onChange={(e) => setAddForm((p) => ({ ...p, role: e.target.value }))} label="角色">
<MenuItem value="user">用户</MenuItem>
<MenuItem value="admin">管理员</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setAddOpen(false)}>取消</Button>
<Button variant="contained" onClick={addUser} disabled={busy}>创建</Button>
</DialogActions>
</Dialog>
{/* 重置密码 */}
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>重置密码</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>重置用户 {pwDialog ? pwDialog.username : ''} 的密码</Typography>
<TextField fullWidth label="新密码(至少6位)" type="password" value={pwForm.p1} onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))} margin="normal" />
<TextField fullWidth label="确认新密码" type="password" value={pwForm.p2} onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))} margin="normal" />
</DialogContent>
<DialogActions>
<Button onClick={() => setPwDialog(null)}>取消</Button>
<Button variant="contained" onClick={resetPw} disabled={busy}>确认重置</Button>
</DialogActions>
</Dialog>
{/* 修改角色 */}
<Dialog open={!!roleDialog} onClose={() => setRoleDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>修改角色</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>修改用户 {roleDialog ? roleDialog.username : ''} 的角色</Typography>
<FormControl fullWidth>
<InputLabel>角色</InputLabel>
<Select value={roleDialog ? roleDialog.role : 'user'} onChange={(e) => setRoleDialog((p) => (p ? { ...p, role: e.target.value } : p))} label="角色">
<MenuItem value="user">普通用户</MenuItem>
<MenuItem value="admin">管理员</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setRoleDialog(null)}>取消</Button>
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React, { useState } from 'react';
import Snackbar from '@mui/material/Snackbar';
import Alert from '@mui/material/Alert';
// 模块级单例:admin 任意处调用 showSnack,由 AdminLayout 内的 SnackHost 消费
let showFn = null;
export function showSnack(msg, severity = 'success') {
if (showFn) showFn(msg, severity);
else window.alert(msg);
}
export default function SnackHost() {
const [open, setOpen] = useState(false);
const [msg, setMsg] = useState('');
const [severity, setSeverity] = useState('success');
showFn = (m, s) => { setMsg(m); setSeverity(s || 'success'); setOpen(true); };
return (
<Snackbar
open={open}
autoHideDuration={2500}
onClose={() => setOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
>
<Alert severity={severity} variant="filled" onClose={() => setOpen(false)}>{msg}</Alert>
</Snackbar>
);
}
+36
View File
@@ -0,0 +1,36 @@
/* ============================================================
* 后台 MD3 全局微调:与前台(public/css/style.css)一致的氛围细节
* 前台 token 变量未注入后台页面,这里按明暗各写一份。
* ============================================================ */
/* 内容区入场动效(对应前台 contentFadeIn */
main {
animation: md3ContentFadeIn 0.28s ease;
}
@keyframes md3ContentFadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
/* 明暗切换过渡 */
body {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 滚动条(对应前台细滚动条) */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #79747e; }
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
/* 减弱动效偏好 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+515
View File
@@ -0,0 +1,515 @@
import { createTheme, alpha } from '@mui/material/styles';
/* ============================================================
* 后台 Material Design 3 主题
*
* - 与前台 public/css/style.css 的 MD3 token 保持同源:
* 默认主色 #6750a4 时直接复用前台 token 值(浅/深两套),
* 自定义主色时用 tonal ramp 近似生成整套 token。
* - 组件形态按 MD3 规范覆盖:pill 按钮、大圆角卡片/弹窗、
* surface 色阶、弱边框 + 低阴影(surface tint)。
* ============================================================ */
const DEFAULT_SEED = '#6750a4';
/* ---------- 颜色工具 ---------- */
function clamp(v) { return Math.min(255, Math.max(0, Math.round(v))); }
function hexToRgb(hex) {
let h = String(hex).replace('#', '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function rgbToHex(r, g, b) {
const to = (v) => clamp(v).toString(16).padStart(2, '0');
return `#${to(r)}${to(g)}${to(b)}`;
}
function mix(a, b, w) {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
return rgbToHex(
ca[0] + (cb[0] - ca[0]) * w,
ca[1] + (cb[1] - ca[1]) * w,
ca[2] + (cb[2] - ca[2]) * w
);
}
function hexToHsl(hex) {
const [r, g, b] = hexToRgb(hex).map((v) => v / 255);
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
let h = 0;
let s = 0;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
if (max === r) h = (g - b) / d + (g < b ? 6 : 0);
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, s, l];
}
function hslToHex(h, s, l) {
h = ((h % 360) + 360) % 360;
const c = (1 - Math.abs(2 * l - 1)) * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = l - c / 2;
let rgb;
if (h < 60) rgb = [c, x, 0];
else if (h < 120) rgb = [x, c, 0];
else if (h < 180) rgb = [0, c, x];
else if (h < 240) rgb = [0, x, c];
else if (h < 300) rgb = [x, 0, c];
else rgb = [c, 0, x];
return rgbToHex((rgb[0] + m) * 255, (rgb[1] + m) * 255, (rgb[2] + m) * 255);
}
function grayscale(hex) {
const [r, g, b] = hexToRgb(hex);
const v = clamp(0.299 * r + 0.587 * g + 0.114 * b);
return rgbToHex(v, v, v);
}
/* MD3 tonal ramp 近似:tone t = 向白/黑按比例混合(t40 即 seed 本身) */
function tone(seed, t) {
if (t >= 40) return mix(seed, '#ffffff', (t - 40) / 60);
return mix(seed, '#000000', (40 - t) / 40);
}
/* 派生色:可旋转色相 / 降低饱和度后再套 tone */
function tonal(seed, t, { hue = 0, sat = 1 } = {}) {
let base = seed;
if (hue) {
const [h, s, l] = hexToHsl(base);
base = hslToHex(h + hue, s, l);
}
if (sat < 1) base = mix(base, grayscale(base), 1 - sat);
return tone(base, t);
}
/* ---------- 前台同源 token(默认主色 #6750a4,取自 public/css/style.css ---------- */
const LIGHT_TOKENS = {
primary: '#6750a4',
onPrimary: '#ffffff',
primaryContainer: '#eadfff',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#ffffff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#ffffff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
surface: '#fffbfe',
onSurface: '#1c1b1f',
surfaceVariant: '#e7e0ec',
onSurfaceVariant: '#49454f',
surfaceContainerLow: '#f7f2fa',
surfaceContainer: '#f3edf7',
surfaceContainerHigh: '#ece6f0',
outline: '#79747e',
outlineVariant: '#cac4d0',
error: '#b3261e',
onError: '#ffffff',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
};
const DARK_TOKENS = {
primary: '#d0bcff',
onPrimary: '#381e72',
primaryContainer: '#4f378b',
onPrimaryContainer: '#eadfff',
secondary: '#ccc2dc',
onSecondary: '#332d41',
secondaryContainer: '#4a4458',
onSecondaryContainer: '#e8def8',
tertiary: '#efb8c8',
onTertiary: '#492532',
tertiaryContainer: '#61343f',
onTertiaryContainer: '#ffd8e4',
surface: '#1c1b1f',
onSurface: '#e6e1e5',
surfaceVariant: '#49454f',
onSurfaceVariant: '#cac4d0',
surfaceContainerLow: '#211f26',
surfaceContainer: '#25232a',
surfaceContainerHigh: '#2b2930',
outline: '#938f99',
outlineVariant: '#49454f',
error: '#f2b8b5',
onError: '#601410',
inverseSurface: '#e6e1e5',
inverseOnSurface: '#313033',
};
function buildPalette(seed, dark) {
if (String(seed).toLowerCase() === DEFAULT_SEED) return dark ? DARK_TOKENS : LIGHT_TOKENS;
// 自定义主色:surface 色阶保持 MD3 基线,色相相关 token 由 tonal ramp 派生
const sat = 0.55; // secondary 适度去饱和,贴近 MD3 参考值
const hue = 32; // tertiary 色相偏移
if (dark) {
return {
primary: tone(seed, 80),
onPrimary: tone(seed, 20),
primaryContainer: tone(seed, 30),
onPrimaryContainer: tone(seed, 90),
secondary: tonal(seed, 80, { sat }),
onSecondary: tonal(seed, 20, { sat }),
secondaryContainer: tonal(seed, 30, { sat }),
onSecondaryContainer: tonal(seed, 90, { sat }),
tertiary: tonal(seed, 80, { hue }),
onTertiary: tonal(seed, 20, { hue }),
tertiaryContainer: tonal(seed, 30, { hue }),
onTertiaryContainer: tonal(seed, 90, { hue }),
...DARK_TOKENS_DERIVED,
};
}
return {
primary: tone(seed, 40),
onPrimary: '#ffffff',
primaryContainer: tone(seed, 90),
onPrimaryContainer: tone(seed, 10),
secondary: tonal(seed, 40, { sat }),
onSecondary: '#ffffff',
secondaryContainer: tonal(seed, 90, { sat }),
onSecondaryContainer: tonal(seed, 10, { sat }),
tertiary: tonal(seed, 40, { hue }),
onTertiary: '#ffffff',
tertiaryContainer: tonal(seed, 90, { hue }),
onTertiaryContainer: tonal(seed, 10, { hue }),
...LIGHT_TOKENS_DERIVED,
};
}
// 派生模式下 surface 相关与 error 沿用 MD3 基线
const LIGHT_TOKENS_DERIVED = {
surface: '#fffbfe',
onSurface: '#1c1b1f',
surfaceVariant: '#e7e0ec',
onSurfaceVariant: '#49454f',
surfaceContainerLow: '#f7f2fa',
surfaceContainer: '#f3edf7',
surfaceContainerHigh: '#ece6f0',
outline: '#79747e',
outlineVariant: '#cac4d0',
error: '#b3261e',
onError: '#ffffff',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
};
const DARK_TOKENS_DERIVED = {
surface: '#1c1b1f',
onSurface: '#e6e1e5',
surfaceVariant: '#49454f',
onSurfaceVariant: '#cac4d0',
surfaceContainerLow: '#211f26',
surfaceContainer: '#25232a',
surfaceContainerHigh: '#2b2930',
outline: '#938f99',
outlineVariant: '#49454f',
error: '#f2b8b5',
onError: '#601410',
inverseSurface: '#e6e1e5',
inverseOnSurface: '#313033',
};
/* ---------- MD3 柔和阴影(surface tint 而非硬投影) ---------- */
function md3Shadows() {
const shadows = ['none'];
for (let i = 1; i <= 24; i += 1) {
const a = Math.min(0.26, 0.05 + i * 0.008).toFixed(3);
const b = Math.min(0.15, 0.03 + i * 0.005).toFixed(3);
const y1 = Math.min(3, 1 + Math.floor(i / 8));
const y2 = Math.min(2, 1 + Math.floor(i / 12));
const b1 = Math.min(8, 2 + Math.floor(i / 3));
const b2 = Math.min(6, 1 + Math.floor(i / 5));
shadows.push(`0px ${y1}px ${b1}px rgba(0,0,0,${a}), 0px ${y2}px ${b2}px rgba(0,0,0,${b})`);
}
return shadows;
}
/* ---------- 主题工厂 ---------- */
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) {
const dark = mode === 'dark';
const p = buildPalette(primary, dark);
const shadows = md3Shadows();
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
// filled / tonal 按钮的 hover 色(MD3 hover = 相邻 tone
const hoverPrimary = dark ? tone(p.primary, 90) : tone(p.primary, 35);
return createTheme({
palette: {
mode,
primary: {
main: p.primary,
contrastText: p.onPrimary,
container: p.primaryContainer,
onContainer: p.onPrimaryContainer,
},
secondary: {
main: p.secondary,
contrastText: p.onSecondary,
container: p.secondaryContainer,
onContainer: p.onSecondaryContainer,
},
tertiary: {
main: p.tertiary,
contrastText: p.onTertiary,
container: p.tertiaryContainer,
onContainer: p.onTertiaryContainer,
},
error: { main: p.error, contrastText: p.onError },
background: { default: p.surface, paper: p.surfaceContainerLow },
text: {
primary: p.onSurface,
secondary: p.onSurfaceVariant,
disabled: alpha(p.onSurface, 0.38),
},
divider: p.outlineVariant,
action: {
hover: alpha(p.onSurface, 0.08),
selected: alpha(p.onSurface, 0.08),
disabled: alpha(p.onSurface, 0.38),
disabledBackground: alpha(p.onSurface, 0.12),
},
},
shape: { borderRadius: 8 },
typography: {
fontFamily,
h4: { fontSize: 28, fontWeight: 500, letterSpacing: 0 },
h5: { fontSize: 24, fontWeight: 500, letterSpacing: 0 },
h6: { fontSize: 22, fontWeight: 500, letterSpacing: 0.15 },
subtitle1: { fontSize: 16, fontWeight: 500, letterSpacing: 0.15 },
body1: { fontSize: 16, lineHeight: 1.5 },
body2: { fontSize: 14, lineHeight: 1.5 },
button: { fontSize: 14, fontWeight: 500, letterSpacing: 0.1 },
caption: { fontSize: 12, fontWeight: 400, letterSpacing: 0.4 },
},
shadows,
components: {
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
MuiAppBar: {
defaultProps: { color: 'inherit', elevation: 0 },
styleOverrides: {
root: {
backgroundColor: p.surfaceContainer,
color: p.onSurface,
boxShadow: 'none',
borderBottom: `1px solid ${p.outlineVariant}`,
backgroundImage: 'none',
},
},
},
/* MD3 导航抽屉:surfaceContainerLow + 右侧细边框 */
MuiDrawer: {
styleOverrides: {
paper: {
backgroundColor: p.surfaceContainerLow,
borderRight: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
},
},
},
/* MD3 导航项:胶囊选中态(secondaryContainer */
MuiListItemButton: {
styleOverrides: {
root: {
borderRadius: 24,
margin: '4px 10px',
paddingTop: 8,
paddingBottom: 8,
'& .MuiListItemIcon-root': { color: p.onSurfaceVariant, minWidth: 40 },
'& .MuiListItemText-primary': { fontWeight: 500, fontSize: 14 },
'&.Mui-selected': {
backgroundColor: p.secondaryContainer,
color: p.onSecondaryContainer,
'&:hover': { backgroundColor: p.secondaryContainer },
'& .MuiListItemIcon-root': { color: p.onSecondaryContainer },
},
},
},
},
/* MD3 按钮:pill 圆角、无阴影、文字不转大写 */
MuiButton: {
defaultProps: { disableElevation: true },
styleOverrides: {
root: {
borderRadius: 20,
textTransform: 'none',
fontWeight: 500,
letterSpacing: '0.1px',
height: 40,
padding: '0 24px',
},
sizeSmall: { height: 32, padding: '0 16px', fontSize: 13 },
sizeLarge: { height: 48, padding: '0 28px', fontSize: 15 },
containedPrimary: {
backgroundColor: p.primary,
color: p.onPrimary,
'&:hover': { backgroundColor: hoverPrimary, boxShadow: shadows[1] },
},
containedError: { boxShadow: 'none' },
outlined: { borderColor: p.outline },
outlinedPrimary: {
borderColor: p.outline,
'&:hover': { borderColor: p.onSurface, backgroundColor: alpha(p.primary, 0.08) },
},
outlinedError: { color: p.error, borderColor: p.outline, '&:hover': { backgroundColor: alpha(p.error, 0.08) } },
textPrimary: { color: p.primary, '&:hover': { backgroundColor: alpha(p.primary, 0.08) } },
textError: { color: p.error },
},
},
/* MD3 卡片:surfaceContainerLow + 弱边框 + 12px 圆角 */
MuiCard: {
styleOverrides: {
root: {
borderRadius: 12,
backgroundColor: p.surfaceContainerLow,
border: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
},
},
},
/* 设置页的普通 Paper(默认 elevation=1)同样 MD3 化 */
MuiPaper: {
styleOverrides: {
rounded: { borderRadius: 12 },
elevation1: {
backgroundColor: p.surfaceContainerLow,
border: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
},
outlined: { borderColor: p.outlineVariant },
},
},
/* MD3 弹窗:28px 大圆角 + surfaceContainerHigh */
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 28,
backgroundColor: p.surfaceContainerHigh,
border: `1px solid ${p.outlineVariant}`,
boxShadow: shadows[4],
backgroundImage: 'none',
},
},
},
MuiDialogTitle: {
styleOverrides: { root: { fontSize: 22, fontWeight: 500, padding: '24px 24px 8px' } },
},
MuiDialogContent: {
styleOverrides: { root: { padding: '8px 24px', color: p.onSurface } },
},
MuiDialogActions: {
styleOverrides: { root: { padding: '16px 24px 24px', gap: 8 } },
},
/* MD3 输入框:4px 圆角 + 中性 outline 边框 */
MuiOutlinedInput: {
styleOverrides: {
root: { borderRadius: 4 },
notchedOutline: { borderColor: p.outline },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: p.onSurface },
},
},
/* MD3 Chip8px 圆角 + outline 边框 */
MuiChip: {
styleOverrides: {
root: { borderRadius: 8, fontWeight: 500 },
outlined: { borderColor: p.outline },
},
},
/* MD3 表格:outline-variant 分隔线、紧凑密度 */
MuiTableCell: {
styleOverrides: {
root: { borderBottom: `1px solid ${p.outlineVariant}`, padding: '14px 16px', fontSize: 14 },
head: { color: p.onSurfaceVariant, fontWeight: 600, fontSize: 13 },
},
},
/* MD3 开关:开 = primary 轨道 + 白 thumb,关 = surfaceContainerHigh 轨道 */
MuiSwitch: {
styleOverrides: {
root: {},
switchBase: {
color: p.outline,
'&.Mui-checked': { color: p.primary },
'&.Mui-checked + .MuiSwitch-track': { backgroundColor: p.primary, opacity: 1 },
},
track: { backgroundColor: p.surfaceContainerHigh, opacity: 1 },
},
},
/* MD3 分段按钮:secondaryContainer 选中态 */
MuiToggleButton: {
styleOverrides: {
root: {
borderRadius: 20,
textTransform: 'none',
borderColor: p.outline,
'&.Mui-selected': {
backgroundColor: p.secondaryContainer,
color: p.onSecondaryContainer,
borderColor: p.outline,
},
},
},
},
/* MD3 头像:primaryContainer 底 + onPrimaryContainer 字 */
MuiAvatar: {
styleOverrides: {
root: { backgroundColor: p.primaryContainer, color: p.onPrimaryContainer },
},
},
/* MD3 提示:inverseSurface 底 */
MuiTooltip: {
styleOverrides: {
tooltip: {
backgroundColor: p.inverseSurface,
color: p.inverseOnSurface,
borderRadius: 4,
fontSize: 12,
padding: '6px 8px',
},
arrow: { color: p.inverseSurface },
},
},
/* 图标按钮去底色圆角适配 */
MuiIconButton: {
styleOverrides: {
root: { borderRadius: 999 },
},
},
},
});
}
export default createMD3Theme;
+16
View File
@@ -0,0 +1,16 @@
import { request } from './client.js';
// 面板链接(routes/admin-links.js
export function listAdminLinks() {
return request('/admin-links');
}
/** data = { title, url, embed_url?, description?, icon?, category?, sort_order?, use_proxy?, version? } */
export function createAdminLink(data) {
return request('/admin-links', { method: 'POST', body: data });
}
export function updateAdminLink(id, data) {
return request('/admin-links/' + id, { method: 'PUT', body: data });
}
export function deleteAdminLink(id) {
return request('/admin-links/' + id, { method: 'DELETE' });
}
+20
View File
@@ -0,0 +1,20 @@
import { request } from './client.js';
/** 启用的公告列表(active=1 */
export function listActive() {
return request('/announcements');
}
// ── 管理员管理 ──────────────────────────────────
export function listAll() {
return request('/announcements?all=1');
}
export function createAnnouncement(data) {
return request('/announcements', { method: 'POST', body: data });
}
export function updateAnnouncement(id, data) {
return request('/announcements/' + id, { method: 'PUT', body: data });
}
export function deleteAnnouncement(id) {
return request('/announcements/' + id, { method: 'DELETE' });
}
+55
View File
@@ -0,0 +1,55 @@
import { request, setToken, notifyAuthChange } from './client.js';
import { applyCaptchaResult } from './captcha.js';
/** 登录;captcha 为 showCaptcha 的结果({type,value},可选):proof → captcha_proofrecaptcha/turnstile → 对应 token */
export function login(username, password, captcha) {
const body = { username, password };
applyCaptchaResult(body, captcha);
return request('/auth/login', { method: 'POST', body });
}
/** 注册;data = { username, password, email, captcha? }captcha 同 login */
export function register(data) {
const body = { username: data.username, password: data.password, email: data.email };
applyCaptchaResult(body, data.captcha);
return request('/auth/register', { method: 'POST', body });
}
/** 获取当前登录用户信息(需登录) */
export function me() {
return request('/auth/me');
}
// ── RainID 单点登录(OIDC) ──────────────────────────────
/** 发起授权码登录:整页跳转到后端 /api/auth/oidc/login(后端 302 至 RainID */
export function oidcLoginRedirect() {
window.location.href = '/api/auth/oidc/login';
}
/** RainID 回跳:用一次性过渡码(30s)换取会话 token → {token, username, role} */
export function oidcExchange(ticket) {
return request('/auth/oidc/exchange', { method: 'POST', body: { ticket } });
}
/** 退出登录:后端无对应接口,仅清除本地 token 并通知登录态变更 */
export function logout() {
setToken(null);
notifyAuthChange();
}
// ── 管理员用户管理 ──────────────────────────────
export function registerByAdmin(username, password, role) {
return request('/auth/register-by-admin', { method: 'POST', body: { username, password, role } });
}
export function listUsers() {
return request('/auth/users');
}
export function deleteUser(id) {
return request('/auth/users/' + id, { method: 'DELETE' });
}
export function resetUserPassword(id, newPassword) {
return request('/auth/users/' + id + '/password', { method: 'PUT', body: { newPassword } });
}
export function setUserRole(id, role) {
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
}
+68
View File
@@ -0,0 +1,68 @@
import { request } from './client.js';
/** 文章列表;all=true 返回全部(含草稿,仅管理员) */
export function listPosts(all) {
return request('/blog/posts' + (all ? '?all=1' : ''));
}
export function getPost(id) {
return request('/blog/posts/' + id);
}
/** data = { title, content, excerpt?, published?, use_markdown? }(仅管理员) */
export function createPost(data) {
return request('/blog/posts', { method: 'POST', body: data });
}
export function updatePost(id, data) {
return request('/blog/posts/' + id, { method: 'PUT', body: data });
}
export function deletePost(id) {
return request('/blog/posts/' + id, { method: 'DELETE' });
}
// ── 搜索 / 标签 / 归档 / 上下篇 / 点赞(博客增强 B2)──────────
export function searchPosts(q) {
return request('/blog/search?q=' + encodeURIComponent(q || ''));
}
export function getTags() {
return request('/blog/tags');
}
export function getTagPosts(name) {
return request('/blog/tag/' + encodeURIComponent(name));
}
export function getArchive() {
return request('/blog/archive');
}
export function getPrevNext(id) {
return request('/blog/posts/' + id + '/prevnext');
}
export function getLikeState(id) {
return request('/blog/posts/' + id + '/like');
}
export function likePost(id) {
return request('/blog/posts/' + id + '/like', { method: 'POST' });
}
export function unlikePost(id) {
return request('/blog/posts/' + id + '/like', { method: 'DELETE' });
}
// ── 评论 ────────────────────────────────────────
export function listComments(postId) {
return request('/blog/comments/' + postId);
}
/** content 评论正文;parentId>0 表示回复某条评论(嵌套) */
export function createComment(postId, content, parentId = 0) {
return request('/blog/comments/' + postId, { method: 'POST', body: { content, parent_id: parentId || 0 } });
}
export function deleteComment(id) {
return request('/blog/comments/' + id, { method: 'DELETE' });
}
// ── 评论审核(管理员)──────────────────────────
export function listPendingComments() {
return request('/blog/comments/pending');
}
export function approveComment(id) {
return request('/blog/comments/' + id + '/approve', { method: 'POST' });
}
export function rejectComment(id) {
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
}
+38
View File
@@ -0,0 +1,38 @@
import { request } from './client.js';
/** 获取内置 SVG 验证码:返回 { token, svg, expires_in } */
export function getImage() {
return request('/captcha/image');
}
/** 校验答案:返回 { success, proof?, error? },成功时 proof 为一次性验证码证明令牌 */
export function verify(token, answer) {
return request('/captcha/verify', { method: 'POST', body: { token, answer } });
}
/** 检查某个动作(login/register/forum)是否需要验证码:返回 { required, type } */
export function required(action) {
return request('/captcha/required', { method: 'POST', body: { action } });
}
// ── Proof of Work(智能验证,内置验证码的防刷前置) ──
export function powChallenge() {
return request('/captcha/pow-challenge');
}
export function powVerify(token, nonce) {
return request('/captcha/pow-verify', { method: 'POST', body: { token, nonce: String(nonce) } });
}
/**
* 将 showCaptcha 的结果按 type 写入请求体:
* - proof → captcha_proof(内置验证码一次性证明令牌)
* - recaptcha → recaptcha_tokenreCAPTCHA siteverify token
* - turnstile → turnstile_tokenTurnstile siteverify token
*/
export function applyCaptchaResult(body, result) {
if (!body || !result || !result.type || !result.value) return body;
if (result.type === 'proof') body.captcha_proof = result.value;
else if (result.type === 'recaptcha') body.recaptcha_token = result.value;
else if (result.type === 'turnstile') body.turnstile_token = result.value;
return body;
}
+23
View File
@@ -0,0 +1,23 @@
const TOKEN_KEY = 'token';
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(t) {
t ? localStorage.setItem(TOKEN_KEY, t) : localStorage.removeItem(TOKEN_KEY);
}
/** 登录态变更通知:登录/退出/头像更新后触发,Layout 监听后重新拉取当前用户 */
export function notifyAuthChange() {
window.dispatchEvent(new Event('authchange'));
}
export async function request(path, { method = 'GET', body, auth = true } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (auth && getToken()) headers.Authorization = 'Bearer ' + getToken();
const res = await fetch('/api' + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({}));
if (!res.ok) { const err = new Error(data.error || '请求失败'); err.status = res.status; throw err; }
return data;
}
+16
View File
@@ -0,0 +1,16 @@
import { request } from './client.js';
/** 重新发送注册邮箱验证码(8 位数字,存入 pending_users */
export function sendVerify(email, username) {
return request('/email/send-verify', { method: 'POST', body: { email, username } });
}
/** 输入 8 位验证码完成注册:{ code, username, password } */
export function completeRegister(code, username, password) {
return request('/email/complete-register', { method: 'POST', body: { code, username, password } });
}
/** 发送 SMTP 测试邮件(仅管理员) */
export function testSmtp(email) {
return request('/email/test', { method: 'POST', body: { email } });
}
+37
View File
@@ -0,0 +1,37 @@
import { request } from './client.js';
// ── 分类 ────────────────────────────────────────
export function listCategories() {
return request('/forum/categories');
}
export function createCategory(data) {
return request('/forum/categories', { method: 'POST', body: data });
}
export function updateCategory(id, data) {
return request('/forum/categories/' + id, { method: 'PUT', body: data });
}
export function deleteCategory(id) {
return request('/forum/categories/' + id, { method: 'DELETE' });
}
// ── 帖子 ────────────────────────────────────────
/** 帖子列表;categoryId 可选 */
export function listPosts(categoryId) {
return request('/forum/posts' + (categoryId ? '?category_id=' + encodeURIComponent(categoryId) : ''));
}
export function getPost(id) {
return request('/forum/posts/' + id);
}
/** data = { category_id, title, content, use_markdown?, sub_category?, captcha_proof? }(需登录) */
export function createPost(data) {
return request('/forum/posts', { method: 'POST', body: data });
}
export function reply(postId, content) {
return request('/forum/posts/' + postId + '/replies', { method: 'POST', body: { content } });
}
export function deletePost(id) {
return request('/forum/posts/' + id, { method: 'DELETE' });
}
export function deleteReply(id) {
return request('/forum/replies/' + id, { method: 'DELETE' });
}
+23
View File
@@ -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' });
}
+30
View File
@@ -0,0 +1,30 @@
import { request } from './client.js';
// ── PIN / 解锁 ──────────────────────────────────
export function pinStatus() {
return request('/passwords/pin-status');
}
export function setPin(pin) {
return request('/passwords/set-pin', { method: 'POST', body: { pin } });
}
export function unlock(pin) {
return request('/passwords/unlock', { method: 'POST', body: { pin } });
}
export function lock() {
return request('/passwords/lock', { method: 'POST' });
}
// ── 密码条目(需已解锁) ───────────────────────
export function listEntries() {
return request('/passwords');
}
/** data = { title, username?, password, url?, notes? } */
export function createEntry(data) {
return request('/passwords', { method: 'POST', body: data });
}
export function updateEntry(id, data) {
return request('/passwords/' + id, { method: 'PUT', body: data });
}
export function deleteEntry(id) {
return request('/passwords/' + id, { method: 'DELETE' });
}
+21
View File
@@ -0,0 +1,21 @@
import { request } from './client.js';
/** 获取当前用户资料(需登录) */
export function getProfile() {
return request('/profile');
}
/** 更新资料(需登录);目前仅支持 avatar = /uploads/avatars/xxx 站内路径 */
export function updateProfile(data) {
return request('/profile', { method: 'PUT', body: data });
}
/** 发送修改密码的邮箱验证码(需登录) */
export function sendPwCode() {
return request('/profile/send-pw-code', { method: 'POST' });
}
/** 校验验证码并修改密码:{ code, oldPassword, newPassword } */
export function changePassword(data) {
return request('/profile/password', { method: 'PUT', body: data });
}
+16
View File
@@ -0,0 +1,16 @@
import { request } from './client.js';
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
export function getPublicSettings() {
return request('/settings/public');
}
/** 全量设置(仅管理员) */
export function getSettings() {
return request('/settings');
}
/** 保存设置(仅管理员) */
export function saveSettings(data) {
return request('/settings', { method: 'PUT', body: data });
}
+19
View File
@@ -0,0 +1,19 @@
import { request } from './client.js';
// ── Web 终端(安全敏感:root shellPIN + 短 TTL token 双认证) ──
// 后端契约见 routes/terminal.js
// GET /api/terminal/status → { hasPin, sessions, maxSessions, idleTimeoutMs }
// POST /api/terminal/auth → { token, expiresIn, setup?, needs_setup?, message? }
/** 检查终端专用密码是否已设置 + 当前会话数 */
export function terminalStatus() {
return request('/terminal/status');
}
/**
* 设置(首次)/校验终端密码,成功发放 5 分钟短 TTL 终端 token。
* 失败抛错:400 密码不合法 / 401 密码错误 / 429 尝试次数过多
*/
export function terminalAuth(pin) {
return request('/terminal/auth', { method: 'POST', body: { pin } });
}
+58
View File
@@ -0,0 +1,58 @@
import { request, getToken } from './client.js';
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
async function upload(path, file, extra) {
const fd = new FormData();
fd.append('file', file);
if (extra) {
for (const [k, v] of Object.entries(extra)) {
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
}
}
const headers = {};
const token = getToken();
if (token) headers.Authorization = 'Bearer ' + token;
const res = await fetch('/api/upload' + path, { method: 'POST', headers, body: fd });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = new Error(data.error || '上传失败');
err.status = res.status;
throw err;
}
return data;
}
/** 普通文件上传;opts 可选 { ref_type, ref_id }。返回 { id, url, tag, ... }tag 为 [image:]/[file:] 插入文本 */
export function uploadFile(file, opts) {
return upload('/file', file, opts);
}
/** 头像上传,成功后直接更新用户 avatar,返回 { url } */
export function uploadAvatar(file) {
return upload('/avatar', file);
}
/** 壁纸上传,返回 { url, filename } */
export function uploadWallpaper(file) {
return upload('/wallpaper', file);
}
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
export function avatarUrl(uid) {
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
}
/** 附件列表;可按 ref_type + ref_id 过滤 */
export function listAttachments(refType, refId) {
const q = [];
if (refType && refId) {
q.push('ref_type=' + encodeURIComponent(refType), 'ref_id=' + encodeURIComponent(refId));
}
return request('/upload/list' + (q.length ? '?' + q.join('&') : ''));
}
/** 删除附件(本人或管理员) */
export function deleteAttachment(id) {
return request('/upload/' + id, { method: 'DELETE' });
}
+73
View File
@@ -0,0 +1,73 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { listPosts } from '../api/blog.js';
/**
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
* 头像 / 简介 / 联系方式来自站点设置(/api/settings/public);
* showRecent=true 时附加"最新文章"卡片(首页用)。
* settings 由父组件从 /api/settings/public 取得后传入,避免重复请求。
*/
export default function BlogSidebar({ settings = {}, showRecent = false, forceShow = false }) {
const [recent, setRecent] = useState(null); // null=加载中
const [recentError, setRecentError] = useState(false);
useEffect(() => {
if (!showRecent) return;
setRecent(null);
setRecentError(false);
listPosts()
.then((posts) => setRecent((posts || []).slice(0, 8)))
.catch(() => { setRecentError(true); setRecent([]); });
}, [showRecent]);
// blog_show_sidebar=0 时整栏隐藏(仅博客页;首页 forceShow 强制显示)
if (!forceShow && settings.blog_show_sidebar === '0') return null;
let contacts = [];
if (settings.homepage_contacts) {
try { contacts = JSON.parse(settings.homepage_contacts); } catch { contacts = []; }
}
return (
<aside className="homepage-sidebar" id="blogSidebar">
<div className="card" style={{ textAlign: 'center' }}>
<div className="homepage-avatar">
{settings.homepage_avatar
? <img src={settings.homepage_avatar} alt="avatar" />
: <span className="material-icons" style={{ fontSize: 64, color: 'var(--md-ref-on-surface-variant)' }}>person</span>}
</div>
<div className="homepage-bio">{settings.homepage_bio || ''}</div>
{contacts.length > 0 && (
<div className="homepage-contacts">
{contacts.map((l, i) => (
<a key={i} href={l.url} target="_blank" rel="noopener" className="hp-contact-link" title={l.title || ''}>
<span className="material-icons">{l.icon || 'link'}</span>
</a>
))}
</div>
)}
</div>
{showRecent && (
<div className="card">
<div className="homepage-recent-header">最新文章</div>
<div className="homepage-recent-list">
{recentError
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载失败</div>
: recent === null
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>加载中...</div>
: recent.length === 0
? <div className="text-muted" style={{ fontSize: 13, textAlign: 'center', padding: '8px 0' }}>暂无文章</div>
: recent.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="homepage-recent-item">
<span className="homepage-recent-title">{p.title}</span>
<span className="homepage-recent-date">{(p.created_at || '').slice(0, 10)}</span>
</Link>
))}
</div>
</div>
)}
</aside>
);
}
+312
View File
@@ -0,0 +1,312 @@
import React, { useEffect, useRef, useState } from 'react';
import * as captchaApi from '../api/captcha.js';
import { useDialog } from '../lib/utils.js';
// ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ──
let pending = null; // { action, type, resolve }
let listener = null;
function notifyHost() {
if (listener) listener();
}
/**
* 验证码流程入口(迁移自 public/js/captcha.js):
* - null 无需验证 / 用户取消
* - object { type: 'proof' | 'recaptcha' | 'turnstile', value }
* proof → 内置验证码通过,value 为后端签发的 captcha_proof
* recaptcha → reCAPTCHA 验证通过,value 为 siteverify token
* turnstile → Turnstile 验证通过,value 为 siteverify token
* 调用方提交时按 type 放入 captcha_proof / recaptcha_token / turnstile_token 字段。
*/
export function showCaptcha(action) {
return new Promise((resolve) => {
captchaApi.required(action)
.then((r) => {
if (!r || !r.required || r.type === 'none') { resolve(null); return; }
pending = { action, type: r.type || 'builtin', resolve };
notifyHost();
})
.catch(() => resolve(null));
});
}
// 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用)
function flushCallbacks(type) {
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
const q = window[key] || [];
window[key] = [];
q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } });
}
// 加载第三方验证码脚本(recaptcha / turnstile
// 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪,
// 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。
function ensureThirdPartyScript(type) {
if (type === 'recaptcha') {
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
if (!window.onRecaptchaLoad) {
window.onRecaptchaLoad = () => flushCallbacks('recaptcha');
}
if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) {
const s = document.createElement('script');
s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit';
s.async = true; s.defer = true;
document.head.appendChild(s);
}
} else {
window.turnstileCallbacks = window.turnstileCallbacks || [];
if (!window.onTurnstileLoad) {
window.onTurnstileLoad = () => flushCallbacks('turnstile');
}
if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) {
const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit';
s.async = true; s.defer = true;
document.head.appendChild(s);
}
}
}
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
function queueRender(type, render) {
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
if (typeof window[lib] !== 'undefined') { render(); return; }
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
window[key] = window[key] || [];
window[key].push(render);
ensureThirdPartyScript(type);
setTimeout(() => {
if (typeof window[lib] !== 'undefined') flushCallbacks(type);
}, 300);
}
// SHA-256Proof of Work 使用)
async function sha256(str) {
const buf = new TextEncoder().encode(str);
const hash = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
// 内置 SVG 验证码弹窗
function BuiltinCaptcha({ onFinish }) {
const [svg, setSvg] = useState('');
const [error, setError] = useState('');
const [answer, setAnswer] = useState('');
const [powDone, setPowDone] = useState(false);
const [verifying, setVerifying] = useState(false);
const tokenRef = useRef(null);
const imgRef = useRef(null);
const loadImage = async () => {
setError('');
setAnswer('');
const data = await captchaApi.getImage();
if (data && data.token) {
tokenRef.current = data.token;
setSvg(data.svg);
}
};
useEffect(() => {
loadImage();
// 智能验证(Proof of Work)后台运行,20s 内找到满足难度的 nonce
(async () => {
try {
const chal = await captchaApi.powChallenge();
if (!chal || !chal.token) return;
const target = '0'.repeat(chal.difficulty);
const start = Date.now();
let nonce = 0;
while (Date.now() - start < 20000) {
const hash = await sha256(chal.prefix + nonce);
if (hash.startsWith(target)) {
await captchaApi.powVerify(chal.token, String(nonce));
setPowDone(true);
return;
}
nonce++;
}
} catch { /* 失败不影响主流程 */ }
})();
}, []);
// 让后端返回的 SVG 自适应弹窗宽度(照搬 captcha.js 的样式处理)
useEffect(() => {
if (svg && imgRef.current) {
const s = imgRef.current.querySelector('svg');
if (s) s.setAttribute('style', 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block');
}
}, [svg]);
const submit = async () => {
if (!powDone) { setError('智能验证尚未完成,请稍候...'); return; }
if (!answer.trim() || !tokenRef.current) { setError('请输入验证码'); return; }
setVerifying(true);
const v = await captchaApi.verify(tokenRef.current, answer.trim());
setVerifying(false);
if (v.success) {
onFinish({ type: 'proof', value: v.proof || '' });
} else {
setError(v.error || '验证码错误');
tokenRef.current = null;
setAnswer('');
loadImage();
}
};
return (
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
<h3 style={{ marginBottom: 12 }}>验证码</h3>
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
{svg
? <div dangerouslySetInnerHTML={{ __html: svg }} />
: <div className="spinner" style={{ width: 24, height: 24, margin: '16px auto' }} />}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}>
<input
type="text"
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
placeholder="输入验证码"
aria-label="输入验证码"
maxLength={6}
autoComplete="off"
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
/>
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
<span className="material-icons">refresh</span>
</button>
</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)' }}>
{powDone ? (
<>
<span className="material-icons" style={{ fontSize: 16, color: '#4caf50' }}>check_circle</span>
<span style={{ color: '#4caf50' }}>智能验证通过</span>
</>
) : (
<>
<span className="material-icons" style={{ fontSize: 16 }}>smart_toy</span>
<span>智能验证中...</span>
</>
)}
</div>
<div className="actions" style={{ justifyContent: 'center', marginTop: 16 }}>
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
<button type="button" className="btn btn-filled" onClick={submit} disabled={verifying}>确认</button>
</div>
</div>
);
}
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
function ThirdPartyCaptcha({ type, onFinish }) {
const siteKey = type === 'recaptcha'
? (window._recaptchaSiteKey || '')
: (window._turnstileSiteKey || '');
const [status, setStatus] = useState('正在加载...');
const widgetRef = useRef(null);
const doneRef = useRef(false);
useEffect(() => {
if (!siteKey) { onFinish(null); return; }
const container = widgetRef.current;
const render = () => {
if (doneRef.current) return; // 弹窗已取消/卸载,不再向已移除的容器渲染
try {
if (type === 'recaptcha') {
const wid = window.grecaptcha.render(container, {
sitekey: siteKey,
callback: () => {
setStatus('验证通过');
let token = '';
try { token = window.grecaptcha.getResponse(wid); } catch { /* 忽略 */ }
setTimeout(() => onFinish({ type: 'recaptcha', value: token || '' }), 300);
},
'expired-callback': () => setStatus('验证已过期'),
});
} else {
const wid = window.turnstile.render(container, {
sitekey: siteKey,
callback: () => {
setStatus('验证通过');
let token = '';
try { token = window.turnstile.getResponse(wid); } catch { /* 忽略 */ }
setTimeout(() => onFinish({ type: 'turnstile', value: token || '' }), 300);
},
'expired-callback': () => setStatus('验证已过期'),
});
}
setStatus('请完成验证');
} catch {
setStatus('加载失败');
setTimeout(() => onFinish(null), 2000);
}
};
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
queueRender(type, render);
// 卸载时不触发重复回调
return () => { doneRef.current = true; };
}, []);
return (
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
<div ref={widgetRef} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }} />
<p className="text-muted" style={{ fontSize: 13 }}>{status}</p>
<div className="actions" style={{ justifyContent: 'center' }}>
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
</div>
</div>
);
}
/**
* 全局挂载的验证码弹窗宿主:放入 Layout 内,配合 showCaptcha(action) 使用。
*/
export default function CaptchaModalHost() {
const [request, setRequest] = useState(null);
useEffect(() => {
listener = () => setRequest(pending);
return () => { listener = null; };
}, []);
const finish = (proof) => {
if (request) {
const resolve = request.resolve;
pending = null;
setRequest(null);
resolve(proof);
}
};
// 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素
const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null));
if (!request) return null;
return (
<div
ref={dialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="验证码"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
onKeyDown={onKeyDown}
>
{request.type === 'recaptcha' ? (
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
) : request.type === 'turnstile' ? (
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
) : (
<BuiltinCaptcha onFinish={finish} />
)}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import React from 'react';
import { Link } from 'react-router-dom';
/**
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
* 纯文本(不含 `<`)按原样文本渲染;含 `<` 时按 HTML+CSS 渲染(self-XSS 与音乐嵌入同级)。
* 仅用于这三个字段,不扩大到其他数据。
*/
function HtmlOrText({ value, className }) {
if (typeof value === 'string' && value.includes('<')) {
return <span className={className} dangerouslySetInnerHTML={{ __html: value }} />;
}
return <span className={className}>{value}</span>;
}
/**
* 前台页脚(按 settings.footer_style 渲染 3 种样式,视觉对齐 /tmp/footer-demo.html 的 02/03/04):
* - classic 经典左右:左 Powered + 版权,右 站点名 v{version} 胶囊徽章
* - columns 分栏导航:品牌 + 固定导航栏 + 底部版权条
* - glass 玻璃卡片:磨砂玻璃卡片(backdrop-filter blur + 半透明底 + 细边框)
* 所有样式都保留站点名 + v{version} 元素;footer_desc 为空时回退到 site_description。
*/
export default function Footer({ settings = {}, version = '' }) {
const siteName = settings.site_name || 'RainWeb';
const style = settings.footer_style || 'classic';
const copyright = settings.footer_copyright || '© 2026 Rainnya Blog. All rights reserved.';
const powered = settings.footer_powered || 'Powered by RainnyaWeb';
const desc = settings.footer_desc || settings.site_description || '';
if (style === 'columns') {
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
const navCols = [
{ title: '导航', links: [
{ to: '/', label: '首页' },
{ to: '/blog.html', label: '博客' },
{ to: '/forum.html', label: '论坛' },
] },
{ title: '其他', links: [
{ to: '/admin', label: '管理后台', external: true },
{ to: '/profile.html', label: '个人中心' },
] },
];
return (
<footer className="footer-columns">
<div className="fc-grid">
<div className="fc-brand">
<div className="brand-row">
<div className="brand-mark">{siteName.charAt(0)}</div>
<div className="brand-name">{siteName}</div>
{version && <span className="brand-version">v{version}</span>}
</div>
{desc && <HtmlOrText value={desc} className="brand-desc" />}
</div>
{navCols.map((col) => (
<nav key={col.title} className="fc-col">
<div className="col-title">{col.title}</div>
<div className="col-links">
{col.links.map((l) =>
l.external ? (
<a key={l.to} href={l.to}>{l.label}</a>
) : (
<Link key={l.to} to={l.to}>{l.label}</Link>
)
)}
</div>
</nav>
))}
</div>
<div className="fc-bottom">
<HtmlOrText value={powered} />
<span className="sep" />
<HtmlOrText value={copyright} />
</div>
</footer>
);
}
if (style === 'glass') {
return (
<div className="footer-glass-wrap">
<span className="blob blob-1"></span>
<span className="blob blob-2"></span>
<span className="blob blob-3"></span>
<footer className="footer-glass">
<div className="fg-left">
<div className="fg-logo">{siteName.charAt(0)}</div>
<div>
<div className="fg-name">{siteName}</div>
<div className="fg-tag">
{desc && <HtmlOrText value={desc} />}
{desc && <span> · </span>}v{version}
</div>
</div>
</div>
<div className="fg-right">
<HtmlOrText value={copyright} className="fg-copyright" />
<HtmlOrText value={powered} className="fg-powered" />
</div>
</footer>
</div>
);
}
// classic(默认):经典左右
return (
<footer className="footer-classic">
<div className="fc-left">
<HtmlOrText value={powered} />
<span className="sep" />
<HtmlOrText value={copyright} />
</div>
<div className="fc-right">
<span>{siteName}</span>
{version && <span className="fc-version">v{version}</span>}
</div>
</footer>
);
}
+209
View File
@@ -0,0 +1,209 @@
import React, { useEffect, useState } from 'react';
import { Link, NavLink, Outlet, useLocation } from 'react-router-dom';
import { useTheme } from '../theme.jsx';
import * as authApi from '../api/auth.js';
import * as settingsApi from '../api/settings.js';
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js';
import MusicEmbed from './MusicEmbed.jsx';
import CaptchaModalHost from './CaptchaModal.jsx';
import Footer from './Footer.jsx';
/** QQ 邮箱自动头像(迁移自 nav.js) */
function qqAvatar(user) {
const m = user && user.email ? String(user.email).match(/^(\d+)@qq\.com$/i) : null;
return m ? 'https://q1.qlogo.cn/g?b=qq&nk=' + m[1] + '&s=100' : '';
}
export default function Layout() {
const { theme, toggleTheme, setTheme } = useTheme();
const location = useLocation();
const [settings, setSettings] = useState({});
const [version, setVersion] = useState('');
const [user, setUser] = useState(null);
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js
useEffect(() => {
fetch('/api/setup/status')
.then((r) => r.json())
.then((s) => {
if (s && !s.setup_complete && location.pathname !== '/setup.html') {
window.location.href = '/setup.html';
}
})
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 加载站点设置 / 版本号 / 登录态(监听 authchange 事件,登录/退出/头像更新后刷新)
useEffect(() => {
settingsApi.getPublicSettings()
.then((s) => {
setSettings(s);
// 供第三方验证码(reCAPTCHA/Turnstile)读取(照搬 nav.js
window._recaptchaSiteKey = s.recaptcha_site_key || '';
window._turnstileSiteKey = s.turnstile_site_key || '';
})
.catch(() => {});
fetch('/api/version')
.then((r) => r.json())
.then((v) => { if (v && v.version) setVersion(v.version); })
.catch(() => {});
const loadUser = () => {
if (getToken()) {
authApi.me().then(setUser).catch(() => { setToken(null); setUser(null); });
} else {
setUser(null);
}
};
loadUser();
window.addEventListener('authchange', loadUser);
return () => window.removeEventListener('authchange', loadUser);
}, []);
// 强制深色模式(迁移自 nav.js applyTheme
useEffect(() => {
if (settings.theme_force_dark === '1') {
setTheme('dark');
document.documentElement.setAttribute('data-theme', 'dark');
}
}, [settings.theme_force_dark, setTheme]);
// 壁纸背景 + 玻璃磨砂(迁移自 nav.js applyTheme,直接作用于 body
useEffect(() => {
const body = document.body;
const wallpaper = settings.theme_wallpaper || '';
const scale = settings.theme_wallpaper_scale || 'cover';
if (wallpaper && settings.theme_wallpaper_enabled !== '0') {
body.classList.add('has-wallpaper');
body.style.setProperty('--wallpaper', `url(${wallpaper})`);
const bgSizeMap = { cover: 'cover', contain: 'contain', repeat: 'auto', stretch: '100% 100%' };
const bgRepeatMap = { repeat: 'repeat', stretch: 'no-repeat', cover: 'no-repeat', contain: 'no-repeat' };
body.style.backgroundSize = bgSizeMap[scale] || 'cover';
body.style.backgroundRepeat = bgRepeatMap[scale] || 'no-repeat';
document.documentElement.style.setProperty('--wallpaper-overlay',
theme === 'dark' ? 'rgba(0,0,0,0.35)' : 'rgba(255,255,255,0.15)');
document.documentElement.style.setProperty('--wallpaper-text',
theme === 'dark' ? '#e6e1e5' : '#1c1b1f');
} else {
body.classList.remove('has-wallpaper');
body.style.removeProperty('--wallpaper');
body.style.backgroundSize = '';
body.style.backgroundRepeat = '';
document.documentElement.style.removeProperty('--wallpaper-overlay');
document.documentElement.style.removeProperty('--wallpaper-text');
}
document.documentElement.style.setProperty('--glass-blur', (settings.glass_blur || '20') + 'px');
}, [settings, theme]);
// 卡片玻璃样式(导航/路由切换后对卡片类名统一应用)
useEffect(() => {
const cs = settings.card_style || 'default';
document.querySelectorAll('.card, .blog-card, .panel-card, .link-card, .forum-post-card, .password-card, .forum-cat-item, .chip').forEach((el) => {
el.classList.toggle('glass-card', cs === 'glass');
});
}, [settings.card_style, location.pathname]);
const handleToggleTheme = () => {
if (settings.theme_force_dark === '1') {
showSnackbar('已强制启用深色模式无法更改');
return;
}
toggleTheme();
};
const handleLogout = () => {
setToken(null);
setUser(null);
notifyAuthChange();
if (settings.rainid_enabled === '1') {
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
window.location.href = '/api/auth/oidc/logout';
return;
}
showSnackbar('已退出登录');
};
const siteName = settings.site_name || 'RainWeb';
const isAdmin = user && user.role === 'admin';
const navClass = 'nav-bar'
+ (settings.nav_style === 'glass' ? ' nav-glass' : '')
+ (settings.nav_style === 'capsule' ? ' nav-capsule' : '');
// 导航标签(迁移自 nav.js 的显隐逻辑)
const tabs = [
{ to: '/', label: '首页', end: true, show: true },
{ to: '/blog.html', label: '博客', show: true },
{ to: '/forum.html', label: '论坛', show: !!user },
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
];
let avatarUrl = user ? (user.avatar || '') : '';
if (user && !avatarUrl) avatarUrl = qqAvatar(user);
return (
<>
<nav className={navClass} id="mainNav">
<div className="nav-left">
<Link to="/" className="nav-brand">{siteName}</Link>
<span className="nav-version">v{version}</span>
<div className="nav-tabs">
{tabs.filter((t) => t.show).map((t) =>
t.fullPage ? (
<a key={t.to} href={t.to} className="nav-tab">{t.label}</a>
) : (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) => 'nav-tab' + (isActive ? ' active' : '')}
>
{t.label}
</NavLink>
)
)}
</div>
</div>
<div className="nav-right">
<button
className="btn-icon"
onClick={handleToggleTheme}
aria-label="切换主题"
title={settings.theme_force_dark === '1' ? '已强制深色模式' : '切换主题'}
>
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
</button>
{user ? (
<>
<Link to="/profile.html" className="btn btn-tonal btn-sm" title="个人中心">
{avatarUrl
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover', marginRight: 4 }} />
: <span className="material-icons" style={{ fontSize: 18, marginRight: 2 }}>person</span>}
{user.username}
</Link>
<button className="btn btn-text btn-sm" onClick={handleLogout}>退出</button>
</>
) : (
<>
<Link to="/login.html" className="btn btn-tonal btn-sm">登录</Link>
<Link to="/register.html" className="btn btn-filled btn-sm">注册</Link>
</>
)}
</div>
</nav>
<main className="page">
<Outlet />
</main>
<Footer settings={settings} version={version} />
<MusicEmbed settings={settings} />
<CaptchaModalHost />
</>
);
}
@@ -0,0 +1,58 @@
import React, { useMemo } from 'react';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { escapeHtml } from '../lib/utils.js';
import { getToken } from '../api/client.js';
/** [image:文件名] → /uploads/ 图片 */
function imageTag(filename) {
if (!filename) return '';
return `<img src="/uploads/${encodeURIComponent(filename)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
}
/** [file:文件名] → 带 token 的下载链接 */
function fileTag(filename) {
if (!filename) return '';
const token = getToken() || '';
return `<a href="/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}" target="_blank" class="file-link" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--md-ref-surface-container);border-radius:8px;margin:4px 0;text-decoration:none;color:var(--md-ref-primary);font-size:14px">
<span class="material-icons" style="font-size:18px">attachment</span> ${escapeHtml(filename)}</a>`;
}
/**
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
*/
export function renderContent(content, useMarkdown) {
if (content == null) return '';
if (!useMarkdown) {
let html = escapeHtml(content);
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f));
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f));
html = html.replace(/\n/g, '<br>');
return DOMPurify.sanitize(html);
}
// Markdown 模式:先抽取自定义标签,避免被 marked 转义
const images = [];
const files = [];
let html = content.replace(/\[image:([^\]]+)\]/g, (m, f) => { images.push(f); return `\x00IMG${images.length - 1}\x00`; });
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => { files.push(f); return `\x00FILE${files.length - 1}\x00`; });
html = marked.parse(html, { breaks: true, gfm: true });
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 = 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 }) {
const html = useMemo(() => renderContent(content, useMarkdown), [content, useMarkdown]);
return <div className="md-body" dangerouslySetInnerHTML={{ __html: html }} />;
}
+57
View File
@@ -0,0 +1,57 @@
import React, { useEffect, useRef, useState } from 'react';
/**
* 音乐嵌入(迁移自 public/js/music-embed.js):
* 读取 settings 的 music_embed_enabled / music_embed_code / music_embed_position /
* music_embed_autohide / music_embed_idle_timeout,支持自动隐藏与悬停展开。
* 全局组件,挂在 Layout 内。
*/
export default function MusicEmbed({ settings }) {
const [collapsed, setCollapsed] = useState(false);
const timerRef = useRef(null);
const enabled = settings.music_embed_enabled === '1';
const code = settings.music_embed_code || '';
const position = settings.music_embed_position || 'right';
const autoHide = settings.music_embed_autohide === '1';
const idleTimeout = (parseInt(settings.music_embed_idle_timeout, 10) || 10) * 1000;
const clearTimer = () => {
if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; }
};
const expand = () => {
clearTimer();
setCollapsed(false);
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
};
const startLeaveTimer = () => {
if (!autoHide) return;
clearTimer();
timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
};
useEffect(() => {
if (!enabled || !code) return;
setCollapsed(false);
if (autoHide) timerRef.current = setTimeout(() => setCollapsed(true), idleTimeout);
return clearTimer;
}, [enabled, code, autoHide, idleTimeout]);
if (!enabled || !code) return null;
return (
<div
className={`music-embed ${position} ${collapsed ? 'collapsed' : 'expanded'}`}
onMouseEnter={expand}
onMouseLeave={startLeaveTimer}
>
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
<span className="material-icons">music_note</span>
</button>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useCallback, useEffect, useRef } from 'react';
/** HTML 转义:& < > " ' 全转义 */
export function escapeHtml(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
switch (ch) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case "'": return '&#39;';
default: return ch;
}
});
}
/** 格式化时间为 YYYY-MM-DD HH:mm */
export function formatDate(input) {
const d = input instanceof Date ? input : new Date(input);
if (isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** 简单节流:fn 在 wait 毫秒内最多执行一次 */
export function throttle(fn, wait = 200) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
return fn.apply(this, args);
}
};
}
/** 底部 toast(依赖 index.html 中的 #snackbar,类名沿用 v1 */
export function showSnackbar(msg) {
const el = document.getElementById('snackbar');
if (!el) return;
el.textContent = msg;
el.classList.remove('hide');
el.classList.add('show');
clearTimeout(el._timer);
el._timer = setTimeout(() => {
el.classList.add('hide');
setTimeout(() => el.classList.remove('show', 'hide'), 300);
}, 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 };
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
createRoot(document.getElementById('root')).render(
<BrowserRouter>
<App />
</BrowserRouter>
);
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { getArchive, listPosts } from '../api/blog.js';
/** 归档页(路由 /archive.html):
* archive API 只返回 {month, count},无按月文章列表接口;
* 列表接口 /blog/posts 无分页返回全部已发布文章(文章量小),
* 前端按 created_at 客户端分组,配合 archive API 的月份与计数展示。
*/
export default function Archive() {
const [months, setMonths] = useState([]); // [{month, count, posts:[]}]
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
Promise.all([getArchive(), listPosts()])
.then(([arc, posts]) => {
const byMonth = new Map();
(posts || []).forEach((p) => {
const m = (p.created_at || '').slice(0, 7); // YYYY-MM
if (!m) return;
if (!byMonth.has(m)) byMonth.set(m, []);
byMonth.get(m).push(p);
});
// 以 archive API 的月份顺序为准(desc),count 以实际分组统计为准
const countMap = new Map();
(arc || []).forEach((a) => countMap.set(a.month, a.count));
const list = Array.from(byMonth.entries()).map(([month, ps]) => ({
month,
count: ps.length,
posts: ps,
}));
// 补齐 archive 里有、分组里没有的月份(理论上不会出现)
(arc || []).forEach((a) => {
if (!byMonth.has(a.month)) list.push({ month: a.month, count: a.count, posts: [] });
});
list.sort((a, b) => (a.month < b.month ? 1 : -1));
setMonths(list);
})
.catch((e) => setError(e.message || '加载失败'))
.finally(() => setLoading(false));
}, []);
const fmtMonth = (m) => {
const [y, mm] = m.split('-');
return `${y}${mm}`;
};
return (
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
</Link>
<h1 className="article-title" style={{ fontSize: 26 }}>
文章归档
{!loading && months.length > 0 && (
<span className="tag-title-count"> {months.reduce((s, m) => s + m.count, 0)} </span>
)}
</h1>
{loading && <div className="loading"><div className="spinner"></div></div>}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{!loading && !error && months.length === 0 && (
<div className="empty-state"><div className="empty-icon">🗂</div><p>暂无文章</p></div>
)}
<div className="archive-list">
{months.map((m) => (
<section key={m.month} className="archive-month">
<h2 className="archive-month-title">
{fmtMonth(m.month)}
<span className="archive-month-count">{m.count} </span>
</h2>
{m.posts.length === 0 ? (
<p className="text-muted" style={{ fontSize: 13 }}>暂无文章</p>
) : (
m.posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="archive-item">
<span className="archive-item-title">{p.title}</span>
<span className="archive-item-date">{(p.created_at || '').slice(0, 10)}</span>
</Link>
))
)}
</section>
))}
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import BlogSidebar from '../components/BlogSidebar.jsx';
import { listPosts, searchPosts, getTags } from '../api/blog.js';
import { getPublicSettings } from '../api/settings.js';
/** 摘要:优先取 excerpt,否则从内容中剥离 markdown 符号截取(照搬 blog.js */
function excerptOf(p) {
if (p.excerpt) return p.excerpt;
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
}
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
* 顶部搜索框(/api/blog/search+ 标签云(/api/blog/tags,点击进 /tag/:name+ 归档入口 */
export default function Blog() {
const [settings, setSettings] = useState({});
const [posts, setPosts] = useState(null); // null=加载中
const [error, setError] = useState('');
// 搜索状态
const [query, setQuery] = useState('');
const [results, setResults] = useState(null); // null=未搜索
const [searching, setSearching] = useState(false);
// 标签云
const [tags, setTags] = useState([]);
useEffect(() => {
getPublicSettings().then(setSettings).catch(() => {});
listPosts()
.then((ps) => setPosts(ps || []))
.catch((e) => setError(e.message || '加载失败'));
getTags()
.then((ts) => setTags(ts || []))
.catch(() => setTags([]));
}, []);
const doSearch = async (e) => {
e && e.preventDefault();
const q = query.trim();
if (!q) { setResults(null); return; }
setSearching(true);
try {
const rs = await searchPosts(q);
setResults(rs || []);
} catch (err) {
setResults([]);
setError(err.message || '搜索失败');
}
setSearching(false);
};
const clearSearch = () => {
setQuery('');
setResults(null);
setError('');
};
// 搜索结果以列表呈现(区别于瀑布流卡片)
const renderResults = () => (
<div className="search-results">
<div className="search-results-head">
<span>搜索结果{results.length}</span>
<button className="btn btn-text btn-sm" onClick={clearSearch}>
<span className="material-icons" style={{ fontSize: 16 }}>close</span> 清空
</button>
</div>
{results.length === 0 ? (
<div className="empty-state"><div className="empty-icon">🔍</div><p>没有找到相关内容</p></div>
) : (
<div className="search-results-list">
{results.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="search-result-item">
<div className="sri-title">{p.title}</div>
{p.excerpt && <div className="sri-excerpt">{p.excerpt}</div>}
<div className="sri-meta">
<span>{p.author_name || '管理员'}</span>
<span className="sep">·</span>
<span>{p.created_at}</span>
{p.tags && <span className="sri-tags">{p.tags}</span>}
</div>
</Link>
))}
</div>
)}
</div>
);
return (
<div className="homepage-layout blog-layout">
<h1 className="sr-only">博客</h1>
<BlogSidebar settings={settings} />
<div className="homepage-content">
{/* 工具条:搜索框 + 归档 */}
<form className="blog-toolbar" onSubmit={doSearch}>
<div className="search-box">
<span className="material-icons">search</span>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索文章标题 / 内容..."
aria-label="搜索文章"
/>
{query && (
<button type="button" className="search-clear" onClick={() => setQuery('')} aria-label="清空输入">
<span className="material-icons">close</span>
</button>
)}
</div>
<button type="submit" className="btn btn-tonal btn-sm" disabled={searching}>
{searching ? '搜索中...' : '搜索'}
</button>
<Link to="/archive.html" className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
</Link>
</form>
{/* 标签云 */}
{tags.length > 0 && (
<div className="tag-cloud card">
<div className="tag-cloud-title">
<span className="material-icons">local_offer</span> 标签
</div>
<div className="tag-cloud-body">
{tags.map((t) => (
<Link key={t.name} to={`/tag/${encodeURIComponent(t.name)}`} className="tag-chip" style={{ fontSize: tagSize(t.count) }}>
{t.name}
<span className="tag-count">{t.count}</span>
</Link>
))}
</div>
</div>
)}
{/* 搜索结果 / 瀑布流 */}
{results !== null ? (
renderResults()
) : (
<>
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{posts && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">📖</div><p>暂无文章</p></div>
)}
{posts && posts.length > 0 && (
<div className="blog-waterfall">
{posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
<div className="blog-featured"></div>
<div className="blog-title">{p.title}</div>
<div className="blog-excerpt">{excerptOf(p)}</div>
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
</div>
)}
</>
)}
</div>
</div>
);
}
/** 标签字号随文章数渐变(经典标签云效果):1 篇 ~12px,8 篇及以上 ~18px */
function tagSize(count) {
const c = Number(count) || 1;
const size = 12 + Math.min(c, 8) * 0.75;
return size.toFixed(1) + 'px';
}
+298
View File
@@ -0,0 +1,298 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import * as blogApi from '../api/blog.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import { showSnackbar } from '../lib/utils.js';
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
function countWords(content) {
const plain = String(content || '')
.replace(/\[image:[^\]]*\]|\[file:[^\]]*\]/g, '')
.replace(/[#*`\[\]()>|~_!-]/g, '');
const cjk = (plain.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
const words = plain.replace(/[\u4e00-\u9fff\u3400-\u4dbf]/g, ' ').trim().split(/\s+/).filter(Boolean).length;
return cjk + words;
}
/**
* 博客详情页(路由 /blog/:id):
* 正文(MarkdownRenderer+ 阅读量/标签/点赞 + 目录/字数 + 上一篇/下一篇 +
* 嵌套评论(parent_id 回复)。
*/
export default function BlogDetail() {
const { id } = useParams();
const [post, setPost] = useState(null);
const [comments, setComments] = useState([]);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [commentText, setCommentText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [prevnext, setPrevnext] = useState(null);
const [like, setLike] = useState({ liked: false, count: 0 });
const [toc, setToc] = useState([]);
const [replyTo, setReplyTo] = useState(null); // {id, name}
const load = useCallback(async () => {
setLoading(true);
setError('');
setReplyTo(null);
try {
const [p, cs, pn, lk] = await Promise.all([
blogApi.getPost(id),
blogApi.listComments(id),
blogApi.getPrevNext(id),
blogApi.getLikeState(id),
]);
setPost(p);
setComments(cs || []);
setPrevnext(pn);
setLike(lk || { liked: false, count: 0 });
} catch (e) {
setError(e.message || '加载失败');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
load();
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
} else {
setUser(null);
}
}, [load]);
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
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 toggleLike = async () => {
if (!user) { showSnackbar('登录后可以点赞'); return; }
const next = !like.liked;
setLike((s) => ({ liked: next, count: Math.max(0, s.count + (next ? 1 : -1)) }));
try {
const r = next ? await blogApi.likePost(id) : await blogApi.unlikePost(id);
setLike({ liked: r.liked, count: r.count });
} catch (e) {
setLike((s) => ({ liked: !s.liked, count: Math.max(0, s.count + (s.liked ? 1 : -1)) }));
showSnackbar(e.message);
}
};
// ── 嵌套评论 ──
const childrenOf = useCallback((pid) => comments.filter((c) => c.parent_id === pid), [comments]);
const rootComments = comments.filter((c) => !c.parent_id);
const renderComment = (c, depth = 0) => {
const kids = childrenOf(c.id);
return (
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
<div className="reply-meta">
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
</div>
<div className="reply-body">{c.content}</div>
{user && (
<button
className="btn btn-text btn-sm reply-btn"
onClick={() => {
setReplyTo({ id: c.id, name: c.author_name || '游客' });
setCommentText(`@${c.author_name || '游客'} `);
}}
>
<span className="material-icons" style={{ fontSize: 14 }}>reply</span> 回复
</button>
)}
{kids.length > 0 && (
<div className="reply-children">
{kids.map((k) => renderComment(k, depth + 1))}
</div>
)}
</div>
);
};
const submitComment = async () => {
const content = commentText.trim();
if (!content) { showSnackbar('评论不能为空'); return; }
setSubmitting(true);
try {
await blogApi.createComment(id, content, replyTo ? replyTo.id : 0);
showSnackbar('评论已发表');
setCommentText('');
setReplyTo(null);
await load();
} catch (e) {
showSnackbar(e.message);
}
setSubmitting(false);
};
if (loading) {
return <div className="loading"><div className="spinner"></div></div>;
}
if (error || !post) {
return (
<div className="empty-state">
<div className="empty-icon"></div>
<p>{error || '文章不存在'}</p>
<Link to="/blog.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回博客</Link>
</div>
);
}
return (
<div className="article-layout">
<div className="blog-article article-main">
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
</Link>
<h1 className="article-title">{post.title}</h1>
<div className="article-meta">
{post.author_name || '管理员'} · {post.created_at}
<span className="meta-stat" title="阅读量">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
</span>
<span className="meta-stat" title="字数"> {wordCount} </span>
</div>
{tags.length > 0 && (
<div className="article-tags">
{tags.map((t) => (
<Link key={t} to={`/tag/${encodeURIComponent(t)}`} className="chip article-tag-chip">{t}</Link>
))}
</div>
)}
<div className="article-actions">
<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="like-count">{like.count}</span>
</button>
{canEdit && (
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
)}
</div>
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
{/* 上一篇 / 下一篇 */}
{prevnext && (prevnext.prev || prevnext.next) && (
<nav className="prevnext-nav">
<div className="pn-col pn-prev">
{prevnext.prev ? (
<Link to={`/blog/${prevnext.prev.id}`}>
<span className="pn-label">上一篇</span>
<span className="pn-title">{prevnext.prev.title}</span>
</Link>
) : (
<span className="pn-disabled"><span className="pn-label">上一篇</span><span className="pn-title">没有了</span></span>
)}
</div>
<div className="pn-col pn-next">
{prevnext.next ? (
<Link to={`/blog/${prevnext.next.id}`}>
<span className="pn-label">下一篇</span>
<span className="pn-title">{prevnext.next.title}</span>
</Link>
) : (
<span className="pn-disabled"><span className="pn-label">下一篇</span><span className="pn-title">没有了</span></span>
)}
</div>
</nav>
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
{comments.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
) : (
<div className="comment-list">
{rootComments.map((c) => renderComment(c))}
</div>
)}
{user ? (
<div className="comment-form">
{replyTo && (
<div className="reply-to-hint">
<span className="material-icons" style={{ fontSize: 15 }}>reply</span>
回复 <strong>@{replyTo.name}</strong>
<button
className="btn btn-text btn-sm"
onClick={() => { setReplyTo(null); setCommentText(''); }}
>
<span className="material-icons" style={{ fontSize: 15 }}>close</span> 取消
</button>
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: replyTo ? 8 : 0 }}>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder={replyTo ? `回复 @${replyTo.name}...` : '写下你的评论...'}
style={{ flex: 1, minHeight: 60, fontSize: 14 }}
/>
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitComment} disabled={submitting}>
发表评论
</button>
</div>
</div>
) : (
<p className="text-muted" style={{ marginTop: 12, fontSize: 14 }}>
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以评论
</p>
)}
</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>
);
}
+99
View File
@@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useTheme } from '../theme.jsx';
import { getToken } from '../api/client.js';
/**
* 面板嵌入页(迁移自 embed.html):
* 入口参数 ?url=&title=&proxy=1 —— iframe 展示目标站点;
* proxy=1 或 HTTPS 页内嵌 HTTP 目标时走 /api/proxy/fetch?url=(代理剥 X-Frame-Options/CSP)。
* 工具栏:关闭(有来源则 history.back)、标题、原站(新标签)、主题切换。
*/
export default function Embed() {
const [searchParams] = useSearchParams();
const url = searchParams.get('url');
const title = searchParams.get('title') || url || '嵌入';
const useProxy = searchParams.get('proxy') === '1';
const { theme, toggleTheme } = useTheme();
const [showHint, setShowHint] = useState(false);
const isHttpsPage = window.location.protocol === 'https:';
const isHttpTarget = url ? url.startsWith('http:') : false;
const needsProxy = useProxy || (isHttpsPage && isHttpTarget);
// proxy 模式:iframe 无法携带 Authorization header,改用 ?token= 认证(后端 queryTokenAuth 转写)
const iframeSrc = url
? (needsProxy
? '/api/proxy/fetch?url=' + encodeURIComponent(url) + '&token=' + encodeURIComponent(getToken() || '')
: url)
: '';
// 非代理嵌入时 2s 后提示"无法嵌入?安装扩展"(照 v1)
useEffect(() => {
if (!url || needsProxy) return;
const t = setTimeout(() => setShowHint(true), 2000);
return () => clearTimeout(t);
}, [url, needsProxy]);
const handleClose = (e) => {
// 有来源页时返回上一页,否则跳首页
if (document.referrer && document.referrer !== window.location.href) {
e.preventDefault();
window.history.back();
}
};
return (
<div className="embed-page" style={{ height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<h1 className="sr-only">嵌入页面</h1>
<div
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 }}
>
<a href="/" className="btn-icon" onClick={handleClose} title="关闭"><span className="material-icons">close</span></a>
<span className="embed-title" style={{ flex: 1, fontWeight: 500, fontSize: 15, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</span>
{url && (
<a href={url} target="_blank" rel="noopener" className="btn btn-text btn-sm" title="在新标签页中打开">
<span className="material-icons">open_in_new</span> 原站
</a>
)}
<button className="btn-icon" onClick={toggleTheme} title="切换主题">
<span className="material-icons">{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
</button>
</div>
{showHint && (
<div style={{ padding: '8px 16px', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', fontSize: 13, textAlign: 'center', flexShrink: 0 }}>
无法嵌入试试安装{' '}
<a href="https://chromewebstore.google.com/search/ignore%20x-frame" target="_blank" rel="noopener" style={{ color: 'inherit', fontWeight: 600, textDecoration: 'underline' }}>
Ignore X-Frame-Headers
</a>{' '}
扩展或点击右上角原站在新标签页打开
<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>
)}
{iframeSrc ? (
<iframe
className="embed-container"
title={title}
style={{ flex: 1, width: '100%', border: 'none' }}
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
loading="lazy"
src={iframeSrc}
/>
) : (
<div className="empty-state" style={{ flex: 1 }}>
<div className="empty-icon">🔗</div>
<p>缺少 url 参数无法加载嵌入内容</p>
</div>
)}
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import { uploadFile } from '../api/upload.js';
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
import { showCaptcha } from '../components/CaptchaModal.jsx';
import { showSnackbar, useDialog } from '../lib/utils.js';
/** 板块的子分类列表 */
function subCatsOf(cat) {
if (!cat || !cat.sub_categories) return [];
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
}
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
export default function Forum() {
const navigate = useNavigate();
const [categories, setCategories] = useState([]);
const [currentCatId, setCurrentCatId] = useState(null); // null=全部最新
const [filterSub, setFilterSub] = useState('');
const [posts, setPosts] = useState(null); // null=加载中
const [loadError, setLoadError] = useState('');
const [user, setUser] = useState(null);
// 发帖弹窗状态
const [showNewPost, setShowNewPost] = useState(false);
const [npCategory, setNpCategory] = useState('');
const [npSub, setNpSub] = useState('');
const [npTitle, setNpTitle] = useState('');
const [npContent, setNpContent] = useState('');
const [npStatus, setNpStatus] = useState('');
const [submitting, setSubmitting] = useState(false);
const fileRef = useRef(null);
const load = useCallback(async (catId, sub) => {
setPosts(null);
setLoadError('');
try {
const ps = catId ? await forumApi.listPosts(catId) : await forumApi.listPosts();
const filtered = sub ? (ps || []).filter((p) => p.sub_category === sub) : (ps || []);
setPosts(filtered);
} catch (e) {
setLoadError(e.message || '加载失败');
setPosts([]);
}
}, []);
useEffect(() => {
forumApi.listCategories().then((cs) => setCategories(cs || [])).catch(() => {});
if (getToken()) me().then(setUser).catch(() => setUser(null));
else setUser(null);
load(null, '');
}, [load]);
const selectCategory = (catId) => {
setCurrentCatId(catId);
setFilterSub('');
load(catId, '');
};
const showAllPosts = () => {
setCurrentCatId(null);
setFilterSub('');
load(null, '');
};
// 发帖按钮:未登录显示"登录发帖"并跳登录页(照 forum.js
const handleNewPostBtn = () => {
if (user) {
setNpCategory(categories.length ? String(categories[0].id) : '');
setNpSub('');
setNpTitle('');
setNpContent('');
setNpStatus('');
setShowNewPost(true);
} else {
navigate('/login.html');
}
};
const handleCategoryChange = (e) => {
setNpCategory(e.target.value);
setNpSub(''); // 切换板块后重置子分类
};
const doUpload = async (file) => {
try {
const data = await uploadFile(file);
setNpContent((c) => c + '\n' + data.tag + '\n');
setNpStatus('已插入: ' + data.tag);
} catch (e) {
showSnackbar(e.message);
}
};
const handleFileSelect = (e) => {
const f = e.target.files && e.target.files[0];
if (f) doUpload(f);
e.target.value = '';
};
const submitPost = async () => {
if (!npTitle.trim() || !npContent.trim()) { showSnackbar('标题和内容不能为空'); return; }
setSubmitting(true);
try {
const base = {
category_id: parseInt(npCategory, 10),
title: npTitle.trim(),
content: npContent.trim(),
sub_category: npSub,
use_markdown: 1,
};
// 验证码:captcha_forum 开启时走 showCaptcha 拿 proof / 第三方 token
const cap = await captchaRequired('forum');
if (cap && cap.required) {
const result = await showCaptcha('forum');
if (result === null) { setSubmitting(false); return; } // 取消
applyCaptchaResult(base, result);
}
await forumApi.createPost(base);
showSnackbar('发布成功');
setShowNewPost(false);
if (currentCatId) selectCategory(currentCatId);
else showAllPosts();
} catch (e) {
showSnackbar(e.message);
}
setSubmitting(false);
};
const currentCat = categories.find((c) => c.id === currentCatId);
const subCats = subCatsOf(currentCat);
// 发帖弹窗焦点管理(B3):Esc 关闭、聚焦首个输入、关闭后焦点还给触发按钮
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
return (
<div className="forum-layout">
<h1 className="sr-only">论坛</h1>
<aside className="forum-sidebar">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
{user ? '发帖' : '登录发帖'}
</button>
</div>
<div className="forum-cat-list">
{categories.map((c) => (
<button
type="button"
key={c.id}
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
onClick={() => selectCategory(c.id)}
>
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
{c.announcement ? (
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
) : null}
</button>
))}
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
<button
type="button"
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
onClick={showAllPosts}
style={{ fontWeight: 500 }}
>
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
</button>
</aside>
<div className="forum-content">
{currentCat && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
<span className="chip chip-static">板块</span>
</div>
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
{currentCat.announcement && (
<div className="announcement-bar">
<span className="material-icons ann-icon">campaign</span>
<span className="ann-content">{currentCat.announcement}</span>
</div>
)}
{subCats.length > 0 && (
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
{subCats.map((s) => (
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
))}
</div>
)}
</div>
)}
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
{loadError && <div className="empty-state"><div className="empty-icon"></div><p>加载失败</p></div>}
{posts !== null && !loadError && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
)}
{posts && posts.length > 0 && (
<div className="forum-post-list">
{posts.map((p) => {
const catName = categories.find((c) => c.id === p.category_id)?.name || '';
return (
<Link key={p.id} to={`/forum/${p.id}`} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
<div className="post-title">{p.title}</div>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{p.author_name || '匿名'}</span>
<span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span>
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
<span className="chip chip-static">{catName}</span>
{p.sub_category ? (
<span className="chip chip-tonal">{p.sub_category}</span>
) : null}
</div>
</Link>
);
})}
</div>
)}
</div>
{/* 发帖弹窗 */}
{showNewPost && (
<div
ref={npDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="发布新帖"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
onKeyDown={npDialogKey}
>
<div className="dialog">
<h3>发布新帖</h3>
<div className="form-group">
<label>板块</label>
<select value={npCategory} onChange={handleCategoryChange}>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>帖子分类可选</label>
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
<option value=""></option>
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="form-group">
<label>标题 *</label>
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
</div>
<div className="form-group">
<label>内容 *</label>
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
<span className="material-icons">upload</span> 上传附件
</button>
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
</div>
</div>
</div>
)}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import { showSnackbar } from '../lib/utils.js';
/**
* 论坛帖子详情(路由 /forum/:id,SPA 内路由):
* 标题/meta/正文(MarkdownRenderer+ 回复列表/回复框(登录检查)+ 删除按钮(作者/admin)。
* 回复无需验证码(照 v1:仅发帖走 captcha_forum)。
*/
export default function ForumDetail() {
const { id } = useParams();
const navigate = useNavigate();
const [post, setPost] = useState(null);
const [replies, setReplies] = useState([]);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [replyText, setReplyText] = useState('');
const [submitting, setSubmitting] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError('');
try {
const data = await forumApi.getPost(id);
setPost(data.post);
setReplies(data.replies || []);
} catch (e) {
setError(e.message || '加载失败');
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
load();
if (getToken()) me().then(setUser).catch(() => setUser(null));
else setUser(null);
}, [load]);
const canDeletePost = user && post && (user.id === post.author_id || user.role === 'admin');
const submitReply = async () => {
const content = replyText.trim();
if (!content) { showSnackbar('回复内容不能为空'); return; }
setSubmitting(true);
try {
await forumApi.reply(id, content);
showSnackbar('回复成功');
setReplyText('');
await load();
} catch (e) {
showSnackbar(e.message);
}
setSubmitting(false);
};
const deletePost = async () => {
if (!window.confirm('确定删除?')) return;
try {
await forumApi.deletePost(post.id);
showSnackbar('已删除');
navigate('/forum.html');
} catch (e) {
showSnackbar(e.message);
}
};
const deleteReply = async (replyId) => {
if (!window.confirm('确定删除?')) return;
try {
await forumApi.deleteReply(replyId);
showSnackbar('已删除');
await load();
} catch (e) {
showSnackbar(e.message);
}
};
if (loading) {
return <div className="loading"><div className="spinner"></div></div>;
}
if (error || !post) {
return (
<div className="empty-state">
<div className="empty-icon"></div>
<p>{error || '帖子不存在'}</p>
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回论坛</Link>
</div>
);
}
return (
<div className="post-detail">
<div className="post-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Link to="/forum.html" className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回
</Link>
{canDeletePost && (
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 'auto' }} onClick={deletePost}>
删除
</button>
)}
</div>
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{post.author_name || '匿名'}</span>
<span>{post.created_at}</span>
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
{post.category_name || ''}
</span>
{post.sub_category && (
<span className="chip" style={{ cursor: 'default', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)', fontSize: 12, padding: '2px 10px' }}>
{post.sub_category}
</span>
)}
</div>
</div>
<div className="post-body">
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
</div>
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>回复 ({replies.length})</h4>
{user ? (
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="写下你的回复...(支持 Markdown"
style={{ flex: 1, minHeight: 60, fontSize: 14, fontFamily: 'monospace' }}
/>
<button className="btn btn-filled btn-sm" style={{ alignSelf: 'flex-end' }} onClick={submitReply} disabled={submitting}>
回复
</button>
</div>
) : (
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>
<Link to="/login.html" style={{ color: 'var(--md-ref-primary)' }}>登录</Link>后可以回复
</p>
)}
{replies.length === 0 ? (
<div className="text-muted" style={{ padding: 16 }}>暂无回复</div>
) : (
replies.map((r) => {
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin');
return (
<div className="reply-item" key={r.id}>
<div className="reply-meta">
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
{canDeleteReply && (
<button
type="button"
aria-label="删除回复"
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
onClick={() => deleteReply(r.id)}
>
删除
</button>
)}
</div>
<div className="reply-body">{r.content}</div>
</div>
);
})
)}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js';
const EMPTY_FORM = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
/** 单个板块的编辑卡片:名称/描述/公告/子分类(逗号分隔)/排序 */
function CategoryEditCard({ cat, onSave, onDelete }) {
const [f, setF] = useState({ ...cat });
const set = (k) => (e) => setF((prev) => ({ ...prev, [k]: e.target.value }));
return (
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="form-group">
<label>板块名称 *</label>
<input type="text" value={f.name} onChange={set('name')} />
</div>
<div className="form-group">
<label>描述</label>
<input type="text" value={f.description} onChange={set('description')} />
</div>
<div className="form-group">
<label>板块公告</label>
<textarea value={f.announcement} onChange={set('announcement')} style={{ minHeight: 60 }} />
</div>
<div className="form-group">
<label>帖子分类逗号分隔</label>
<input type="text" value={f.sub_categories} onChange={set('sub_categories')} placeholder="例: 求助,分享,讨论,建议" />
</div>
<div className="form-group">
<label>排序</label>
<input type="number" value={f.sort_order} onChange={set('sort_order')} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)' }} onClick={() => onDelete(cat)}>
删除板块
</button>
<button className="btn btn-filled btn-sm" onClick={() => onSave(f)}>保存设置</button>
</div>
</div>
);
}
/** 板块管理(admin):分类增删改 + 子分类编辑(迁移自 forum-manage.html,扩展为全板块管理) */
export default function ForumManage() {
const navigate = useNavigate();
const [authed, setAuthed] = useState(null); // null=校验中
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [form, setForm] = useState(EMPTY_FORM);
const [saving, setSaving] = useState(false);
useEffect(() => {
const token = getToken();
if (!token) { navigate('/login.html'); return; }
me()
.then((u) => {
if (u.role !== 'admin') {
showSnackbar('需要管理员权限');
setAuthed(false);
setTimeout(() => navigate('/'), 1000);
return;
}
setAuthed(true);
loadCategories();
})
.catch(() => navigate('/login.html'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadCategories = async () => {
setLoading(true);
try {
const cs = await forumApi.listCategories();
setCategories(cs || []);
} catch (e) {
showSnackbar(e.message);
}
setLoading(false);
};
const saveCategory = async (f) => {
const data = {
name: (f.name || '').trim(),
description: f.description || '',
announcement: f.announcement || '',
sub_categories: f.sub_categories || '',
sort_order: parseInt(f.sort_order, 10) || 0,
};
if (!data.name) { showSnackbar('名称不能为空'); return; }
try {
await forumApi.updateCategory(f.id, data);
showSnackbar('保存成功');
loadCategories();
} catch (e) {
showSnackbar(e.message);
}
};
const deleteCategory = async (cat) => {
if (!window.confirm(`确定删除板块「${cat.name}」?板块下的帖子将一并删除`)) return;
try {
await forumApi.deleteCategory(cat.id);
showSnackbar('已删除');
loadCategories();
} catch (e) {
showSnackbar(e.message);
}
};
const createCategory = async () => {
const data = {
name: form.name.trim(),
description: form.description.trim(),
announcement: form.announcement.trim(),
sub_categories: form.sub_categories.trim(),
sort_order: parseInt(form.sort_order, 10) || 0,
};
if (!data.name) { showSnackbar('名称不能为空'); return; }
setSaving(true);
try {
await forumApi.createCategory(data);
showSnackbar('创建成功');
setForm(EMPTY_FORM);
setShowCreate(false);
loadCategories();
} catch (e) {
showSnackbar(e.message);
}
setSaving(false);
};
if (authed === null) {
return <div className="loading"><div className="spinner"></div></div>;
}
if (authed === false) return null;
return (
<div style={{ maxWidth: 800, margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
<a href="/forum.html" className="btn-icon"><span className="material-icons">arrow_back</span></a>
<h1 className="page-title" style={{ margin: 0, flex: 1 }}>板块管理</h1>
<button className="btn btn-filled btn-sm" onClick={() => setShowCreate((v) => !v)}>
<span className="material-icons" style={{ fontSize: 16 }}>add</span> 新建板块
</button>
</div>
{showCreate && (
<div className="card" style={{ padding: 24, marginBottom: 16 }}>
<div className="form-group">
<label>板块名称 *</label>
<input type="text" value={form.name} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="form-group">
<label>描述</label>
<input type="text" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} />
</div>
<div className="form-group">
<label>板块公告</label>
<textarea value={form.announcement} onChange={(e) => setForm((p) => ({ ...p, announcement: e.target.value }))} style={{ minHeight: 60 }} />
</div>
<div className="form-group">
<label>帖子分类逗号分隔</label>
<input type="text" value={form.sub_categories} onChange={(e) => setForm((p) => ({ ...p, sub_categories: e.target.value }))} placeholder="例: 求助,分享,讨论,建议" />
</div>
<div className="form-group">
<label>排序</label>
<input type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} />
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-text btn-sm" onClick={() => setShowCreate(false)}>取消</button>
<button className="btn btn-filled btn-sm" onClick={createCategory} disabled={saving}>创建</button>
</div>
</div>
)}
{loading ? (
<div className="loading"><div className="spinner"></div></div>
) : categories.length === 0 ? (
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无板块</p></div>
) : (
categories.map((c) => (
<CategoryEditCard key={c.id} cat={c} onSave={saveCategory} onDelete={deleteCategory} />
))
)}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useEffect, useState } from 'react';
import BlogSidebar from '../components/BlogSidebar.jsx';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import { getPublicSettings } from '../api/settings.js';
/**
* 首页(迁移自 index.html 的内联 HOMEPAGE):
* 侧栏(头像/简介/联系方式 + 最新文章)+ 主页内容(settings.homepage_contentMarkdown)。
*/
export default function Home() {
const [settings, setSettings] = useState({});
const [contentError, setContentError] = useState(false);
useEffect(() => {
getPublicSettings()
.then((s) => { setSettings(s); setContentError(false); })
.catch(() => { setSettings({}); setContentError(true); });
}, []);
return (
<div className="homepage-layout">
<h1 className="sr-only">首页</h1>
<BlogSidebar settings={settings} showRecent forceShow />
<div className="homepage-content">
<div className="card">
{contentError
? <p style={{ color: 'var(--md-ref-on-surface-variant)' }}>内容加载失败</p>
: <MarkdownRenderer content={settings.homepage_content || ''} useMarkdown />}
</div>
</div>
</div>
);
}
+213
View File
@@ -0,0 +1,213 @@
import React, { useEffect, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import * as authApi from '../api/auth.js';
import * as settingsApi from '../api/settings.js';
import { required as captchaRequired } from '../api/captcha.js';
import { showCaptcha } from '../components/CaptchaModal.jsx';
import { getToken, setToken, notifyAuthChange } from '../api/client.js';
/** RainID OIDC 回跳错误码 → 用户可读文案 */
const OIDC_ERROR_TEXT = {
access_denied: '已在 RainID 拒绝授权,可重新登录',
consent_required: '请重新完整走 RainID 登录',
login_required: '请重新完整走 RainID 登录',
invalid_grant: '登录状态已失效,请重新登录',
rate_limited: '请求过于频繁,请稍后再试',
invalid_scope: '配置错误,请联系管理员',
invalid_request: '配置错误,请联系管理员',
};
/**
* 登录页(迁移自 login.html):
* captcha.required('login') 判断 → 需要则显示"点击进行人机验证"按钮 → showCaptcha('login') 拿 proof
* 成功后 setToken + 通知登录态变更 + 跳转来源页或首页。
* RainID 单点登录:rainid_enabled 时显示「使用 RainID 登录」按钮;回跳(?oidc_ticket / ?oidc_error
* 在此页面收尾——一次性过渡码换 token / 错误码映射提示。ROPC 表单逻辑不变(后端已转发)。
*/
export default function Login() {
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [showPw, setShowPw] = useState(false);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [capRequired, setCapRequired] = useState(false);
const [capDone, setCapDone] = useState(false);
const [capResult, setCapResult] = useState(null);
const [rainidEnabled, setRainidEnabled] = useState(false);
const [oidcBusy, setOidcBusy] = useState(false);
/** 清理地址栏的 oidc 过渡参数(替换为纯路径,避免残留/刷新重复兑换) */
const clearOidcParams = () => {
window.history.replaceState({}, '', window.location.pathname);
};
/** RainID 回跳收尾:一次性过渡码换 token → 复用本地登录成功逻辑 */
const handleOidcTicket = async (ticket) => {
setBusy(true);
try {
const data = await authApi.oidcExchange(ticket);
setToken(data.token);
notifyAuthChange();
clearOidcParams();
const from = location.state && location.state.from;
navigate(from || '/');
} catch (e) {
clearOidcParams();
setError(e.message || 'RainID 登录失败,请重试');
setBusy(false);
}
};
useEffect(() => {
// 1) RainID 回跳优先处理(一次性码 30s 有效,须先兑换)
// 注意:OIDC 走整页跳转,此 effect 仅在页面挂载时执行一次;
// 兑换后 navigate 触发 location 变化时不再重跑(避免覆盖 from 目标)。
const params = new URLSearchParams(window.location.search);
const ticket = params.get('oidc_ticket');
const oidcError = params.get('oidc_error');
if (ticket) { handleOidcTicket(ticket); return; }
if (oidcError) {
setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败,请重试');
clearOidcParams();
return;
}
// 2) 已登录直接回首页
if (getToken()) { navigate('/', { replace: true }); return; }
// 3) 公开设置:rainid_enabled → 显示 RainID 按钮
settingsApi.getPublicSettings()
.then((s) => { if (s.rainid_enabled === '1') setRainidEnabled(true); })
.catch(() => {});
// 4) 人机验证需求
captchaRequired('login')
.then((r) => { if (r && r.required) setCapRequired(true); })
.catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const doCaptcha = async () => {
const result = await showCaptcha('login');
if (result) { setCapResult(result); setCapDone(true); }
};
/** RainID 整页跳转(loading 防重复点击) */
const handleRainId = () => {
if (oidcBusy) return;
setOidcBusy(true);
authApi.oidcLoginRedirect();
};
const handleLogin = async () => {
if (!username.trim() || !password) { setError('请输入用户名和密码'); return; }
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
setError('');
setBusy(true);
try {
const data = await authApi.login(username.trim(), password, capResult || undefined);
setToken(data.token);
notifyAuthChange();
const from = location.state && location.state.from;
navigate(from || '/');
} catch (e) {
// 401 文案透传(含 RainID 已开启时的 2FA 提示:"该账号已开启二次验证,请使用 RainID 登录"
setError(e.message || '登录失败');
setBusy(false);
// 验证码 proof 为一次性消费:无论密码错还是 proof 已被使用,登录失败后均作废,
// 重置「已验证」标记允许用户重新完成验证,否则按钮保持 disabled 死锁。
setCapDone(false);
setCapResult(null);
}
};
return (
<div className="login-page" style={{ minHeight: '70vh' }}>
<div className="card login-card">
<h1>登录</h1>
<p className="subtitle">欢迎回到 RainWeb</p>
<div className="form-group">
<label>用户名</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="输入用户名"
autoComplete="username"
autoFocus
onKeyDown={(e) => { if (e.key === 'Enter') document.getElementById('loginPw').focus(); }}
/>
</div>
<div className="form-group">
<label>密码</label>
<div style={{ position: 'relative' }}>
<input
id="loginPw"
type={showPw ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="输入密码"
autoComplete="current-password"
style={{ width: '100%', paddingRight: 44 }}
onKeyDown={(e) => { if (e.key === 'Enter') handleLogin(); }}
/>
<button
type="button"
tabIndex="-1"
style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', color: 'var(--md-ref-on-surface-variant)', cursor: 'pointer', padding: 4, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={() => setShowPw((v) => !v)}
>
<span className="material-icons" style={{ fontSize: 20 }}>{showPw ? 'visibility' : 'visibility_off'}</span>
</button>
</div>
</div>
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
{capRequired && (
<button
className="btn w-full"
onClick={doCaptcha}
disabled={capDone}
style={{
display: 'inline-flex',
marginBottom: 8,
justifyContent: 'center',
height: 44,
...(capDone
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
: { background: '#fff', color: '#333', border: '1px solid #333' }),
}}
>
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
</button>
)}
{rainidEnabled && (
<button
className="btn btn-outline w-full"
onClick={handleRainId}
disabled={busy || oidcBusy}
style={{ display: 'inline-flex', marginBottom: 8, justifyContent: 'center', height: 44, gap: 8, alignItems: 'center' }}
>
<span className="material-icons" style={{ fontSize: 20 }}>fingerprint</span>
<span>{oidcBusy ? '正在跳转 RainID…' : '使用 RainID 登录'}</span>
</button>
)}
<button className="btn btn-filled w-full" onClick={handleLogin} disabled={busy}>
{busy ? '登录中...' : '登录'}
</button>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<span className="text-muted">没有账户</span>
<Link to="/register.html" className="btn-text btn" style={{ fontSize: 14 }}>注册</Link>
</div>
<div style={{ textAlign: 'center', marginTop: 8 }}>
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}> 返回主页</Link>
</div>
</div>
</div>
);
}
+536
View File
@@ -0,0 +1,536 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as pwApi from '../api/passwords.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import { showSnackbar, useDialog } from '../lib/utils.js';
/**
* 密码学安全的随机密码生成器(crypto.getRandomValues,替代 v1 的 Math.random)。
* 可选长度与字符集,确保每个选中的字符集至少出现一次后洗牌。
*/
function makePassword({ length = 16, lower = true, upper = true, digits = true, symbols = true } = {}) {
const sets = [];
if (lower) sets.push('abcdefghijklmnopqrstuvwxyz');
if (upper) sets.push('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
if (digits) sets.push('0123456789');
if (symbols) sets.push('!@#$%^&*()_+');
if (sets.length === 0) return '';
const all = sets.join('');
const total = Math.max(length, sets.length);
const rand = new Uint32Array(total);
crypto.getRandomValues(rand);
const chars = [];
let idx = 0;
// 每个选中字符集至少贡献一个字符,保证复杂度
for (const s of sets) chars.push(s[rand[idx++] % s.length]);
for (; idx < length; idx++) chars.push(all[rand[idx] % all.length]);
// Fisher-Yates 洗牌(同样走 crypto
const shuf = new Uint32Array(chars.length);
crypto.getRandomValues(shuf);
for (let i = chars.length - 1; i > 0; i--) {
const j = shuf[i] % (i + 1);
[chars[i], chars[j]] = [chars[j], chars[i]];
}
return chars.join('');
}
/**
* 密码箱(迁移自 passwords.html + passwords.js):
* PIN 状态检查 → 未设 PIN 引导设置 / 已设解锁 / 解锁后条目列表 + 增删改 + 复制 + 生成器 + 锁定。
* 全部交互用 React 数据绑定,无字符串拼接事件(修复 v1 M6 onclick 注入点)。
* 注意:GET /api/passwords/ 返回的是会话解锁后服务端解密出的明文密码。
*/
export default function Passwords() {
const navigate = useNavigate();
const [phase, setPhase] = useState('loading'); // loading | setup | locked | unlocked
const [entries, setEntries] = useState(null); // null=加载中
const [entriesError, setEntriesError] = useState('');
const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState('');
const [pinBusy, setPinBusy] = useState(false);
// 设置/修改 PIN 弹窗
const [pinDialog, setPinDialog] = useState(false);
const [newPin, setNewPin] = useState('');
const [confirmPin, setConfirmPin] = useState('');
const [pinSaving, setPinSaving] = useState(false);
// 修改 PIN 警告确认(修改后旧密文无法解密,需重新添加条目)
const [pinChangeWarn, setPinChangeWarn] = useState(false);
// 添加/编辑条目弹窗
const [pwDialog, setPwDialog] = useState(false);
const [editingId, setEditingId] = useState(null);
const [fTitle, setFTitle] = useState('');
const [fUsername, setFUsername] = useState('');
const [fPassword, setFPassword] = useState('');
const [fUrl, setFUrl] = useState('');
const [fNotes, setFNotes] = useState('');
const [pwSaving, setPwSaving] = useState(false);
// 详情 / 生成器 / 删除确认
const [detailId, setDetailId] = useState(null);
const [genDialog, setGenDialog] = useState(false);
const [genLen, setGenLen] = useState(16);
const [genLower, setGenLower] = useState(true);
const [genUpper, setGenUpper] = useState(true);
const [genDigits, setGenDigits] = useState(true);
const [genSymbols, setGenSymbols] = useState(true);
const [genResult, setGenResult] = useState('');
const [confirmDialog, setConfirmDialog] = useState(false);
const [confirmBusy, setConfirmBusy] = useState(false);
useEffect(() => {
const token = getToken();
if (!token) { showSnackbar('请先登录'); setTimeout(() => navigate('/login.html'), 1000); return; }
me()
.then((u) => {
if (u.role !== 'admin') {
showSnackbar('需要管理员权限');
setTimeout(() => navigate('/'), 1000);
return;
}
pwApi.pinStatus()
.then((s) => {
if (!s.hasPin) setPhase('setup');
else if (s.unlocked) { setPhase('unlocked'); loadEntries(); }
else setPhase('locked');
})
.catch((e) => showSnackbar(e.message));
})
.catch(() => navigate('/login.html'));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadEntries = async () => {
setEntries(null);
setEntriesError('');
try {
const list = await pwApi.listEntries();
setEntries(list || []);
} catch (e) {
setEntriesError(e.message || '加载失败');
setEntries([]);
}
};
const savePin = async () => {
// 校验文案与后端 setPin 保持一致(PIN ≥6 位,建议含字母;v1 的"至少4位"已修正)
if (!newPin || newPin.length < 6) { showSnackbar('PIN 至少 6 位,建议包含字母'); return; }
if (newPin !== confirmPin) { showSnackbar('两次输入的 PIN 不一致'); return; }
setPinSaving(true);
try {
await pwApi.setPin(newPin);
showSnackbar('PIN 设置成功');
setPinDialog(false);
setNewPin(''); setConfirmPin('');
setPhase('unlocked');
loadEntries();
} catch (e) {
showSnackbar(e.message);
}
setPinSaving(false);
};
const submitPin = async () => {
if (!pinInput) { showSnackbar('请输入 PIN 码'); return; }
setPinBusy(true);
try {
await pwApi.unlock(pinInput);
showSnackbar('已解锁');
setPinInput('');
setPinError('');
setPhase('unlocked');
loadEntries();
} catch (e) {
// 后端错误直显:401 'PIN 错误' / 429 '尝试次数过多,请稍后再试'(3 次/5 分钟锁定)
setPinError(e.message || '解锁失败');
setPinInput('');
}
setPinBusy(false);
};
const lockVault = async () => {
try {
await pwApi.lock();
setPhase('locked');
setPinInput('');
setPinError('');
setPinDialog(false); setPwDialog(false); setDetailId(null); setGenDialog(false); setConfirmDialog(false); setPinChangeWarn(false);
showSnackbar('已锁定');
} catch (e) {
showSnackbar(e.message);
}
};
const openPinDialog = () => {
setNewPin('');
setConfirmPin('');
setPinDialog(true);
};
// 修改 PIN:先弹警告确认,确认后再打开设置弹窗
const openPinChange = () => {
setPinChangeWarn(true);
};
const proceedPinChange = () => {
setPinChangeWarn(false);
openPinDialog();
};
const openAddDialog = () => {
setEditingId(null);
setFTitle(''); setFUsername(''); setFPassword(''); setFUrl(''); setFNotes('');
setPwDialog(true);
};
const openEditDialog = (entry) => {
setEditingId(entry.id);
setFTitle(entry.title);
setFUsername(entry.username || '');
setFPassword(entry.password || '');
setFUrl(entry.url || '');
setFNotes(entry.notes || '');
setPwDialog(true);
};
const savePassword = async () => {
if (!fTitle.trim() || !fPassword) { showSnackbar('标题和密码不能为空'); return; }
const data = {
title: fTitle.trim(),
username: fUsername.trim(),
password: fPassword,
url: fUrl.trim(),
notes: fNotes.trim(),
};
setPwSaving(true);
try {
if (editingId) await pwApi.updateEntry(editingId, data);
else await pwApi.createEntry(data);
showSnackbar('保存成功');
setPwDialog(false);
loadEntries();
} catch (e) {
showSnackbar(e.message);
}
setPwSaving(false);
};
const confirmDelete = async () => {
setConfirmBusy(true);
try {
await pwApi.deleteEntry(detailId);
showSnackbar('已删除');
setConfirmDialog(false);
setDetailId(null);
loadEntries();
} catch (e) {
showSnackbar(e.message);
}
setConfirmBusy(false);
};
const openGenerator = () => {
setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }));
setGenDialog(true);
};
const useGenerated = () => {
setFPassword(genResult);
setGenDialog(false);
};
const copyToClipboard = (text, label) => {
navigator.clipboard.writeText(text)
.then(() => showSnackbar(label + ' 已复制'))
.catch(() => showSnackbar('复制失败'));
};
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') {
return <div className="loading"><div className="spinner"></div></div>;
}
return (
<>
{/* PIN 屏(设置 或 解锁)——全屏覆盖页,非弹窗 */}
{phase === 'setup' || phase === 'locked' ? (
<div className="pin-overlay">
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
<div style={{ fontSize: 18, fontWeight: 500 }}>
{phase === 'setup' ? '首次使用,请设置 PIN 码' : '请输入 PIN 码解锁'}
</div>
{pinError && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 8 }}>{pinError}</div>}
{phase === 'locked' && (
<input
type="password"
className="pin-input"
maxLength={6}
inputMode="numeric"
autoComplete="off"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
/>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
<button className="btn btn-filled" onClick={phase === 'setup' ? openPinDialog : submitPin} disabled={pinBusy}>
{phase === 'setup' ? '设置' : (pinBusy ? '解锁中...' : '解锁')}
</button>
</div>
</div>
) : (
/* 已解锁:密码箱 */
<div>
<div className="admin-header">
<h2>已保存的密码</h2>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-tonal" onClick={lockVault}>
<span className="material-icons">lock</span> 锁定
</button>
<button className="btn btn-text" onClick={openPinChange}>
<span className="material-icons">edit</span> 修改 PIN
</button>
<button className="btn btn-filled" onClick={openAddDialog}>
<span className="material-icons">add</span> 添加
</button>
</div>
</div>
{entries === null && <div className="loading"><div className="spinner"></div></div>}
{entriesError && (
<div className="empty-state"><div className="empty-icon"></div><p>加载失败: {entriesError}</p></div>
)}
{entries && entries.length === 0 && (
<div className="empty-state"><div className="empty-icon">🔒</div><p>暂无密码记录</p></div>
)}
{entries && entries.length > 0 && (
<div className="password-grid">
{entries.map((p) => (
<div key={p.id} className="password-card-wrap">
<button type="button" className="card password-card" onClick={() => setDetailId(p.id)}>
<div className="pw-title">{p.title}</div>
<div className="pw-username">{p.username || '无用户名'}</div>
</button>
<div className="pw-actions">
<button
type="button"
className="btn-icon"
style={{ width: 40, height: 40, fontSize: 16 }}
title="复制密码"
aria-label={`复制 ${p.title} 的密码`}
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
>
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ===== 弹窗统一挂载在顶层(setup/locked 阶段也能弹出——功能修复关键,勿移入 phase 分支) ===== */}
{/* 结构与论坛发帖弹窗一致:.dialog-overlay + .dialogMD3 CSS 变量天然适配浅/深色模式 */}
{/* 设置/修改 PIN 弹窗 */}
{pinDialog && (
<div
ref={pinDlgRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="设置 PIN 码"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPinDialog(false); }}
onKeyDown={pinDlgKey}
>
<div className="dialog" style={{ maxWidth: 360 }}>
<h3>设置 PIN </h3>
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>PIN 码用于加密保护您的密码数据</p>
<div className="form-group">
<label>PIN *至少6位建议包含字母</label>
<input type="password" value={newPin} onChange={(e) => setNewPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
</div>
<div className="form-group">
<label>确认 PIN *</label>
<input type="password" value={confirmPin} onChange={(e) => setConfirmPin(e.target.value)} maxLength={20} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setPinDialog(false)}>取消</button>
<button className="btn btn-filled" onClick={savePin} disabled={pinSaving}>确认</button>
</div>
</div>
</div>
)}
{/* 添加/编辑条目弹窗 */}
{pwDialog && (
<div
ref={pwDlgRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label={editingId ? '编辑密码' : '添加密码'}
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
onKeyDown={pwDlgKey}
>
<div className="dialog">
<h3>{editingId ? '编辑密码' : '添加密码'}</h3>
<div className="form-group">
<label>标题 *</label>
<input type="text" value={fTitle} onChange={(e) => setFTitle(e.target.value)} />
</div>
<div className="form-group">
<label>用户名</label>
<input type="text" value={fUsername} onChange={(e) => setFUsername(e.target.value)} />
</div>
<div className="form-group">
<label>密码 *</label>
<input type="text" value={fPassword} onChange={(e) => setFPassword(e.target.value)} />
<div style={{ marginTop: 4 }}>
<button className="btn btn-text btn-sm" onClick={openGenerator}>生成随机密码</button>
</div>
</div>
<div className="form-group">
<label>网址</label>
<input type="url" value={fUrl} onChange={(e) => setFUrl(e.target.value)} />
</div>
<div className="form-group">
<label>备注</label>
<textarea value={fNotes} onChange={(e) => setFNotes(e.target.value)} />
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
<button className="btn btn-filled" onClick={savePassword} disabled={pwSaving}>保存</button>
</div>
</div>
</div>
)}
{/* 详情弹窗 */}
{detailEntry && (
<div
ref={detailDlgRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label={detailEntry.title}
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setDetailId(null); }}
onKeyDown={detailDlgKey}
>
<div className="dialog" style={{ maxWidth: 500 }}>
<h3>{detailEntry.title}</h3>
<div className="password-detail">
<div className="pw-field">
<span className="pw-label">用户名</span>
<span className="pw-value">{detailEntry.username || ''}</span>
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</button>
</div>
<div className="pw-field">
<span className="pw-label">密码</span>
<span className="pw-value">{detailEntry.password}</span>
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</button>
</div>
{detailEntry.url ? (
<div className="pw-field">
<span className="pw-label">网址</span>
<span className="pw-value">
<a href={detailEntry.url} target="_blank" rel="noopener" style={{ color: 'var(--md-ref-primary)' }}>{detailEntry.url}</a>
</span>
</div>
) : null}
{detailEntry.notes ? (
<div className="pw-field" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 4 }}>
<span className="pw-label">备注</span>
<span style={{ fontSize: 14 }}>{detailEntry.notes}</span>
</div>
) : null}
</div>
<div className="actions" style={{ marginTop: 16 }}>
<button className="btn btn-text" onClick={() => { setDetailId(null); openEditDialog(detailEntry); }}>编辑</button>
<button className="btn btn-text" style={{ color: 'var(--md-ref-error)' }} onClick={() => { setDetailId(null); setConfirmDialog(true); }}>删除</button>
<button className="btn btn-text" onClick={() => setDetailId(null)}>关闭</button>
</div>
</div>
</div>
)}
{/* 密码生成器弹窗 */}
{genDialog && (
<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 }}>
<h3>生成随机密码</h3>
<div className="form-group">
<label>长度</label>
<input type="number" min={6} max={64} value={genLen} onChange={(e) => setGenLen(parseInt(e.target.value, 10) || 16)} />
</div>
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genLower} onChange={(e) => setGenLower(e.target.checked)} /> 小写</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genUpper} onChange={(e) => setGenUpper(e.target.checked)} /> 大写</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genDigits} onChange={(e) => setGenDigits(e.target.checked)} /> 数字</label>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 14 }}><input type="checkbox" checked={genSymbols} onChange={(e) => setGenSymbols(e.target.checked)} /> 符号</label>
</div>
{genResult && (
<div style={{ padding: 12, borderRadius: 8, background: 'var(--md-ref-surface-container)', fontFamily: 'monospace', fontSize: 16, textAlign: 'center', marginBottom: 12, wordBreak: 'break-all' }}>
{genResult}
</div>
)}
<div className="actions">
<button className="btn btn-text" onClick={() => setGenDialog(false)}>取消</button>
<button className="btn btn-text" onClick={() => setGenResult(makePassword({ length: genLen, lower: genLower, upper: genUpper, digits: genDigits, symbols: genSymbols }))}>重新生成</button>
<button className="btn btn-filled" onClick={useGenerated} disabled={!genResult}>使用此密码</button>
</div>
</div>
</div>
)}
{/* 修改 PIN 警告确认弹窗 */}
{pinChangeWarn && (
<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">
<h3>确认修改 PIN</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}> 修改 PIN 现有密码条目将无法解密需要重新添加确定继续</p>
<div className="actions">
<button className="btn btn-text" onClick={() => setPinChangeWarn(false)}>取消</button>
<button className="btn btn-danger" onClick={proceedPinChange}>确定继续</button>
</div>
</div>
</div>
)}
{/* 删除确认弹窗 */}
{confirmDialog && (
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
<div className="dialog">
<h3>确认操作</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录</p>
<div className="actions">
<button className="btn btn-text" onClick={() => setConfirmDialog(false)}>取消</button>
<button className="btn btn-danger" onClick={confirmDelete} disabled={confirmBusy}>确认删除</button>
</div>
</div>
</div>
)}
</>
);
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
export default function Placeholder({ name }) {
return (
<div className="page">
<div className="card" style={{ textAlign: 'center', padding: '64px 16px' }}>
<h2 style={{ marginBottom: '12px' }}>{name}</h2>
<p style={{ color: 'var(--md-ref-on-surface-variant)' }}>建设中</p>
</div>
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as profileApi from '../api/profile.js';
import * as authApi from '../api/auth.js';
import { avatarUrl, uploadAvatar } from '../api/upload.js';
import { getToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
/** FileReader + Image 加载图片(头像裁剪前读取) */
function loadImage(file) {
return new Promise((resolve, reject) => {
const r = new FileReader();
r.onload = () => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = reject;
i.src = r.result;
};
r.readAsDataURL(file);
});
}
/** 个人中心(迁移自 profile.html):资料 / 头像上传(512x512 裁剪)/ 邮箱状态 / 修改密码 / 退出 */
export default function Profile() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [avatar, setAvatar] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fileRef = useRef(null);
// 修改密码弹窗(两步:发送验证码 → 输入验证码/新旧密码)
const [pwDialog, setPwDialog] = useState(false);
const [pwStep, setPwStep] = useState(1);
const [pwCode, setPwCode] = useState('');
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
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 () => {
setLoading(true);
setError('');
try {
const u = await profileApi.getProfile();
setUser(u);
// 头像:站内上传或 QQ 自动头像(avatar-url 接口处理)
const av = await avatarUrl(u.id);
setAvatar(av.url || '');
} catch (e) {
setError(e.message || '加载失败');
if (e.status === 401) { navigate('/login.html'); return; }
}
setLoading(false);
}, [navigate]);
useEffect(() => {
if (!getToken()) { navigate('/login.html'); return; }
load();
}, [load, navigate]);
const handleAvatarChange = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file) return;
try {
// 客户端裁剪为 512x512(照 profile.html
const img = await loadImage(file);
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 512, 512);
const s = Math.min(img.width, img.height);
const sx = (img.width - s) / 2;
const sy = (img.height - s) / 2;
ctx.drawImage(img, sx, sy, s, s, 0, 0, 512, 512);
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.85));
// 用 File 携带文件名,满足后端扩展名/MIME 校验
const avatarFile = new File([blob], 'avatar.jpg', { type: 'image/jpeg' });
await uploadAvatar(avatarFile);
showSnackbar('头像已更新');
notifyAuthChange(); // 刷新全局登录态头像
load();
} catch (err) {
showSnackbar(err.message);
}
};
const handleLogout = () => {
authApi.logout();
navigate('/');
};
const sendPwCode = async () => {
setPwBusy(true);
try {
await profileApi.sendPwCode();
showSnackbar('验证码已发送');
setPwStep(2);
} catch (e) {
showSnackbar(e.message);
}
setPwBusy(false);
};
const changePassword = async () => {
if (!pwCode || pwCode.length !== 8) { showSnackbar('请输入完整的 8 位验证码'); return; }
if (!oldPw || !newPw) { showSnackbar('请填写完整'); return; }
if (newPw.length < 6) { showSnackbar('新密码至少6位'); return; }
if (newPw !== confirmPw) { showSnackbar('两次密码不一致'); return; }
setPwBusy(true);
try {
await profileApi.changePassword({ code: pwCode.trim(), oldPassword: oldPw, newPassword: newPw });
showSnackbar('密码已修改');
setPwDialog(false);
setPwStep(1);
setPwCode(''); setOldPw(''); setNewPw(''); setConfirmPw('');
} catch (e) {
showSnackbar(e.message);
}
setPwBusy(false);
};
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<h1 className="page-title">个人中心</h1>
{loading ? (
<div className="loading"><div className="spinner"></div></div>
) : error || !user ? (
<div className="empty-state"><div className="empty-icon"></div><p>{error || '加载失败'}</p></div>
) : (
<>
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 16 }}>
<div style={{ position: 'relative' }}>
{avatar ? (
<img src={avatar} alt="avatar" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div style={{ width: 64, height: 64, borderRadius: '50%', background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 28, fontWeight: 600 }}>
{(user.username || '?').charAt(0).toUpperCase()}
</div>
)}
<button
className="btn-icon"
title="更换头像"
onClick={() => fileRef.current && fileRef.current.click()}
style={{ position: 'absolute', bottom: -4, right: -4, width: 28, height: 28, background: 'var(--md-ref-surface-container)', fontSize: 14, boxShadow: '0 2px 8px var(--md-shadow)' }}
>
<span className="material-icons" style={{ fontSize: 16 }}>camera_alt</span>
</button>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleAvatarChange} />
</div>
<div>
<h3 style={{ fontSize: 20, fontWeight: 600 }}>{user.username}</h3>
<span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
</div>
</div>
<div className="form-group">
<label>邮箱注册后不可修改</label>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 16px', borderRadius: 12, background: 'var(--md-ref-surface-container)', fontSize: 15 }}>
<span>{user.email || '未设置'}</span>
{user.email_verified
? <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-primary-container)', color: 'var(--md-ref-on-primary-container)' }}>已验证</span>
: <span className="chip" style={{ cursor: 'default', fontSize: 12, background: 'var(--md-ref-error)', color: 'var(--md-ref-on-error)' }}>未验证</span>}
</div>
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '24px 0' }} />
<h4 style={{ fontWeight: 500, marginBottom: 12 }}>安全设置</h4>
<button className="btn btn-outline" onClick={() => { setPwStep(1); setPwDialog(true); }}>修改密码</button>
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)', marginLeft: 8 }} onClick={handleLogout}>退出登录</button>
</div>
<div className="card" style={{ textAlign: 'center', padding: 16, color: 'var(--md-ref-on-surface-variant)', fontSize: 13 }}>
UID: {user.id} · 注册时间: {user.created_at}
</div>
</>
)}
{/* 修改密码弹窗 */}
{pwDialog && (
<div
ref={pwDlgRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="修改密码"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setPwDialog(false); }}
onKeyDown={pwDlgKey}
>
<div className="dialog">
<h3>修改密码</h3>
{pwStep === 1 ? (
<>
<p className="text-muted" style={{ marginBottom: 16, fontSize: 14 }}>验证码将发送到您的注册邮箱</p>
<button className="btn btn-filled w-full" onClick={sendPwCode} disabled={pwBusy}>
{pwBusy ? '发送中...' : '发送验证码'}
</button>
</>
) : (
<>
<div className="form-group">
<label>验证码</label>
<input type="text" value={pwCode} onChange={(e) => setPwCode(e.target.value)} placeholder="8 位验证码" maxLength={8} style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }} />
</div>
<div className="form-group">
<label>原密码</label>
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} />
</div>
<div className="form-group">
<label>新密码至少6位</label>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} />
</div>
<div className="form-group">
<label>确认新密码</label>
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} />
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setPwDialog(false)}>取消</button>
<button className="btn btn-filled" onClick={changePassword} disabled={pwBusy}>确认修改</button>
</div>
</>
)}
</div>
</div>
)}
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import * as authApi from '../api/auth.js';
import * as emailApi from '../api/email.js';
import * as settingsApi from '../api/settings.js';
import { required as captchaRequired } from '../api/captcha.js';
import { showCaptcha } from '../components/CaptchaModal.jsx';
import { getToken } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js';
/** RainID 注册页地址(后端 rainid_register_redirect=1 时整页跳转) */
const RAINID_REGISTER_URL = 'https://rainid.rainnya.asia/register';
/**
* 注册页(迁移自 register.html):
* 注册 → 后端返回 requires_verification 时进入邮箱验证步骤(8 位验证码 → /api/email/complete-register),
* 支持重新发送验证码(/api/email/send-verify);验证码 proof 流转与登录一致。
* RainIDrainid_register_redirect=1 → 整页跳转 RainID 注册(不显示本地表单);
* 否则保留本地注册,表单下方提供「使用 RainID 登录」入口。
*/
export default function Register() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [verifyMode, setVerifyMode] = useState(false);
const [code, setCode] = useState('');
const [capRequired, setCapRequired] = useState(false);
const [capDone, setCapDone] = useState(false);
const [capResult, setCapResult] = useState(null);
const [rainidEnabled, setRainidEnabled] = useState(false);
const [redirecting, setRedirecting] = useState(false); // 正在跳转 RainID 注册
useEffect(() => {
if (getToken()) { navigate('/', { replace: true }); return; }
settingsApi.getPublicSettings()
.then((s) => {
if (s.rainid_register_redirect === '1') {
// 注册托管给 RainID:短暂提示后整页替换(避免白屏感)
setRedirecting(true);
setTimeout(() => { window.location.replace(RAINID_REGISTER_URL); }, 400);
return;
}
if (s.rainid_enabled === '1') setRainidEnabled(true);
})
.catch(() => {})
.finally(() => {
captchaRequired('register')
.then((r) => { if (r && r.required) setCapRequired(true); })
.catch(() => {});
});
}, [navigate]);
const doCaptcha = async () => {
const result = await showCaptcha('register');
if (result) { setCapResult(result); setCapDone(true); }
};
/** RainID 登录入口(与登录页同逻辑) */
const handleRainId = () => {
authApi.oidcLoginRedirect();
};
const handleRegister = async () => {
if (!username.trim() || !email.trim() || !password) { setError('请填写所有必填项'); return; }
if (password.length < 6) { setError('密码至少6位'); return; }
if (password !== confirm) { setError('两次密码不一致'); return; }
if (capRequired && !capDone) { setError('请先点击验证按钮完成验证'); return; }
setError('');
setBusy(true);
try {
const data = await authApi.register({
username: username.trim(),
password,
email: email.trim(),
captcha: capResult || undefined,
});
if (data.requires_verification) {
setVerifyMode(true); // 进入邮箱验证步骤
} else {
showSnackbar('注册成功,请登录');
setTimeout(() => navigate('/login.html'), 1000);
}
} catch (e) {
setError(e.message || '注册失败');
// 与登录一致:验证码 proof 一次性消费,注册失败后重置验证状态允许重新验证
setCapDone(false);
setCapResult(null);
}
setBusy(false);
};
const handleVerify = async () => {
if (!code || code.length !== 8) { setError('请输入完整的 8 位验证码'); return; }
setError('');
try {
await emailApi.completeRegister(code.trim(), username.trim(), password);
showSnackbar('注册成功,请登录');
setTimeout(() => navigate('/login.html'), 1000);
} catch (e) {
setError(e.message);
}
};
const resendCode = async () => {
try {
await emailApi.sendVerify(email.trim(), username.trim());
showSnackbar('验证码已重新发送');
} catch (e) {
showSnackbar(e.message);
}
};
// RainID 注册托管:整页跳转前短暂显示提示
if (redirecting) {
return (
<div className="register-page" style={{ minHeight: '70vh' }}>
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
<span className="material-icons" style={{ fontSize: 36, color: 'var(--md-ref-primary)' }}>fingerprint</span>
<h1 style={{ fontSize: 22, fontWeight: 500, marginTop: 12 }}>正在跳转 RainID 注册</h1>
<p className="text-muted" style={{ fontSize: 14, marginTop: 8 }}>
本平台注册已由 RainID 单点登录托管即将跳转
</p>
</div>
</div>
);
}
return (
<div className="register-page" style={{ minHeight: '70vh' }}>
<div className="card register-card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px' }}>
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 8 }}>
{verifyMode ? '邮箱验证' : '创建账户'}
</h1>
<p className="subtitle" style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
{verifyMode ? '请输入邮箱中的验证码' : '加入 RainWeb'}
</p>
{!verifyMode && (
<>
<div className="form-group">
<label>用户名 *</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} placeholder="用户名" autoComplete="username" />
</div>
<div className="form-group">
<label>邮箱 *</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" autoComplete="email" />
</div>
<div className="form-group">
<label>密码 *</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
</div>
<div className="form-group">
<label>确认密码 *</label>
<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder="再次输入密码" autoComplete="new-password" />
</div>
</>
)}
{verifyMode && (
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<div className="form-group">
<label style={{ fontSize: 16, fontWeight: 600, marginBottom: 8 }}>邮箱验证码</label>
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>验证码已发送到您的邮箱请输入 8 位数字验证码</p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="输入 8 位验证码"
maxLength={8}
inputMode="numeric"
style={{ textAlign: 'center', fontSize: 24, letterSpacing: 8 }}
onKeyDown={(e) => { if (e.key === 'Enter') handleVerify(); }}
/>
</div>
<button className="btn btn-filled w-full" onClick={handleVerify} disabled={busy}>验证并完成注册</button>
<button className="btn btn-text btn-sm mt-16" onClick={resendCode} style={{ marginTop: 8 }}>重新发送验证码</button>
</div>
)}
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 12 }}>{error}</div>}
{!verifyMode && (
<>
{capRequired && (
<button
className="btn w-full"
onClick={doCaptcha}
disabled={capDone}
style={{
display: 'inline-flex',
marginBottom: 8,
justifyContent: 'center',
height: 44,
...(capDone
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
: { background: '#fff', color: '#333', border: '1px solid #333' }),
}}
>
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
<span>{capDone ? '验证通过' : '点击进行人机验证'}</span>
</button>
)}
<button className="btn btn-filled w-full" onClick={handleRegister} disabled={busy}>
{busy ? '注册中...' : '注册'}
</button>
<div style={{ textAlign: 'center', marginTop: 16 }}>
<span className="text-muted">已有账户</span>
<Link to="/login.html" className="btn-text btn" style={{ fontSize: 14 }}>登录</Link>
</div>
{rainidEnabled && (
<div style={{ textAlign: 'center', marginTop: 8 }}>
<button
type="button"
className="btn btn-text btn-sm"
onClick={handleRainId}
style={{ fontSize: 14, display: 'inline-flex', alignItems: 'center', gap: 4 }}
>
<span className="material-icons" style={{ fontSize: 16 }}>fingerprint</span>
已有 RainID 账号使用 RainID 登录
</button>
</div>
)}
</>
)}
<div style={{ textAlign: 'center', marginTop: verifyMode ? 16 : 8 }}>
<Link to="/" className="btn-text btn" style={{ fontSize: 14 }}> 返回主页</Link>
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import { request } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js';
const THEMES = [
{ color: '#6750a4', name: 'default', label: '默认' },
{ color: '#0288d1', name: 'ocean', label: '海洋' },
{ color: '#2e7d32', name: 'nature', label: '自然' },
{ color: '#e65100', name: 'sunset', label: '日落' },
];
/**
* 初始化向导(迁移自 setup.html + routes/setup.js 三步流程):
* 1. 管理员密码(≥6 位、两次一致、不能等于 admin123)→ 2. 网站名称 + 主题色 → 3. 确认并完成。
* 提交 POST /api/setup/complete;已完成初始化时提示并跳首页。
*/
export default function Setup() {
const [status, setStatus] = useState(null); // null=检查中
const [step, setStep] = useState(1);
const [adminPw, setAdminPw] = useState('');
const [adminPwConfirm, setAdminPwConfirm] = useState('');
const [siteName, setSiteName] = useState('RainWeb');
const [theme, setTheme] = useState({ color: '#6750a4', name: 'default', label: '默认' });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
useEffect(() => {
request('/setup/status')
.then((s) => setStatus(s || {}))
.catch(() => setStatus({ setup_complete: true })); // 接口异常不阻塞,视为已完成
}, []);
const next = (n) => {
setError('');
if (n === 2) {
if (adminPw.length < 6) { setError('密码至少6位'); return; }
if (adminPw !== adminPwConfirm) { setError('两次密码不一致'); return; }
if (adminPw === 'admin123') { setError('新密码不能与默认密码相同'); return; }
}
setStep(n);
};
const complete = async () => {
setBusy(true);
try {
await request('/setup/complete', {
method: 'POST',
body: {
password: adminPw,
site_name: siteName.trim() || 'RainWeb',
primary_color: theme.color,
theme_preset: theme.name,
},
});
showSnackbar('初始化完成!');
setTimeout(() => { window.location.href = '/'; }, 1000);
} catch (e) {
setError(e.message || '初始化失败');
setBusy(false);
}
};
if (status === null) {
return <div className="loading"><div className="spinner"></div></div>;
}
// 已完成初始化:提示并跳首页
if (status.setup_complete) {
return (
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ width: '100%', maxWidth: 420, padding: '40px 32px', textAlign: 'center' }}>
<h1 style={{ fontSize: 28, fontWeight: 500, marginBottom: 8 }}> 已完成初始化</h1>
<p className="text-muted" style={{ marginBottom: 24, fontSize: 14 }}>RainWeb 已初始化完成可正常使用</p>
<a href="/" className="btn btn-filled w-full">进入首页</a>
</div>
</div>
);
}
const stepDot = (n) => ({
width: n === step ? 28 : 10,
height: 10,
borderRadius: n === step ? 5 : '50%',
background: n < step ? 'var(--md-ref-primary-container)' : (n === step ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
transition: 'all 0.3s',
});
return (
<div style={{ minHeight: '70vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
<div className="card" style={{ width: '100%', maxWidth: 480, padding: '40px 32px' }}>
<h1 style={{ fontSize: 28, fontWeight: 500, textAlign: 'center', marginBottom: 4 }}>🌧 RainWeb</h1>
<p style={{ textAlign: 'center', color: 'var(--md-ref-on-surface-variant)', marginBottom: 28, fontSize: 14 }}>
欢迎使用请完成以下初始化设置
</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', marginBottom: 24 }}>
{[1, 2, 3].map((n) => <div key={n} style={stepDot(n)} />)}
</div>
{step === 1 && (
<div>
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>设置管理员密码</h3>
<div className="form-group">
<label>新密码 *</label>
<input type="password" value={adminPw} onChange={(e) => setAdminPw(e.target.value)} placeholder="至少6位" autoComplete="new-password" />
</div>
<div className="form-group">
<label>确认密码 *</label>
<input type="password" value={adminPwConfirm} onChange={(e) => setAdminPwConfirm(e.target.value)} placeholder="再次输入" autoComplete="new-password" onKeyDown={(e) => { if (e.key === 'Enter') next(2); }} />
</div>
<button className="btn btn-filled w-full" onClick={() => next(2)}>下一步</button>
</div>
)}
{step === 2 && (
<div>
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>网站设置</h3>
<div className="form-group">
<label>网站名称</label>
<input type="text" value={siteName} onChange={(e) => setSiteName(e.target.value)} placeholder="我的网站" />
</div>
<div className="form-group">
<label>选择主题色</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 12 }}>
{THEMES.map((t) => (
<div
key={t.name}
onClick={() => setTheme({ color: t.color, name: t.name, label: t.label })}
style={{
padding: 16,
borderRadius: 12,
border: '2px solid ' + (theme.name === t.name ? 'var(--md-ref-primary)' : 'var(--md-ref-outline-variant)'),
cursor: 'pointer',
textAlign: 'center',
background: theme.name === t.name ? 'var(--md-ref-primary-container)' : 'transparent',
transition: 'all 0.2s',
}}
>
<div style={{ width: 32, height: 32, borderRadius: '50%', margin: '0 auto 8px', background: t.color }} />
<div>{t.label}</div>
</div>
))}
</div>
</div>
<button className="btn btn-filled w-full" onClick={() => next(3)}>下一步</button>
</div>
)}
{step === 3 && (
<div>
<h3 style={{ fontWeight: 500, marginBottom: 16 }}>确认并完成</h3>
<div className="card" style={{ padding: 16, marginBottom: 16, fontSize: 14 }}>
<div style={{ marginBottom: 8 }}><strong>管理员密码</strong>: 已设置</div>
<div style={{ marginBottom: 8 }}><strong>网站名称</strong>: {siteName.trim() || 'RainWeb'}</div>
<div><strong>主题色</strong>: {theme.label}</div>
</div>
<button className="btn btn-filled w-full" onClick={complete} disabled={busy}>
{busy ? '处理中...' : '完成初始化'}
</button>
</div>
)}
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginTop: 12 }}>{error}</div>}
</div>
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import React, { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { getTagPosts } from '../api/blog.js';
/** 摘要:优先 excerpt,否则剥离 markdown 符号截取 */
function excerptOf(p) {
if (p.excerpt) return p.excerpt;
return (p.content || '').replace(/[#*`\[\]()>|~_]/g, '').slice(0, 200);
}
/** 标签页(路由 /tag/:name):该标签下的文章列表,复用博客卡片样式 */
export default function Tag() {
const { name } = useParams();
const [posts, setPosts] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
setPosts(null);
setError('');
getTagPosts(name)
.then((ps) => setPosts(ps || []))
.catch((e) => setError(e.message || '加载失败'));
}, [name]);
const displayName = name || '';
return (
<div className="blog-article" style={{ maxWidth: 720, margin: '0 auto' }}>
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回博客
</Link>
<h1 className="article-title" style={{ fontSize: 26 }}>
标签{displayName}
{posts && <span className="tag-title-count">{posts.length} </span>}
</h1>
{!posts && !error && (
<div className="loading"><div className="spinner"></div></div>
)}
{error && (
<div className="empty-state"><div className="empty-icon"></div><p>{error}</p></div>
)}
{posts && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">🏷</div><p>该标签下暂无文章</p></div>
)}
{posts && posts.length > 0 && (
<div className="blog-waterfall">
{posts.map((p) => (
<Link key={p.id} to={`/blog/${p.id}`} className="card blog-card" style={{ textDecoration: 'none' }}>
<div className="blog-featured"></div>
<div className="blog-title">{p.title}</div>
<div className="blog-excerpt">{excerptOf(p)}</div>
<div className="blog-meta">{p.author_name || '管理员'} · {p.created_at}</div>
</Link>
))}
</div>
)}
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import React, { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import * as blogApi from '../api/blog.js';
import { uploadFile } from '../api/upload.js';
import MarkdownRenderer from '../components/MarkdownRenderer.jsx';
import { showSnackbar } from '../lib/utils.js';
/**
* 写文章页(路由 /write.html?edit=id 进入编辑模式):
* 标题/摘要/内容(Markdown)/发布开关;附件上传用 uploadFile + [image:]/[file:] 标签插入;
* 支持拖拽图片上传与预览(迁移自 write.html 内联脚本)。
*/
export default function Write() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const editId = searchParams.get('edit') ? parseInt(searchParams.get('edit'), 10) : null;
const [title, setTitle] = useState('');
const [excerpt, setExcerpt] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [published, setPublished] = useState(true);
const [preview, setPreview] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState('');
const fileRef = useRef(null);
const contentRef = useRef(null);
// 编辑模式:加载已有文章
useEffect(() => {
if (!editId) return;
blogApi.getPost(editId)
.then((p) => {
setTitle(p.title);
setExcerpt(p.excerpt || '');
setTags(p.tags || '');
setContent(p.content);
setPublished(!!p.published);
})
.catch((e) => setLoadError(e.message || '加载文章失败'));
}, [editId]);
const insertTag = (tag) => {
setContent((c) => c + '\n' + tag + '\n');
};
const doUpload = async (file) => {
try {
const data = await uploadFile(file);
insertTag(data.tag);
setUploadStatus('已插入: ' + data.tag);
} catch (e) {
showSnackbar(e.message);
}
};
const handleFileSelect = (e) => {
const f = e.target.files && e.target.files[0];
if (f) doUpload(f);
e.target.value = '';
};
const handleDrop = async (e) => {
e.preventDefault();
if (contentRef.current) contentRef.current.style.borderColor = '';
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length === 0) { showSnackbar('请拖入图片文件'); return; }
for (const f of files) await doUpload(f);
if (contentRef.current) contentRef.current.focus();
};
const submit = async () => {
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
setSaving(true);
const data = {
title: title.trim(),
content: content.trim(),
excerpt: excerpt.trim(),
tags: tags.trim(),
published,
use_markdown: 1,
};
try {
let id = editId;
if (editId) {
await blogApi.updatePost(editId, data);
showSnackbar('已更新');
} else {
const created = await blogApi.createPost(data);
id = created.id;
showSnackbar('已发布');
}
navigate('/blog/' + id);
} catch (e) {
showSnackbar(e.message);
}
setSaving(false);
};
return (
<div className="write-page" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 16px' }}>
<div className="write-header" style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>
{editId ? '编辑文章' : '写文章'}
</h1>
<a href="/blog.html" className="btn btn-text btn-sm">
<span className="material-icons">arrow_back</span> 返回博客
</a>
</div>
<div className="card" style={{ padding: 24 }}>
{loadError ? (
<div className="empty-state">
<div className="empty-icon"></div>
<p>{loadError}</p>
</div>
) : (
<>
<div className="form-group">
<label>标题 *</label>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="文章标题" />
</div>
<div className="form-group">
<label>摘要</label>
<input type="text" value={excerpt} onChange={(e) => setExcerpt(e.target.value)} placeholder="简短摘要" />
</div>
<div className="form-group">
<label>标签</label>
<input type="text" value={tags} onChange={(e) => setTags(e.target.value)} placeholder="多个标签用英文逗号分隔,如:技术,生活,随笔" />
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>多个标签用英文逗号分隔用于标签云与标签页</div>
</div>
<div className="form-group">
<label>内容 *</label>
<textarea
ref={contentRef}
value={content}
onChange={(e) => setContent(e.target.value)}
style={{ minHeight: 350, fontFamily: 'monospace', fontSize: 14 }}
placeholder="支持 Markdown 语法,拖入图片自动上传"
onDragOver={(e) => {
e.preventDefault();
if (contentRef.current) contentRef.current.style.borderColor = 'var(--md-ref-primary)';
}}
onDragLeave={() => { if (contentRef.current) contentRef.current.style.borderColor = ''; }}
onDrop={handleDrop}
/>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
<span className="material-icons">upload</span> 上传附件/图片
</button>
<button className="btn btn-text btn-sm" onClick={() => setPreview((v) => !v)}>
<span className="material-icons">visibility</span> 预览
</button>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginLeft: 'auto', fontSize: 14, fontWeight: 500, color: 'var(--md-ref-on-surface-variant)' }}>
<input type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} /> 发布
</label>
</div>
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
{uploadStatus && (
<div className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>{uploadStatus}</div>
)}
{preview && (
<div style={{ padding: 16, background: 'var(--md-ref-surface-container-low)', borderRadius: 12, marginBottom: 16, maxHeight: 400, overflowY: 'auto' }}>
<MarkdownRenderer content={content} useMarkdown />
</div>
)}
<button className="btn btn-filled" onClick={submit} disabled={saving}>
{editId ? '保存修改' : '发布文章'}
</button>
</>
)}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
// 与 v1 一致:深色模式由 document.documentElement 上的 data-theme="dark" 控制
// public/css/style.css 使用 [data-theme="dark"] 选择器,非 .dark 类)
const THEME_KEY = 'theme';
const ThemeContext = createContext(null);
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
} else {
document.documentElement.removeAttribute('data-theme');
}
}
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
applyTheme(theme);
localStorage.setItem(THEME_KEY, theme);
}, [theme]);
const value = useMemo(
() => ({
theme,
toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
setTheme,
}),
[theme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme 必须在 ThemeProvider 内使用');
return ctx;
}
+799
View File
@@ -0,0 +1,799 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import CloseIcon from '@mui/icons-material/Close';
import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorIcon from '@mui/icons-material/Error';
import LockIcon from '@mui/icons-material/Lock';
import HomeIcon from '@mui/icons-material/Home';
import WidgetsIcon from '@mui/icons-material/Widgets';
import AddIcon from '@mui/icons-material/Add';
import SearchIcon from '@mui/icons-material/Search';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import TerminalIcon from '@mui/icons-material/Terminal';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import SettingsIcon from '@mui/icons-material/Settings';
import { Group, Panel, Separator } from 'react-resizable-panels';
import { useTheme } from '@mui/material/styles';
import {
DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter,
} from '@dnd-kit/core';
import { getToken } from '../../api/client.js';
import { me } from '../../api/auth.js';
import usePanels from './hooks/usePanels.js';
import useWorkbenchLayout, {
VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB,
} from './hooks/useWorkbenchLayout.js';
import Toolbar from './components/Toolbar.jsx';
import PanelsView from './components/Sidebar.jsx';
import NotesView from './components/NoteEditor.jsx';
import VaultView from './components/VaultDrawer.jsx';
import SidebarPane from './components/SidebarPane.jsx';
import ActivityBar from './components/ActivityBar.jsx';
import CollapseArrow from './components/CollapseArrow.jsx';
import ToolWindow from './components/ToolWindow.jsx';
import TabStrip from './components/TabStrip.jsx';
import ContentView from './components/ContentView.jsx';
import StatusBar from './components/StatusBar.jsx';
import PanelFrame from './components/PanelFrame.jsx';
import AddPanelDialog from './components/AddPanelDialog.jsx';
import PanelIcon from './components/PanelIcon.jsx';
import SnackHost, { showSnack } from '../../admin/snack.jsx';
/* ============================================================
* Workbench:工作台页面(VSCode 风格改造,Lane A
*
* 布局(react-resizable-panelsv4 APIGroup/Panel/Separator):
* 顶栏 56 | 活动栏48 侧栏(可拖 180-360,可折叠) 主区(标签+内容+底部终端) 侧栏 活动栏48 | 状态栏 24
* - 左侧活动栏=主控视图(面板),右侧=功能视图(记事本/密码箱)+ 底部终端按钮
* - dnd-kit:视图标签可在左右活动栏之间拖拽换边(workbench.viewOrder 持久化)
* - 视图双模式:侧边栏(默认)/ 标签页(进主区 TabStrip);浮动为 P2 占位
* - 面板 iframe 仍走 PanelFrameLRU 3),弹窗方式走 Dialog
* ============================================================ */
const LAYOUT_KEY = 'workbench.layout';
const MAIN_ACTIVE_KEY = 'workbench.mainActive'; // 主区激活标签(刷新后恢复上次激活项)
/** 侧边栏默认宽度(px):箭头/活动栏图标展开时的目标宽度 */
const SIDEBAR_DEFAULT_WIDTH = { left: 240, right: 300 };
/** 视为「已折叠」的宽度阈值(px)。RRP v4 isCollapsed 用零容差比较(尺寸必须精确等于 0),
* 布局归一化把面板挤到亚像素宽度时会误判为「未折叠」,导致 expand() 失效、侧栏卡死。
* 右侧栏是布局末尾的柔性面板最易被挤压,因此用阈值 + resize() 兜底。 */
const SIDEBAR_COLLAPSED_PX = 24;
function persistMainActive(key) {
try {
if (key) localStorage.setItem(MAIN_ACTIVE_KEY, key);
else localStorage.removeItem(MAIN_ACTIVE_KEY);
} catch { /* 隐私模式忽略 */ }
}
/** 布局持久化:{left, main, right} 三栏百分比(0-100),活动栏固定 48px 不入库 */
function readLayout() {
try {
const raw = localStorage.getItem(LAYOUT_KEY);
const a = raw ? JSON.parse(raw) : null;
if (!a || typeof a !== 'object') return null;
return {
left: Number(a.left) || 0,
main: Number(a.main) || 0,
right: Number(a.right) || 0,
};
} catch { return null; }
}
function writeLayout(v) {
try { localStorage.setItem(LAYOUT_KEY, JSON.stringify(v)); } catch { /* 隐私模式忽略 */ }
}
function AdminGuard({ children }) {
const [state, setState] = useState('checking'); // checking | denied | ok
useEffect(() => {
if (!getToken()) { window.location.href = '/login.html'; return; }
me()
.then((u) => {
if (u.role !== 'admin') setState('denied');
else setState('ok');
})
.catch(() => { window.location.href = '/login.html'; });
}, []);
if (state === 'checking') {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
<CircularProgress />
</Box>
);
}
if (state === 'denied') {
return (
<Box
sx={{
height: '100vh', display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 2, px: 3, textAlign: 'center',
}}
>
<LockIcon sx={{ fontSize: 48, color: 'error.main' }} />
<Typography variant="h6">需要管理员权限</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary', maxWidth: 360 }}>
面板工作台仅对管理员开放如需使用请使用管理员账号登录或返回前台
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" startIcon={<HomeIcon />} href="/">返回前台</Button>
<Button variant="contained" href="/login.html">重新登录</Button>
</Box>
</Box>
);
}
return children;
}
function EmptyState({ icon, title, hint, action, onAction }) {
return (
<Box
sx={{
flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center',
}}
>
{icon}
<Box>
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>{title}</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{hint}</Typography>
</Box>
{action && <Button variant="contained" startIcon={<AddIcon />} onClick={onAction}>{action}</Button>}
</Box>
);
}
function AboutDialog({ open, onClose }) {
return (
<Dialog open={open} onClose={onClose} maxWidth="xs">
<DialogTitle>关于 Rain Work</DialogTitle>
<DialogContent>
<Typography variant="body2" sx={{ mb: 1 }}>
Rain Work 工作台 v2.1.0 管理面板记事本密码箱与终端的一体化工作区
</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
终端为管理员 Root Shell需单独设置终端密码记事本文件保存在服务器 workspace 目录
面板可拖拽到左右任一活动栏按你的习惯排布
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>关闭</Button>
</DialogActions>
</Dialog>
);
}
/** 面板分隔条:可拖拽 = 4px 命中区 + 悬停高亮;不可拖拽 = 1px 细线 */
function ResizeSeparator({ disabled = false }) {
const muiTheme = useTheme();
const [hover, setHover] = useState(false);
return (
<Separator
disabled={disabled}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
style={{
width: disabled ? 1 : 4,
background: hover ? muiTheme.palette.primary.main : 'transparent',
transition: 'background .12s ease',
}}
/>
);
}
function WorkbenchContent() {
const panels = usePanels();
const layout = useWorkbenchLayout();
const {
links, loadError, reload,
openPanels, renderedIds,
activeId, activePanel, modalPanel, closeModal,
canBack, canForward, goBack, goForward,
openPanel, closePanel, togglePin, toggleGroup, setMode,
pinned, groups, modes,
search, setSearch, filteredPanels, openFirstMatch,
} = panels;
const [addOpen, setAddOpen] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
const [mainRefresh, setMainRefresh] = useState(0);
const [modalRefresh, setModalRefresh] = useState(0);
// 主区激活标签:同步从 localStorage 恢复(workbench.mainActive),刷新后回到上次激活项
const mainActiveInit = useRef(null);
const [activeMainKey, setActiveMainKey] = useState(() => {
let v = null;
try { v = localStorage.getItem(MAIN_ACTIVE_KEY); } catch { /* ignore */ }
mainActiveInit.current = v;
return v;
});
const [activeDrag, setActiveDrag] = useState(null); // dnd overlay {id,label}
const searchRef = useRef(null);
// ---- react-resizable-panels 引用 ----
const groupRef = useRef(null);
const mainRef = useRef(null); // 主面板(始终 ≥minSize 可见),用作 setLayout 像素↔百分比换算探针
const leftRef = useRef(null);
const rightRef = useRef(null);
const savedLayout = useRef(readLayout());
// 侧边栏折叠状态(供折叠箭头方向/语义;拖拽折叠也会经 onResize 同步)
const [leftCollapsed, setLeftCollapsed] = useState(
() => !!savedLayout.current && savedLayout.current.left === 0
);
const [rightCollapsed, setRightCollapsed] = useState(
() => !!savedLayout.current && savedLayout.current.right === 0
);
// v4 布局为 { PanelId: 百分比 };活动栏有 min/max 48px 硬约束,传多少都会被钳回 48
const defaultLayout = savedLayout.current
? {
'activity-l': 3,
'sidebar-l': Math.max(0, Math.min(40, savedLayout.current.left)),
// main 上限 75%:防止恢复时主区过大挤压两侧栏(右侧栏在布局末尾最易被挤到亚像素卡死)
main: Math.max(20, Math.min(75, savedLayout.current.main)),
'sidebar-r': Math.max(0, Math.min(40, savedLayout.current.right)),
'activity-r': 3,
}
: undefined;
const persistLayout = useCallback((l) => {
const v = (id) => Math.round(l?.[id] ?? 0);
writeLayout({ left: v('sidebar-l'), main: v('main'), right: v('sidebar-r') });
}, []);
/** 立即保存布局(折叠/展开等程序化变更时兜底,拖拽走 onLayoutChanged */
const saveLayoutNow = useCallback(() => {
try { persistLayout(groupRef.current?.getLayout()); } catch { /* 布局尚未就绪 */ }
}, [persistLayout]);
// 拖拽期间 onLayoutChanged 高频触发:200ms 防抖后再写 localStorage,避免阻塞主线程
const layoutTimer = useRef(null);
const handleLayoutChanged = useCallback((layout, { isUserInteraction }) => {
if (!isUserInteraction) return;
clearTimeout(layoutTimer.current);
layoutTimer.current = setTimeout(() => persistLayout(layout), 200);
}, [persistLayout]);
useEffect(() => () => clearTimeout(layoutTimer.current), []);
/** 程序化调整侧边栏宽度:走 groupRef.setLayout() 整体替换布局。
* 不能再依赖 ref.collapse()/resize()/expand()——RRP 命令式 API 按 pivot 相邻面板分配
* delta,右侧栏的右邻 activity-r 是刚性面板(minSize=maxSize=48px),delta 被其 clamp
* 全部吸收 → 布局不变 → 静默 no-op;拖拽走 Separator(邻面板 main 柔性)所以只有拖拽有效。
* setLayout() 不走 pivotK() 直接归一化 + 逐面板 clamp,目标尺寸落在目标面板上。 */
const applySidebarSize = useCallback((side, targetPx) => {
const group = groupRef.current;
if (!group) return;
const cur = group.getLayout();
// defaultLayoutDeferred 期间 getLayout() 返回 {}setLayout 也不应用——直接跳过
if (!cur || Object.keys(cur).length === 0) return;
// 用任一可见面板换算组宽度:groupPx = inPixels / (asPercentage/100)
let groupPx = 0;
for (const probe of [mainRef.current, rightRef.current, leftRef.current]) {
try {
const g = probe?.getSize();
if (g && g.inPixels > 0 && g.asPercentage > 0) {
groupPx = g.inPixels / (g.asPercentage / 100);
break;
}
} catch { /* 面板尚未就绪 */ }
}
const id = side === 'left' ? 'sidebar-l' : 'sidebar-r';
if (groupPx > 0) {
group.setLayout({ ...cur, [id]: (targetPx / groupPx) * 100 });
} else if (targetPx === 0) {
group.setLayout({ ...cur, [id]: 0 }); // 折叠:无法换算也直接置 0
} else {
group.setLayout({ ...cur, [id]: side === 'left' ? 20 : 25 }); // 退化固定百分比,K 的 clamp 兜底
}
}, []);
const toggleSidebar = useCallback((side) => {
// 用状态(阈值判定)而非 ref.isCollapsed()RRP 零容差比较在亚像素宽度下会误判
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
applySidebarSize(side, collapsed ? SIDEBAR_DEFAULT_WIDTH[side] : 0);
setTimeout(saveLayoutNow, 0); // setLayout 的 onLayoutChanged 不带 isUserInteraction,须手动兜底写盘
}, [leftCollapsed, rightCollapsed, applySidebarSize, saveLayoutNow]);
// ---- 视图(活动栏 / 侧栏 / 标签页) ----
const leftActive = layout.activeViewOf('left');
const rightActive = layout.activeViewOf('right');
const leftBarViews = layout.order.left.map((id) => VIEWS[id]).filter(Boolean);
const rightBarViews = layout.order.right.map((id) => VIEWS[id]).filter(Boolean);
// 侧栏只渲染 sidebar 模式的视图(tab 模式的进主区标签页)
const leftPaneViews = leftBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
const rightPaneViews = rightBarViews.filter((v) => layout.mode[v.id] === VIEW_MODE_SIDEBAR);
const isViewActive = (vid) => {
if (layout.mode[vid] === VIEW_MODE_TAB) return effectiveActiveKey === 'view:' + vid;
const side = layout.sideOf[vid];
return side ? layout.activeViewOf(side) === vid : false;
};
/** 主区标签列表(每次渲染重建,保证 render() 闭包拿到最新状态) */
const buildMainTabs = () => {
const tabs = [];
openPanels.forEach((p) => {
tabs.push({
key: 'panel:' + p.id,
type: 'panel',
title: p.title,
closable: true,
icon: <PanelIcon url={p.embed_url || p.url} title={p.title} size={14} />,
panel: p,
});
});
VIEW_IDS.forEach((id) => {
if (layout.mode[id] !== VIEW_MODE_TAB) return;
const v = VIEWS[id];
tabs.push({
key: 'view:' + id,
type: id,
title: v.label,
closable: true,
icon: <v.Icon sx={{ fontSize: 14, color: 'text.secondary' }} />,
render: () => renderViewContent(id),
});
});
return tabs;
};
// 视图内容渲染(先定义:buildMainTabs / renderPaneView 均依赖)
const renderViewContent = (viewId) => {
if (viewId === 'panels') {
return (
<PanelsView
loading={links === null && !loadError}
error={loadError}
onRetry={reload}
links={links || []}
filtered={filteredPanels}
search={search}
activeId={activeId}
pinned={pinned}
onSelect={openPanel}
onPin={togglePin}
groups={groups}
onToggleGroup={toggleGroup}
modes={modes}
onSetMode={setMode}
onAdd={() => setAddOpen(true)}
/>
);
}
if (viewId === 'notes') return <NotesView />;
if (viewId === 'vault') return <VaultView open={isViewActive('vault')} />;
return null;
};
const mainTabs = buildMainTabs();
// 活动主标签:优先记住的用户选择(含持久化的上次激活项),失效则回退到第一个
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
? activeMainKey
: (mainTabs[0]?.key ?? null);
// 打开面板 / 历史导航时自动切到对应 iframe 标签。
// 首次挂载若已有持久化激活项(可能指向 tab 模式视图)则尊重它,只消费一次;
// 之后每次 activeId 变化都同步并持久化。
useEffect(() => {
if (activeId == null) return;
if (mainActiveInit.current) { mainActiveInit.current = null; return; }
const key = 'panel:' + activeId;
setActiveMainKey(key);
persistMainActive(key);
}, [activeId]);
const panelRenderKeys = new Set(renderedIds.map((id) => 'panel:' + id));
const handleSelectTab = (key) => {
setActiveMainKey(key);
persistMainActive(key);
if (key.startsWith('panel:')) openPanel(key.slice('panel:'.length));
};
const handleCloseTab = (key) => {
if (key.startsWith('panel:')) closePanel(key.slice('panel:'.length));
else if (key.startsWith('view:')) setViewModeSafely(key.slice('view:'.length), VIEW_MODE_SIDEBAR);
};
/** 侧边栏 Panel onResize:阈值判定折叠状态(拖拽折叠/亚像素挤压都覆盖) */
const onSidebarResize = useCallback((side) => (size) => {
const px = size && typeof size === 'object' ? size.inPixels : size;
const collapsed = px < SIDEBAR_COLLAPSED_PX;
if (side === 'left') setLeftCollapsed(collapsed);
else setRightCollapsed(collapsed);
}, []);
const categories = [...new Set((links || []).map((p) => p.category || '默认'))];
const handleOpenExternal = () => {
if (activePanel) window.open(activePanel.embed_url || activePanel.url, '_blank', 'noopener');
};
const handleSaved = (panel) => {
showSnack(`已添加「${panel.title}`);
setAddOpen(false);
reload().then(() => openPanel(panel.id, panel));
};
/** 模式切换入口:切换前派发保存事件(记事本立即 flush 未落盘草稿),再切模式 */
const setViewModeSafely = useCallback((viewId, mode) => {
if (viewId === 'notes') {
try { window.dispatchEvent(new CustomEvent('workbench:notes-save-request')); } catch { /* ignore */ }
}
layout.setViewMode(viewId, mode);
}, [layout]);
/** 侧栏内单个视图:ToolWindow 标题栏 + 内容 */
const renderPaneView = (viewId) => {
const v = VIEWS[viewId];
const VIcon = v.Icon;
return (
<>
<ToolWindow
icon={<VIcon sx={{ fontSize: 17, color: 'primary.main' }} />}
title={v.label}
mode={layout.mode[viewId]}
onSetMode={(m) => setViewModeSafely(viewId, m)}
onFloat={() => showSnack('浮动模式即将上线', 'info')}
closeTitle="关闭侧边栏"
onClose={() => {
// X = 关闭整个侧边栏(折叠到 0 宽度),活动栏图标仍可重新展开
const side = layout.sideOf[viewId];
toggleSidebar(side);
}}
/>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{renderViewContent(viewId)}
</Box>
</>
);
};
const renderPlaceholder = (side) => {
const vid = layout.activeViewOf(side);
const v = VIEWS[vid];
if (!v) return null;
return (
<>
<v.Icon sx={{ fontSize: 28, color: 'text.disabled' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{v.label}已在标签页中打开
</Typography>
<Button size="small" variant="outlined" onClick={() => setViewModeSafely(vid, VIEW_MODE_SIDEBAR)}>
移回侧边栏
</Button>
</>
);
};
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起(VSCode 语义) */
const handleActivateView = (viewId, side) => {
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
const alreadyActive = layout.activeViewOf(side) === viewId;
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
layout.activateView(viewId);
// setLayout 整体替换:展开到默认宽度 / 折叠到 0,绕开 pivot 刚性邻居吸收问题
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
setTimeout(saveLayoutNow, 0);
};
// ---- 菜单栏配置 ----
const menus = [
{
label: '文件', items: [
{ label: '添加面板…', Icon: AddIcon, action: () => setAddOpen(true) },
{ label: '刷新面板列表', Icon: RefreshIcon, action: () => reload() },
{ divider: true },
{ label: '返回前台', Icon: HomeIcon, action: () => { window.location.href = '/'; } },
],
},
{
label: '编辑', items: [
{ label: '搜索面板', Icon: SearchIcon, action: () => searchRef.current?.focus() },
{ label: '刷新当前面板', Icon: RefreshIcon, action: () => { if (activePanel) setMainRefresh((n) => n + 1); } },
],
},
{
label: '视图', items: [
{ label: '切换左侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('left') },
{ label: '切换右侧边栏', Icon: ViewSidebarIcon, action: () => toggleSidebar('right') },
{ divider: true },
...VIEW_IDS.map((id) => ({
label: VIEWS[id].label,
Icon: VIEWS[id].Icon,
action: () => { setViewModeSafely(id, VIEW_MODE_SIDEBAR); layout.activateView(id); },
})),
],
},
{
label: '终端', items: [
// 终端功能临时下线(TerminalPanel 已归档到 archive/),保留占位提示,后续恢复
{ label: '终端功能即将回归', Icon: TerminalIcon },
],
},
{
label: '帮助', items: [
{ label: '关于工作台', Icon: InfoOutlinedIcon, action: () => setAboutOpen(true) },
{ label: '管理后台', Icon: SettingsIcon, action: () => { window.location.href = '/admin/dashboard'; } },
],
},
];
// ---- dnd-kit 拖拽(活动栏标签换边 / 同侧排序;键盘操作走 Tab+Enter 激活) ----
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
);
const handleDragStart = (event) => {
const id = String(event.active.id);
if (!id.startsWith('view:')) return;
const v = VIEWS[id.slice('view:'.length)];
if (v) setActiveDrag({ id: v.id, label: v.label, Icon: v.Icon });
};
const handleDragEnd = (event) => {
setActiveDrag(null);
const { active, over } = event;
if (!over) return;
const vid = String(active.id).replace(/^view:/, '');
if (!VIEWS[vid]) return;
const overStr = String(over.id);
if (overStr.startsWith('activity:')) {
const side = overStr.slice('activity:'.length);
layout.moveView(vid, side);
} else if (overStr.startsWith('view:')) {
const overVid = overStr.slice('view:'.length);
const mySide = layout.sideOf[vid];
const overSide = layout.sideOf[overVid];
if (mySide && overSide && mySide === overSide) layout.reorderView(vid, overVid);
else if (overSide) layout.moveView(vid, overSide);
}
};
// ---- 主区内容(加载/错误/空态;有标签时一律走 ContentView 分发) ----
let mainBody;
if (links === null && !loadError) {
mainBody = (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress size={36} />
</Box>
);
} else if (loadError) {
mainBody = (
<EmptyState
icon={<ErrorIcon sx={{ fontSize: 44, color: 'error.main' }} />}
title="面板列表加载失败"
hint={loadError}
action="重试"
onAction={reload}
/>
);
} else if (mainTabs.length === 0 && links.length === 0) {
mainBody = (
<EmptyState
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'primary.main' }} />}
title="还没有任何面板"
hint="添加常用后台 / 监控 / 工具站到工作台,一个界面全部打开"
action="添加第一个面板"
onAction={() => setAddOpen(true)}
/>
);
} else if (mainTabs.length === 0) {
mainBody = (
<EmptyState
icon={<WidgetsIcon sx={{ fontSize: 44, color: 'text.disabled' }} />}
title="未打开面板"
hint="从左侧选择一个面板开始,或搜索后按 Enter 直达"
action="打开一个面板"
onAction={() => { if (filteredPanels[0]) openPanel(filteredPanels[0].id); }}
/>
);
} else {
mainBody = (
<ContentView
tabs={mainTabs}
activeKey={effectiveActiveKey}
refreshNonce={mainRefresh}
panelRenderKeys={panelRenderKeys}
/>
);
}
return (
<>
<Toolbar
menus={menus}
canBack={canBack}
canForward={canForward}
onBack={goBack}
onForward={goForward}
onRefresh={() => { if (activePanel) setMainRefresh((n) => n + 1); }}
currentPanel={activePanel}
onOpenExternal={handleOpenExternal}
search={search}
onSearchChange={setSearch}
onSearchEnter={() => {
if (!openFirstMatch() && search.trim()) showSnack('未找到匹配的面板', 'info');
}}
searchInputRef={searchRef}
onBackHome={() => { window.location.href = '/'; }}
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveDrag(null)}
>
<Group
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={handleLayoutChanged}
groupRef={groupRef}
style={{ flex: 1, minHeight: 0, display: 'flex' }}
>
{/* 左侧活动栏 */}
<Panel id="activity-l" defaultSize={48} minSize={48} maxSize={48}>
<ActivityBar
side="left"
views={leftBarViews}
activeId={leftActive}
onActivate={(id) => handleActivateView(id, 'left')}
/>
</Panel>
<ResizeSeparator disabled />
{/* 左侧侧边栏 */}
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
<SidebarPane
side="left"
views={leftPaneViews}
activeId={leftActive}
renderView={renderPaneView}
renderPlaceholder={() => renderPlaceholder('left')}
/>
</Panel>
<ResizeSeparator />
{/* 主区:标签条 + 内容 + 底部终端 */}
<Panel id="main" minSize={320} panelRef={mainRef}>
{/* 注意:RRP Panel 内容容器是 block 布局(maxHeight:100% + flexGrow:1),
这里必须用 height:'100%' 而非 flex:1,否则高度塌陷导致内容挤顶部 */}
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', minWidth: 0, overflow: 'hidden', position: 'relative' }}>
{mainTabs.length > 0 && (
<TabStrip
tabs={mainTabs}
activeKey={effectiveActiveKey}
onSelect={handleSelectTab}
onClose={handleCloseTab}
onAdd={() => setAddOpen(true)}
/>
)}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
{mainBody}
</Box>
{/* 侧边栏折叠箭头:贴主区整高的边缘(即侧边栏与主区 Separator 位置)垂直居中,
折叠为 0 后主区仍在 → 箭头始终可点,支持再点击展开 */}
<CollapseArrow side="left" collapsed={leftCollapsed} onToggle={() => toggleSidebar('left')} />
<CollapseArrow side="right" collapsed={rightCollapsed} onToggle={() => toggleSidebar('right')} />
</Box>
</Panel>
<ResizeSeparator />
{/* 右侧侧边栏 */}
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef} onResize={onSidebarResize('right')}>
<SidebarPane
side="right"
views={rightPaneViews}
activeId={rightActive}
renderView={renderPaneView}
renderPlaceholder={() => renderPlaceholder('right')}
/>
</Panel>
<ResizeSeparator disabled />
{/* 右侧活动栏 */}
<Panel id="activity-r" defaultSize={48} minSize={48} maxSize={48}>
<ActivityBar
side="right"
views={rightBarViews}
activeId={rightActive}
onActivate={(id) => handleActivateView(id, 'right')}
/>
</Panel>
</Group>
{/* 拖拽浮层(纯展示,禁止渲染 useSortable 组件) */}
<DragOverlay dropAnimation={null}>
{activeDrag ? (
<Box
sx={{
px: 1.25, py: 1, borderRadius: 2,
bgcolor: 'background.paper', border: 1, borderColor: 'primary.main',
boxShadow: (t) => t.shadows[6],
display: 'flex', alignItems: 'center', gap: 1,
fontSize: 12.5, fontWeight: 600,
}}
>
<activeDrag.Icon sx={{ fontSize: 16, color: 'primary.main' }} />
{activeDrag.label}
</Box>
) : null}
</DragOverlay>
</DndContext>
<StatusBar left={`已打开 ${openPanels.length} 个面板${activePanel ? ` · ${activePanel.title}` : ''}`} />
<AddPanelDialog
open={addOpen}
onClose={() => setAddOpen(false)}
categories={categories}
onSaved={handleSaved}
onSetMode={setMode}
/>
{/* 弹窗打开方式:Dialog 内嵌 PanelFrame */}
{modalPanel && (
<Dialog
open
fullWidth
maxWidth="lg"
onClose={closeModal}
sx={{ '& .MuiDialog-paper': { height: '85vh', maxHeight: '85vh' } }}
>
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1.5, pr: 1 }}>
<PanelIcon url={modalPanel.embed_url || modalPanel.url} title={modalPanel.title} size={22} />
<Typography noWrap sx={{ flex: 1, fontSize: 16, fontWeight: 500 }}>
{modalPanel.title}
</Typography>
<Tooltip title="在新标签页打开">
<IconButton
size="small"
onClick={() => window.open(modalPanel.embed_url || modalPanel.url, '_blank', 'noopener')}
>
<OpenInNewIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="刷新">
<IconButton size="small" onClick={() => setModalRefresh((n) => n + 1)}>
<RefreshIcon fontSize="small" />
</IconButton>
</Tooltip>
<IconButton size="small" onClick={closeModal} aria-label="关闭弹窗">
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent sx={{ p: 0, display: 'flex', flex: 1, minHeight: 0 }}>
<PanelFrame panel={modalPanel} active refreshNonce={modalRefresh} />
</DialogContent>
</Dialog>
)}
<AboutDialog open={aboutOpen} onClose={() => setAboutOpen(false)} />
<SnackHost />
</>
);
}
export default function Workbench() {
return (
<AdminGuard>
<Box
sx={{
display: 'flex', flexDirection: 'column', height: '100vh', overflow: 'hidden',
bgcolor: 'background.default',
}}
>
<WorkbenchContent />
</Box>
</AdminGuard>
);
}
@@ -0,0 +1,752 @@
import React, {
forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState,
} from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import CircularProgress from '@mui/material/CircularProgress';
import AddIcon from '@mui/icons-material/Add';
import CloseIcon from '@mui/icons-material/Close';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import ErrorIcon from '@mui/icons-material/Error';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import TerminalIcon from '@mui/icons-material/Terminal';
import RefreshIcon from '@mui/icons-material/Refresh';
import Stack from '@mui/material/Stack';
import { useTheme } from '@mui/material/styles';
import { Terminal } from '@xterm/xterm';
import '@xterm/xterm/css/xterm.css';
import { FitAddon } from '@xterm/addon-fit';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { terminalStatus, terminalAuth } from '../../../api/terminal.js';
import { showSnack } from '../../../admin/snack.jsx';
/* ============================================================
* TerminalPanel:底部终端面板(xterm.js v6
*
* 安全门禁(后端 routes/terminal.js 契约):
* /api/terminal/status → hasPin ? /api/terminal/auth(pin) → 短 TTL token
* → ws(s)://host/ws/terminal?token=…(Root shelladmin + 终端密码双认证)
*
* - 最多 3 个会话(标签页切换,keep-alivedisplay 显隐不销毁)
* - 消息协议:输入/输出二进制;控制 JSON {type:'auth'|'resize'|'ping'|'bye'}
* - 断线指数退避重连 [250,500,1000,2000,4000,8000],恢复后 term.reset()
* - 关闭码:4001 token 过期→回 PIN 门;1013/503 会话满;1011 node-pty 不可用;
* 1000(exit)/4000(空闲) 为服务端主动终止→会话结束,需用户显式「重新连接」
* (不自动重生 root shell);仅 1006 等网络异常自动重连
* - CJK 必装 unicode11,字体栈 JetBrains Mono + Noto Sans Mono CJK SC
* - 明暗主题由 MUI MD3 palette 派生,随 data-theme 切换自动更新
* ============================================================ */
const MAX_SESSIONS = 3;
const BACKOFF = [250, 500, 1000, 2000, 4000, 8000];
const PING_MS = 30000;
const TOKEN_MARGIN_MS = 4 * 60 * 1000; // 5 分钟 TTL,提前 1 分钟视为过期
const FONT = '"JetBrains Mono","Noto Sans Mono CJK SC","Microsoft YaHei",monospace';
/* ---------- 颜色工具(由 MD3 palette 派生,不硬编码) ---------- */
function hexToRgb(hex) {
let h = String(hex).replace('#', '');
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
if (!/^[0-9a-f]{6}$/i.test(h)) return [128, 128, 128];
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function mixHex(a, b, w) {
const ca = hexToRgb(a);
const cb = hexToRgb(b);
return `#${ca.map((v, i) => Math.round(v + (cb[i] - v) * w).toString(16).padStart(2, '0')).join('')}`;
}
function xtermTheme(muiTheme) {
const p = muiTheme.palette;
const dark = p.mode === 'dark';
const bg = p.background.paper;
const black = mixHex(p.text.primary, bg, 0.6);
const brightBlack = mixHex(p.text.primary, bg, 0.2);
const base = [
black, // 0 黑
p.error.main, // 1 红
p.success.main, // 2 绿
p.warning.main, // 3 黄
p.info.main, // 4 蓝
p.tertiary.main, // 5 品红
mixHex(p.secondary.main, p.info.main, 0.5), // 6 青
p.text.primary, // 7 白
];
const bright = base.map((c) => mixHex(c, dark ? '#ffffff' : '#000000', dark ? 0.55 : 0.45));
const name = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
const out = {
background: bg,
foreground: p.text.primary,
cursor: p.primary.main,
cursorAccent: bg,
selectionBackground: mixHex(p.primary.main, bg, 0.72),
brightBlack,
};
name.forEach((n, i) => { out[n] = base[i]; out['bright' + n[0].toUpperCase() + n.slice(1)] = bright[i]; });
return out;
}
/* ---------- WebSocket URL ---------- */
// 认证 token 由 /api/terminal/auth 种 HttpOnly cookieterminal_token5 分钟),
// WS 握手同源自动携带,不再拼 ?token= 到 URL(避免落入 access log)。
function wsUrl() {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${proto}//${window.location.host}/ws/terminal`;
}
/**
* 终端面板
* @param {{open: boolean}} props open=底部面板是否展开(展开才查状态;收起 keep-alive
* @ref newSession() / lock()
*/
const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
const muiTheme = useTheme();
const themeObj = useMemo(() => xtermTheme(muiTheme), [muiTheme]);
// ---- 门禁状态:checking | needs-setup | locked | ready | error ----
const [gate, setGate] = useState('checking');
const [gateError, setGateError] = useState('');
const [pinInput, setPinInput] = useState('');
const [showPin, setShowPin] = useState(false);
const [pinBusy, setPinBusy] = useState(false);
// ---- 会话:{id,title,status} + 底层实例(term/ws 等) ----
const [sessions, setSessions] = useState([]);
const [activeId, setActiveId] = useState(null);
const [banner, setBanner] = useState(null); // {type:'warn'|'error', text}
const instRef = useRef({}); // id → {term,fit,ro,ws,backoffIdx,timers,autoReconnect,closed}
const tokenRef = useRef({ value: null, expiresAt: 0 });
const seqRef = useRef(0);
const stripRef = useRef(null); // 会话标签条(方向键 roving tabindex 用)
const patchStatus = useCallback((id, status) => {
setSessions((prev) => prev.map((s) => (s.id === id ? { ...s, status } : s)));
}, []);
/* ---------- WS 连接层(一次性初始化,全走 ref,无陈旧闭包) ---------- */
const manager = useRef(null);
if (!manager.current) {
const M = {};
M.sendResize = (id) => {
const inst = instRef.current[id];
if (!inst || !inst.ws || inst.ws.readyState !== 1 || !inst.term) return;
try {
inst.ws.send(JSON.stringify({ type: 'resize', cols: inst.term.cols, rows: inst.term.rows }));
} catch { /* ignore */ }
};
M.handleControl = (id, text) => {
try {
const msg = JSON.parse(text);
if (msg.type === 'bye') { /* server 主动结束,交由 close 处理 */ }
// 'pong' / 未知控制消息忽略
} catch { /* 非 JSON 文本忽略 */ }
};
M.scheduleReconnect = (id) => {
const inst = instRef.current[id];
if (!inst || inst.closed) return;
const delay = BACKOFF[Math.min(inst.backoffIdx, BACKOFF.length - 1)];
inst.backoffIdx = Math.min(inst.backoffIdx + 1, BACKOFF.length - 1);
clearTimeout(inst.reconnectTimer);
inst.reconnectTimer = setTimeout(() => M.connectSession(id), delay);
};
M.connectSession = async (id) => {
const inst = instRef.current[id];
if (!inst || inst.closed) return;
// 防双 WS 竞态:关闭旧连接并置空其回调(避免旧 onclose 触发多余重连)
try {
if (inst.ws) { inst.ws.onclose = null; inst.ws.onerror = null; try { inst.ws.close(); } catch {} }
} catch {}
inst.ws = null;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
inst.gen = (inst.gen || 0) + 1;
const gen = inst.gen;
patchStatus(id, 'connecting');
// 无有效 token → 回 PIN 门(4001 过期或从未认证)
const token = tokenRef.current.value;
if (!token) {
inst.autoReconnect = false;
setGate('locked');
setGateError('终端密码已过期,请重新输入');
return;
}
// 先查状态:后端不可用 / 会话满 给明确提示,避免盲连
try {
const st = await terminalStatus();
if (st.sessions >= st.maxSessions) {
inst.autoReconnect = false;
patchStatus(id, 'error');
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
return;
}
} catch {
if (!inst.closed && gen === inst.gen) {
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
patchStatus(id, 'reconnecting');
M.scheduleReconnect(id);
}
return;
}
let ws = null;
try { ws = new WebSocket(wsUrl()); } catch { /* 构造失败走 onerror/onclose */ }
if (!ws) { M.scheduleReconnect(id); return; }
inst.ws = ws;
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
if (inst.closed || gen !== inst.gen) { try { ws.close(); } catch {} return; }
inst.backoffIdx = 0;
setBanner(null);
try { ws.send(JSON.stringify({ type: 'auth', token })); } catch {}
M.sendResize(id);
// 断线恢复后清屏,避免旧输出残留
try { inst.term?.reset(); } catch {}
patchStatus(id, 'connected');
inst.pingTimer = setInterval(() => {
if (inst.closed || gen !== inst.gen || ws.readyState !== 1) return;
try { ws.send(JSON.stringify({ type: 'ping' })); } catch {}
}, PING_MS);
};
ws.onmessage = (ev) => {
if (inst.closed || gen !== inst.gen) return;
if (typeof ev.data === 'string') { M.handleControl(id, ev.data); return; }
try {
inst.term?.write(ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : ev.data);
} catch { /* ignore */ }
};
ws.onerror = () => { try { ws.close(); } catch {} };
ws.onclose = (ev) => {
if (inst.closed || gen !== inst.gen) return;
clearInterval(inst.pingTimer);
inst.ws = null;
// 关闭码语义(后端 routes/terminal.js 定义)
if (ev.code === 4001) { // token 无效/过期 → 回 PIN 门重新认证
tokenRef.current = { value: null, expiresAt: 0 };
inst.autoReconnect = false;
setGate('locked');
setGateError('终端密码已过期,请重新输入');
return;
}
if (ev.code === 1013 || ev.code === 503) { // 会话超限
inst.autoReconnect = false;
patchStatus(id, 'error');
setBanner({ type: 'error', text: '终端会话数已达上限(最多 3 个),请稍后再试' });
return;
}
if (ev.code === 1011) { // pty 启动失败 / node-pty 未安装
inst.autoReconnect = false;
patchStatus(id, 'error');
setBanner({ type: 'error', text: '终端服务不可用:node-pty 未安装或启动失败' });
return;
}
// 服务端主动结束会话:exit(1000) / 空闲超时(4000)
// 视为会话终止,不自动重生新 root shell;由用户显式「重新连接」
if (ev.code === 1000 || ev.code === 4000) {
inst.autoReconnect = false;
inst.endedReason = ev.code === 4000 ? 'idle' : 'exit';
patchStatus(id, 'ended');
return;
}
// 其余(1006 网络异常等)→ 指数退避自动重连
setBanner({ type: 'warn', text: '终端服务未连接,正在重试…' });
if (inst.autoReconnect) { patchStatus(id, 'reconnecting'); M.scheduleReconnect(id); }
else patchStatus(id, 'error');
};
};
manager.current = M;
}
/* ---------- 门禁 ---------- */
const checkGate = useCallback(async () => {
setGate('checking');
setGateError('');
try {
const st = await terminalStatus();
if (tokenRef.current.value && Date.now() < tokenRef.current.expiresAt) { setGate('ready'); return; }
setGate(st.hasPin ? 'locked' : 'needs-setup');
} catch (e) {
setGate('error');
setGateError(e.message || '无法连接终端服务');
}
}, []);
// 首次展开面板才查状态(收起时 keep-alive,不重复请求)
useEffect(() => {
if (open && gate === 'checking') checkGate();
}, [open, gate, checkGate]);
const submitPin = useCallback(async () => {
const pin = pinInput.trim();
if (!pin) { setGateError('请输入终端密码'); return; }
setPinBusy(true);
setGateError('');
try {
const res = await terminalAuth(pin);
tokenRef.current = { value: res.token, expiresAt: Date.now() + TOKEN_MARGIN_MS };
setPinInput('');
setShowPin(false);
setGate('ready');
// 有会话在等 token → 全部重新连接
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
});
} catch (e) {
setGateError(e.message || '认证失败');
setPinInput('');
} finally {
setPinBusy(false);
}
}, [pinInput]);
/* ---------- 会话生命周期 ---------- */
const newSession = useCallback(() => {
if (gate !== 'ready') {
showSnack('请先解锁终端', 'info');
return;
}
const live = Object.keys(instRef.current).filter((k) => instRef.current[k] && !instRef.current[k].closed);
if (live.length >= MAX_SESSIONS) {
setBanner({ type: 'error', text: `终端会话数已达上限(最多 ${MAX_SESSIONS} 个)` });
return;
}
seqRef.current += 1;
const id = 't' + seqRef.current;
instRef.current[id] = {
id, term: null, fit: null, ro: null, ws: null,
backoffIdx: 0, reconnectTimer: null, pingTimer: null,
autoReconnect: true, closed: false, gen: 0,
};
setSessions((prev) => [...prev, { id, title: `终端 ${seqRef.current}`, status: 'connecting' }]);
setActiveId(id);
setBanner(null);
}, [gate]);
const closeSession = useCallback((id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.closed = true;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
try { if (inst.ws && inst.ws.readyState === 1) inst.ws.send(JSON.stringify({ type: 'bye' })); } catch {}
try { inst.ws?.close(1000, 'bye'); } catch {}
try { inst.term?.dispose(); } catch {}
try { inst.ro?.disconnect(); } catch {}
instRef.current[id] = null;
// 状态更新不嵌套:先算剩余列表,再分别 set(避免 updater 内副作用)
const remaining = sessions.filter((s) => s.id !== id);
setSessions(remaining);
setActiveId((cur) => (cur === id ? (remaining[remaining.length - 1]?.id ?? null) : cur));
setBanner(null);
}, [sessions]);
const lockTerminal = useCallback(() => {
tokenRef.current = { value: null, expiresAt: 0 };
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.closed = true;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
try { inst.ws?.close(1000, 'bye'); } catch {}
try { inst.term?.dispose(); } catch {}
try { inst.ro?.disconnect(); } catch {}
instRef.current[id] = null;
});
setSessions([]);
setActiveId(null);
setBanner(null);
setGate('locked');
}, []);
useImperativeHandle(ref, () => ({ newSession, lock: lockTerminal }), [newSession, lockTerminal]);
// 解锁后自动建首个会话
useEffect(() => {
if (gate === 'ready' && sessions.length === 0) newSession();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gate]);
// 卸载时统一销毁(keep-alive 场景下不触发)
useEffect(() => () => {
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.closed = true;
clearTimeout(inst.reconnectTimer);
clearInterval(inst.pingTimer);
try { inst.ws?.close(); } catch {}
try { inst.term?.dispose(); } catch {}
try { inst.ro?.disconnect(); } catch {}
});
}, []);
// 主题随 muiTheme / data-theme 更新(后台主题静态,运行期一般不触发)
useEffect(() => {
Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (inst?.term) inst.term.options.theme = themeObj;
});
}, [themeObj]);
/** xterm 挂载点:会话创建后容器出现时调用;隐藏恢复时重新 fit */
const mountTerminal = useCallback((id, el) => {
const inst = instRef.current[id];
if (!inst || !el) return;
if (inst.term) {
requestAnimationFrame(() => {
try {
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
} catch {}
});
return;
}
let term;
let fit = null;
try {
term = new Terminal({
fontSize: 13,
fontFamily: FONT,
lineHeight: 1.35,
cursorBlink: true,
scrollback: 3000,
theme: themeObj,
});
fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
term.open(el);
inst.term = term;
inst.fit = fit;
} catch (e) {
// xterm 初始化异常:显式报错而非白屏/整树崩溃
console.error('[TerminalPanel] xterm 初始化失败:', e);
patchStatus(id, 'error');
setBanner({ type: 'error', text: `终端初始化失败:${e?.message || '未知错误'}` });
return;
}
term.onData((d) => {
const ws = inst.ws;
if (!ws || ws.readyState !== 1) return;
try { ws.send(new TextEncoder().encode(d)); } catch {}
});
term.onResize(({ cols, rows }) => {
const ws = inst.ws;
if (ws && ws.readyState === 1) {
try { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } catch {}
}
});
const ro = new ResizeObserver(() => {
if (el.clientWidth > 0 && el.clientHeight > 0) {
try { fit.fit(); manager.current.sendResize(id); } catch {}
}
});
ro.observe(el);
inst.ro = ro;
// rAF fit 同样做尺寸守卫:容器零尺寸时跳过,避免 0 行列渲染成空白
requestAnimationFrame(() => {
try {
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit.fit(); manager.current.sendResize(id); }
} catch {}
});
manager.current.connectSession(id);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeObj]);
/* ---------- 渲染 ---------- */
// PIN / 设置门禁
if (gate === 'checking' || gate === 'needs-setup' || gate === 'locked' || gate === 'error') {
return (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
{gate === 'checking' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5 }}>
<CircularProgress size={26} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>正在检查终端服务</Typography>
</Box>
)}
{gate === 'error' && (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5, px: 3, textAlign: 'center' }}>
<ErrorIcon sx={{ fontSize: 38, color: 'error.main' }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>{gateError || '无法连接终端服务'}</Typography>
<Button size="small" variant="outlined" onClick={checkGate}>重试</Button>
</Box>
)}
{(gate === 'needs-setup' || gate === 'locked') && (
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitPin(); }} sx={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3 }}>
<TerminalIcon sx={{ fontSize: 34, color: 'primary.main', mb: 0.5 }} />
<Typography sx={{ fontWeight: 600, fontSize: 15 }}>
{gate === 'needs-setup' ? '首次使用:设置终端密码' : '输入终端密码解锁'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary', textAlign: 'center', mb: 1 }}>
{gate === 'needs-setup'
? '终端为管理员 Root Shell,请设置专用密码(至少 8 位,与账号密码独立)'
: '终端密码与账号密码相互独立,仅管理员可访问'}
</Typography>
<TextField
autoFocus
size="small"
type={showPin ? 'text' : 'password'}
label="终端密码"
value={pinInput}
onChange={(e) => { setPinInput(e.target.value); if (gateError) setGateError(''); }}
error={!!gateError}
helperText={gateError || ' '}
autoComplete={gate === 'needs-setup' ? 'new-password' : 'current-password'}
slotProps={{
htmlInput: { maxLength: 128, 'aria-label': '终端密码' },
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" edge="end" aria-label={showPin ? '隐藏密码' : '显示密码'} onClick={() => setShowPin((v) => !v)}>
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
sx={{ width: 300, maxWidth: '100%' }}
/>
<Button
type="submit"
variant="contained"
disabled={pinBusy || !pinInput}
startIcon={pinBusy ? <CircularProgress size={16} color="inherit" /> : <LockOpenIcon />}
sx={{ mt: 1 }}
>
{pinBusy ? '处理中…' : gate === 'needs-setup' ? '设置并连接' : '解锁终端'}
</Button>
</Box>
)}
</Box>
);
}
// ready:会话标签栏 + 终端
const live = sessions.length;
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
const moveSessionTab = (i) => {
const n = sessions.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
setActiveId(sessions[j].id);
tabEls()[j]?.focus();
};
const handleTabKeyDown = (e, index) => {
if (e.key === 'ArrowRight') { e.preventDefault(); moveSessionTab(index + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveSessionTab(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveSessionTab(0); }
else if (e.key === 'End') { e.preventDefault(); moveSessionTab(sessions.length - 1); }
};
/** 会话结束后显式重连:新建 WS 连接 = 服务端 spawn 新 shell */
const reconnectSession = (id) => {
const inst = instRef.current[id];
if (!inst) return;
inst.autoReconnect = true;
delete inst.endedReason;
manager.current.connectSession(id);
};
return (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', bgcolor: 'background.paper' }}>
{/* 会话标签栏 */}
<Box
ref={stripRef}
role="tablist"
aria-label="终端会话"
sx={{
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, height: 32, flexShrink: 0,
borderBottom: 1, borderColor: 'divider',
}}
>
{sessions.map((s, index) => {
const active = s.id === activeId;
return (
<Box
key={s.id}
id={`wb-term-tab-${s.id}`}
role="tab"
aria-selected={active}
aria-controls={`wb-term-panel-${s.id}`}
tabIndex={active ? 0 : -1}
onClick={() => setActiveId(s.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
height: 24, borderRadius: 0.5, cursor: 'pointer', flexShrink: 0, outline: 'none',
bgcolor: active ? 'action.selected' : 'transparent',
color: active ? 'text.primary' : 'text.secondary',
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
}}
>
<Box
component="span"
aria-hidden
sx={{
width: 7, height: 7, borderRadius: '50%', flexShrink: 0,
bgcolor: s.status === 'connected' ? 'success.main'
: s.status === 'error' ? 'error.main'
: s.status === 'ended' ? 'text.disabled' : 'warning.main',
}}
/>
<Typography sx={{ fontSize: 12, fontWeight: active ? 600 : 500 }}>{s.title}</Typography>
{live > 1 && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); closeSession(s.id); }}
aria-label={`关闭 ${s.title}`}
sx={{ width: 24, height: 24, borderRadius: 0.5, '& svg': { fontSize: 15 } }}
>
<CloseIcon />
</IconButton>
)}
</Box>
);
})}
{live < MAX_SESSIONS && (
<Tooltip title="新建终端会话">
<IconButton size="small" onClick={newSession} aria-label="新建终端会话" sx={{ ml: 0.25 }}>
<AddIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
<Box sx={{ flex: 1 }} />
<Tooltip title="锁定终端(需重新输入密码)">
<IconButton size="small" onClick={lockTerminal} aria-label="锁定终端" sx={{ mr: 0.25 }}>
<LockIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
{/* 连接状态横幅 */}
{banner && (
<Box
role="status"
sx={{
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5, flexShrink: 0,
bgcolor: banner.type === 'error' ? 'error.main' : 'warning.main',
color: banner.type === 'error' ? 'error.contrastText' : 'warning.contrastText',
borderBottom: 1, borderColor: 'divider',
}}
>
<Box
component="span"
aria-hidden
sx={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, bgcolor: 'currentColor', opacity: 0.8 }}
/>
<Typography variant="caption" sx={{ flex: 1, fontWeight: 500 }}>{banner.text}</Typography>
<Button
size="small"
onClick={() => Object.keys(instRef.current).forEach((id) => {
const inst = instRef.current[id];
if (inst && !inst.closed) { inst.autoReconnect = true; manager.current.connectSession(id); }
})}
sx={{ minHeight: 24, px: 1, color: 'inherit' }}
>
重试
</Button>
<IconButton size="small" onClick={() => setBanner(null)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Box>
)}
{/* 终端区 */}
<Box sx={{ flex: 1, minHeight: 0, position: 'relative', bgcolor: 'background.paper' }}>
{sessions.map((s) => (
<Box
key={s.id}
id={`wb-term-panel-${s.id}`}
role="tabpanel"
aria-labelledby={`wb-term-tab-${s.id}`}
sx={{
position: 'absolute', inset: 0,
display: activeId === s.id ? 'block' : 'none',
}}
>
<Box ref={(el) => mountTerminal(s.id, el)} sx={{ width: '100%', height: '100%' }} />
{/* 未连接状态条:防止"白屏"——始终可见的连接进度/错误提示(banner 可被关掉) */}
{s.status !== 'connected' && s.status !== 'ended' && (
<Box
role="status"
sx={{
position: 'absolute', left: 0, right: 0, bottom: 0, zIndex: 3,
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.5,
bgcolor: s.status === 'error' ? 'error.main' : 'warning.main',
color: s.status === 'error' ? 'error.contrastText' : 'warning.contrastText',
borderTop: 1, borderColor: 'divider',
}}
>
{s.status !== 'error' && <CircularProgress size={12} color="inherit" />}
<Typography variant="caption" sx={{ fontWeight: 500 }}>
{s.status === 'connecting' ? '正在连接终端服务…'
: s.status === 'reconnecting' ? '连接中断,正在重试…'
: '终端服务不可用'}
</Typography>
</Box>
)}
{/* 会话终止覆盖层:exit/空闲超时后不自动重生,显式重连 */}
{s.status === 'ended' && (
<Box
role="status"
sx={{
position: 'absolute', inset: 0, zIndex: 2,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.25,
bgcolor: 'background.paper', px: 3, textAlign: 'center',
}}
>
<TerminalIcon sx={{ fontSize: 28, color: 'text.disabled' }} />
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
{instRef.current[s.id]?.endedReason === 'idle' ? '会话已空闲断开' : '终端会话已结束'}
</Typography>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{instRef.current[s.id]?.endedReason === 'idle'
? '长时间无输入,连接被服务端关闭。点击「重新连接」可新建会话。'
: '输入 exit 退出或进程已结束。点击「重新连接」可新建会话。'}
</Typography>
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" startIcon={<RefreshIcon fontSize="small" />} onClick={() => reconnectSession(s.id)}>
重新连接
</Button>
<Button size="small" variant="text" onClick={() => closeSession(s.id)}>
关闭会话
</Button>
</Stack>
</Box>
)}
</Box>
))}
{sessions.length === 0 && (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1 }}>
<TerminalIcon sx={{ fontSize: 30, color: 'text.disabled' }} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>点击 新建终端会话</Typography>
</Box>
)}
</Box>
</Box>
);
});
export default TerminalPanel;
@@ -0,0 +1,124 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
/* ============================================================
* ActivityBarVSCode 风格活动栏 48px竖排图标
*
* - 左侧主控视图面板右侧功能视图记事本/密码箱
* - 活动项左侧 2.5px primary 指示条 + selected
* - 点击未激活 激活并展开侧栏已激活 收起侧栏VSCode 语义
* - 拖拽@dnd-kit sortable标签可拖到对侧活动栏 视图换边
* 同侧可排序父级 DndContext 负责 moveView / reorderView 落库
* - 无障碍role=toolbar仅活动项 tabIndex=0roving
* /Home/End 移动焦点Enter/空格 激活活动项 aria-controls 指向侧栏容器
* 侧栏内由 aria-live 播报当前视图 SidebarPane
* ============================================================ */
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
const {
attributes, listeners, setNodeRef, transform, transition, isDragging,
} = useSortable({ id: 'view:' + view.id });
const VIcon = view.Icon;
return (
<Tooltip title={view.label} placement="right">
<Box
ref={setNodeRef}
data-activity-item
{...attributes}
{...listeners}
role="button"
tabIndex={active ? 0 : -1}
aria-label={view.label}
aria-pressed={active}
aria-controls={active ? `wb-sidebar-${side}` : undefined}
onClick={() => onActivate(view.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onActivate(view.id); }
else if (e.key === 'ArrowDown') { e.preventDefault(); moveFocus(index + 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); moveFocus(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveFocus(0); }
else if (e.key === 'End') { e.preventDefault(); moveFocus(count - 1); }
}}
sx={{
position: 'relative', width: 48, height: 48, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'grab', touchAction: 'none', outline: 'none',
color: active ? 'primary.onContainer' : 'text.secondary',
bgcolor: active ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: active ? 'action.selected' : 'action.hover' },
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3, outlineStyle: 'solid' },
opacity: isDragging ? 0.35 : 1,
transform: transform ? `translate3d(${transform.x}px, ${transform.y}px, 0)` : undefined,
transition,
}}
>
{active && (
<Box
aria-hidden
sx={{
position: 'absolute', left: 0, top: 10, bottom: 10, width: 2.5,
bgcolor: 'primary.main', borderRadius: 1,
}}
/>
)}
<VIcon sx={{ fontSize: 22 }} />
</Box>
</Tooltip>
);
}
export default function ActivityBar({
side, views, activeId, onActivate,
}) {
const right = side === 'right';
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
const barRef = useRef(null);
const itemEls = () => Array.from(barRef.current?.querySelectorAll('[data-activity-item]') || []);
const moveFocus = (i) => {
const n = views.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
itemEls()[j]?.focus();
};
return (
<Box
ref={setNodeRef}
role="toolbar"
aria-label={right ? '右侧活动栏' : '左侧活动栏'}
aria-orientation="vertical"
aria-controls={`wb-sidebar-${side}`}
sx={{
width: 48, height: '100%', flexShrink: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center',
bgcolor: isOver ? 'action.hover' : 'background.paper',
borderRight: right ? 0 : 1,
borderLeft: right ? 1 : 0,
borderColor: 'divider',
}}
>
<SortableContext items={views.map((v) => 'view:' + v.id)} strategy={verticalListSortingStrategy}>
<Box ref={barRef} sx={{ display: 'flex', flexDirection: 'column', pt: 0.5 }}>
{views.map((v, index) => (
<ActivityTab
key={v.id}
view={v}
side={side}
index={index}
count={views.length}
moveFocus={moveFocus}
active={activeId === v.id}
onActivate={onActivate}
/>
))}
</Box>
</SortableContext>
<Box sx={{ flex: 1 }} />
</Box>
);
}
@@ -0,0 +1,192 @@
import React, { useMemo, useState } from 'react';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import Autocomplete from '@mui/material/Autocomplete';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import WebAssetIcon from '@mui/icons-material/WebAsset';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import { createAdminLink } from '../../../api/adminLinks.js';
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
/* ============================================================
* AddPanelDialog添加面板
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式内嵌/新标签/弹窗
* + 嵌入 URL + 代理开关保存到 admin_links复用现有 API
* ============================================================ */
const MODE_META = [
{ mode: OPEN_MODE_EMBED, label: '内嵌', Icon: WebAssetIcon },
{ mode: OPEN_MODE_TAB, label: '新标签', Icon: OpenInNewIcon },
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
];
function normalizeUrl(raw) {
const s = (raw || '').trim();
if (!s) return '';
return /^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : 'https://' + s;
}
export default function AddPanelDialog({ open, onClose, categories = [], onSaved, onSetMode }) {
const [form, setForm] = useState({
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
use_proxy: false, embed_url: '',
});
const [err, setErr] = useState('');
const host = useMemo(() => {
try { return new URL(normalizeUrl(form.url)).hostname; } catch { return ''; }
}, [form.url]);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
const save = async () => {
const title = form.title.trim();
const url = normalizeUrl(form.url);
if (!title) { setErr('请输入面板标题'); return; }
if (!url) { setErr('请输入面板链接'); return; }
try {
const panel = await createAdminLink({
title,
url,
embed_url: form.embed_url.trim(),
category: form.category.trim() || '默认',
use_proxy: form.use_proxy ? 1 : 0,
description: '',
icon: '',
sort_order: 0,
version: '',
});
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
setErr('');
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
onSaved(panel);
} catch (e) {
setErr(e.message || '保存失败');
}
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle>添加面板</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="标题 *"
value={form.title}
onChange={set('title')}
margin="normal"
placeholder="例如:服务器监控"
autoFocus
/>
<TextField
fullWidth
label="链接 URL *"
value={form.url}
onChange={set('url')}
margin="normal"
placeholder="example.com 或 https://…"
error={!!err && !form.url.trim()}
/>
{/* favicon 实时预览 */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1, mb: 0.5 }}>
<Box
sx={{
width: 32, height: 32, borderRadius: 1.5, border: 1, borderColor: 'divider',
display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'background.paper', overflow: 'hidden',
}}
>
{host ? (
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
alt=""
width={20}
height={20}
loading="lazy"
style={{ display: 'block' }}
onError={(e) => { e.target.style.visibility = 'hidden'; }}
/>
) : (
<WebAssetIcon sx={{ fontSize: 18, color: 'text.disabled' }} />
)}
</Box>
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
{host ? `站点:${host}` : '输入链接后预览站点图标'}
</Typography>
</Box>
<Autocomplete
freeSolo
fullWidth
value={form.category}
onChange={(e, v) => setForm((p) => ({ ...p, category: v || '' }))}
onInputChange={(e, v) => setForm((p) => ({ ...p, category: v }))}
options={categories}
renderInput={(params) => (
<TextField {...params} label="分组" margin="normal" placeholder="默认" />
)}
/>
<Typography variant="caption" sx={{ display: 'block', mt: 1, mb: 0.5, color: 'text.secondary' }}>
打开方式
</Typography>
<ToggleButtonGroup
exclusive
fullWidth
size="small"
value={form.openMode}
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, openMode: v })); }}
aria-label="打开方式"
>
{MODE_META.map(({ mode, label, Icon }) => (
<ToggleButton key={mode} value={mode} sx={{ py: 0.75 }}>
<Icon fontSize="small" sx={{ mr: 0.5 }} />{label}
</ToggleButton>
))}
</ToggleButtonGroup>
{form.openMode === OPEN_MODE_EMBED && (
<Box sx={{ mt: 1.5 }}>
<FormControlLabel
control={
<Switch
checked={form.use_proxy}
onChange={(e) => setForm((p) => ({ ...p, use_proxy: e.target.checked }))}
/>
}
label="通过代理嵌入(绕过 X-Frame-Options 限制)"
/>
<TextField
fullWidth
label="嵌入 URL(可选,iframe 专用)"
value={form.embed_url}
onChange={set('embed_url')}
margin="dense"
placeholder="留空则使用上面的链接"
/>
</Box>
)}
{err && (
<Typography variant="body2" sx={{ mt: 1.5, color: 'error.main' }}>{err}</Typography>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>取消</Button>
<Button variant="contained" onClick={save}>添加</Button>
</DialogActions>
</Dialog>
);
}
@@ -0,0 +1,48 @@
import React from 'react';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
/* ============================================================
* CollapseArrow侧边栏折叠箭头VSCode 风格
*
* - 由父级绝对定位到主区边缘即侧边栏与主区的 Separator 位置垂直居中
* 侧边栏折叠为 0 后主区仍在箭头始终可见可点支持"再点击展开"
* - 箭头始终指向栏的方向展开时指栏点击收起折叠后反向点击展开
* - 可见性**常驻可见**不透明背景background.paper+ 实心边框 + 阴影
* opacity 展开 0.9 / 折叠 1hover 全亮加深阴影不再半透明叠加 iframe 混色
* - 28x28 触控目标zIndex 12 盖在 iframe 内容之上
* ============================================================ */
export default function CollapseArrow({ side, collapsed = false, onToggle }) {
const right = side === 'right';
const label = collapsed ? `展开${right ? '右' : '左'}侧边栏` : `折叠${right ? '右' : '左'}侧边栏`;
//
const Icon = collapsed
? (right ? ChevronLeftIcon : ChevronRightIcon)
: (right ? ChevronRightIcon : ChevronLeftIcon);
return (
<Tooltip title={label} placement={right ? 'left' : 'right'}>
<IconButton
size="small"
aria-label={label}
onClick={onToggle}
sx={{
position: 'absolute', top: '50%', [right ? 'right' : 'left']: 2,
transform: 'translateY(-50%)', zIndex: 12,
width: 28, height: 28, minWidth: 28, p: 0,
opacity: collapsed ? 1 : 0.9,
bgcolor: 'background.paper', border: 1, borderColor: 'divider',
boxShadow: (t) => t.shadows[2],
transition: 'opacity .12s ease, background-color .12s ease, box-shadow .12s ease',
'&:hover': { opacity: 1, bgcolor: 'action.hover', boxShadow: (t) => t.shadows[4] },
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -2 },
}}
>
<Icon sx={{ fontSize: 18 }} />
</IconButton>
</Tooltip>
);
}
@@ -0,0 +1,53 @@
import React from 'react';
import Box from '@mui/material/Box';
import PanelFrame from './PanelFrame.jsx';
import { tabBtnId, tabPanelId } from './TabStrip.jsx';
/* ============================================================
* ContentView主区内容分发按标签 type
*
* - 'panel' PanelFrameiframeLRU panelRenderKeys 控制挂载
* - 其他 react 类型 tab.render() 返回对应组件记事本/密码箱/面板列表
* 仅激活标签可见其余保持挂载keep-alive 保留 iframe 与编辑状态
* 每个标签包一层 role=tabpanel TabStrip tab 通过 id 配对ARIA
* ============================================================ */
export default function ContentView({ tabs, activeKey, refreshNonce = 0, panelRenderKeys = null }) {
return (
<>
{tabs.map((tab) => {
const active = tab.key === activeKey;
if (tab.type === 'panel') {
// LRU + 使 iframe
if (panelRenderKeys && !panelRenderKeys.has(tab.key)) return null;
return (
<Box
key={tab.key}
id={tabPanelId(tab.key)}
role="tabpanel"
aria-labelledby={tabBtnId(tab.key)}
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
>
<PanelFrame
panel={tab.panel}
active={active}
refreshNonce={refreshNonce}
/>
</Box>
);
}
return (
<Box
key={tab.key}
id={tabPanelId(tab.key)}
role="tabpanel"
aria-labelledby={tabBtnId(tab.key)}
sx={{ display: active ? 'block' : 'none', width: '100%', height: '100%' }}
>
{tab.render ? tab.render() : null}
</Box>
);
})}
</>
);
}
@@ -0,0 +1,118 @@
import React, { useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import Typography from '@mui/material/Typography';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
/* ============================================================
* MenuBarVSCode 风格菜单栏MUI Menu配置数组驱动
*
* menus = [{ label, items: [{ label, Icon?, action?, shortcut?, divider? }] }]
* - role=menubar / menuitem顶层 roving tabindex + 方向键导航
* / Home / End 移动焦点 / Enter / 空格 打开菜单
* - 菜单内部导航由 MUI Menu 自带方向键 + Enter/Esc
* ============================================================ */
export default function MenuBar({ menus = [] }) {
const [openIdx, setOpenIdx] = useState(null);
const [anchorEl, setAnchorEl] = useState(null);
const [focusIdx, setFocusIdx] = useState(0); // roving tabindex
const barRef = useRef(null);
const btnEls = () => Array.from(barRef.current?.querySelectorAll('[data-menu-btn]') || []);
const focusBtn = (i) => {
const n = menus.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
setFocusIdx(j);
btnEls()[j]?.focus();
};
const openMenu = (i) => {
setFocusIdx(i);
setAnchorEl(btnEls()[i]);
setOpenIdx(i);
};
const close = () => { setOpenIdx(null); setAnchorEl(null); };
const handleTopKeyDown = (e, i) => {
switch (e.key) {
case 'ArrowRight': e.preventDefault(); focusBtn(i + 1); break;
case 'ArrowLeft': e.preventDefault(); focusBtn(i - 1); break;
case 'Home': e.preventDefault(); focusBtn(0); break;
case 'End': e.preventDefault(); focusBtn(menus.length - 1); break;
case 'ArrowDown':
case 'Enter':
case ' ':
e.preventDefault();
openMenu(i); // MUI Menu
break;
default: break;
}
};
return (
<Box
ref={barRef}
component="nav"
role="menubar"
aria-label="主菜单"
sx={{ display: 'flex', alignItems: 'center' }}
>
{menus.map((menu, i) => (
<React.Fragment key={menu.label}>
<Button
data-menu-btn
role="menuitem"
aria-haspopup="true"
aria-expanded={openIdx === i}
tabIndex={focusIdx === i ? 0 : -1}
size="small"
onClick={(e) => {
setFocusIdx(i);
if (openIdx === i) { close(); return; }
setAnchorEl(e.currentTarget);
setOpenIdx(i);
}}
onKeyDown={(e) => handleTopKeyDown(e, i)}
sx={{
minWidth: 0, px: 1, height: 30, borderRadius: 1,
fontSize: 13, color: openIdx === i ? 'primary.main' : 'text.primary',
'&:hover': { bgcolor: openIdx === i ? 'action.selected' : 'action.hover' },
}}
>
{menu.label}
<KeyboardArrowDownIcon sx={{ fontSize: 14, ml: 0.25, color: 'text.disabled' }} />
</Button>
<Menu
open={openIdx === i}
anchorEl={anchorEl}
onClose={close}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
MenuListProps={{ sx: { py: 0.5, minWidth: 200 } }}
>
{menu.items.map((item, j) => (
item.divider ? (
<Divider key={`d-${j}`} sx={{ my: 0.5 }} />
) : (
<MenuItem
key={item.label + j}
dense
onClick={() => { close(); item.action?.(); }}
>
{item.Icon && <item.Icon sx={{ fontSize: 16, mr: 1.25, color: 'text.secondary' }} />}
<span style={{ flex: 1 }}>{item.label}</span>
{item.shortcut && (
<Typography variant="caption" sx={{ ml: 3, color: 'text.disabled' }}>{item.shortcut}</Typography>
)}
</MenuItem>
)
))}
</Menu>
</React.Fragment>
))}
</Box>
);
}
@@ -0,0 +1,264 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import TextField from '@mui/material/TextField';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import NoteAltIcon from '@mui/icons-material/NoteAlt';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import ErrorIcon from '@mui/icons-material/Error';
import useNotes from '../hooks/useNotes.js';
import ConfirmDialog from '../../../admin/ConfirmDialog.jsx';
/* ============================================================
* NotesView记事本视图VSCode 侧边栏 / 标签页内容
*
* NoteEditor 裁剪去掉浮窗拖拽 / fixed 侧栏 / 自带标题栏
* 标题栏由父级 ToolWindow 提供保留
* 状态条当前文件 + 保存状态 编辑器自动保存Ctrl+S
* 文件管理器新建/双击重命名/右键菜单/删除
* 常驻挂载keep-alive切换视图不丢草稿
* ============================================================ */
/** 精简时间格式:今天 HH:mm / 今年 MM-DD / 往年 YYYY-MM-DD */
function fmtTime(ms) {
if (!ms) return '';
const d = new Date(ms);
const now = new Date();
const pad = (n) => String(n).padStart(2, '0');
const hm = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
if (d.toDateString() === now.toDateString()) return hm;
if (d.getFullYear() === now.getFullYear()) return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${hm}`;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** 新建/重命名弹窗 */
function NameDialog({ open, title, initial, confirmText, exists, busy, onClose, onConfirm }) {
const [value, setValue] = useState(initial);
const [err, setErr] = useState('');
React.useEffect(() => {
if (open) { setValue(initial); setErr(''); }
}, [open, initial]);
const submit = () => {
let name = value.trim();
if (!name) { setErr('请输入文件名'); return; }
if (!name.toLowerCase().endsWith('.txt')) name += '.txt';
if (exists.includes(name.toLowerCase())) { setErr('同名笔记已存在'); return; }
onConfirm(name);
};
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<TextField
fullWidth
autoFocus
size="small"
label="文件名"
value={value}
onChange={(e) => { setValue(e.target.value); setErr(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
error={!!err}
helperText={err || '仅支持 .txt 文本文件'}
inputProps={{ style: { fontSize: 14 } }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>取消</Button>
<Button variant="contained" disabled={busy} onClick={submit}>{confirmText}</Button>
</DialogActions>
</Dialog>
);
}
export default function NotesView() {
const notes = useNotes();
const {
notes: list, loadError, refresh,
activeName, content, dirty, saving, busy,
open, updateContent, saveNow, create, rename, remove, nextUntitled,
} = notes;
const [newOpen, setNewOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState(null);
const [delTarget, setDelTarget] = useState(null);
const [ctx, setCtx] = useState(null); // { name, anchorEl }
const handleCreate = (name) => { setNewOpen(false); create(name); };
const handleRename = (name) => { setRenameTarget(null); rename(ctx?.name || renameTarget, name); };
const handleDelete = async () => {
const name = delTarget;
setDelTarget(null);
await remove(name);
};
const statusText = saving ? '保存中…' : dirty ? '未保存' : '已保存';
return (
<Box component="section" aria-label="记事本" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{/* 状态条 */}
<Box sx={{ px: 1.5, py: 0.5, display: 'flex', alignItems: 'center', gap: 1, bgcolor: 'background.default', borderBottom: 1, borderColor: 'divider', flexShrink: 0 }}>
<Typography variant="caption" noWrap sx={{ flex: 1, color: 'text.secondary' }} title={activeName || ''}>
{activeName || '未打开笔记'}
</Typography>
<Typography variant="caption" sx={{ flexShrink: 0, color: saving ? 'text.secondary' : dirty ? 'warning.main' : 'success.main', fontWeight: 500 }}>
{statusText}
</Typography>
</Box>
{/* 编辑器(上半) */}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', p: 0.5 }}>
<TextField
fullWidth
multiline
variant="outlined"
value={content}
onChange={(e) => updateContent(e.target.value)}
onKeyDown={(e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveNow(); }
}}
disabled={!activeName || busy}
placeholder={activeName ? '开始输入…(自动保存,Ctrl+S 立即保存)' : '从下方选择或新建一篇笔记'}
inputProps={{ 'aria-label': '笔记内容', style: { fontSize: 13, lineHeight: 1.6 } }}
sx={{
flex: 1, minHeight: 0,
'& .MuiInputBase-root': { height: '100%', alignItems: 'flex-start', borderRadius: 1 },
'& .MuiInputBase-inputMultiline': { height: '100% !important', overflow: 'auto !important', boxSizing: 'border-box' },
}}
/>
</Box>
{/* 文件管理器(下半) */}
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', borderTop: 1, borderColor: 'divider' }}>
<Box sx={{ display: 'flex', alignItems: 'center', px: 1.25, py: 0.5, flexShrink: 0 }}>
<Typography sx={{ flex: 1, fontSize: 11, fontWeight: 700, letterSpacing: 0.5, color: 'text.secondary' }}>
笔记{list ? list.length : 0}
</Typography>
<Tooltip title="新建笔记">
<IconButton size="small" onClick={() => setNewOpen(true)} aria-label="新建笔记"><AddIcon fontSize="small" /></IconButton>
</Tooltip>
</Box>
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
{list === null && !loadError ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}><CircularProgress size={20} /></Box>
) : loadError ? (
<Box sx={{ px: 2, py: 2, textAlign: 'center' }}>
<ErrorIcon sx={{ fontSize: 24, color: 'error.main', mb: 0.5 }} />
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block', mb: 0.5 }}>{loadError}</Typography>
<Button size="small" variant="outlined" onClick={refresh}>重试</Button>
</Box>
) : list.length === 0 ? (
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
<NoteAltIcon sx={{ fontSize: 28, color: 'text.disabled', mb: 0.5 }} />
<Typography variant="caption" sx={{ color: 'text.secondary', display: 'block' }}>暂无笔记点击上方 新建</Typography>
</Box>
) : (
<List dense disablePadding>
{list.map((n) => {
const active = n.name === activeName;
return (
<ListItemButton
key={n.name}
onClick={() => open(n.name)}
onDoubleClick={() => setRenameTarget(n.name)}
onContextMenu={(e) => { e.preventDefault(); setCtx({ name: n.name, anchorEl: e.currentTarget }); }}
onKeyDown={(e) => {
// F2 Enter
if (e.key === 'F2') { e.preventDefault(); setRenameTarget(n.name); }
}}
aria-label={`打开笔记 ${n.name}`}
sx={{
minHeight: 40, borderRadius: 0, m: 0, px: 0,
bgcolor: active ? 'primary.container' : 'transparent',
color: active ? 'primary.onContainer' : 'text.primary',
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
'&:hover .nb-del': { opacity: 1 },
'&:focus-visible .nb-del': { opacity: 1 },
}}
>
<NoteAltIcon fontSize="small" sx={{ mx: 1, flexShrink: 0, opacity: 0.7 }} />
<Box sx={{ flex: 1, minWidth: 0, py: 0.5 }}>
<Typography noWrap sx={{ fontSize: 12.5, fontWeight: active ? 600 : 500 }}>{n.name}</Typography>
<Typography variant="caption" sx={{ color: 'text.disabled', fontSize: 10 }}>
{fmtTime(n.mtime)}
</Typography>
</Box>
<IconButton
className="nb-del"
size="small"
onClick={(e) => { e.stopPropagation(); setDelTarget(n.name); }}
aria-label={`删除 ${n.name}`}
sx={{ opacity: 0, width: 28, height: 28, mr: 0.5, transition: 'opacity .12s' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</ListItemButton>
);
})}
</List>
)}
</Box>
</Box>
{/* 右键菜单 */}
<Menu
open={!!ctx}
anchorEl={ctx?.anchorEl || null}
onClose={() => setCtx(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<MenuItem dense onClick={() => { if (ctx) setRenameTarget(ctx.name); setCtx(null); }}>
<EditOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />重命名
</MenuItem>
<MenuItem dense onClick={() => { if (ctx) setDelTarget(ctx.name); setCtx(null); }}>
<DeleteOutlinedIcon fontSize="small" sx={{ mr: 1, color: 'error.main' }} />删除
</MenuItem>
</Menu>
<NameDialog
open={newOpen}
title="新建笔记"
initial={nextUntitled}
confirmText="创建"
exists={(list || []).map((n) => n.name.toLowerCase())}
busy={busy}
onClose={() => setNewOpen(false)}
onConfirm={handleCreate}
/>
<NameDialog
open={!!renameTarget}
title="重命名笔记"
initial={renameTarget || ''}
confirmText="保存"
exists={(list || []).map((n) => n.name.toLowerCase()).filter((n) => n !== renameTarget?.toLowerCase())}
busy={busy}
onClose={() => setRenameTarget(null)}
onConfirm={handleRename}
/>
<ConfirmDialog
open={!!delTarget}
title="删除笔记"
message={`确定要删除 "${delTarget || ''}" 吗?此操作不可恢复。`}
onClose={() => setDelTarget(null)}
onConfirm={handleDelete}
confirmText="删除"
/>
</Box>
);
}
@@ -0,0 +1,266 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Stack from '@mui/material/Stack';
import IconButton from '@mui/material/IconButton';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import RefreshIcon from '@mui/icons-material/Refresh';
import ErrorIcon from '@mui/icons-material/Error';
import CloseIcon from '@mui/icons-material/Close';
import { getToken } from '../../../api/client.js';
/* ============================================================
* PanelFrame单个面板的 iframe 容器
*
* - 懒加载首次激活才创建 iframe DOM之后 display:none 保留状态
* - 超时 + load 双保险加载中显示覆盖层超时X-Frame-Options
* 拒绝 / 目标无响应显示失败提示 + 在新标签页打开回退
* - proxy 模式use_proxy=1 HTTPS 页内嵌 HTTP 目标时走
* /api/proxy/fetch?url=&token=proxy X-Frame-Options/CSP
* token /api/proxy/token 签发的 5 分钟短 TTL JWT避免把 7
* token 暴露在 iframe URL 模块级缓存4 分钟刷新一次
* - 站内 URL/ 开头为同源内容剥离 allow-same-origin 防其访问
* 父页面 DOM跨源面板保留 allow-same-origin多数站点依赖
* - LRU 由父级 Workbench 控制挂载/卸载此组件不自行卸载
* ============================================================ */
const LOAD_TIMEOUT_MS = 15000;
const TOKEN_TTL_MS = 4 * 60 * 1000; // 4 token 5
const HINT_TTL_MS = 5000; // 5
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
let proxyTokenCache = { promise: null, expiresAt: 0 };
function fetchProxyToken() {
const now = Date.now();
if (proxyTokenCache.promise && now < proxyTokenCache.expiresAt) return proxyTokenCache.promise;
const p = fetch('/api/proxy/token', {
headers: { Authorization: 'Bearer ' + (getToken() || '') },
})
.then(async (res) => {
if (!res.ok) throw new Error('proxy token 获取失败');
const data = await res.json();
return data.token;
})
.catch((e) => {
proxyTokenCache.promise = null;
throw e;
});
proxyTokenCache.promise = p;
proxyTokenCache.expiresAt = now + TOKEN_TTL_MS;
return p;
}
export default function PanelFrame({ panel, active = false, refreshNonce = 0 }) {
const [mounted, setMounted] = useState(active);
const [status, setStatus] = useState('loading'); // loading | loaded | timeout
const [proxySrc, setProxySrc] = useState('');
const [showHint, setShowHint] = useState(false);
const iframeRef = useRef(null);
const timerRef = useRef(null);
const hintTimerRef = useRef(null);
const statusRef = useRef('loading');
const targetUrl = panel.embed_url || panel.url || '';
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
// URL allow-same-origin DOM
const isInternal = targetUrl.startsWith('/');
const sandbox = isInternal
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
// proxy 5 TTL token iframe URL退
useEffect(() => {
let alive = true;
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
setProxySrc('');
fetchProxyToken()
.then((tok) => {
if (alive) {
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
}
})
.catch(() => {
if (alive) {
statusRef.current = 'timeout';
setStatus('timeout');
}
});
return () => { alive = false; };
}, [needsProxy, targetUrl]);
const src = needsProxy ? proxySrc : targetUrl;
// iframe
useEffect(() => {
if (active) setMounted(true);
}, [active]);
const startTimer = () => {
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
if (statusRef.current === 'loading') {
statusRef.current = 'timeout';
setStatus('timeout');
}
}, LOAD_TIMEOUT_MS);
};
// /
useEffect(() => {
if (!mounted || !src) return;
statusRef.current = 'loading';
setStatus('loading');
setShowHint(false);
startTimer();
return () => clearTimeout(timerRef.current);
}, [mounted, src]);
// reload
const lastNonce = useRef(refreshNonce);
useEffect(() => {
if (!active || refreshNonce === lastNonce.current) return;
lastNonce.current = refreshNonce;
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshNonce]);
const handleLoad = () => {
clearTimeout(timerRef.current);
statusRef.current = 'loaded';
setStatus('loaded');
// proxy X-Frame-Options
// 5
if (!needsProxy) {
setShowHint(true);
clearTimeout(hintTimerRef.current);
hintTimerRef.current = setTimeout(() => setShowHint(false), HINT_TTL_MS);
}
};
const handleRetry = () => {
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
};
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
return (
<Box
sx={{
position: 'relative', width: '100%', height: '100%', overflow: 'hidden',
display: active ? 'block' : 'none', bgcolor: 'background.paper', flexShrink: 0,
}}
>
{mounted && (
<iframe
ref={iframeRef}
title={panel.title || '面板'}
src={src}
onLoad={handleLoad}
sandbox={sandbox}
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
/>
)}
{status === 'loading' && (
<Box
sx={{
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1.5,
bgcolor: 'background.default',
}}
>
<CircularProgress size={32} />
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
正在加载 {panel.title || '面板'}
</Typography>
</Box>
)}
{status === 'timeout' && (
<Box
sx={{
position: 'absolute', inset: 0, zIndex: 2, display: 'flex',
flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 2,
bgcolor: 'background.default', px: 3, textAlign: 'center',
}}
>
<ErrorIcon sx={{ fontSize: 40, color: 'text.disabled' }} />
<Box>
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>面板加载超时</Typography>
<Typography variant="body2" sx={{ color: 'text.secondary' }}>
目标站点可能拒绝嵌入X-Frame-Options或响应过慢
</Typography>
</Box>
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="outlined"
startIcon={<OpenInNewIcon fontSize="small" />}
href={targetUrl}
target="_blank"
rel="noopener"
>
在新标签页打开
</Button>
<Button
size="small"
variant="text"
startIcon={<RefreshIcon fontSize="small" />}
onClick={handleRetry}
>
重试
</Button>
</Stack>
</Box>
)}
{/* 加载成功后的非阻塞提示条:空白页可能被 X-Frame-Options 拒绝 */}
{showHint && status === 'loaded' && !needsProxy && (
<Box
role="status"
sx={{
position: 'absolute', left: '50%', transform: 'translateX(-50%)', bottom: 16,
zIndex: 3, maxWidth: '92%', display: 'flex', alignItems: 'center', gap: 0.5,
px: 1.5, py: 0.5, borderRadius: 2,
bgcolor: 'primary.container', color: 'primary.onContainer',
border: 1, borderColor: 'divider', boxShadow: (t) => t.shadows[3],
}}
>
<Typography variant="caption" sx={{ fontWeight: 500 }}>
若页面空白可能被 X-Frame-Options 拒绝
</Typography>
<Button
size="small"
component="a"
href={targetUrl}
target="_blank"
rel="noopener"
sx={{ minHeight: 28, px: 1, color: 'primary.onContainer', fontWeight: 700 }}
>
在新标签打开
</Button>
<IconButton size="small" onClick={() => setShowHint(false)} aria-label="关闭提示" sx={{ color: 'inherit' }}>
<CloseIcon fontSize="small" />
</IconButton>
</Box>
)}
</Box>
);
}
@@ -0,0 +1,79 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
/* ============================================================
* 面板图标Google favicon 服务 + 失败回退站点首字母 + 确定性 MD3 容器色
*
* - 成功显示 favicon不变
* - 失败/ host显示首字母色块类似 Google Avatar
* 背景色从 MD3 容器色primary/secondary/tertiary.container
* 域名/标题哈希确定性选取比默认地球图标更可辨识
* ============================================================ */
/** 确定性回退色板:MD3 token(不硬编码 hex),按哈希循环选取 */
const FALLBACK_PALETTE = [
{ bg: 'primary.container', fg: 'primary.onContainer' },
{ bg: 'secondary.container', fg: 'secondary.onContainer' },
{ bg: 'tertiary.container', fg: 'tertiary.onContainer' },
];
function hashString(s) {
let h = 0;
for (let i = 0; i < s.length; i += 1) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function hostOf(url) {
try {
const u = /^[a-z][a-z0-9+.-]*:\/\//i.test(url) ? url : 'https://' + url;
return new URL(u).hostname;
} catch { return ''; }
}
export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
const [error, setError] = useState(false);
const host = hostOf(url);
// +
const letter = ((title && title[0]) || (host && host[0]) || '?').toUpperCase();
const { bg, fg } = FALLBACK_PALETTE[hashString(host || title || '?') % FALLBACK_PALETTE.length];
// host/ favicon 404/API
if (!host || error) {
return (
<Box
aria-hidden
sx={{
width: size, height: size, borderRadius: size / 4, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: bg, color: fg,
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
...sx,
}}
>
{letter}
</Box>
);
}
return (
<Box
aria-hidden
sx={{
width: size, height: size, borderRadius: size / 4, overflow: 'hidden',
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'background.paper', ...sx,
}}
>
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
alt=""
width={size}
height={size}
loading="lazy"
onError={() => setError(true)}
style={{ display: 'block' }}
/>
</Box>
);
}
@@ -0,0 +1,272 @@
import React, { Fragment, useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import Skeleton from '@mui/material/Skeleton';
import StarIcon from '@mui/icons-material/Star';
import StarBorderIcon from '@mui/icons-material/StarBorder';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import FolderOpenIcon from '@mui/icons-material/FolderOpen';
import AddIcon from '@mui/icons-material/Add';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import WebAssetIcon from '@mui/icons-material/WebAsset';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import WidgetsIcon from '@mui/icons-material/Widgets';
import ErrorIcon from '@mui/icons-material/Error';
import PanelIcon from './PanelIcon.jsx';
import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePanels.js';
/* ============================================================
* PanelsView面板列表视图VSCode 侧边栏面板内容
*
* - 固定区pin+ admin_links category 分组
* - 当前项左侧 2px primary 指示条 + primary-container 选中底
* - 分组折叠持久化右键切换打开方式底部 添加
* - 搜索时展示扁平结果列表
* - 宽度/折叠由外部 react-resizable-panels 布局管理本组件不再自持
* ============================================================ */
const MODE_META = [
{ mode: OPEN_MODE_EMBED, label: '内嵌打开', Icon: WebAssetIcon },
{ mode: OPEN_MODE_TAB, label: '新标签打开', Icon: OpenInNewIcon },
{ mode: OPEN_MODE_MODAL, label: '弹窗打开', Icon: OpenInFullIcon },
];
export default function PanelsView({
loading = false, error = '', onRetry,
links = [], filtered = [], search = '',
activeId = null, pinned = [], onSelect, onPin,
groups = {}, onToggleGroup,
modes = {}, onSetMode,
onAdd,
}) {
const [ctx, setCtx] = useState(null); // { id, anchorEl }
const groupOrder = useMemo(() => {
const order = []; const seen = new Set();
links.forEach((p) => {
const c = p.category || '默认';
if (!seen.has(c)) { seen.add(c); order.push(c); }
});
return order;
}, [links]);
const byGroup = useMemo(() => {
const m = {};
links.forEach((p) => {
const c = p.category || '默认';
(m[c] = m[c] || []).push(p);
});
return m;
}, [links]);
const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
const renderItem = (p) => {
const selected = String(p.id) === String(activeId);
const isPinned = pinned.includes(String(p.id));
const mode = modes[p.id] || OPEN_MODE_EMBED;
return (
<Box
key={p.id}
sx={{
display: 'flex', alignItems: 'center', position: 'relative',
'&:hover .wb-pin': { opacity: 1 },
}}
>
<ListItemButton
selected={selected}
onClick={() => onSelect(p.id)}
onContextMenu={(e) => { e.preventDefault(); setCtx({ id: p.id, anchorEl: e.currentTarget }); }}
aria-label={`打开面板 ${p.title}`}
sx={{
flex: 1, minHeight: 40, borderRadius: 0, m: 0, pl: 2, pr: 4,
position: 'relative',
'&.Mui-selected': {
bgcolor: 'primary.container', color: 'primary.onContainer',
'&:hover': { bgcolor: 'primary.container' },
},
'&:hover': { bgcolor: 'action.hover' },
}}
>
{selected && (
<Box
aria-hidden
sx={{
position: 'absolute', left: 0, top: '22%', bottom: '22%',
width: 2, bgcolor: 'primary.main', borderRadius: 1,
}}
/>
)}
<PanelIcon url={p.embed_url || p.url} title={p.title} size={20} sx={{ mr: 1 }} />
<ListItemText
primary={p.title}
primaryTypographyProps={{ fontSize: 13, fontWeight: selected ? 600 : 500, noWrap: true }}
sx={{ my: 0, minWidth: 0 }}
/>
{mode !== OPEN_MODE_EMBED && (
<Box
component="span"
title={MODE_META.find((m) => m.mode === mode)?.label}
sx={{ mr: 0.5, fontSize: 10, color: 'text.disabled', whiteSpace: 'nowrap' }}
>
{mode === OPEN_MODE_TAB ? '新标签' : '弹窗'}
</Box>
)}
</ListItemButton>
<IconButton
className="wb-pin"
size="small"
onClick={() => onPin(p.id)}
aria-label={isPinned ? `取消固定 ${p.title}` : `固定 ${p.title}`}
sx={{
position: 'absolute', right: 4, opacity: isPinned ? 1 : 0,
transition: 'opacity .12s ease', color: isPinned ? 'warning.main' : 'text.secondary',
}}
>
{isPinned ? <StarIcon fontSize="small" /> : <StarBorderIcon fontSize="small" />}
</IconButton>
</Box>
);
};
const sectionLabel = (text) => (
<Typography
component="div"
sx={{
px: 2, pt: 1.25, pb: 0.5, fontSize: 11, fontWeight: 700,
letterSpacing: 0.6, color: 'text.secondary',
}}
>
{text}
</Typography>
);
const listBody = (() => {
if (loading) {
return (
<Box sx={{ px: 2, pt: 1 }}>
{[0, 1, 2, 3, 4, 5].map((i) => (
<Skeleton key={i} variant="rounded" height={36} sx={{ my: 1, bgcolor: 'action.hover' }} />
))}
</Box>
);
}
if (error) {
return (
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
<ErrorIcon sx={{ fontSize: 32, color: 'error.main', mb: 1 }} />
<Typography variant="body2" sx={{ color: 'text.secondary', mb: 1.5 }}>面板加载失败</Typography>
<Button size="small" variant="outlined" onClick={onRetry}>重试</Button>
</Box>
);
}
if (links.length === 0) {
return (
<Box sx={{ px: 3, py: 4, textAlign: 'center' }}>
<WidgetsIcon sx={{ fontSize: 32, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" sx={{ color: 'text.secondary' }}>还没有面板点击下方按钮添加</Typography>
</Box>
);
}
if (search.trim()) {
return (
<>
{sectionLabel(`搜索结果(${filtered.length}`)}
{filtered.length === 0 ? (
<Typography variant="body2" sx={{ px: 2, py: 2, color: 'text.secondary' }}>无匹配面板</Typography>
) : (
<List dense disablePadding>{filtered.map(renderItem)}</List>
)}
</>
);
}
return (
<>
{pinnedPanels.length > 0 && (
<>
{sectionLabel('固定')}
<List dense disablePadding>{pinnedPanels.map(renderItem)}</List>
<Divider sx={{ mx: 2, my: 0.5 }} />
</>
)}
{groupOrder.map((name) => {
const collapsedGroup = !!groups[name];
const items = byGroup[name] || [];
return (
<Fragment key={name}>
<ListItemButton
onClick={() => onToggleGroup(name)}
aria-expanded={!collapsedGroup}
sx={{
minHeight: 36, borderRadius: 0, m: 0, px: 1.5,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<ListItemIcon sx={{ minWidth: 30 }}>
<FolderOpenIcon fontSize="small" sx={{ color: 'text.secondary' }} />
</ListItemIcon>
<ListItemText
primary={name}
primaryTypographyProps={{ fontSize: 12, fontWeight: 700, letterSpacing: 0.4, color: 'text.secondary' }}
sx={{ my: 0 }}
/>
<Typography variant="caption" sx={{ mr: 0.5, color: 'text.disabled' }}>{items.length}</Typography>
{collapsedGroup
? <ExpandMoreIcon fontSize="small" sx={{ color: 'text.secondary' }} />
: <ExpandLessIcon fontSize="small" sx={{ color: 'text.secondary' }} />}
</ListItemButton>
<Collapse in={!collapsedGroup} unmountOnExit timeout={150}>
<List dense disablePadding>{items.map(renderItem)}</List>
</Collapse>
</Fragment>
);
})}
</>
);
})();
return (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', overflowX: 'hidden' }}>
{listBody}
</Box>
<Divider sx={{ flexShrink: 0 }} />
<Box sx={{ p: 1, flexShrink: 0 }}>
<Button fullWidth size="small" startIcon={<AddIcon />} onClick={onAdd}>添加面板</Button>
</Box>
{/* 右键菜单:切换打开方式 */}
<Menu
open={!!ctx}
anchorEl={ctx?.anchorEl || null}
onClose={() => setCtx(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
>
{MODE_META.map(({ mode, label, Icon }) => (
<MenuItem
key={mode}
selected={(modes[ctx?.id] || OPEN_MODE_EMBED) === mode}
onClick={() => {
if (ctx) { onSetMode(ctx.id, mode); onSelect(ctx.id); }
setCtx(null);
}}
>
<Icon fontSize="small" sx={{ mr: 1, color: 'text.secondary' }} />
{label}
</MenuItem>
))}
</Menu>
</Box>
);
}
@@ -0,0 +1,70 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
/* ============================================================
* SidebarPaneVSCode 风格侧边栏容器一侧一个
*
* - 该侧全部 sidebar 模式视图**常驻挂载**display 显隐切换
* 切换视图/切标签页不丢编辑状态记事本草稿密码箱解锁态
* - 每个视图由 ToolWindow 提供标题栏内容按 activeView 显示
* - 视图处于 tab 模式时该视图不在侧栏 显示占位 + 移回侧边栏
* - 无障碍容器 id=wb-sidebar-{side} 供活动栏 aria-controls
* aria-live 播报当前视图配合活动栏焦点提示
* ============================================================ */
export default function SidebarPane({ side, views, activeId, renderView, renderPlaceholder }) {
const right = side === 'right';
const activeView = views.find((v) => v.id === activeId);
return (
<Box
component="aside"
id={`wb-sidebar-${side}`}
aria-label={right ? '右侧侧边栏' : '左侧侧边栏'}
sx={{
height: '100%', minWidth: 0, overflow: 'hidden', position: 'relative',
display: 'flex', flexDirection: 'column',
bgcolor: 'background.paper',
}}
>
{/* 活动栏激活后的屏幕阅读器播报(视觉隐藏) */}
<Box
aria-live="polite"
sx={{
position: 'absolute', width: 1, height: 1, overflow: 'hidden',
clip: 'rect(0 0 0 0)', whiteSpace: 'nowrap', m: -1, p: 0, border: 0,
}}
>
{activeView ? `当前视图:${activeView.label}` : '侧边栏为空'}
</Box>
{views.map((v) => {
const active = activeId === v.id;
return (
<Box
key={v.id}
sx={{
flex: 1, minHeight: 0, display: active ? 'flex' : 'none',
flexDirection: 'column',
}}
>
{renderView(v.id)}
</Box>
);
})}
{views.length === 0 && (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', px: 3, textAlign: 'center' }}>
<Typography variant="caption" sx={{ color: 'text.disabled' }}>
从另一侧活动栏拖一个功能标签到这里
</Typography>
</Box>
)}
{views.length > 0 && !views.some((v) => v.id === activeId) && renderPlaceholder && (
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 1, px: 3, textAlign: 'center' }}>
{renderPlaceholder()}
</Box>
)}
</Box>
);
}
@@ -0,0 +1,30 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
/* ============================================================
* StatusBar底部状态栏可选VSCode 风格
* 当前上下文主机:端口 + 版本
* ============================================================ */
export default function StatusBar({ left, right = null }) {
const host = typeof window !== 'undefined' ? window.location.host : '';
return (
<Box
role="status"
sx={{
height: 24, flexShrink: 0, display: 'flex', alignItems: 'center',
px: 1.5, gap: 1.5, bgcolor: 'primary.container', color: 'primary.onContainer',
fontSize: 11, borderTop: 1, borderColor: 'divider',
}}
>
<Box
component="span"
aria-hidden
sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: 'success.main', flexShrink: 0 }}
/>
<Typography noWrap sx={{ fontSize: 11, fontWeight: 500, flex: 1, minWidth: 0 }}>{left}</Typography>
<Typography noWrap sx={{ fontSize: 11, opacity: 0.85 }}>{right || host}</Typography>
</Box>
);
}
@@ -0,0 +1,113 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import AddIcon from '@mui/icons-material/Add';
/* ============================================================
* TabStrip主区标签页条VSCode 风格浏览器标签交互
*
* - tabs{ key, title, icon?, closable? }iframe 面板与
* tab 模式视图共用一套标签 Workbench 拼装
* - 键盘 切换Home/End 首尾roving tabindex
* - ARIA每个 tab ContentView tabpanel 通过 id 配对
* tabBtnId / tabPanelId冒号等字符已做无害化
* ============================================================ */
/** tab 按钮元素 idtab.key 可能含 ':',替换为下划线保证合法 */
export function tabBtnId(key) {
return 'wb-tab-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
}
/** 对应 tabpanel 元素 id */
export function tabPanelId(key) {
return 'wb-panel-' + String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
}
export default function TabStrip({ tabs, activeKey, onSelect, onClose, onAdd }) {
const stripRef = useRef(null);
const tabEls = () => Array.from(stripRef.current?.querySelectorAll('[role="tab"]') || []);
const moveTo = (i) => {
const n = tabs.length;
if (n === 0) return;
const j = ((i % n) + n) % n;
onSelect(tabs[j].key);
tabEls()[j]?.focus();
};
const handleKeyDown = (e, index) => {
if (e.key === 'ArrowRight') { e.preventDefault(); moveTo(index + 1); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); moveTo(index - 1); }
else if (e.key === 'Home') { e.preventDefault(); moveTo(0); }
else if (e.key === 'End') { e.preventDefault(); moveTo(tabs.length - 1); }
};
return (
<Box
ref={stripRef}
role="tablist"
aria-label="已打开的面板与视图"
sx={{
display: 'flex', alignItems: 'center', gap: 0.25, px: 0.5, py: 0.5,
height: 40, flexShrink: 0, bgcolor: 'background.paper',
borderBottom: 1, borderColor: 'divider',
overflowX: 'auto', overflowY: 'hidden',
}}
>
{tabs.map((tab, index) => {
const active = tab.key === activeKey;
return (
<Box
key={tab.key}
id={tabBtnId(tab.key)}
role="tab"
aria-selected={active}
aria-label={tab.title}
aria-controls={tabPanelId(tab.key)}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(tab.key)}
onKeyDown={(e) => handleKeyDown(e, index)}
sx={{
display: 'flex', alignItems: 'center', gap: 0.5, pl: 1, pr: 0.25,
height: 28, borderRadius: 1, cursor: 'pointer', flexShrink: 0, outline: 'none',
bgcolor: active ? 'primary.container' : 'transparent',
color: active ? 'primary.onContainer' : 'text.secondary',
'&:hover': { bgcolor: active ? 'primary.container' : 'action.hover' },
'&:focus-visible': { boxShadow: (t) => `inset 0 0 0 2px ${t.palette.primary.main}` },
}}
>
{tab.icon}
<Typography
noWrap
sx={{
fontSize: 12, fontWeight: active ? 600 : 500, maxWidth: 140,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{tab.title}
</Typography>
{tab.closable && (
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onClose(tab.key); }}
aria-label={`关闭 ${tab.title}`}
sx={{ width: 20, height: 20, borderRadius: 0.5, '& svg': { fontSize: 14 } }}
>
<CloseIcon />
</IconButton>
)}
</Box>
);
})}
{onAdd && (
<Tooltip title="添加面板">
<IconButton size="small" onClick={onAdd} sx={{ ml: 0.25, flexShrink: 0 }} aria-label="添加面板">
<AddIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
}
@@ -0,0 +1,81 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import ViewSidebarIcon from '@mui/icons-material/ViewSidebar';
import TabIcon from '@mui/icons-material/Tab';
import OpenInFullIcon from '@mui/icons-material/OpenInFull';
import CloseIcon from '@mui/icons-material/Close';
import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js';
/* ============================================================
* ToolWindow通用可停靠面板侧边栏 / 标签页双模式 + 浮动占位
*
* - 标题栏图标 + 标题 + 模式切换侧边栏/标签页+ 浮动占位按钮
* P2 未实现点击只触发 onFloat 提示+ 关闭按钮 + 自定义 actions
* - 纯展示组件无内部状态模式/关闭行为由父级注入
* ============================================================ */
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, closeTitle = '关闭', actions = null, children }) {
const modeBtn = (m, label, MIcon) => (
<Tooltip key={m} title={label}>
<IconButton
size="small"
onClick={() => onSetMode?.(m)}
aria-label={label}
aria-pressed={mode === m}
sx={{
width: 26, height: 26, borderRadius: 1,
color: mode === m ? 'primary.main' : 'text.secondary',
bgcolor: mode === m ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: mode === m ? 'action.selected' : 'action.hover' },
}}
>
<MIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
);
return (
<Box
sx={{
display: 'flex', alignItems: 'center', gap: 0.25,
px: 1.25, height: 44, flexShrink: 0,
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
}}
>
{icon}
<Typography noWrap sx={{ flex: 1, minWidth: 0, ml: 0.25, fontSize: 13, fontWeight: 600 }}>
{title}
</Typography>
{modeBtn(VIEW_MODE_SIDEBAR, '侧边栏模式', ViewSidebarIcon)}
{modeBtn(VIEW_MODE_TAB, '标签页模式', TabIcon)}
{onFloat && (
<Tooltip title="浮动模式(即将上线)">
<IconButton
size="small"
onClick={onFloat}
aria-label="浮动模式"
sx={{ width: 26, height: 26, borderRadius: 1 }}
>
<OpenInFullIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
{actions}
{onClose && (
<Tooltip title={closeTitle}>
<IconButton
size="small"
onClick={onClose}
aria-label={closeTitle}
sx={{ width: 26, height: 26, borderRadius: 1 }}
>
<CloseIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
)}
</Box>
);
}
@@ -0,0 +1,143 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import InputBase from '@mui/material/InputBase';
import { useTheme } from '@mui/material/styles';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RefreshIcon from '@mui/icons-material/Refresh';
import SearchIcon from '@mui/icons-material/Search';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import HomeIcon from '@mui/icons-material/Home';
import MenuBar from './MenuBar.jsx';
import PanelIcon from './PanelIcon.jsx';
/* ============================================================
* ToolbarVSCode 风格顶栏高度 56px
*
* Rain Work(渐变字标) | 菜单栏 | | 地址栏(自适应填满) | 搜索 | 返回
* - 地址栏仅展示当前面板 URL嵌入地址优先只读可点开新标签
* flex:1 填满顶栏剩余空间左侧内容变宽时自然缩减
* - 搜索框面板名/URL/分组Enter 直达逻辑由 Workbench 注入
* ============================================================ */
export default function Toolbar({
menus = [],
canBack = false, canForward = false, onBack, onForward, onRefresh,
currentPanel = null, onOpenExternal,
search = '', onSearchChange, onSearchEnter,
searchInputRef = null, onBackHome,
}) {
const muiTheme = useTheme();
const searchRef = useRef(null);
const inputRef = searchInputRef || searchRef;
const currentUrl = currentPanel ? (currentPanel.embed_url || currentPanel.url || '') : '';
const logoGradient = `linear-gradient(135deg, ${muiTheme.palette.primary.main} 0%, ${muiTheme.palette.tertiary.main} 100%)`;
return (
<Box
component="header"
sx={{
height: 56, flexShrink: 0, display: 'flex', alignItems: 'center',
gap: 0.5, px: 1, zIndex: 10,
bgcolor: 'background.paper', borderBottom: 1, borderColor: 'divider',
}}
>
{/* 品牌:纯文字 Logo,MD3 渐变字重 */}
<Box
component="span"
aria-label="Rain Work"
sx={{
fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
WebkitTextFillColor: 'transparent', color: 'transparent',
userSelect: 'none',
}}
>
Rain Work
</Box>
<MenuBar menus={menus} />
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
{/* 导航:后退 / 前进 / 刷新 */}
<Tooltip title="后退">
<span><IconButton size="small" onClick={onBack} disabled={!canBack} aria-label="后退"><ArrowBackIcon fontSize="small" /></IconButton></span>
</Tooltip>
<Tooltip title="前进">
<span><IconButton size="small" onClick={onForward} disabled={!canForward} aria-label="前进"><ArrowForwardIcon fontSize="small" /></IconButton></span>
</Tooltip>
<Tooltip title="刷新当前面板">
<span><IconButton size="small" onClick={onRefresh} disabled={!currentPanel} aria-label="刷新"><RefreshIcon fontSize="small" /></IconButton></span>
</Tooltip>
{/* flex:1 maxWidth
左侧菜单/导航变宽时自然缩减顶栏始终占满 */}
<Box
role="textbox"
aria-label="当前面板地址"
sx={{
flex: 1, minWidth: 120, maxWidth: 'none', display: 'flex', alignItems: 'center', gap: 0.75,
mx: 0.75, height: 34, px: 1.25,
bgcolor: 'background.default', border: 1, borderColor: 'divider', borderRadius: 1.25,
'&:focus-within': { borderColor: 'primary.main' },
}}
>
{currentPanel && <PanelIcon url={currentUrl} title={currentPanel.title} size={16} />}
<InputBase
readOnly
fullWidth
value={currentUrl}
placeholder="未打开面板"
inputProps={{ 'aria-label': '当前面板地址', style: { cursor: 'default', fontSize: 12.5 } }}
sx={{ color: 'text.secondary' }}
/>
{currentPanel && (
<Tooltip title="在新标签页打开原站">
<IconButton size="small" onClick={onOpenExternal} aria-label="在新标签页打开原站">
<OpenInNewIcon sx={{ fontSize: 16 }} />
</IconButton>
</Tooltip>
)}
</Box>
{/* 搜索 */}
<Box sx={{ position: 'relative', flexShrink: 0 }}>
<SearchIcon
sx={{
position: 'absolute', left: 9, top: '50%', transform: 'translateY(-50%)',
fontSize: 16, color: 'text.secondary', pointerEvents: 'none',
}}
/>
<InputBase
ref={inputRef}
value={search}
onChange={(e) => onSearchChange?.(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { onSearchEnter?.(); inputRef.current?.blur(); } }}
placeholder="搜索面板"
inputProps={{ 'aria-label': '搜索面板', style: { paddingLeft: 30, fontSize: 12.5 } }}
sx={{
height: 34, width: { xs: 100, sm: 150 }, px: 0.5,
bgcolor: 'background.default', border: 1, borderColor: 'divider',
borderRadius: 20, '&:focus-within': { borderColor: 'primary.main' },
}}
/>
</Box>
{/* 返回前台 */}
<Button
color="inherit"
onClick={onBackHome}
startIcon={<HomeIcon sx={{ fontSize: 17 }} />}
sx={{ ml: 0.5, minWidth: 0, px: { xs: 1, sm: 1.25 }, flexShrink: 0, '& .MuiButton-startIcon': { mr: { xs: 0, sm: 0.5 } } }}
>
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' }, fontSize: 13 }}>返回</Box>
</Button>
</Box>
);
}
@@ -0,0 +1,390 @@
import React, { useCallback, useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Collapse from '@mui/material/Collapse';
import CircularProgress from '@mui/material/CircularProgress';
import Tooltip from '@mui/material/Tooltip';
import CloseIcon from '@mui/icons-material/Close';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import KeyIcon from '@mui/icons-material/Key';
import SearchIcon from '@mui/icons-material/Search';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ErrorIcon from '@mui/icons-material/Error';
import useVault from '../hooks/useVault.js';
import { showSnack } from '../../../admin/snack.jsx';
/* ============================================================
* VaultView密码箱视图VSCode 侧边栏 / 标签页内容
*
* VaultDrawer 裁剪去掉 MUI Drawer 壳与自带标题栏/关闭按钮
* 由父级 ToolWindow 提供锁定按钮并入解锁后的搜索行
* 标题栏之外全部逻辑保留
* loading noPin引导设置| lockedPIN 输入 unlocked搜索+条目
* 锁定 lock API切换视图不锁定方便取用
* @param {{open: boolean}} open 视图是否处于激活激活时重新同步服务端状态
* ============================================================ */
/** 剪贴板写入:优先 Async Clipboard,失败时降级 execCommand(非安全上下文/LAN 部署) */
async function copyText(text) {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch { return false; }
}
}
/** 展开详情中的单字段行:标签 + 值(网址可点开)+ 复制按钮 */
function DetailField({ label, value, href, secret, onCopy }) {
// http(s)/mailto
const safeHref = href && /^(https?|mailto):/i.test(href) ? href : null;
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.75, minWidth: 0 }}>
<Typography variant="caption" sx={{ color: 'text.secondary', width: 40, flexShrink: 0 }}>{label}</Typography>
{safeHref ? (
<Typography
variant="body2"
component="a"
href={safeHref}
target="_blank"
rel="noopener noreferrer"
title={value}
sx={{
flexGrow: 1, minWidth: 0, color: 'primary.main',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
>
{value}
</Typography>
) : (
<Typography
variant="body2"
title={value}
sx={{
flexGrow: 1, minWidth: 0,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
fontFamily: secret ? 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace' : undefined,
letterSpacing: secret ? '0.04em' : undefined,
}}
>
{value || '—'}
</Typography>
)}
{onCopy ? (
<IconButton size="small" aria-label={`复制${label}`} onClick={onCopy} sx={{ flexShrink: 0 }}>
<ContentCopyIcon sx={{ fontSize: 15 }} />
</IconButton>
) : null}
</Box>
);
}
/** 单条密码:行内标题 + 用户名 + 复制密码,点击展开详情 */
function VaultEntryRow({ entry, expanded, onToggle, onCopy }) {
return (
<ListItem disablePadding sx={{ display: 'block' }}>
<ListItemButton
onClick={onToggle}
selected={expanded}
aria-expanded={expanded}
sx={{ pr: 1 }}
>
<ListItemIcon>
<KeyIcon fontSize="small" />
</ListItemIcon>
<ListItemText
primary={entry.title}
secondary={entry.username || '无用户名'}
primaryTypographyProps={{ noWrap: true, fontWeight: 500 }}
secondaryTypographyProps={{ noWrap: true }}
/>
<Tooltip title="复制密码">
<IconButton
size="small"
aria-label={`复制 ${entry.title} 的密码`}
onClick={(e) => { e.stopPropagation(); onCopy(entry.password, '密码'); }}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
<ExpandMoreIcon
fontSize="small"
sx={{
color: 'text.secondary', ml: 0.25,
transform: expanded ? 'rotate(180deg)' : 'none',
transition: 'transform 0.2s ease',
}}
/>
</ListItemButton>
<Collapse in={expanded} unmountOnExit>
<Box sx={{ px: 2, py: 1, mx: 1, mb: 1, bgcolor: 'action.hover', borderRadius: 2 }}>
<DetailField
label="用户名"
value={entry.username}
onCopy={entry.username ? () => onCopy(entry.username, '用户名') : undefined}
/>
<DetailField
label="密码"
value={entry.password}
secret
onCopy={entry.password ? () => onCopy(entry.password, '密码') : undefined}
/>
{entry.url ? (
<DetailField label="网址" value={entry.url} href={entry.url} onCopy={() => onCopy(entry.url, '网址')} />
) : null}
{entry.notes ? <DetailField label="备注" value={entry.notes} /> : null}
</Box>
</Collapse>
</ListItem>
);
}
/**
* 密码箱视图
* @param {boolean} open 视图是否激活激活时重新同步服务端会话状态
*/
export default function VaultView({ open = false }) {
const vault = useVault({ open });
// PIN
const [pinInput, setPinInput] = useState('');
const [pinError, setPinError] = useState('');
const [showPin, setShowPin] = useState(false);
// id
const [expandedId, setExpandedId] = useState(null);
useEffect(() => {
if (open) {
setPinInput('');
setPinError('');
setShowPin(false);
setExpandedId(null);
}
}, [open]);
const submitUnlock = useCallback(async () => {
const pin = pinInput.trim();
if (!pin) { setPinError('请输入 PIN 码'); return; }
setPinError('');
try {
await vault.unlock(pin);
setPinInput('');
setShowPin(false);
showSnack('已解锁');
} catch (e) {
setPinError(e.message || '解锁失败');
setPinInput('');
}
}, [pinInput, vault]);
const handleLock = useCallback(async () => {
try {
await vault.lock();
setExpandedId(null);
showSnack('已锁定');
} catch (e) {
showSnack(e.message || '锁定失败', 'error');
}
}, [vault]);
const copyToClipboard = useCallback(async (text, label) => {
const ok = await copyText(text);
if (ok) showSnack(`${label} 已复制`);
else showSnack('复制失败', 'error');
}, []);
/* ---------- 各状态下的主体 ---------- */
const renderBody = () => {
switch (vault.status) {
case 'loading':
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 2 }}>
<CircularProgress size={28} />
<Typography variant="body2" color="text.secondary">正在检查密码库状态</Typography>
</Box>
);
case 'error':
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
<ErrorIcon color="error" sx={{ fontSize: 40 }} />
<Typography variant="body2" color="text.secondary">{vault.statusError}</Typography>
<Button variant="outlined" onClick={vault.checkStatus} sx={{ mt: 1 }}>重试</Button>
</Box>
);
case 'noPin':
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 1.5, textAlign: 'center', px: 2 }}>
<LockOpenIcon sx={{ fontSize: 44, color: 'text.disabled' }} />
<Typography variant="subtitle1" sx={{ fontWeight: 500 }}>未设置 PIN</Typography>
<Typography variant="body2" color="text.secondary">请先到密码库页设置 PIN 后再使用</Typography>
<Button variant="contained" component="a" href="/passwords.html" sx={{ mt: 1 }}>前往密码库页</Button>
</Box>
);
case 'locked':
return (
<Box component="form" onSubmit={(e) => { e.preventDefault(); submitUnlock(); }} sx={{ mt: 1 }}>
<Typography variant="subtitle1" sx={{ mb: 1.5, fontWeight: 500 }}>输入 PIN 码解锁</Typography>
<TextField
autoFocus
fullWidth
size="small"
type={showPin ? 'text' : 'password'}
label="PIN 码"
value={pinInput}
onChange={(e) => { setPinInput(e.target.value); if (pinError) setPinError(''); }}
onKeyDown={(e) => { if (e.key === 'Enter') submitUnlock(); }}
error={!!pinError}
helperText={pinError || ' '}
autoComplete="off"
spellCheck={false}
slotProps={{
htmlInput: { maxLength: 20, 'aria-label': 'PIN 码' },
input: {
endAdornment: (
<InputAdornment position="end">
<IconButton
size="small"
edge="end"
aria-label={showPin ? '隐藏 PIN' : '显示 PIN'}
onClick={() => setShowPin((v) => !v)}
>
{showPin ? <VisibilityOffIcon fontSize="small" /> : <VisibilityIcon fontSize="small" />}
</IconButton>
</InputAdornment>
),
},
}}
/>
<Button
variant="contained"
fullWidth
disabled={vault.unlocking || !pinInput}
onClick={submitUnlock}
startIcon={vault.unlocking ? <CircularProgress size={18} color="inherit" /> : <LockOpenIcon />}
sx={{ mt: 2 }}
>
{vault.unlocking ? '解锁中…' : '解锁'}
</Button>
</Box>
);
case 'unlocked':
default: {
const { entries, entriesError, filteredEntries, search, setSearch } = vault;
const empty = filteredEntries.length === 0;
return (
<Box>
{/* 搜索 + 锁定(标题栏由 ToolWindow 提供,锁定并入此行) */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 0.5 }}>
<TextField
fullWidth
size="small"
placeholder="搜索标题或用户名"
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
htmlInput: { 'aria-label': '搜索密码' },
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" sx={{ color: 'text.secondary' }} />
</InputAdornment>
),
endAdornment: search ? (
<InputAdornment position="end">
<IconButton size="small" edge="end" aria-label="清空搜索" onClick={() => setSearch('')}>
<CloseIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null,
},
}}
/>
<Tooltip title="锁定密码库">
<span>
<IconButton aria-label="锁定密码库" onClick={handleLock} disabled={vault.locking} size="small">
<LockIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</Box>
{entries === null && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={26} />
</Box>
)}
{entriesError && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1, py: 3, textAlign: 'center' }}>
<ErrorIcon color="error" />
<Typography variant="body2" color="text.secondary">加载失败{entriesError}</Typography>
<Button size="small" variant="outlined" onClick={vault.loadEntries}>重试</Button>
</Box>
)}
{entries && !entriesError && empty && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
{search ? '无匹配的密码记录' : '暂无密码记录'}
</Typography>
</Box>
)}
{entries && !entriesError && !empty && (
<List disablePadding>
{filteredEntries.map((e) => (
<VaultEntryRow
key={e.id}
entry={e}
expanded={expandedId === e.id}
onToggle={() => setExpandedId(expandedId === e.id ? null : e.id)}
onCopy={copyToClipboard}
/>
))}
</List>
)}
</Box>
);
}
}
};
return (
<Box component="section" aria-label="密码箱" sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flex: 1, minHeight: 0, overflowY: 'auto', px: 1.5, py: 1.5 }}>
{renderBody()}
</Box>
</Box>
);
}
@@ -0,0 +1,231 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as notesApi from '../../../api/notes.js';
import { showSnack } from '../../../admin/snack.jsx';
/* ============================================================
* useNotes工作台记事本状态管理
*
* - 列表加载/错误态当前编辑文件activeName草稿内容 + 服务端快照
* - 自动保存输入防抖 900msCtrl+S 立即保存dirty = 草稿 快照
* - 新建 / 重命名新文件 + 删旧文件/ 删除全程复用 notes API
* - 会话保持sidebartab 模式切换/切视图会重挂载组件
* · 草稿存模块级 sessionCache重挂载瞬间恢复光标上下文不丢
* · activeName 持久化到 localStorageworkbench.notesActive跨刷新恢复上次文件
* · 监听 window 'workbench:notes-save-request'Workbench 切换模式前派发
* 立即 flush 未落盘的 <900ms 草稿
* ============================================================ */
const AUTOSAVE_MS = 900;
const NOTES_ACTIVE_KEY = 'workbench.notesActive';
/** 模块级草稿缓存:组件重挂载后直接恢复,不依赖服务端往返 */
const sessionCache = { name: null, text: '' };
export default function useNotes() {
const [notes, setNotes] = useState(null); // null = 加载中
const [loadError, setLoadError] = useState('');
const [activeName, setActiveName] = useState(() => {
try { return localStorage.getItem(NOTES_ACTIVE_KEY) || null; } catch { return null; }
});
// 草稿与快照初始化取自缓存(若缓存有同名文件),避免模式切换丢内容
const [content, setContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
const [savedContent, setSavedContent] = useState(() => (sessionCache.name ? sessionCache.text : ''));
const [saving, setSaving] = useState(false); // 保存请求进行中
const [busy, setBusy] = useState(false); // 打开/新建/重命名进行中
const draftRef = useRef({ name: null, text: '' }); // 最新草稿(供防抖回调读取)
const debounceRef = useRef(null);
const restoredRef = useRef(false); // 恢复会话只执行一次
// ---- 列表加载(静默刷新:保留现有列表,避免自动保存时闪烁) ----
const refresh = useCallback(() => {
setLoadError('');
return notesApi.listNotes()
.then((ls) => {
const arr = Array.isArray(ls) ? ls : [];
setNotes(arr);
setActiveName((prev) => (prev && arr.some((n) => n.name === prev) ? prev : null));
})
.catch((e) => { setLoadError(e.message || '加载失败'); });
}, []);
useEffect(() => { refresh(); }, [refresh]);
// ---- 保存(PUT ----
// 注意:必须声明在引用它的 useEffect(会话恢复 / save-request 监听)之前,
// 否则渲染期求值依赖数组会触发 TDZCannot access before initialization)。
const saveContent = useCallback((name, text) => {
setSaving(true);
return notesApi.updateNote(name, text)
.then((res) => {
setSavedContent(text);
sessionCache.text = text;
setNotes((prev) => (prev
? prev.map((n) => (n.name === res.name ? { ...n, size: res.size, mtime: res.mtime } : n))
: prev));
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setSaving(false));
}, []);
// ---- 会话恢复(挂载一次):缓存有同名草稿→直接恢复并推给服务端(幂等);
// 无缓存但记住了文件名→从服务端读取 ----
useEffect(() => {
if (restoredRef.current) return;
if (activeName === null) { restoredRef.current = true; return; }
if (sessionCache.name === activeName) {
restoredRef.current = true;
saveContent(activeName, sessionCache.text);
return;
}
restoredRef.current = true;
open(activeName);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ---- activeName 持久化(模式切换/刷新后恢复上次文件) ----
useEffect(() => {
try {
if (activeName) localStorage.setItem(NOTES_ACTIVE_KEY, activeName);
else localStorage.removeItem(NOTES_ACTIVE_KEY);
} catch { /* 隐私模式忽略 */ }
}, [activeName]);
// ---- 模式切换前的显式保存:Workbench 派发 workbench:notes-save-request ----
useEffect(() => {
const handler = () => {
clearTimeout(debounceRef.current);
const d = draftRef.current;
if (d.name) saveContent(d.name, d.text);
};
window.addEventListener('workbench:notes-save-request', handler);
return () => window.removeEventListener('workbench:notes-save-request', handler);
}, [saveContent]);
// 当前文件被外部删除/失效时清空编辑器
useEffect(() => {
if (activeName === null) {
setContent('');
setSavedContent('');
draftRef.current = { name: null, text: '' };
}
}, [activeName]);
// ---- 打开文件 ----
const open = useCallback((name) => {
setBusy(true);
return notesApi.getNote(name)
.then((res) => {
const text = res.content || '';
setActiveName(name);
setContent(text);
setSavedContent(text);
draftRef.current = { name, text };
sessionCache.name = name;
sessionCache.text = text;
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setBusy(false));
}, []);
// ---- 编辑(防抖自动保存 + 实时写缓存) ----
const updateContent = useCallback((text) => {
setContent(text);
draftRef.current = { name: activeName, text };
sessionCache.name = activeName;
sessionCache.text = text;
clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const d = draftRef.current;
if (d.name) saveContent(d.name, d.text);
}, AUTOSAVE_MS);
}, [activeName, saveContent]);
// ---- 立即保存(Ctrl+S / 切换文件前 / save-request ----
const saveNow = useCallback(() => {
clearTimeout(debounceRef.current);
const d = draftRef.current;
if (d.name) return saveContent(d.name, d.text);
return Promise.resolve();
}, [saveContent]);
// ---- 新建 ----
const create = useCallback((name) => {
setBusy(true);
return notesApi.createNote(name, '')
.then((res) => {
setActiveName(res.name);
setContent('');
setSavedContent('');
draftRef.current = { name: res.name, text: '' };
sessionCache.name = res.name;
sessionCache.text = '';
return refresh();
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setBusy(false));
}, [refresh]);
// ---- 重命名(新文件写入旧内容 + 删旧文件) ----
const rename = useCallback(async (oldName, newName) => {
setBusy(true);
try {
// 若正在编辑该文件,用草稿内容;否则先读服务端内容
let text = draftRef.current.text;
if (activeName !== oldName) {
const res = await notesApi.getNote(oldName);
text = res.content || '';
}
await notesApi.createNote(newName, text);
await notesApi.deleteNote(oldName);
if (activeName === oldName) {
setActiveName(newName);
setSavedContent(text);
draftRef.current = { name: newName, text };
sessionCache.name = newName;
sessionCache.text = text;
}
await refresh();
showSnack('重命名成功');
} catch (e) {
showSnack(e.message, 'error');
} finally {
setBusy(false);
}
}, [activeName, refresh]);
// ---- 删除 ----
const remove = useCallback(async (name) => {
setBusy(true);
try {
await notesApi.deleteNote(name);
if (activeName === name) {
clearTimeout(debounceRef.current);
setActiveName(null);
sessionCache.name = null;
sessionCache.text = '';
}
await refresh();
} catch (e) {
showSnack(e.message, 'error');
} finally {
setBusy(false);
}
}, [activeName, refresh]);
// ---- 新建默认名:untitled-N.txt(避开已有名) ----
const nextUntitled = useMemo(() => {
const names = new Set((notes || []).map((n) => n.name.toLowerCase()));
let i = 1;
while (names.has(`untitled-${i}.txt`)) i += 1;
return `untitled-${i}.txt`;
}, [notes]);
const dirty = activeName !== null && content !== savedContent;
return {
notes, loadError, refresh,
activeName, content, dirty, saving, busy,
open, updateContent, saveNow, create, rename, remove, nextUntitled,
};
}
@@ -0,0 +1,254 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { listAdminLinks } from '../../../api/adminLinks.js';
/* ============================================================
* 工作台面板状态Lane A 核心 hook
*
* 管理面板列表admin_links打开集合当前面板历史栈
* 固定pin分组折叠打开方式侧边栏折叠搜索
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStoragekey: workbench.*
* ============================================================ */
export const OPEN_MODE_EMBED = 'embed'; // 内嵌 iframe
export const OPEN_MODE_TAB = 'tab'; // 新标签页
export const OPEN_MODE_MODAL = 'modal'; // 弹窗打开
const STORE = {
open: 'workbench.open', // 打开的面板 id 数组
active: 'workbench.active', // 当前面板 id
history: 'workbench.history', // { ids: [], index: n } 历史栈
groups: 'workbench.collapsedGroups', // { 分组名: true } 折叠
pinned: 'workbench.pinned', // 固定面板 id 数组
modes: 'workbench.openModes', // { id: 'embed'|'tab'|'modal' }
};
function read(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch { return fallback; }
}
function write(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式等场景忽略 */ }
}
export default function usePanels() {
const [links, setLinks] = useState(null); // null = 加载中
const [loadError, setLoadError] = useState('');
// 初始化即清洗(治愈已污染的 localStorage 存量数据):
// 面板 id 统一为字符串 + 去重。此前 openPanel 被传入数字(p.id)与字符串
// (handleSelectTab 的 key.slice) 两种类型,prev.includes 严格比较漏判 →
// 同一面板以两份加入 openIds → 双 iframe 并排"分屏"、closePanel 关不掉。
const [openIds, setOpenIds] = useState(() => {
const a = read(STORE.open, []);
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
});
const [activeId, setActiveId] = useState(() => {
const v = read(STORE.active, null);
return v == null ? null : String(v);
});
const [history, setHistory] = useState(() => {
const h = read(STORE.history, null);
if (h && Array.isArray(h.ids)) {
const ids = [...new Set(h.ids.map(String))];
return { ids, index: Math.min(Number(h.index) || -1, ids.length - 1) };
}
return { ids: [], index: -1 };
});
const [groups, setGroups] = useState(() => read(STORE.groups, {})); // name -> true 表示折叠
const [pinned, setPinned] = useState(() => {
const a = read(STORE.pinned, []);
return Array.isArray(a) ? [...new Set(a.map(String))] : [];
});
const [modes, setModes] = useState(() => read(STORE.modes, {}));
const [modalId, setModalId] = useState(null); // 弹窗打开的面板 id(不持久化)
const [search, setSearch] = useState('');
// LRU 引用(内存态,不需要持久化):面板 id -> 最近使用时间戳
const recency = useRef({});
// ---- 持久化 ----
useEffect(() => write(STORE.open, openIds), [openIds]);
useEffect(() => write(STORE.active, activeId), [activeId]);
useEffect(() => write(STORE.history, history), [history]);
useEffect(() => write(STORE.groups, groups), [groups]);
useEffect(() => write(STORE.pinned, pinned), [pinned]);
useEffect(() => write(STORE.modes, modes), [modes]);
// ---- 加载面板列表(reconcile 全部用函数式更新,消除陈旧闭包竞态) ----
const reload = useCallback(() => {
setLoadError('');
setLinks(null);
return listAdminLinks()
.then((ls) => {
const arr = Array.isArray(ls) ? ls : [];
setLinks(arr);
const valid = new Set(arr.map((p) => String(p.id)));
// String 归一化后再过滤,防止存量数字/字符串混杂 id 绕过校验
setOpenIds((prev) => prev.map(String).filter((id) => valid.has(id)));
setPinned((prev) => prev.map(String).filter((id) => valid.has(id)));
setModes((prev) => Object.fromEntries(Object.entries(prev).filter(([id]) => valid.has(id))));
setHistory((prev) => {
const ids = prev.ids.map(String).filter((id) => valid.has(id));
return { ids, index: Math.min(prev.index, ids.length - 1) };
});
return arr;
})
.catch((e) => { setLoadError(e.message || '加载失败'); });
}, []);
useEffect(() => { reload(); }, [reload]);
// activeId 兜底(派生逻辑):重载后当前面板已被删除时,
// 从历史栈 / 打开集合取最近一个,避免指向不存在的面板
useEffect(() => {
if (!links) return;
const valid = new Set(links.map((p) => String(p.id)));
if (activeId && valid.has(String(activeId))) return;
const fallback = (history.index >= 0 ? history.ids[history.index] : null)
?? openIds[0]
?? null;
setActiveId(fallback);
}, [links, activeId, history, openIds]);
const touch = useCallback((id) => { recency.current[id] = Date.now(); }, []);
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
const openPanel = useCallback((id, panelOverride) => {
id = String(id); // 统一字符串,避免数字/字符串双份加入 openIds
const panel = panelOverride || (links && links.find((p) => String(p.id) === id));
if (!panel) return false;
touch(id);
const mode = modes[id] || OPEN_MODE_EMBED;
if (mode === OPEN_MODE_TAB) {
window.open(panel.embed_url || panel.url, '_blank', 'noopener');
return false;
}
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
if (activeId === id) return false;
setOpenIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
setHistory((prev) => {
const ids = prev.ids.slice(0, prev.index + 1);
if (ids[ids.length - 1] === id) return prev;
ids.push(id);
return { ids, index: ids.length - 1 };
});
setActiveId(id);
return true;
}, [activeId, links, modes, touch]);
// ---- 历史栈:◀ 后退 / ▶ 前进 ----
const goBack = useCallback(() => {
if (history.index <= 0) return;
const index = history.index - 1;
const id = history.ids[index];
if (id == null) return;
setHistory({ ...history, index });
setActiveId(id);
touch(id);
}, [history, touch]);
const goForward = useCallback(() => {
if (history.index >= history.ids.length - 1) return;
const index = history.index + 1;
const id = history.ids[index];
if (id == null) return;
setHistory({ ...history, index });
setActiveId(id);
touch(id);
}, [history, touch]);
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
const closePanel = useCallback((id) => {
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
const nextOpen = openIds.filter((x) => x !== id);
setOpenIds(nextOpen);
delete recency.current[id];
const histIds = history.ids.filter((x) => x !== id);
let index = history.index;
const removedAt = history.ids.indexOf(id);
if (removedAt !== -1 && removedAt < index) index -= 1;
if (index > histIds.length - 1) index = histIds.length - 1;
setHistory({ ids: histIds, index: index < 0 ? -1 : index });
const nextActive = histIds[index] ?? nextOpen[0] ?? null;
if (nextActive) setActiveId(nextActive); else setActiveId(null);
}, [history, openIds]);
const togglePin = useCallback((id) => {
id = String(id); // 防止固定集合再次被数字污染
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}, []);
const toggleGroup = useCallback((name) => {
setGroups((prev) => ({ ...prev, [name]: !prev[name] }));
}, []);
const setMode = useCallback((id, mode) => {
setModes((prev) => ({ ...prev, [id]: mode }));
}, []);
const closeModal = useCallback(() => setModalId(null), []);
// ---- LRU 渲染集:当前面板 + 最近用过的 2 个(最多 3 个活跃 iframe ----
const renderedIds = useMemo(() => {
const active = activeId ? String(activeId) : null;
if (!active) return [];
const others = openIds.filter((id) => String(id) !== active);
const sorted = others
.map((id) => ({ id, ts: recency.current[id] || 0 }))
.sort((a, b) => b.ts - a.ts)
.map((x) => x.id);
return [active, ...sorted.slice(0, 2)];
}, [activeId, openIds]);
const panelsById = useMemo(() => {
const m = {};
(links || []).forEach((p) => { m[p.id] = p; });
return m;
}, [links]);
const activePanel = activeId ? (panelsById[activeId] || null) : null;
const modalPanel = modalId ? (panelsById[modalId] || null) : null;
const openPanels = openIds.map((id) => panelsById[id]).filter(Boolean);
// ---- 搜索过滤(面板名 / URL / 分组) ----
const filteredPanels = useMemo(() => {
if (!search.trim()) return links || [];
const q = search.trim().toLowerCase();
return (links || []).filter((p) =>
String(p.title || '').toLowerCase().includes(q)
|| String(p.url || '').toLowerCase().includes(q)
|| String(p.embed_url || '').toLowerCase().includes(q)
|| String(p.category || '').toLowerCase().includes(q)
);
}, [links, search]);
// 搜索框 Enter:直达第一个匹配面板(空搜索不触发)
const openFirstMatch = useCallback(() => {
if (!search.trim()) return false;
const first = filteredPanels[0];
if (first) { openPanel(first.id); setSearch(''); return true; }
return false;
}, [filteredPanels, openPanel, search]);
return {
// 数据
links, loadError, reload, panelsById,
// 打开集合
openIds, openCount: openIds.length, openPanels, renderedIds,
activeId, activePanel, modalId, modalPanel, closeModal,
// 历史栈
history,
canBack: history.index > 0,
canForward: history.index >= 0 && history.index < history.ids.length - 1,
goBack, goForward,
// 操作
openPanel, closePanel, togglePin, toggleGroup, setMode,
// 分组 / 固定 / 打开方式
pinned, groups, modes,
// 搜索
search, setSearch, filteredPanels, openFirstMatch,
};
}
@@ -0,0 +1,104 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as pwApi from '../../../api/passwords.js';
/**
* 密码库抽屉的状态管理Lane B
*
* 状态机 status
* loading 正在检查 PIN 状态pinStatus
* error 检查失败可重试
* noPin 未设置 PIN引导去密码库页
* locked 已设 PIN 但未解锁PIN 输入屏
* unlocked 已解锁搜索 + 条目列表
*
* 约定关闭抽屉不锁定只有 lock() 才锁定方便切面板取用
* 会话在服务端内存中滑动过期1 小时每次打开抽屉会重新同步一次状态
*/
export default function useVault({ open } = {}) {
const [status, setStatus] = useState('loading');
const [statusError, setStatusError] = useState('');
const [entries, setEntries] = useState(null); // null = 条目加载中
const [entriesError, setEntriesError] = useState('');
const [search, setSearch] = useState('');
const [unlocking, setUnlocking] = useState(false);
const [locking, setLocking] = useState(false);
/** 拉取条目列表(解锁后),失败进入错误态并保留重试按钮 */
const loadEntries = useCallback(async () => {
setEntries(null);
setEntriesError('');
try {
const list = await pwApi.listEntries();
setEntries(list || []);
} catch (e) {
setEntriesError(e.message || '加载失败');
setEntries([]);
}
}, []);
/** 同步服务端 PIN / 会话状态 */
const checkStatus = useCallback(async () => {
setStatus('loading');
setStatusError('');
try {
const s = await pwApi.pinStatus();
if (!s.hasPin) setStatus('noPin');
else if (s.unlocked) { setStatus('unlocked'); loadEntries(); }
else setStatus('locked');
} catch (e) {
setStatusError(e.message || '检查密码库状态失败');
setStatus('error');
}
}, [loadEntries]);
// 每次抽屉打开(含重新打开)都同步一次状态;
// 若父组件保持挂载仅切换 open,也在这里触发。
useEffect(() => {
if (open) checkStatus();
}, [open, checkStatus]);
/** 解锁:成功则进入条目列表;失败抛出后端错误(401 PIN 错误 / 429 次数过多) */
const unlock = useCallback(async (pin) => {
setUnlocking(true);
try {
await pwApi.unlock(pin);
setStatus('unlocked');
setSearch('');
await loadEntries();
} finally {
setUnlocking(false);
}
}, [loadEntries]);
/** 锁定:清空本地条目与搜索,回到 PIN 输入屏 */
const lock = useCallback(async () => {
setLocking(true);
try {
await pwApi.lock();
setStatus('locked');
setEntries(null);
setEntriesError('');
setSearch('');
} finally {
setLocking(false);
}
}, []);
/** 按标题 / 用户名过滤(不区分大小写) */
const filteredEntries = useMemo(() => {
const q = search.trim().toLowerCase();
const base = entries || [];
if (!q) return base;
return base.filter((e) =>
(e.title || '').toLowerCase().includes(q) ||
(e.username || '').toLowerCase().includes(q));
}, [entries, search]);
return {
status, statusError,
entries, entriesError,
search, setSearch, filteredEntries,
unlocking, locking,
checkStatus, loadEntries, unlock, lock,
};
}
@@ -0,0 +1,187 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import WidgetsIcon from '@mui/icons-material/Widgets';
import NoteAltIcon from '@mui/icons-material/NoteAlt';
import KeyIcon from '@mui/icons-material/Key';
/* ============================================================
* useWorkbenchLayoutVSCode 风格工作台布局状态Lane A 新增
*
* - 视图注册表 VIEWS左侧主控面板+ 右侧功能区记事本/密码箱
* - order{ left: [viewId], right: [viewId] } 视图在左右活动栏的归属
* dnd-kit 拖拽换边后持久化key: workbench.viewOrder
* - active{ left: viewId, right: viewId } 每侧当前显示的视图
* - mode{ [viewId]: 'sidebar' | 'tab' | 'float' } 展示模式
* sidebar 默认tab 进主区标签页float P2 占位本期不实现
* - terminalOpen底部终端面板是否展开
* ============================================================ */
/** 视图注册表:id → 元信息(新增视图只需在此登记 + Workbench 提供渲染) */
export const VIEWS = {
panels: { id: 'panels', label: '面板', Icon: WidgetsIcon, hint: '面板工作台' },
notes: { id: 'notes', label: '记事本', Icon: NoteAltIcon, hint: '笔记与文件' },
vault: { id: 'vault', label: '密码箱', Icon: KeyIcon, hint: '密码管理' },
};
export const VIEW_IDS = Object.keys(VIEWS);
export const VIEW_MODE_SIDEBAR = 'sidebar';
export const VIEW_MODE_TAB = 'tab';
export const VIEW_MODE_FLOAT = 'float'; // P2:本期仅占位
const STORE = {
order: 'workbench.viewOrder', // { left: [], right: [] }
active: 'workbench.viewActive', // { left: id, right: id }
mode: 'workbench.viewMode', // { id: 'sidebar'|'tab' }
terminal: 'workbench.terminalOpen',
};
const DEFAULT_ORDER = { left: ['panels'], right: ['notes', 'vault'] };
const DEFAULT_ACTIVE = { left: 'panels', right: 'notes' };
const DEFAULT_MODE = { panels: VIEW_MODE_SIDEBAR, notes: VIEW_MODE_SIDEBAR, vault: VIEW_MODE_SIDEBAR };
function read(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch { return fallback; }
}
function write(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
}
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
function sanitizeOrder(saved) {
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
const seen = new Set();
const clean = (arr) => {
const out = [];
arr.forEach((id) => {
if (!VIEW_IDS.includes(id) || seen.has(id)) return; // 先查重,后登记
seen.add(id);
out.push(id);
});
return out;
};
const left = clean(saved.left);
const right = clean(saved.right);
VIEW_IDS.forEach((id) => { if (!seen.has(id)) right.push(id); });
// 兜底:持久化可能把视图全挪到一侧(或空数组),刷新后另一侧空白打不开。
// 保证每侧至少一个视图(panels 优先左侧,notes 次之)。
if (left.length === 0 && right.length > 0) {
const preferred = ['panels', 'notes'].find((id) => right.includes(id));
const move = preferred || right[0];
left.push(move);
right.splice(right.indexOf(move), 1);
}
if (right.length === 0 && left.length > 0) {
const preferred = ['notes', 'vault'].find((id) => left.includes(id));
const move = preferred || left[0];
right.push(move);
left.splice(left.indexOf(move), 1);
}
return { left, right };
}
/** 校验 active:指向的视图必须仍在本侧,否则回退到该侧第一个 */
function sanitizeActive(saved, order) {
if (!saved || typeof saved !== 'object') return { ...DEFAULT_ACTIVE };
const pick = (side, fallback) => (
order[side].includes(saved[side]) ? saved[side] : (order[side][0] || fallback)
);
return { left: pick('left', 'panels'), right: pick('right', 'notes') };
}
/** 校验 mode:只接受 sidebar/tabfloat 为保留值,不持久化激活) */
function sanitizeMode(saved) {
const out = { ...DEFAULT_MODE };
if (saved && typeof saved === 'object') {
VIEW_IDS.forEach((id) => {
if (saved[id] === VIEW_MODE_SIDEBAR || saved[id] === VIEW_MODE_TAB) out[id] = saved[id];
});
}
return out;
}
export default function useWorkbenchLayout() {
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
const [active, setActive] = useState(() => sanitizeActive(read(STORE.active, null), order));
const [mode, setMode] = useState(() => sanitizeMode(read(STORE.mode, null)));
const [terminalOpen, setTerminalOpen] = useState(() => read(STORE.terminal, false));
useEffect(() => write(STORE.order, order), [order]);
useEffect(() => write(STORE.active, active), [active]);
useEffect(() => write(STORE.mode, mode), [mode]);
useEffect(() => write(STORE.terminal, terminalOpen), [terminalOpen]);
/** 视图 id → 所在侧 */
const sideOf = useMemo(() => {
const m = {};
order.left.forEach((id) => { m[id] = 'left'; });
order.right.forEach((id) => { m[id] = 'right'; });
return m;
}, [order]);
const viewsOf = useCallback((side) => [...order[side]], [order]);
/** 每侧有效激活视图(active 指向已移走的视图时回退到第一个) */
const activeViewOf = useCallback(
(side) => (order[side].includes(active[side]) ? active[side] : order[side][0] || null),
[order, active]
);
/** 激活某视图(无论在哪侧) */
const activateView = useCallback((id) => {
const side = sideOf[id];
if (!side) return;
setActive((prev) => ({ ...prev, [side]: id }));
}, [sideOf]);
const setViewMode = useCallback((id, m) => {
if (!VIEW_IDS.includes(id)) return;
setMode((prev) => ({ ...prev, [id]: m }));
}, []);
/** 拖拽换边:视图从一侧移到另一侧,并激活目标侧 */
const moveView = useCallback((id, toSide) => {
if (toSide !== 'left' && toSide !== 'right') return;
setOrder((prev) => {
const fromSide = (['left', 'right']).find((s) => prev[s].includes(id));
if (!fromSide || fromSide === toSide) return prev;
const next = {
left: [...prev.left.filter((x) => x !== id)],
right: [...prev.right.filter((x) => x !== id)],
};
next[toSide].push(id);
return next;
});
setActive((prev) => ({ ...prev, [toSide]: id }));
}, []);
/** 同侧排序(dnd-kit arrayMove 语义) */
const reorderView = useCallback((id, overId) => {
setOrder((prev) => {
const side = (['left', 'right']).find((s) => prev[s].includes(id));
const overSide = (['left', 'right']).find((s) => prev[s].includes(overId));
if (!side || side !== overSide) return prev;
const list = prev[side];
const from = list.indexOf(id);
const to = list.indexOf(overId);
if (from < 0 || to < 0 || from === to) return prev;
const next = [...list];
next.splice(from, 1);
next.splice(to, 0, id);
return { ...prev, [side]: next };
});
}, []);
const toggleTerminal = useCallback(() => setTerminalOpen((v) => !v), []);
const openTerminal = useCallback(() => setTerminalOpen(true), []);
return {
order, sideOf, viewsOf,
active, activeViewOf, activateView,
mode, setViewMode,
moveView, reorderView,
terminalOpen, toggleTerminal, openTerminal,
};
}
+36
View File
@@ -0,0 +1,36 @@
/* ============================================================
* 工作台模块导出frontend/src/tools/workbench
* 便于未来升级 / 替换 / 独立打包
*
* 组件命名与文件名的兼容说明
* - Sidebar.jsx 导出 PanelsView面板列表视图VSCode 侧栏内容
* - NoteEditor.jsx 导出 NotesView记事本视图
* - VaultDrawer.jsx 导出 VaultView密码箱视图
* ============================================================ */
export { default as Workbench } from './Workbench.jsx';
export { default as Toolbar } from './components/Toolbar.jsx';
export { default as PanelFrame } from './components/PanelFrame.jsx';
export { default as AddPanelDialog } from './components/AddPanelDialog.jsx';
export { default as PanelIcon } from './components/PanelIcon.jsx';
export { default as PanelsView } from './components/Sidebar.jsx';
export { default as NotesView } from './components/NoteEditor.jsx';
export { default as VaultView } from './components/VaultDrawer.jsx';
export { default as ActivityBar } from './components/ActivityBar.jsx';
export { default as SidebarPane } from './components/SidebarPane.jsx';
export { default as ToolWindow } from './components/ToolWindow.jsx';
export { default as TabStrip } from './components/TabStrip.jsx';
export { default as ContentView } from './components/ContentView.jsx';
// 终端面板临时下线:组件归档到 archive/(前端入口已移除,恢复时改回 components/ 路径)
export { default as TerminalPanel } from './archive/TerminalPanel.jsx';
export { default as StatusBar } from './components/StatusBar.jsx';
export { default as MenuBar } from './components/MenuBar.jsx';
export { default as useNotes } from './hooks/useNotes.js';
export { default as useWorkbenchLayout } from './hooks/useWorkbenchLayout.js';
export { VIEWS, VIEW_IDS, VIEW_MODE_SIDEBAR, VIEW_MODE_TAB, VIEW_MODE_FLOAT } from './hooks/useWorkbenchLayout.js';
export {
default as usePanels,
OPEN_MODE_EMBED,
OPEN_MODE_TAB,
OPEN_MODE_MODAL,
} from './hooks/usePanels.js';
+171
View File
@@ -0,0 +1,171 @@
// RainID OIDC 接入共享逻辑(routes/auth.js ROPC 密码登录 / routes/oidc.js 授权码流共用)
// 安全要点:
// - client_secret 双通道:优先 .env.jsongitignored),回退后台站点设置
// site_settings.rainid_client_secretroutes/settings.js ALLOWED_SET 可写、GET 不返回,不泄露)
// - rainid_enabled='1' 但 client_id/secret 任一通道均缺失 → enabled=falsefail-closed,本地登录不受影响)
// - 影子账号按 sub 绑定(唯一索引兜底);email_verified 是自动绑定闸门(防撞绑)
// - 影子账号随机不可登录密码,禁止本地密码登入
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const bcrypt = require('bcryptjs');
const openidClient = require('openid-client'); // v6 函数式 APINode >=23 支持 require(esm)
const db = require('../db');
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
// 机密 client_secret 双通道读取(优先级从高到低):
// ① 环境变量 RAINID_CLIENT_SECRETserver.js 启动时从 .env.json 的 cfg.rainid_client_secret 读入)
// ② .env.json 直接读取
// ③ 后台站点设置 site_settings.rainid_client_secret(可写不可读,GET /api/settings 不返回)
function getClientSecret() {
if (process.env.RAINID_CLIENT_SECRET) return process.env.RAINID_CLIENT_SECRET;
try {
const cfg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '.env.json'), 'utf8'));
if (cfg.rainid_client_secret) return cfg.rainid_client_secret;
} catch { /* .env.json 缺失/损坏 → 走 settings 回退 */ }
return db.getSetting('rainid_client_secret') || '';
}
function getOidcSettings() {
const clientId = db.getSetting('rainid_client_id');
const clientSecret = getClientSecret();
return {
// fail-closedenabled 声明为 true 仅当三项齐全(rainid_enabled + client_id + secret
enabled: db.getSetting('rainid_enabled') === '1' && !!clientId && !!clientSecret,
clientId,
clientSecret,
discoveryUrl: db.getSetting('rainid_discovery_url') || DISCOVERY_DEFAULT,
registerRedirect: db.getSetting('rainid_register_redirect') === '1',
};
}
// discovery 结果缓存(RainID JWKS 多 key 轮换由 SDK 自动处理;设置变更需重启进程生效)
let configCache = null;
async function getOidcConfig() {
if (configCache) return configCache;
const s = getOidcSettings();
if (!s.clientId || !s.clientSecret) {
const err = new Error('RainID 客户端未配置(rainid_client_id / rainid_client_secret');
err.code = 'RAINID_NOT_CONFIGURED';
throw err;
}
configCache = await openidClient.discovery(s.discoveryUrl, s.clientId, s.clientSecret);
return configCache;
}
// 站点基址:redirect_uri / 回跳前端页面用。优先 site_url 设置,回退请求头(反代注意 X-Forwarded-Proto
function siteBase(req) {
const u = db.getSetting('site_url');
if (u) return String(u).replace(/\/+$/, '');
return (req.protocol || 'http') + '://' + (req.get('host') || 'localhost');
}
// 影子账号 查/建(sub 为 OIDC 稳定绑定键):
// 1. rainid_user_id = sub 命中 → 直接返回
// 2. email_verified=true 且本地存在同 email 且未绑定 → 自动绑定(防撞绑:仅 RainID 已验证邮箱可绑)
// 3. 否则创建影子账号(username=preferred_username 或 rainid_<sub前8>,冲突加后缀;
// password=随机不可登录;email_verified=1;全站无 admin 时首个用户为 admin
// 4. 并发兜底:唯一索引冲突 → 重查按 sub 返回
function findOrCreateRainidUser(sub, profile) {
if (!sub) {
const e = new Error('RainID userinfo 缺少 sub');
e.code = 'RAINID_NO_SUB';
throw e;
}
const email = String(profile.email || '').trim().toLowerCase();
// 1) 已绑定
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
if (user) return user;
// 2) 同 email 自动绑定(条件 UPDATE:仅绑定未绑定的本地号,防并发/防覆盖其他 sub)
if (email && profile.email_verified) {
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
if (local && !local.rainid_user_id) {
db.run('UPDATE users SET rainid_user_id = ? WHERE id = ? AND rainid_user_id = ?', [sub, local.id, '']);
const updated = db.get('SELECT * FROM users WHERE id = ?', [local.id]);
if (updated && updated.rainid_user_id === sub) return updated;
}
}
// 3) 创建影子账号
let username = profile.preferred_username || ('rainid_' + String(sub).slice(0, 8));
if (db.get('SELECT id FROM users WHERE username = ?', [username])) {
username = username + '_' + String(sub).slice(0, 4);
}
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
const adminCount = db.get("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
const role = adminCount && adminCount.c > 0 ? 'user' : 'admin';
try {
const id = db.run(
'INSERT INTO users (username, password, email, email_verified, role, avatar, rainid_user_id) VALUES (?, ?, ?, 1, ?, ?, ?)',
[username, randomHash, email, role, profile.picture || '', sub]
);
return db.get('SELECT * FROM users WHERE id = ?', [id]);
} catch (e) {
// 并发兜底:唯一索引冲突 → 重查按 sub 登录
const dup = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
if (dup) return dup;
throw e;
}
}
// ROPC 密码登录:RainID token 端点 grant_type=passwordopenid-client genericGrantRequest 自动带 client 认证)
// 成功 → 影子账号 → 返回 { ok:true, user };失败 → { ok:false, status, error }
async function rainidRopcLogin(username, password) {
const s = getOidcSettings();
if (!s.enabled) return { ok: false, status: 400, error: 'RainID 登录未启用' };
let config;
try { config = await getOidcConfig(); }
catch { return { ok: false, status: 500, error: 'RainID 客户端未配置' }; }
try {
const tokens = await openidClient.genericGrantRequest(config, 'password', {
username,
password,
scope: 'openid profile email',
});
// id_token 声明(签名/iss/aud 校验由 SDK 承担);拿不到 sub 则 userinfo 跳过 subject 校验
let expectedSub = openidClient.skipSubjectCheck;
try {
const claims = tokens.claims();
if (claims && claims.sub) expectedSub = claims.sub;
} catch { /* id_token 解析失败不阻断(userinfo 为准) */ }
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, expectedSub);
const user = findOrCreateRainidUser(userinfo.sub, userinfo);
return { ok: true, user };
} catch (err) {
return mapOidcError(err);
}
}
// RainID OAuth 错误 → HTTP 状态 + 文案(RainID 对接文档 §5.3 / §12 错误码总表)
function mapOidcError(err) {
const code = err && err.error;
const desc = (err && err.error_description) || '';
switch (code) {
case 'invalid_grant':
// 2FA 用户 ROPC 被拒 → 固定文案;其余统一"用户名/邮箱或密码错误"(防枚举)
return { ok: false, status: 401, error: desc.includes('二次验证')
? '该账号已开启二次验证,请使用 RainID 登录'
: '用户名/邮箱或密码错误' };
case 'rate_limited':
return { ok: false, status: 429, error: '尝试过于频繁,请稍后再试' };
case 'invalid_client':
case 'invalid_scope':
case 'invalid_request':
return { ok: false, status: 500, error: 'RainID 配置错误,请联系管理员' };
default:
return { ok: false, status: 502, error: 'RainID 服务暂不可用,请稍后再试' };
}
}
module.exports = {
getOidcSettings,
getOidcConfig,
getClientSecret,
siteBase,
findOrCreateRainidUser,
rainidRopcLogin,
mapOidcError,
};
+5 -2
View File
@@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const db = require('../db');
const SECRET = process.env.JWT_SECRET || 'rainweb-secret-key-2024'; const SECRET = process.env.JWT_SECRET;
function authMiddleware(req, res, next) { function authMiddleware(req, res, next) {
const header = req.headers.authorization; const header = req.headers.authorization;
@@ -17,7 +18,9 @@ function authMiddleware(req, res, next) {
} }
function adminOnly(req, res, next) { function adminOnly(req, res, next) {
if (req.user.role !== 'admin') { // 查库复查角色,防止 JWT 中过期/被篡改的角色信息直接放行
const u = db.get('SELECT role FROM users WHERE id = ?', [req.user.id]);
if (!u || u.role !== 'admin') {
return res.status(403).json({ error: '需要管理员权限' }); return res.status(403).json({ error: '需要管理员权限' });
} }
next(); next();
+2630 -13
View File
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More