96 Commits
Author SHA1 Message Date
miaomiao 69ab5676b4 优化 RainWeb CLI 运维命令 2026-09-05 21:01:32 +08:00
miaomiao af79e5864f 修复工单系统竞态与接口安全问题 2026-09-05 16:14:43 +08:00
miaomiao a992bd841d 将最近工单改为单行列表 2026-09-05 03:23:21 +08:00
miaomiao 7e16ba1989 修复最近工单列表内容裁切 2026-09-05 03:19:17 +08:00
miaomiao c539216146 修复后台工单处理布局与控件 2026-09-05 03:12:14 +08:00
miaomiao e7bb30b4bd fix: 优化后台工单工作区布局 2026-09-05 02:57:38 +08:00
miaomiao 8e890d9a96 feat: 新增工单反馈系统 2026-09-05 02:42:51 +08:00
miaomiao e505a170b7 feat: 工作台 proxy 深度改造(Muximux 模式)——/proxy/{slug}/ 路径前缀 + HTML 改写 + 运行时 shim + WebSocket + cookie 鉴权 + 按面板信任模型 sandbox,支持嵌入几乎所有 SPA 2026-08-22 00:17:39 +08:00
miaomiao 77e8ab76ee fix: 顶栏移动端响应式(删除登录态退出按钮统一走个人中心,汉堡永不被挤,移动端用户名隐藏/版本隐藏) 2026-08-15 11:05:56 +08:00
miaomiao 0c8ea58d22 fix: 个人中心昵称/博客保存修复 + 顶栏搜索框居中(5条限制) + UI 修复(顶栏选中高亮/UID开关/Banner/Tab/登录验证码按钮) 2026-08-14 03:18:30 +08:00
miaomiao cf8bf9eb83 fix: 个人中心布局整理(昵称/博客/签名/QQ 统一列布局+单个保存按钮,消除多按钮/空白碎片) 2026-08-14 03:00:07 +08:00
miaomiao ee0086b252 feat: 昵称+头衔+UID 全站显示(系统身份/自定义头衔双层,主要位恒显#id 评论位开关)+ 用户管理大弹窗编辑 + 用户主页视觉修复(Banner/tab/博客/QQ)+ 评论通知回退 admin + 全量评论列表 + 头像 bug 修复 + 站长帖判断改 author_username 2026-08-14 02:36:57 +08:00
miaomiao 4f3a2cf9a8 fix: 论坛管理页白屏(<Paper> 组件漏 import,ReferenceError 导致 React 卸载整树) 2026-08-14 01:41:51 +08:00
miaomiao 46b4c335dc fix: 后台侧边栏单成员组渲染崩溃(React #31——对象数组直接当子元素,改为统一 map 渲染并保留多组折叠) 2026-08-14 01:35:49 +08:00
miaomiao 92381b5c78 feat: 后台侧边栏重构(6组折叠导航 + 设置项搜索命令面板)+ 设置项挪窝(评论→博客、代理→面板链接、论坛私密开关补 UI) 2026-08-14 01:32:05 +08:00
miaomiao 171f1f5893 feat: 后台搜索引擎验证文件上传(Bing/Google 站长工具,存 public/ 根目录,白名单+内容校验+防覆盖) 2026-08-13 22:21:44 +08:00
miaomiao 6dfa3dd5c1 feat: 页脚分栏支持最多三栏并行(品牌+3栏目4列自适应布局,编辑器与后端校验上限 3) 2026-08-13 15:44:30 +08:00
miaomiao c8707cf704 feat: 公开个人主页(/u/:id 名片+统计+帖子/回复内容流,全站头像可点跳转,bio 签名+最后活跃) 2026-08-13 02:12:14 +08:00
miaomiao 4c2017cd2c feat: 附件真锁(锁定块内附件改走 /api/upload/locked 鉴权接口,JWT 解锁 token)+ 更换软件授权协议(定制版 LICENSE) 2026-08-13 01:25:52 +08:00
miaomiao 13f4a68c06 fix: 头像 QQ 邮箱回退失效(SQL 补 qq_from_email 提取列,不暴露 email 本体) 2026-08-13 01:10:34 +08:00
miaomiao 31f7394280 feat: 品牌图标本地资源库(QQ/B站/Telegram/GitHub/Gitea/微信 SVG)+ 联系链接双通道 2026-08-13 00:53:59 +08:00
miaomiao 1a0dd7e9d1 feat: 头像 QQ 邮箱自动判定(@qq.com 前缀提取 QQ 号,无需手动填写) 2026-08-13 00:35:14 +08:00
miaomiao 1ba9542479 fix: [lock:] 已解锁块渲染为锁块容器(组件层切段,同款背景边框+🔓标识) 2026-08-13 00:12:25 +08:00
miaomiao 914ad4239b feat: 用户头像(自传>QQ>RainID 优先级 + author_avatar 全接口)+ profile QQ 管理 + RainID 个人中心跳转 2026-08-13 00:09:12 +08:00
miaomiao 299ac3c027 fix: [lock:] 已解锁内容剥离内联标签(admin/作者视角不再透出 [lock:xxx] 原文) 2026-08-13 00:01:01 +08:00
miaomiao 38be22700d feat: [lock:] markdown 锁定(login/reply/password 真锁,服务端剥离 + 解锁接口 + SSR 打码) 2026-08-12 23:39:12 +08:00
miaomiao 620d931fa6 fix: /out 确认页豁免 CSP(内联倒计时脚本被 script-src 'self' 拦截) 2026-08-12 22:46:18 +08:00
miaomiao 63b89071e3 feat: 外链拦截放行 rainnya.asia 家族域名(含子域名直接跳转不走确认页) 2026-08-12 22:33:13 +08:00
miaomiao 2cf6368abf feat: 页脚栏目自定义(后台编辑器+前台渲染)+ 外链跳转确认页(/out 5秒倒计时+全站拦截) 2026-08-12 22:27:46 +08:00
miaomiao 5c220f7ecf feat: 站长保护(逃生通道写死 admin + 站长帖仅本人可删改)+ 移动端汉堡菜单 + 站长帖操作显隐 2026-08-12 20:52:16 +08:00
miaomiao e097fa260b fix: 论坛楼主正文壁纸模式改玻璃半透材质(对齐楼层) 2026-08-12 19:51:07 +08:00
miaomiao dd2e1f9c84 feat: 论坛帖子二次编辑(作者/版主/admin,Write 编辑分支 + 更新标记 + edit_count) 2026-08-12 19:47:54 +08:00
miaomiao 29add7efac fix: 论坛楼主正文加卡片背景 + 字号对齐博客(可读性) 2026-08-12 19:40:35 +08:00
miaomiao c7eadec61c fix: 版块图标校验放行 /uploads/ 相对路径(上传图标保存被拒) 2026-08-12 19:36:16 +08:00
miaomiao 4325e5aabe feat: 版块图标上传(≤1MB,png/jpg/gif/webp,sha去重,未设置降级首字+哈希色) 2026-08-12 19:06:58 +08:00
miaomiao 46bc4bef26 feat: RSS 三源(博客/论坛全站/版块级)+ 后台 RSS 管理页 + 前台订阅按钮 + SSR head 注入 2026-08-12 18:59:00 +08:00
miaomiao f91df3916a feat: 版主可改版块 profile(名称/描述/图标/底色,独立接口 + 管理台版块设置 Tab) 2026-08-12 18:47:48 +08:00
miaomiao b301a44caf feat: 版主子管理页(帖子管理/公告/禁言)+ 版块级禁言(migrations v5) 2026-08-12 18:44:47 +08:00
miaomiao a9726df1ee feat: 论坛贴吧风格大升级(L1版块索引+L2版块页+L3楼层帖+置顶/加精/多版主/admin超管+后台帖子管理+分享) 2026-08-12 18:14:12 +08:00
miaomiao cdd72e29d1 feat: Write 页双模式(?type=forum 论坛发帖)+ 论坛发帖跳转 Write 页,统一 MarkdownEditor 2026-08-12 17:38:05 +08:00
miaomiao 6f9691c18e feat: 共用 MarkdownEditor 组件(左编辑右实时预览+防抖+工具栏+拖拽上传),博客写作/论坛发帖迁移 2026-08-12 17:32:45 +08:00
miaomiao bfbcf69e22 feat: 验证码 8s 加载超时自动降级内置 SVG(captcha_type=both 时后端兼容) 2026-08-12 17:04:19 +08:00
miaomiao bc007c165f fix: L1 安全响应头中间件(nosniff/SAMEORIGIN/Referrer/Permissions/CSP,proxy 路径豁免) 2026-08-12 16:46:17 +08:00
miaomiao 040f9bf2ed fix: 安全加固(M1 导入注入 + L1-L9 响应头/限流/时序/附件/密码箱)+ 博客分享按钮 + 论坛游客可见开关 2026-08-12 16:20:38 +08:00
miaomiao 93c1fb9c7a fix: 安全审计修复(H3 express.json 恢复 + H2 代理 iframe 沙箱 + H4 论坛越权 + M2/M3/M4/M5/M7) 2026-08-12 16:10:11 +08:00
miaomiao 7dbaf2277f feat: 分享链接跳转 SPA 完整版(/blog/:id/share → 列表页 share 参数 → 客户端导航) 2026-08-12 04:07:11 +08:00
miaomiao 57851e4daa feat: 全站 SEO 优化(serveIndex 按路由 meta/OG/JSON-LD + sitemap 修复 + robots noindex + 品牌 OG 图/favicon) 2026-08-12 02:47:16 +08:00
miaomiao b38dd5c6db docs: RainID 接入指南(审计视角 + 实现视角 + 总索引,任意项目通用) 2026-08-11 02:04:07 +08:00
miaomiao 3cfb9a552c fix: rainid_register_redirect 加入 PUBLIC_KEYS(前台注册页可读,跳转 RainID 生效) 2026-08-11 00:03:34 +08:00
miaomiao 2fc18789d8 fix: openid-client discovery URL 实例 + ROPC 错误文案细分(未配置 vs discovery 失败) 2026-08-10 21:43:19 +08:00
miaomiao 28769cbfa7 feat: release.sh 支持 --local 本地打包模式(不推送不发布) 2026-08-10 21:15:42 +08:00
miaomiao 3a7997521b chore: 一键打包发布脚本(bump版本→构建→tag→源码包→Gitea release) 2026-08-10 21:12:15 +08:00
miaomiao 5b6691d2c1 feat: admin 本地密码逃生通道(RainID 配置错误时仍可登录后台) 2026-08-10 21:09:14 +08:00
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
135 changed files with 21064 additions and 1644 deletions
+1
View File
@@ -8,3 +8,4 @@ server.pid
releases/
public/dist/
backups/
workspace/
+5 -2
View File
@@ -9,10 +9,13 @@ 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
node cli.js <cmd> # status | health | db-check | start | stop | restart | port [N] | password | captcha | config | backup | upgrade
```
- 端口:`.env.json`gitignored`{"port": N}` 或环境变量 `PORT`,默认 3001vite 代理目标硬编码 3101vite.config.js),后端端口改了就同步改。
- CLI`status``health``db-check``config``backup list` 支持 `--json``health`/`db-check` 检查失败返回非 0。`backup prune` 必须明确 `--keep N`,默认只预览,只有 `--yes` 才删除;`backup restore <file>` 必须服务停止并带 `--yes`,不会默认覆盖。
- CLI 的 `start/stop/restart` 只操作经过 cwd 与命令行双重校验、确认属于本项目 `server.js` 的 PID,停止会先优雅等待。`upgrade` 仅供自托管机器本地执行:升级前备份,要求 git 工作区干净,执行 `git pull --ff-only``npm install``npm run build`,服务原本运行时重启并健康检查;失败返回非 0。
- CLI 密码命令默认交互输入;`config` 对密码、secret、token、JWT、私钥和 API key 等敏感配置统一脱敏。验证码参数需使用受支持的类型和值。
- 验证方式:后端改动启动后 `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)。
@@ -52,7 +55,7 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
## 版本与更新
- 版本号在根目录 `VERSION` 文件,与 `package.json` 的 version 需同步。
- **Web 更新接口已移除**(P0 删除 `/api/update/check``/api/update/run`,无 RCE 面)。升级只走 `cli.js upgrade`git pull(本地 origingit.rainnya.asia 镜像)+ npm install + 重启
- **Web 更新接口已移除**(P0 删除 `/api/update/check``/api/update/run`,无 RCE 面)。升级只走自托管机器本地的 `cli.js upgrade`升级前备份、检查干净工作区、`git pull --ff-only`(本地 origingit.rainnya.asia 镜像)+ npm install + npm run build + 服务重启/健康检查
- 上传附件在 `uploads/`gitignored,头像在 `uploads/avatars/`,白名单扩展名),壁纸在 `public/wallpaper/`(仅 .gitkeep 入库)。
## 约定
+47 -17
View File
@@ -1,21 +1,51 @@
MIT License
# RainWeb 软件授权协议(定制版)
Copyright (c) 2024 Xianyunah
**版本 1.0 · 2026-08-12**
**原作者(Initial Author):XianyunahRainnya 家族)**
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
## 一、授权范围
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
本协议授予任何获得本软件副本(含源码、文档、构建产物)的个人或组织(下称"被授权人")以下权利:
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
1. **使用**:允许安装、运行、复制本软件,用于个人学习、研究、内部开发。
2. **修改**:允许对本软件源码进行修改、扩展、二次开发。
3. **分发**:允许分发本软件的原始版本或修改后的衍生版本。
## 二、禁止事项
被授权人**不得**
1. **商业使用**:不得将本软件或其衍生版本用于任何商业目的,包括但不限于:
- 直接销售本软件或其衍生版本
- 提供收费的托管服务(SaaS/PaaS)
- 将本软件作为商业产品的组成部分
- 通过本软件获取直接或间接的商业收益
2. **闭源**:本软件及其衍生版本必须保持源代码公开可用,不得以任何形式闭源或隐藏源代码。
3. **使用品牌**:不得使用「Rainnya」「雨喵」「RainWeb」名称、logo、视觉标识(含紫色雨夜视觉主题)对本软件的衍生版本进行品牌宣传、背书或暗示官方身份,除非获得原作者书面授权。
## 三、义务条款(Copyleft
1. **衍生版本必须开源**:任何基于本软件的修改、扩展、整合版本,其源代码必须遵循本协议开源发布,且必须包含本 LICENSE 文件。
2. **同协议授权**:衍生版本必须使用与本协议相同或更严格(限制更多)的授权条款。
3. **署名**:任何二次分发(原始或衍生版本)必须保留本 LICENSE 文件,并显著标明:
- 最初作者:**XianyunahRainnya 家族)**
- 原始项目名称与原始仓库地址
- 若为修改版,须明确标注"基于 RainWeb 修改"并说明修改内容
## 四、免责声明
本软件按"现状"(AS IS)提供,不附带任何明示或暗示的担保,包括但不限于适销性、特定用途适用性与非侵权保证。原作者对使用本软件产生的任何损害概不负责,无论基于何种法律理论(合同、侵权或其他)。
## 五、协议终止
被授权人违反本协议任何条款,其本协议项下的所有权利自动终止,且须立即停止使用、分发并销毁全部副本。
## 六、其他
- 本协议与 GPL-3.0 的精神一致(copyleft),但额外附加了非商业与品牌保留条款,属"源代码可用(source-available"授权,**非 OSI 认证的开源协议**。
- 若本协议任一条款被判定无效,不影响其余条款效力。
- 授权事宜咨询:rainnya.asia
---
**版权 © 2024 XianyunahRainnya 家族)· 保留所有权利**
+21 -11
View File
@@ -106,15 +106,23 @@ npm run cli -- <command>
| 命令 | 说明 |
|------|------|
| `status` | 查看运行状态 |
| `start` | 启动服务器 |
| `stop` | 停止服务器 |
| `restart` | 重启服务器 |
| `status [--json]` | 查看版本、服务、数据库和备份状态 |
| `health [--json]` | HTTP 健康检查和 SQLite 完整性检查;不健康时返回非 0 |
| `db-check [--json]` | 只读检查数据库完整性和关键表 |
| `start` | 校验 PID 后启动服务器,已运行时不会重复启动 |
| `stop` | 只停止确认属于本项目的 `server.js`,优雅等待退出 |
| `restart` | 优雅停止并等待后启动服务器 |
| `port [number]` | 查看/修改端口 |
| `password [new-pass]` | 修改管理员密码 |
| `captcha` | 交互式配置验证码 |
| `config` | 查看所有配置 |
| `upgrade` | 一键升级(git pull + npm install + 重启) |
| `password` | 交互式修改管理员密码(兼容旧的末尾参数写法) |
| `captcha` | 交互式配置验证码;也支持 `--type``--login``--register``--forum``--failed``--threshold` |
| `config [--json]` | 查看配置,敏感值统一脱敏 |
| `backup` | 创建数据库备份并立即做完整性检查 |
| `backup list [--json]` | 列出备份并检查 SQLite 完整性 |
| `backup prune --keep N [--yes]` | 预览清理旧备份;只有显式 `--yes` 才删除 |
| `backup restore <file> --yes` | 服务停止后恢复指定备份;显式确认才覆盖,旧库会保留 |
| `upgrade` | 升级前备份,检查干净工作区,`git pull --ff-only`、安装、构建并在服务原本运行时重启验活 |
通用选项:`--yes` 跳过确认,`--json` 输出 JSONHTTP 检查可用 `--timeout 100-60000` 设置毫秒超时。密码默认交互输入,不建议在命令行中传递明文密码。
## 技术栈
@@ -214,16 +222,18 @@ npm start
## 升级
```bash
# 方法1: 一键升级(推荐)
# 方法1一键升级(推荐;仅限自托管机器本地执行
node cli.js upgrade
# 方法2: 手动
git pull
# 方法2手动
git pull --ff-only
npm install
npm run build
node cli.js restart
```
`upgrade` 要求 git 工作区干净,只执行 fast-forward 更新;失败会返回非 0,不会假报成功。`backup prune` 默认只预览不删除,`backup restore` 必须同时满足服务已停止和 `--yes`
## 版本
当前版本记录在项目根目录的 `VERSION` 文件中,导航栏标题右侧会显示当前版本号。
+1 -1
View File
@@ -1 +1 @@
2.0.3
2.3.2
+583 -279
View File
@@ -1,328 +1,632 @@
#!/usr/bin/env node
const { execSync, spawn } = require('child_process');
const { execFileSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const bcrypt = require('bcryptjs');
const http = require('http');
const Database = require('better-sqlite3');
const CONFIG_PATH = path.join(__dirname, '.env.json');
const PROJECT_ROOT = __dirname;
const CONFIG_PATH = path.join(PROJECT_ROOT, '.env.json');
const DB_PATH = path.join(PROJECT_ROOT, 'data', 'rainweb.db');
const BACKUP_DIR = path.join(PROJECT_ROOT, 'backups');
const PID_PATH = path.join(PROJECT_ROOT, 'server.pid');
const SERVER_PATH = path.join(PROJECT_ROOT, 'server.js');
const PKG = require('./package.json');
const db = require('./db');
// 只读命令不加载 db.js,避免检查状态时意外触发数据库迁移或写入。
let writableDb = null;
function getWritableDb() {
if (!writableDb) writableDb = require('./db');
return writableDb;
}
const HELP = `
RainWeb CLI v${PKG.version}
Usage: node cli.js <command> [options]
用法:node cli.js <命令> [参数] [选项]
Commands:
status Show server and system status
start Start the server
restart Restart the server
stop Stop the server
port [number] Show or change listen port (default: 3001)
password [new-pass] Change admin password (leave empty for prompt)
captcha Interactive captcha rule configuration
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)
help Show this help
命令:
status 查看版本、服务、数据库和备份状态
health HTTP 健康检查和 SQLite 完整性检查
db-check 只读检查 SQLite 完整性和关键表
start 启动服务器(防重复启动)
stop 优雅停止本项目的 server.js
restart 停止后等待并启动服务器
port [端口] 查看或修改监听端口
password 交互式修改管理员密码
captcha 交互式配置验证码规则
config 查看配置(敏感值统一脱敏)
backup 创建数据库备份
backup list 列出并检查备份完整性
backup prune --keep <数量> 预览清理旧备份;加 --yes 才会删除
backup restore <文件> --yes 服务停止后恢复备份,显式确认才覆盖
upgrade 备份、检查工作区、快进升级、构建并验活
help 显示帮助
Examples:
node cli.js status
node cli.js port 8080
node cli.js password MyNewP@ss123
node cli.js captcha
node cli.js backup
node cli.js upgrade
通用选项:
--yes 跳过需要确认的操作
--json 以 JSON 输出(status/health/config/backup list 等支持)
--timeout <毫秒> 设置 HTTP 检查超时(100-60000
说明:
password 默认交互输入;为兼容旧脚本仍接受末尾明文参数,但不在帮助示例中推荐。
upgrade 仅用于自托管机器上的本地操作,不会修改云端或远程服务。
`;
async function main() {
const cmd = process.argv[2] || 'help';
switch (cmd) {
case 'status': return cmdStatus();
case 'start': return cmdStart();
case 'restart': return cmdRestart();
case 'stop': return cmdStop();
case 'port': return cmdPort();
case 'password': return cmdPassword();
case 'captcha': return cmdCaptcha();
case 'config': return cmdConfig();
case 'backup': return cmdBackup();
case 'upgrade': return cmdUpgrade();
case 'help':
default:
console.log(HELP);
class CliError extends Error {
constructor(message, code = 1) {
super(message);
this.name = 'CliError';
this.exitCode = code;
}
}
// === Status ===
async function cmdStatus() {
console.log(`RainWeb v${PKG.version}`);
console.log(`Node.js: ${process.version}`);
console.log(`Platform: ${process.platform}`);
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
try {
await httpGet('http://localhost:' + (getConfigPort()));
console.log('Server: RUNNING');
} catch {
console.log('Server: STOPPED');
}
// Show admin info
try {
await db.getDb();
const admin = db.get("SELECT id, username, email, email_verified FROM users WHERE role = 'admin'");
if (admin) {
console.log(`Admin: ${admin.username} (email: ${admin.email || 'not set'}, verified: ${admin.email_verified ? 'yes' : 'no'})`);
function parseArgs(argv) {
const positionals = [];
const options = { yes: false, json: false };
const valueOptions = new Set(['keep', 'timeout', 'type', 'login', 'register', 'forum', 'failed', 'threshold']);
const booleanOptions = new Set(['yes', 'json']);
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === '--') {
positionals.push(...argv.slice(i + 1));
break;
}
} catch {}
if (!token.startsWith('--')) {
positionals.push(token);
continue;
}
const match = token.match(/^--([^=]+)(?:=(.*))?$/);
if (!match) throw new CliError(`无法解析选项:${token}`);
const name = match[1];
let value = match[2];
if (booleanOptions.has(name)) {
if (value !== undefined) throw new CliError(`选项 --${name} 不接受参数`);
options[name] = true;
continue;
}
if (!valueOptions.has(name)) throw new CliError(`未知选项:--${name}`);
if (value === undefined) {
value = argv[i + 1];
if (!value || value.startsWith('--')) throw new CliError(`选项 --${name} 缺少参数`);
i += 1;
}
options[name] = value;
}
return { positionals, ...options };
}
function readJson(file, fallback = {}) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
}
// === Port ===
function getConfigPort() {
try {
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
return config.port || 3001;
} catch { return 3001; }
const config = readJson(CONFIG_PATH);
const value = Number(process.env.PORT || config.port || 3001);
return Number.isInteger(value) && value >= 1 && value <= 65535 ? value : 3001;
}
function setConfigPort(port) {
let config = {};
try { config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')); } catch {}
const config = readJson(CONFIG_PATH);
config.port = port;
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
}
function cmdPort() {
const arg = process.argv[3];
if (arg) {
const port = parseInt(arg);
if (isNaN(port) || port < 1 || port > 65535) {
console.error('Invalid port number. Use 1-65535.');
process.exit(1);
}
setConfigPort(port);
console.log(`Port set to ${port}. Restart to apply.`);
} else {
console.log(`Current port: ${getConfigPort()}`);
}
function dbExists() { return fs.existsSync(DB_PATH); }
function openReadonlyDatabase(file = DB_PATH) {
if (!fs.existsSync(file)) throw new CliError(`数据库不存在:${file}`);
try { return new Database(file, { readonly: true, fileMustExist: true }); }
catch (error) { throw new CliError(`无法打开数据库:${error.message}`); }
}
// === Password ===
async function cmdPassword() {
await warnIfServerRunning();
let newPass = process.argv[3];
if (!newPass) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newPass = await new Promise(resolve => {
rl.question('New admin password (min 6 chars): ', resolve);
});
rl.close();
}
if (!newPass || newPass.length < 6) {
console.error('Password must be at least 6 characters.');
process.exit(1);
}
await db.getDb();
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
if (!admin) { console.error('No admin user found.'); process.exit(1); }
const hash = bcrypt.hashSync(newPass, 10);
db.run('UPDATE users SET password = ? WHERE id = ?', [hash, admin.id]);
console.log('Admin password updated successfully.');
function inspectDatabase(file = DB_PATH) {
const connection = openReadonlyDatabase(file);
try {
const quickCheck = connection.pragma('quick_check', { simple: true });
const foreignKeyErrors = connection.prepare('PRAGMA foreign_key_check').all();
const tables = connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name").all().map((row) => row.name);
const requiredTables = ['users', 'site_settings', 'tickets', 'ticket_messages', 'ticket_events'];
const missingTables = requiredTables.filter((name) => !tables.includes(name));
return {
file,
size: fs.statSync(file).size,
userVersion: connection.pragma('user_version', { simple: true }),
quickCheck,
foreignKeyErrors,
missingTables,
valid: quickCheck === 'ok' && foreignKeyErrors.length === 0 && missingTables.length === 0,
};
} catch (error) {
throw new CliError(`数据库完整性检查失败:${error.message}`);
} finally { connection.close(); }
}
// === Captcha ===
async function cmdCaptcha() {
await warnIfServerRunning();
await db.getDb();
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const q = (q) => new Promise(resolve => rl.question(q, resolve));
console.log('=== Captcha Rule Configuration ===\n');
console.log('Current settings:');
['login','register','forum','failed'].forEach(k => {
const v = db.get("SELECT value FROM site_settings WHERE key = 'captcha_" + k + "'");
console.log(` ${k}: ${v ? v.value : '0'}`);
});
const type = db.get("SELECT value FROM site_settings WHERE key = 'captcha_type'");
console.log(` type: ${type ? type.value : 'builtin'}\n`);
const typeAns = await q('Captcha type (builtin/recaptcha/both) [' + (type ? type.value : 'builtin') + ']: ');
if (typeAns) db.run("UPDATE site_settings SET value=? WHERE key='captcha_type'", [typeAns]);
for (const scope of ['login', 'register', 'forum']) {
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'}]: `);
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): ');
db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed'", [failAns.toLowerCase() === 'y' ? '1' : '0']);
if (failAns.toLowerCase() === 'y') {
const threshold = await q('Failed attempts threshold (default 5): ');
if (threshold) db.run("UPDATE site_settings SET value=? WHERE key='captcha_failed_threshold'", [threshold]);
}
rl.close();
console.log('\nCaptcha rules updated.');
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '未知';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GiB`;
}
// === Config ===
async function cmdConfig() {
await db.getDb();
const rows = db.all("SELECT key, value FROM site_settings ORDER BY key");
console.log('=== Site Configuration ===\n');
const secrets = ['smtp_pass', 'recaptcha_secret_key'];
rows.forEach(r => {
let val = r.value;
if (secrets.includes(r.key) && val) val = '****' + val.slice(-4);
console.log(` ${r.key}: ${val || '(empty)'}`);
});
console.log(`\n listen_port: ${getConfigPort()}`);
function formatMtime(date) {
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
// === Start / Stop / Restart ===
function findPidFile() { return path.join(__dirname, 'server.pid'); }
function print(value, args) {
if (args.json) console.log(JSON.stringify(value, null, 2));
else console.log(value);
}
function maskValue(key, value) {
if (value === undefined || value === null || value === '') return '(空)';
const sensitive = /(pass(word)?|secret|token|private|jwt|credential|api[_-]?key|client[_-]?secret|pin[_-]?(hash|salt)|terminal_pin)/i.test(key);
if (!sensitive) return String(value);
const text = String(value);
return text.length <= 4 ? '****' : `****${text.slice(-4)}`;
}
function getPidFromFile() {
if (!fs.existsSync(PID_PATH)) return null;
const raw = fs.readFileSync(PID_PATH, 'utf8').trim();
if (!/^\d+$/.test(raw)) return { invalid: true, raw };
return Number(raw);
}
function isRunning(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try { process.kill(pid, 0); return true; } catch { return false; }
}
async function cmdStop() {
const pidFile = findPidFile();
if (fs.existsSync(pidFile)) {
const pid = parseInt(fs.readFileSync(pidFile, 'utf8'));
if (isRunning(pid)) {
try { process.kill(pid); console.log('Server stopped (PID: ' + pid + ')'); } catch { console.log('Could not stop process.'); }
} else { console.log('Server not running.'); }
fs.unlinkSync(pidFile);
} else {
// Try to find node process
console.log('No PID file found. Try: taskkill /F /IM node.exe (Windows) or pkill node (Linux/Mac)');
function readProcessDetails(pid) {
if (!isRunning(pid)) return null;
let cwd = '';
let command = '';
if (process.platform === 'win32') {
try {
const script = "$p=Get-CimInstance Win32_Process -Filter 'ProcessId = ' + $args[0]; if ($p) { $p | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress }";
const output = execFileSync('powershell.exe', ['-NoProfile', '-Command', script, String(pid)], { encoding: 'utf8' }).trim();
const processInfo = output ? JSON.parse(output) : null;
command = processInfo && processInfo.CommandLine ? processInfo.CommandLine : '';
} catch {}
}
}
async function cmdStart() {
const port = getConfigPort();
const proc = spawn('node', ['server.js'], {
cwd: __dirname,
stdio: 'inherit',
env: { ...process.env, PORT: String(port) },
detached: true,
});
proc.unref();
fs.writeFileSync(findPidFile(), String(proc.pid));
console.log(`Server starting on port ${port} (PID: ${proc.pid})`);
}
async function cmdRestart() {
await cmdStop();
await new Promise(r => setTimeout(r, 1000));
await cmdStart();
}
// === Backup ===
async function cmdBackup() {
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() {
console.log('=== RainWeb Upgrade ===\n');
if (!fs.existsSync(path.join(__dirname, '.git'))) {
console.error('不是 git 仓库,无法 upgrade。请先 git clone 安装。');
process.exit(1);
}
console.log('1. Pulling latest code via git...');
try {
execSync('git pull', { cwd: __dirname, stdio: 'inherit' });
} catch {
console.error('Git pull failed. Check for conflicts.');
process.exit(1);
}
console.log('\n2. Installing dependencies...');
try {
execSync('npm install', { cwd: __dirname, stdio: 'inherit' });
} catch {
console.error('npm install failed.');
process.exit(1);
}
console.log('\n3. Restarting server...');
await cmdRestart();
console.log('\n=== Upgrade complete! ===');
}
// === Helper ===
// 写命令(password/captcha)在连接库前探测 server 是否运行,仅提示不阻止执行
async function warnIfServerRunning() {
try {
await httpGet('http://localhost:' + (getConfigPort()));
console.log('警告:server 正在运行,并发写库可能失败或等待,建议先停止 server 再执行');
if (process.platform !== 'win32') {
cwd = fs.realpathSync(`/proc/${pid}/cwd`);
command = fs.readFileSync(`/proc/${pid}/cmdline`).toString().replace(/\0/g, ' ').trim();
}
} catch {}
if (!command && process.platform !== 'win32') {
try { command = execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).trim(); } catch {}
}
const normalizedCommand = command.replace(/\\/g, '/');
const normalizedServer = SERVER_PATH.replace(/\\/g, '/');
const hasServerScript = normalizedCommand.includes(normalizedServer)
|| (cwd === PROJECT_ROOT && /(?:^|\s)server\.js(?:\s|$)/.test(command));
const owned = (process.platform === 'win32' ? hasServerScript : cwd === PROJECT_ROOT && hasServerScript)
&& !/cli\.js/.test(command);
return { pid, cwd, command, owned };
}
function httpGet(url) {
return new Promise((resolve, reject) => {
http.get(url, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve(d)); })
.on('error', reject);
function listCandidatePids() {
try { return fs.readdirSync('/proc').filter((entry) => /^\d+$/.test(entry)).map(Number); }
catch {
try { return execFileSync('ps', ['-A', '-o', 'pid='], { encoding: 'utf8' }).split(/\s+/).filter(Boolean).map(Number); }
catch { return []; }
}
}
function findOwnedServers() {
return listCandidatePids().filter((pid) => pid !== process.pid).map(readProcessDetails).filter((details) => details && details.owned);
}
function getOwnedServer() {
const pidInfo = getPidFromFile();
if (pidInfo && pidInfo.invalid) throw new CliError(`PID 文件格式错误:${PID_PATH}`);
if (Number.isInteger(pidInfo)) {
if (!isRunning(pidInfo)) {
try { fs.unlinkSync(PID_PATH); } catch {}
} else {
const details = readProcessDetails(pidInfo);
if (!details || !details.owned) throw new CliError(`拒绝操作:${PID_PATH} 中的 PID ${pidInfo} 不属于本项目的 server.js`);
return details;
}
}
const servers = findOwnedServers();
if (servers.length > 1) throw new CliError(`发现多个本项目 server.js 进程(${servers.map((item) => item.pid).join(', ')}),请先人工处理`);
return servers[0] || null;
}
async function waitForExit(pid, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!isRunning(pid)) return true;
await new Promise((resolve) => setTimeout(resolve, 100));
}
return !isRunning(pid);
}
function probeHttp(port, timeoutMs = 3000) {
return new Promise((resolve) => {
const started = Date.now();
const request = http.get({ hostname: '127.0.0.1', port, path: '/', timeout: timeoutMs }, (response) => {
response.resume();
response.on('end', () => resolve({ reachable: true, healthy: response.statusCode >= 200 && response.statusCode < 400, statusCode: response.statusCode, latencyMs: Date.now() - started }));
});
request.on('timeout', () => request.destroy(new Error('请求超时')));
request.on('error', (error) => resolve({ reachable: false, healthy: false, error: error.message, latencyMs: Date.now() - started }));
});
}
main().catch(console.error);
function getTimeout(args) {
const value = args.timeout === undefined ? 3000 : Number(args.timeout);
if (!Number.isInteger(value) || value < 100 || value > 60000) throw new CliError('--timeout 必须是 100-60000 的整数(毫秒)');
return value;
}
async function waitForStartedServer(pid, port, timeoutMs = 12000) {
const deadline = Date.now() + timeoutMs;
let last = null;
while (Date.now() < deadline) {
const details = readProcessDetails(pid);
if (!details || !details.owned) throw new CliError(`新启动的 PID ${pid} 未能确认属于本项目 server.js`);
last = await probeHttp(port, 1000);
if (last.healthy) return last;
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new CliError(`服务器启动后健康检查失败:${last && last.error ? last.error : `HTTP ${last && last.statusCode ? last.statusCode : '无响应'}`}`);
}
async function stopServer({ quiet = false } = {}) {
const server = getOwnedServer();
if (!server) {
if (!quiet) console.log('服务器当前未运行。');
return { stopped: false, pid: null };
}
process.kill(server.pid, 'SIGTERM');
let exited = await waitForExit(server.pid, 10000);
if (!exited) {
const stillOwned = readProcessDetails(server.pid);
if (!stillOwned || !stillOwned.owned) throw new CliError(`PID ${server.pid} 在等待期间不再确认属于本项目,未强制结束`);
process.kill(server.pid, 'SIGKILL');
exited = await waitForExit(server.pid, 3000);
}
if (!exited) throw new CliError(`服务器未能退出(PID: ${server.pid}`);
if (fs.existsSync(PID_PATH) && getPidFromFile() === server.pid) fs.unlinkSync(PID_PATH);
if (!quiet) console.log(`服务器已停止(PID: ${server.pid})。`);
return { stopped: true, pid: server.pid };
}
async function startServer({ quiet = false } = {}) {
const existing = getOwnedServer();
if (existing) {
if (!quiet) console.log(`服务器已经运行(PID: ${existing.pid})。`);
return { started: false, alreadyRunning: true, pid: existing.pid, port: getConfigPort() };
}
const port = getConfigPort();
const child = spawn(process.execPath, [SERVER_PATH], { cwd: PROJECT_ROOT, stdio: 'ignore', detached: true, env: { ...process.env, PORT: String(port) } });
if (!child.pid) throw new CliError('无法启动服务器进程');
fs.writeFileSync(PID_PATH, `${child.pid}\n`, { mode: 0o644 });
try { await waitForStartedServer(child.pid, port); }
catch (error) {
if (isRunning(child.pid)) {
const details = readProcessDetails(child.pid);
if (details && details.owned) { process.kill(child.pid, 'SIGTERM'); await waitForExit(child.pid, 3000); }
}
if (fs.existsSync(PID_PATH) && getPidFromFile() === child.pid) fs.unlinkSync(PID_PATH);
throw error;
}
child.unref();
if (!quiet) console.log(`服务器已启动(端口: ${port}PID: ${child.pid})。`);
return { started: true, alreadyRunning: false, pid: child.pid, port };
}
function listBackupFiles() {
if (!fs.existsSync(BACKUP_DIR)) return [];
return fs.readdirSync(BACKUP_DIR).filter((file) => /^rainweb-.+\.db$/.test(file)).map((file) => path.join(BACKUP_DIR, file)).filter((file) => fs.statSync(file).isFile()).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
}
function inspectBackup(file) {
try {
const result = inspectDatabase(file);
const integrityValid = result.quickCheck === 'ok' && result.foreignKeyErrors.length === 0;
return { file: path.basename(file), size: result.size, modifiedAt: formatMtime(fs.statSync(file).mtime), valid: integrityValid, schemaReady: result.missingTables.length === 0, quickCheck: result.quickCheck, foreignKeyErrors: result.foreignKeyErrors.length, missingTables: result.missingTables, userVersion: result.userVersion };
} catch (error) {
return { file: path.basename(file), size: fs.existsSync(file) ? fs.statSync(file).size : 0, modifiedAt: fs.existsSync(file) ? formatMtime(fs.statSync(file).mtime) : '', valid: false, schemaReady: false, error: error.message };
}
}
async function createBackup() {
if (!dbExists()) throw new CliError(`数据库不存在:${DB_PATH}`);
fs.mkdirSync(BACKUP_DIR, { recursive: true });
const now = new Date();
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}${String(now.getMilliseconds()).padStart(3, '0')}`;
const destination = path.join(BACKUP_DIR, `rainweb-${stamp}.db`);
const source = new Database(DB_PATH, { readonly: true, fileMustExist: true });
try { await source.backup(destination); } finally { source.close(); }
const check = inspectBackup(destination);
if (!check.valid) { try { fs.unlinkSync(destination); } catch {} throw new CliError(`备份已生成但完整性检查失败:${check.error || check.quickCheck}`); }
return { path: destination, ...check };
}
function resolveBackupFile(input) {
if (!input) throw new CliError('请指定备份文件名,例如 backup restore rainweb-YYYYMMDD-HHmmss.db');
const candidate = path.resolve(BACKUP_DIR, input);
if (path.dirname(candidate) !== path.resolve(BACKUP_DIR)) throw new CliError('备份文件必须位于 backups/ 目录内');
if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) throw new CliError(`备份文件不存在:${candidate}`);
return candidate;
}
function readSettingRows() {
if (!dbExists()) return [];
const connection = openReadonlyDatabase();
try { return connection.prepare('SELECT key, value FROM site_settings ORDER BY key').all(); } finally { connection.close(); }
}
function normalizeSwitch(value, label) {
const normalized = String(value || '').toLowerCase();
if (['1', 'on', 'yes', 'y', 'true'].includes(normalized)) return '1';
if (['0', 'off', 'no', 'n', 'false'].includes(normalized)) return '0';
throw new CliError(`${label} 必须是 on/off、yes/no 或 1/0`);
}
function ask(rl, question) { return new Promise((resolve) => rl.question(question, resolve)); }
async function cmdStatus(args) {
const port = getConfigPort();
const httpState = await probeHttp(port, getTimeout(args));
let server = null;
let serverError = null;
try { server = getOwnedServer(); } catch (error) { serverError = error.message; }
let database = null;
if (dbExists()) { try { database = inspectDatabase(); } catch (error) { database = { valid: false, error: error.message }; } }
const latest = listBackupFiles()[0];
const result = { version: PKG.version, node: process.version, platform: process.platform, port, server: { running: !!server, pid: server ? server.pid : null, error: serverError, http: httpState }, database: database || { valid: false, error: '数据库不存在' }, latestBackup: latest ? inspectBackup(latest) : null };
if (args.json) return print(result, args);
console.log(`RainWeb v${result.version}`);
console.log(`Node.js${result.node}`);
console.log(`平台:${result.platform}`);
console.log(`端口:${result.port}`);
console.log(`服务:${result.server.running ? `运行中(PID: ${result.server.pid}` : result.server.error || '未运行'}`);
console.log(`HTTP${httpState.healthy ? `正常(${httpState.statusCode}${httpState.latencyMs} ms` : '不可用'}`);
console.log(`数据库:${result.database.valid ? '完整性正常' : result.database.error || '检查失败'}`);
console.log(`最近备份:${result.latestBackup ? `${result.latestBackup.file}${formatBytes(result.latestBackup.size)}` : '无'}`);
}
async function cmdHealth(args) {
const port = getConfigPort();
const [httpState, database] = await Promise.all([probeHttp(port, getTimeout(args)), Promise.resolve().then(() => (dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在' }))]);
const result = { healthy: httpState.healthy && database.valid, port, http: httpState, database };
print(args.json ? result : `HTTP${httpState.healthy ? '正常' : `失败(${httpState.error || httpState.statusCode || '无响应'}`}\n数据库:${database.valid ? '完整性正常' : database.error || '检查失败'}\n总体:${result.healthy ? '健康' : '不健康'}`, args);
return result.healthy ? 0 : 2;
}
async function cmdDbCheck(args) {
const result = dbExists() ? inspectDatabase() : { valid: false, error: '数据库不存在', file: DB_PATH };
const foreignKeyCount = Array.isArray(result.foreignKeyErrors) ? result.foreignKeyErrors.length : result.foreignKeyErrors;
print(args.json ? result : `数据库:${result.file}\n版本:${result.userVersion ?? '未知'}\nquick_check${result.quickCheck || '未执行'}\n外键错误:${foreignKeyCount ?? '未知'}\n关键表缺失:${result.missingTables ? result.missingTables.join(', ') || '无' : '未知'}\n结果:${result.valid ? '通过' : result.error || '失败'}`, args);
return result.valid ? 0 : 2;
}
async function cmdPort(args) {
const value = args.positionals[1];
if (value === undefined) return print(args.json ? { port: getConfigPort() } : `当前端口:${getConfigPort()}`, args);
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 65535) throw new CliError('端口必须是 1-65535 的整数');
setConfigPort(Number(value));
print(args.json ? { port: Number(value), message: '端口已保存,重启后生效' } : `端口已设置为 ${value},重启后生效。`, args);
}
async function cmdPassword(args) {
await warnIfServerRunning();
let newPass = args.positionals[1];
if (!newPass) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
newPass = await ask(rl, '请输入新的管理员密码(至少 6 位):');
const confirm = await ask(rl, '请再次输入密码:');
rl.close();
if (newPass !== confirm) throw new CliError('两次输入的密码不一致');
}
if (!newPass || newPass.length < 6) throw new CliError('密码至少需要 6 位');
const db = getWritableDb();
db.getDb();
const admin = db.get("SELECT id FROM users WHERE role = 'admin'");
if (!admin) throw new CliError('未找到管理员账号');
db.run('UPDATE users SET password = ? WHERE id = ?', [bcrypt.hashSync(newPass, 10), admin.id]);
print(args.json ? { message: '管理员密码已更新' } : '管理员密码已更新。', args);
}
async function cmdCaptcha(args) {
await warnIfServerRunning();
const db = getWritableDb();
db.getDb();
const scopes = ['login', 'register', 'forum'];
const currentType = db.getSetting('captcha_type') || 'builtin';
const allowedTypes = new Set(['none', 'builtin', 'recaptcha', 'turnstile', 'both']);
const typeArg = args.type;
if (typeArg && !allowedTypes.has(typeArg)) throw new CliError('--type 必须是 none、builtin、recaptcha、turnstile 或 both');
const hasOptions = typeArg || scopes.some((scope) => args[scope] !== undefined) || args.failed !== undefined || args.threshold !== undefined;
const updates = {};
if (hasOptions) {
updates.captcha_type = typeArg || currentType;
for (const scope of scopes) if (args[scope] !== undefined) updates[`captcha_${scope}`] = normalizeSwitch(args[scope], `--${scope}`);
if (args.failed !== undefined) updates.captcha_failed = normalizeSwitch(args.failed, '--failed');
if (args.threshold !== undefined) {
if (!/^\d+$/.test(String(args.threshold)) || Number(args.threshold) < 1) throw new CliError('--threshold 必须是正整数');
updates.captcha_failed_threshold = String(Number(args.threshold));
}
} else {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
console.log('验证码配置(直接回车保留当前值)');
const type = await ask(rl, `类型 none/builtin/recaptcha/turnstile/both [${currentType}]`);
const selectedType = type || currentType;
if (!allowedTypes.has(selectedType)) { rl.close(); throw new CliError('验证码类型不合法'); }
updates.captcha_type = selectedType;
for (const scope of scopes) {
const current = db.getSetting(`captcha_${scope}`) || '0';
const answer = await ask(rl, `启用 ${scope} 验证码?(y/n) [${current === '1' ? 'y' : 'n'}]`);
updates[`captcha_${scope}`] = answer ? normalizeSwitch(answer, scope) : current;
}
const failed = await ask(rl, `失败尝试后启用验证码?(y/n) [${db.getSetting('captcha_failed') === '1' ? 'y' : 'n'}]`);
updates.captcha_failed = failed ? normalizeSwitch(failed, 'failed') : (db.getSetting('captcha_failed') || '0');
if (updates.captcha_failed === '1') {
const threshold = await ask(rl, `失败次数阈值 [${db.getSetting('captcha_failed_threshold') || '5'}]`);
if (threshold) {
if (!/^\d+$/.test(threshold) || Number(threshold) < 1) { rl.close(); throw new CliError('失败次数阈值必须是正整数'); }
updates.captcha_failed_threshold = threshold;
}
}
rl.close();
}
for (const [key, value] of Object.entries(updates)) db.setSetting(key, value);
print(args.json ? { message: '验证码规则已更新', settings: updates } : '验证码规则已更新。', args);
}
async function cmdConfig(args) {
const settings = Object.fromEntries(readSettingRows().map((row) => [row.key, maskValue(row.key, row.value)]));
const envConfig = readJson(CONFIG_PATH);
const safeEnv = Object.fromEntries(Object.entries(envConfig).map(([key, value]) => [key, maskValue(key, value)]));
const result = { settings, env: safeEnv, listen_port: getConfigPort() };
if (args.json) return print(result, args);
console.log('=== RainWeb 配置 ===');
for (const [key, value] of Object.entries(settings)) console.log(` ${key}${value}`);
for (const [key, value] of Object.entries(safeEnv)) console.log(` env.${key}${value}`);
console.log(` listen_port${result.listen_port}`);
}
async function cmdBackup(args) {
const action = args.positionals[1] || 'create';
if (action === 'create') {
const result = await createBackup();
print(args.json ? result : `备份成功:${result.path}\n完整性:通过(${formatBytes(result.size)}`, args);
return;
}
if (action === 'list') {
const result = listBackupFiles().map(inspectBackup);
print(args.json ? result : (result.length ? result.map((item) => `${item.valid ? '正常' : '损坏'}${item.valid && !item.schemaReady ? '(待迁移)' : ''} ${item.file} ${formatBytes(item.size)} ${item.modifiedAt}`).join('\n') : '暂无备份。'), args);
return;
}
if (action === 'prune') {
if (args.keep === undefined) throw new CliError('prune 必须明确指定 --keep <数量>;未指定时不会删除任何文件');
if (!/^\d+$/.test(String(args.keep)) || Number(args.keep) < 1) throw new CliError('--keep 必须是正整数');
const keep = Number(args.keep);
const remove = listBackupFiles().slice(keep);
const result = { keep, candidates: remove.map((file) => path.basename(file)), deleted: [] };
if (args.yes) for (const file of remove) { fs.unlinkSync(file); result.deleted.push(path.basename(file)); }
print(args.json ? result : (args.yes ? `已删除 ${result.deleted.length} 个旧备份,保留最新 ${keep} 个。` : `预览:将删除 ${remove.length} 个旧备份;加 --yes 才会执行。\n${result.candidates.join('\n')}`), args);
return;
}
if (action === 'restore') {
if (!args.yes) throw new CliError('restore 会覆盖当前数据库,必须显式使用 --yes;且服务必须已停止');
if (getOwnedServer()) throw new CliError('restore 必须先停止 RainWeb 服务');
const httpState = await probeHttp(getConfigPort(), getTimeout(args));
if (httpState.reachable) throw new CliError('检测到 HTTP 服务仍在运行,拒绝 restore');
const sourcePath = resolveBackupFile(args.positionals[2]);
const check = inspectBackup(sourcePath);
if (!check.valid) throw new CliError(`备份完整性检查失败,拒绝恢复:${check.error || check.quickCheck}`);
if (!dbExists()) fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const tempPath = `${DB_PATH}.restore-${process.pid}-${Date.now()}`;
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
try { await source.backup(tempPath); } finally { source.close(); }
let oldPath = null;
try {
if (fs.existsSync(DB_PATH)) { oldPath = `${DB_PATH}.before-restore-${Date.now()}`; fs.renameSync(DB_PATH, oldPath); }
fs.renameSync(tempPath, DB_PATH);
} catch (error) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch {}
try { if (oldPath && fs.existsSync(oldPath) && !fs.existsSync(DB_PATH)) fs.renameSync(oldPath, DB_PATH); } catch {}
throw new CliError(`恢复失败,已尝试保留原数据库:${error.message}`);
}
print(args.json ? { message: '数据库已恢复', source: sourcePath, previousDatabase: oldPath } : `数据库已恢复:${sourcePath}\n原数据库保留为:${oldPath || '无'}`, args);
return;
}
throw new CliError(`未知 backup 子命令:${action}`);
}
function runCommand(command, commandArgs, label) {
console.log(`${label}${command} ${commandArgs.join(' ')}`);
try { execFileSync(command, commandArgs, { cwd: PROJECT_ROOT, stdio: 'inherit' }); }
catch (error) { throw new CliError(`${label}失败(退出码 ${error.status ?? '未知'}`); }
}
function getNpmCommand() {
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
}
async function cmdUpgrade(args) {
if (args.json) throw new CliError('upgrade 不支持 --json,请使用普通输出查看升级过程');
if (!fs.existsSync(path.join(PROJECT_ROOT, '.git'))) throw new CliError('当前目录不是 git 仓库,无法升级');
let status;
try { status = execFileSync('git', ['status', '--porcelain'], { cwd: PROJECT_ROOT, encoding: 'utf8' }); }
catch (error) { throw new CliError(`无法读取 git 工作区:${error.message}`); }
if (status.trim()) throw new CliError('git 工作区不干净,已停止升级;请先处理本地修改或未跟踪文件');
const backup = await createBackup();
console.log(`升级前备份完成:${backup.file}`);
const wasRunning = !!getOwnedServer();
if (wasRunning) await stopServer();
try {
runCommand('git', ['pull', '--ff-only'], 'git 快进更新');
runCommand(getNpmCommand(), ['install'], '依赖安装');
runCommand(getNpmCommand(), ['run', 'build'], '前端构建');
if (wasRunning) {
await startServer();
const health = await cmdHealth({ json: false });
if (health !== 0) throw new CliError('升级后健康检查未通过');
}
} catch (error) {
if (wasRunning) {
let running = false;
try { running = !!getOwnedServer(); } catch {}
if (!running) {
try { await startServer(); } catch (restartError) { console.error(`升级失败后恢复启动也失败:${restartError.message}`); }
}
}
throw error instanceof CliError ? error : new CliError(error.message);
}
console.log('升级完成。');
}
async function cmdPortlessStart() { await startServer(); }
async function cmdPortlessStop() { await stopServer(); }
async function cmdPortlessRestart() { await stopServer(); await startServer(); }
async function warnIfServerRunning() {
let owned = null;
try { owned = getOwnedServer(); } catch {}
if (owned) console.log('警告:server 正在运行,并发写库可能等待;建议先停止服务。');
}
async function dispatch(args) {
const command = args.positionals[0] || 'help';
switch (command) {
case 'status': return cmdStatus(args);
case 'health': return cmdHealth(args);
case 'db-check': return cmdDbCheck(args);
case 'start': return cmdPortlessStart(args);
case 'stop': return cmdPortlessStop(args);
case 'restart': return cmdPortlessRestart(args);
case 'port': return cmdPort(args);
case 'password': return cmdPassword(args);
case 'captcha': return cmdCaptcha(args);
case 'config': return cmdConfig(args);
case 'backup': return cmdBackup(args);
case 'upgrade': return cmdUpgrade(args);
case 'help': return console.log(HELP);
default: throw new CliError(`未知命令:${command}\n\n${HELP}`);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const result = await dispatch(args);
if (Number.isInteger(result)) process.exitCode = result;
}
main().catch((error) => {
const exitCode = error.exitCode || 1;
if (process.argv.includes('--json')) console.error(JSON.stringify({ error: error.message, exitCode }));
else console.error(`错误:${error.message}`);
process.exitCode = exitCode;
});
+274 -1
View File
@@ -27,18 +27,25 @@ function initTables() {
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, email TEXT DEFAULT '', email_verified INTEGER DEFAULT 0,
role TEXT DEFAULT 'user', avatar TEXT DEFAULT '',
nickname TEXT DEFAULT '', title TEXT DEFAULT '', title_color TEXT DEFAULT '', website TEXT DEFAULT '',
bio TEXT DEFAULT '', last_active_at TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`);
db.exec(`CREATE TABLE IF NOT EXISTS pending_users (
id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL,
password TEXT NOT NULL, email TEXT NOT NULL, token TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT (datetime('now')))`);
// M2:启动时清理超 15 分钟的 pending 注册记录(验证码过期,防表无限增长 / 验证码长期间可试)
db.exec("DELETE FROM pending_users WHERE created_at < datetime('now','-15 minutes')");
db.exec(`CREATE TABLE IF NOT EXISTS admin_links (
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL,
url TEXT NOT NULL, embed_url TEXT DEFAULT '', description TEXT DEFAULT '',
icon TEXT DEFAULT '', category TEXT DEFAULT '默认', sort_order INTEGER DEFAULT 0,
use_proxy INTEGER DEFAULT 0, version TEXT DEFAULT '',
proxy_headers TEXT DEFAULT '', proxy_skip_tls_verify INTEGER DEFAULT 0,
permissions TEXT DEFAULT '', scale REAL DEFAULT 1.0, trusted INTEGER DEFAULT 1,
slug TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')))`);
// Migration: add columns if missing
try { db.exec('ALTER TABLE admin_links ADD COLUMN use_proxy INTEGER DEFAULT 0'); } catch {}
@@ -122,6 +129,20 @@ function initTables() {
db.exec(`CREATE TABLE IF NOT EXISTS site_settings (
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
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_published ON blog_posts(published)');
db.exec('CREATE INDEX IF NOT EXISTS idx_blog_comments_post ON blog_comments(post_id)');
@@ -154,6 +175,235 @@ function migrateSchema() {
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 {}
} },
// v4: 论坛贴吧升级——置顶/加精/版主/图标
{ version: 4, up: () => {
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_pinned INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE forum_posts ADD COLUMN is_essence INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE forum_categories ADD COLUMN icon_color TEXT DEFAULT ''"); } catch {}
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_moderators (
category_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
PRIMARY KEY (category_id, user_id),
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_posts_cat ON forum_posts(category_id, is_pinned DESC, created_at DESC)"); } catch {}
} },
// v5: 论坛版块级禁言
{ version: 5, up: () => {
try { db.exec(`CREATE TABLE IF NOT EXISTS forum_mutes (
category_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
muted_until DATETIME, -- NULL = 永久
created_at DATETIME DEFAULT (datetime('now')),
created_by INTEGER NOT NULL,
PRIMARY KEY (category_id, user_id),
FOREIGN KEY (category_id) REFERENCES forum_categories(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)`); } catch {}
try { db.exec("CREATE INDEX IF NOT EXISTS idx_forum_mutes_cat ON forum_mutes(category_id)"); } catch {}
} },
// v6: RSS 版块级订阅——版块 feed 开关
{ version: 6, up: () => {
try { db.exec("ALTER TABLE forum_categories ADD COLUMN feed_enabled INTEGER DEFAULT 1"); } catch {}
} },
// v7: 论坛帖子二次编辑计数
{ version: 7, up: () => {
try { db.exec("ALTER TABLE forum_posts ADD COLUMN edit_count INTEGER DEFAULT 0"); } catch {}
} },
// v8: markdown [lock:] 锁定——博客/论坛帖子存锁定元数据 JSON([{type, hash}],不含块原文)
{ version: 8, up: () => {
try { db.exec("ALTER TABLE blog_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
try { db.exec("ALTER TABLE forum_posts ADD COLUMN locks TEXT DEFAULT '[]'"); } catch {}
} },
// v9: 用户 QQ 号字段(头像优先级:自传 > QQ 头像 > RainID 头像)
{ version: 9, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN qq TEXT DEFAULT ''"); } catch {}
} },
// v10: 公开个人主页——用户个性签名
{ version: 10, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''"); } catch {}
} },
// v11: 公开个人主页——最后活跃时间(datetime 字符串,跨天更新)
{ version: 11, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN last_active_at TEXT DEFAULT ''"); } catch {}
} },
// v12: 用户对外昵称(显示名:nickname||username
{ version: 12, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN nickname TEXT DEFAULT ''"); } catch {}
} },
// v13: 自定义头衔 + 头衔颜色(帖子/评论作者位展示)
{ version: 13, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN title TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE users ADD COLUMN title_color TEXT DEFAULT ''"); } catch {}
} },
// v14: 个人博客链接(公开主页展示)
{ version: 14, up: () => {
try { db.exec("ALTER TABLE users ADD COLUMN website TEXT DEFAULT ''"); } catch {}
} },
// v15: 工作台 SPA 代理——面板链接加代理配置列
// proxy_headers=转发自定义头 JSON、proxy_skip_tls_verify、permissions=iframe 委托权限 JSON 数组、
// scale=嵌入缩放、trusted=信任模型(1 保留 allow-same-origin)、slug=唯一标识)
{ version: 15, up: () => {
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_headers TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE admin_links ADD COLUMN proxy_skip_tls_verify INTEGER DEFAULT 0"); } catch {}
try { db.exec("ALTER TABLE admin_links ADD COLUMN permissions TEXT DEFAULT ''"); } catch {}
try { db.exec("ALTER TABLE admin_links ADD COLUMN scale REAL DEFAULT 1.0"); } catch {}
try { db.exec("ALTER TABLE admin_links ADD COLUMN trusted INTEGER DEFAULT 1"); } catch {}
try { db.exec("ALTER TABLE admin_links ADD COLUMN slug TEXT DEFAULT ''"); } catch {}
} },
// v16: 站内工单——用户反馈、公开回复、内部备注及业务事件
{ version: 16, up: () => {
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_no TEXT NOT NULL UNIQUE,
requester_id INTEGER,
subject TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other',
priority TEXT NOT NULL DEFAULT 'normal',
status TEXT NOT NULL DEFAULT 'open',
source TEXT NOT NULL DEFAULT 'site',
source_url TEXT DEFAULT '',
source_type TEXT DEFAULT '',
source_id INTEGER DEFAULT 0,
browser_info TEXT DEFAULT '',
assignee_id INTEGER,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
last_reply_at DATETIME DEFAULT NULL,
first_response_at DATETIME,
resolved_at DATETIME,
closed_at DATETIME,
revision INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
author_id INTEGER,
content TEXT NOT NULL,
is_internal INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
actor_id INTEGER,
event_type TEXT NOT NULL,
field_name TEXT DEFAULT '',
old_value TEXT DEFAULT '',
new_value TEXT DEFAULT '',
detail TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
)`);
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)'); } catch {}
try { db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)'); } catch {}
} },
// v17: 工单并发版本与回复时间修正;为已存在的部分工单表补齐字段
{ version: 17, up: () => {
db.exec(`CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_no TEXT NOT NULL UNIQUE,
requester_id INTEGER,
subject TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'other',
priority TEXT NOT NULL DEFAULT 'normal',
status TEXT NOT NULL DEFAULT 'open',
source TEXT NOT NULL DEFAULT 'site',
source_url TEXT DEFAULT '',
source_type TEXT DEFAULT '',
source_id INTEGER DEFAULT 0,
browser_info TEXT DEFAULT '',
assignee_id INTEGER,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
last_reply_at DATETIME DEFAULT NULL,
first_response_at DATETIME,
resolved_at DATETIME,
closed_at DATETIME,
revision INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (requester_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS ticket_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
author_id INTEGER,
content TEXT NOT NULL,
is_internal INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT (datetime('now')),
updated_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE SET NULL
)`);
db.exec(`CREATE TABLE IF NOT EXISTS ticket_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket_id INTEGER NOT NULL,
actor_id INTEGER,
event_type TEXT NOT NULL,
field_name TEXT DEFAULT '',
old_value TEXT DEFAULT '',
new_value TEXT DEFAULT '',
detail TEXT DEFAULT '',
created_at DATETIME DEFAULT (datetime('now')),
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
)`);
const ensureColumns = (table, columns) => {
const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((column) => column.name));
for (const [name, definition] of columns) {
if (!existing.has(name)) {
try { db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`); } catch {}
}
}
};
ensureColumns('tickets', [
['ticket_no', "TEXT NOT NULL DEFAULT ''"], ['requester_id', 'INTEGER'],
['subject', "TEXT NOT NULL DEFAULT ''"], ['description', "TEXT NOT NULL DEFAULT ''"],
['category', "TEXT NOT NULL DEFAULT 'other'"], ['priority', "TEXT NOT NULL DEFAULT 'normal'"],
['status', "TEXT NOT NULL DEFAULT 'open'"], ['source', "TEXT NOT NULL DEFAULT 'site'"],
['source_url', "TEXT DEFAULT ''"], ['source_type', "TEXT DEFAULT ''"], ['source_id', 'INTEGER DEFAULT 0'],
['browser_info', "TEXT DEFAULT ''"], ['assignee_id', 'INTEGER'],
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
['last_reply_at', 'DATETIME DEFAULT NULL'], ['first_response_at', 'DATETIME DEFAULT NULL'],
['resolved_at', 'DATETIME DEFAULT NULL'], ['closed_at', 'DATETIME DEFAULT NULL'],
['revision', 'INTEGER NOT NULL DEFAULT 0'],
]);
ensureColumns('ticket_messages', [
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['author_id', 'INTEGER'],
['content', "TEXT NOT NULL DEFAULT ''"], ['is_internal', 'INTEGER NOT NULL DEFAULT 0'],
['created_at', 'DATETIME DEFAULT NULL'], ['updated_at', 'DATETIME DEFAULT NULL'],
]);
ensureColumns('ticket_events', [
['ticket_id', 'INTEGER NOT NULL DEFAULT 0'], ['actor_id', 'INTEGER'],
['event_type', "TEXT NOT NULL DEFAULT ''"], ['field_name', "TEXT DEFAULT ''"],
['old_value', "TEXT DEFAULT ''"], ['new_value', "TEXT DEFAULT ''"],
['detail', "TEXT DEFAULT ''"], ['created_at', 'DATETIME DEFAULT NULL'],
]);
db.exec(`UPDATE tickets SET last_reply_at = NULL
WHERE NOT EXISTS (SELECT 1 FROM ticket_messages WHERE ticket_messages.ticket_id = tickets.id)`);
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_requester ON tickets(requester_id, updated_at DESC)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_status_updated ON tickets(status, updated_at DESC)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_assignee ON tickets(assignee_id, status, updated_at DESC)');
db.exec('CREATE INDEX IF NOT EXISTS idx_tickets_category ON tickets(category, updated_at DESC)');
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_messages_ticket ON ticket_messages(ticket_id, created_at ASC)');
db.exec('CREATE INDEX IF NOT EXISTS idx_ticket_events_ticket ON ticket_events(ticket_id, created_at ASC)');
} },
];
for (const m of migrations) {
if (current < m.version) { m.up(); db.exec('PRAGMA user_version = ' + m.version); }
@@ -186,6 +436,7 @@ function seedDefaults() {
smtp_from_name: 'RainWeb',
theme_wallpaper: '',
theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: '1',
nav_style: 'default',
card_style: 'default',
glass_blur: '20',
@@ -194,6 +445,10 @@ function seedDefaults() {
captcha_register: '0',
captcha_forum: '0',
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: '',
@@ -204,8 +459,19 @@ function seedDefaults() {
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: '',
// 页脚栏目自定义:JSON 字符串 [{title, links:[{label,url}]}];空串时前台回退现有硬编码栏目
footer_columns: '',
comment_moderate: '0',
comment_notify: '0',
// 评论等次要作者位是否显示 UID 后缀('1'=显示,'0'=隐藏)
show_uid_in_comments: '1',
// 论坛游客可见开关:'1'=游客可浏览论坛;'0'=游客需登录(读接口 401 + SSR 404SEO 权衡)
forum_guest_visible: '1',
// RSS 订阅源:feed_forum_enabled='1' 才对外暴露论坛 feed(默认关,用户没开时不暴露);
// feed_show_full='1'=全文 / '0'=摘要;feed_max_items 为每源条数上限
feed_forum_enabled: '0',
feed_show_full: '0',
feed_max_items: '20',
};
for (const [k, v] of Object.entries(defaults)) {
if (!get('SELECT value FROM site_settings WHERE key = ?', [k])) {
@@ -254,6 +520,13 @@ function setSetting(key, value) {
else run('INSERT INTO site_settings (key, value) VALUES (?, ?)', [key, value]);
}
// 事务辅助:多步写操作原子执行(better-sqlite3 原生事务;失败自动 ROLLBACK)
// 供 L8 改 PIN 重加密、以及任何需要"要么全成要么全不"的批量写场景使用。
function transaction(fn) {
if (!db) throw new Error('Database not initialized');
return db.transaction(fn)();
}
function seedSampleData() {
const existing = get("SELECT COUNT(*) as c FROM forum_categories");
if (existing && existing.c > 0) return;
@@ -288,4 +561,4 @@ function seedSampleData() {
}
}
module.exports = { getDb, run, get, all, getSetting, setSetting };
module.exports = { getDb, run, get, all, getSetting, setSetting, transaction };
@@ -0,0 +1,231 @@
# RainID OIDC 单点登录接入指南(通用版)
> **本文档性质**:架构审计视角的通用接入指导。不绑定任何技术栈,面向 RainVideo / 雨测 / 工具箱 / RainnyaAI / 新应用的接入方。
> **参考实现**:RainWeb(已完整接入,含踩坑记录)——本文多处标注「RainWeb 案例」。
> **核心原则(RainID 侧铁律)**RainID 只做标准 OIDC/OAuth2,不发明私有协议。各站只需一个 `discovery_url` 即可接入,但**接入质量(验签、绑定、登出、吊销处理)完全由各站负责**——标准协议不替你兜底。
---
## 1. 接入前必读
### 1.1 RainID 的架构定位
| 维度 | 结论 |
|---|---|
| 职责边界 | 只做**身份认证**(认证你是谁):登录、2FA、会话、密码、邮箱验证。**不做授权**(你能干什么):角色/权限/数据归属由各站本地维护 |
| 集成面 | 一个 `discovery_url``https://rainid.rainnya.asia/oauth`)+ 标准 OIDC 库,自动发现全部端点 |
| token 形态 | id_tokenRS256 JWT5 分钟,**只含 `sub`**);access_token**opaque 不透明**60 分钟,仅供 userinfo 消费,**不要解析**);refresh_token30 天,**每次使用即轮换**) |
| 绑定键 | `sub`——稳定、永不变更、跨应用唯一的用户标识。**所有绑定以 sub 为准** |
| 会话 | RainID 会话(签名 cookieHttpOnly+SameSite=Lax**不共享给各站**。各站只持有自己的会话 + refresh token |
**架构含义**:你的应用不会"成为 RainID 的一部分",而是「信任 RainID 的认证结果 + 自己管理授权数据」。身份数据源在 RainID,业务数据源在你。**边界越清晰,迁移成本越低。**
### 1.2 client 登记要求(Admin 后台)
| 字段 | 要求 | 风险点 |
|---|---|---|
| `confidential` | 必须机密 client(有 secret)。**secret 明文仅创建/轮换时返回一次** | 创建后立即存入服务端配置,绝不进前端代码/日志/Git |
| `redirect_uris` | 授权码回调白名单,每行一个,**精确匹配** | 未登记即拒绝(open redirect 防线)——这是 RainID 侧强制,各站回调处理也**不要接受任意 redirect_uri** |
| `grant_types` | `authorization_code`+`refresh_token` / `password` / `client_credentials`,按 §2 决策树选 | 最小化:用不到的 grant 不登记 |
| `require_pkce` | 默认 true,**建议强制**(防授权码注入) | 机密 client 也建议保留 |
| `scope` | 白名单:请求的 OIDC scope 必须 ⊆ 白名单,否则 `invalid_scope` | 只申请需要的 scope |
| `post_logout_redirect_uris` | 登出回跳白名单 | 未登记 → 400,登出流程断链 |
**创建后立即生效(热更新,无需重启 RainID)。**
### 1.3 必读的 RainID 行为(影响你的架构设计)
1. **id_token 只含 sub**OIDC Core 5.4 语义):`name`/`email`/`picture` 必须调 userinfo`/oauth/me`)获取。**不要假设 id_token 里有人名邮箱**。
2. **refresh 轮换 + 复用检测**:旧 refresh 一用即废;已消费的 refresh 被再次使用 → 整条 grant 链吊销(按 grantId,不误伤其他 client)。→ **各站 BFF 必须存最新 refresh,且并发刷新要加锁/串行**(并发用同一 refresh 会触发吊销)。
3. **账号事件全链吊销**:改密/换绑/2FA 变更/封禁 → 该用户全部 refresh 立即失效。→ 各站必须把「refresh 返回 400」当成**常态事件**处理:清会话 → 重新走登录。
4. **无 front-channel / backchannel logoutoidc-provider v9 限制)**:全局登出由**各站 BFF 各自调 end_session** 承担。→ 单点登出不是自动的,需要各站配合,接入时必须明确这一点(否则用户会投诉"登出一个站别的站还登着")。
5. **ROPC 的 2FA 硬限制**:2FA 用户一律拒绝 ROPC。→ 你的登录 UI 必须同时支持「密码直连」与「跳 RainID 登录页」两条路径,ROPC 被拒时引导跳页。
---
## 2. 协议选型决策树
```
有浏览器吗?
├── 有 → 授权码 + PKCE(唯一正解)
│ ├── SSR 传统渲染 → 服务端持有 code_verifier/state/session
│ └── SPA → 必须 BFF(后端持 refresh,前端只拿短期 access + 自身 cookie
├── 没有浏览器,但替用户登录(用户在你的登录页输 RainID 账号密码)→ ROPC(仅服务端)
└── 没有浏览器,也不替用户(服务间调用)→ client_credentials
```
| 场景 | 协议 | 产物 | 关键约束 |
|---|---|---|---|
| 有浏览器(RainVideo/雨测/工具箱/新应用主流) | 授权码 + PKCE | access + id_token + refresh | state 防 CSRF、PKCE 强制、SPA 必须 BFF |
| 服务端代理登录(沿用自家登录页形态) | ROPC | 同上 | 机密 client + **密码直连白名单** + 非 2FA 用户 |
| 服务间授权(RainnyaAI 调各站 API | client_credentials | access(无 sub30 分钟) | 机密 client + 业务 scope 需 RainID 侧登记 |
### 各项目的推荐组合
| 项目 | 推荐 | 理由 |
|---|---|---|
| RainVideo | 授权码 + PKCESSR/BFF) | 有用户浏览,走标准流,天然支持 2FA |
| 雨测 | 授权码 + PKCE | 同上;若登录页形态想保持自家风格可参考 RainWeb 的**双通道**ROPC + 授权码并存) |
| 工具箱 | 授权码 + PKCE | 同上 |
| RainnyaAI | 授权码 + PKCE(面向用户)+ client_credentials(服务间) | 两种都要:用户登录走标准流,机器调用走 client_credentials |
| 新应用 | 授权码 + PKCE,无脑默认 | 除非有明确的服务端代理需求才加 ROPC |
**架构决策点**:ROPC 是**妥协方案**(拿不到 2FA、凭据过手服务端、撞库面大),RainID 侧已收紧(白名单 + 共享账号锁 + 统一文案)。新项目能走授权码就**不要**为 ROPC 设计登录页。RainWeb 是历史形态(已有本地登录页)才保留 ROPC 双通道。
---
## 3. 账户体系整合模式(重点)
接入前必须回答的问题:**你的应用现在的用户数据怎么办?** 三个模式,按"本地自主权"从高到低:
### 模式 A:影子账号(推荐,RainWeb 采用)
- **做法**:本地用户表新增一列 `rainid_user_id = sub`(唯一索引)。登录时查 sub → 有则登录,无则**自动创建**影子账号(用户名取 `preferred_username``rainid_<sub前8>`,密码为**随机不可登录**值,角色默认 user)。
- **数据归属**:本地业务数据(帖子、评论、面板配置)挂在本影子账号的本地 id 上。
- **优点**:改动最小、现有数据零迁移、角色/权限完全本地自治、可按 sub 重建账号。
- **风险**:影子账号与 RainID 账号生命周期解耦(RainID 注销后影子账号还在,需自己定义展示策略);同名冲突(RainWeb 用 sub 前缀+后缀兜底)。
- **RainWeb 案例**`findOrCreateRainidUser` 三步——①按 sub 查;②`email_verified=true` 且本地有同 email 未绑定号 → 条件 UPDATE 自动绑定(**防撞绑闸门**);③新建(首个绑定用户自动 admin)。并发用唯一索引兜底重查。
### 模式 B:混合绑定(本地账号 + OIDC 绑定)
- **做法**:保留本地注册/密码登录,OIDC 登录成功后按 `email_verified=true` 的 email 匹配本地账号并绑定 sub;未匹配则创建影子账号。
- **优点**:老用户无感迁移(首次用 RainID 登录即绑定原账号);保留本地逃生通道。
- **风险**:**邮箱撞绑**——必须只信任 RainID 已验证的邮箱(`email_verified`),否则攻击者注册同名邮箱即可接管他人账号(RainWeb 的 UPDATE 带 `AND rainid_user_id = ''` 条件 + email_verified 闸门);本地密码与 RainID 密码并存形成**双凭证面**,需明确优先级。
- **RainWeb 案例**auth.js 中**本地 admin 保留 bcrypt 逃生通道**(防 RainID 配置错误锁死后台),影子账号禁止本地密码登入(`rainid_user_id 非空且无 password → 拒绝`)——这是双凭证面治理的范本:**要么本地密码可用(真逃生通道),要么彻底禁用(影子账号),不允许"本地密码残留但功能正常"的中间态**。
### 模式 C:全托管(完全依赖 IdP)
- **做法**:本地不存用户,只存 `sub` 列表或每次从 userinfo 实时取身份,权限也映射自 IdP claims。
- **优点**:零账号管理。
- **风险**:RainID 不管角色/授权(文档附录 A.8 明说)——你仍要本地映射授权;RainID 不可达时**全站不可登录**(无降级);数据归属必须挂在 sub 上(迁移/审计困难)。**RainID 当前不适合**(无自定义 claims 通道),新应用若选此模式要自行建立 sub→角色映射表——这其实就是模式 A 的退化版,不推荐。
### 对比与建议
| 维度 | A 影子账号 | B 混合绑定 | C 全托管 |
|---|---|---|---|
| 改动量 | 小 | 中 | 小 |
| 老数据迁移 | 零(或按 email 一次性绑定) | 平滑 | 需重建 |
| 本地自治权 | 高 | 中 | 低 |
| 风险面 | 生命周期解耦 | 撞绑 + 双凭证 | 单点故障 + 授权真空 |
| 适用 | **新应用、存量小的站** | 存量用户多的老站 | 不推荐 |
**推荐**:新应用一律模式 A。存量站用模式 B 但必须实现「email_verified 闸门 + 条件绑定 + 双凭证治理」。**任何模式下,角色/权限永远本地维护,绝不从 IdP 直接派生(除非你改 RainID)。**
---
## 4. 安全清单(不可遗漏)
### 4.1 登录链路
- [ ] **state 一次性**:发起登录生成随机 state,服务端存 session(限时),回调校验**并删除**RainWeb 用内存 Map 10 分钟 TTL + delete
- [ ] **PKCE S256 强制**code_verifier 只存服务端,code_challenge 走 authorize,交换时带 code_verifier
- [ ] **redirect_uri 精确匹配**:回调处理用自己构建的完整回调地址(`site_url + /oauth/callback`)校验,不接受请求里的任意 redirect_uri
- [ ] **id_token 验签三要素**:签名(RS256 + discovery 的 jwks_uri,多 key 轮换按 kid 自动取)+ **iss**(必须等于 discovery 的 issuer+ **aud**(必须等于你的 client_id+ exp。用 openid-client / jose 等标准库,**不要手写 JWT 解析**
- [ ] **sub 缺失即拒绝**id_token/userinfo 无 sub → 中止登录
### 4.2 凭证与 token 管理
- [ ] **client_secret 只存服务端**:环境变量或不可读的后端设置表;明文仅创建时出现过一次
- [ ] **token 不进 URL**:回调后不把 token 放 query 跳转前端;用**一次性短 TTL ticket**RainWeb30 秒 ticket 换本地 JWT
- [ ] **refresh 只存 BFF**SPA 纯前端存 refresh = XSS 一键全丢(RainID 文档 §4.3 明令禁止)
- [ ] **access_token 是 opaque**:不解析,只调 userinfo
- [ ] **refresh 串行刷新**:并发用同一 refresh 会触发复用检测 → 整链吊销(BFF 加刷新锁)
- [ ] **refresh 失败即登出**:捕获 `invalid_grant` → 清会话 → 重新走授权码登录(吊销联动是常态,不是异常)
### 4.3 用户与绑定
- [ ] **邮箱撞绑防线**:仅当 `email_verified=true` 才允许按 email 自动绑定;绑定 UPDATE 带「未绑定」条件(`AND rainid_user_id = ''`
- [ ] **影子账号不可本地密码登入**:随机密码 + 登入路径禁止
- [ ] **逃生通道**(可选但强烈建议):至少一个本地管理员账号保留独立密码,防 RainID 配置错误/不可达时锁死后台(RainWeb 案例)
- [ ] **2FA 处理**:ROPC 被拒(固定文案提示 2FA)→ UI 引导走 RainID 登录页
- [ ] **各站自限流**RainID 有账号锁(5 次/15 分钟)+ IP 限流,但各站登录接口也要自限流(RainWeb:15 分钟 10 次)
### 4.4 登出与吊销
- [ ] **登出联动**:各站登出 = 调 end_session(清 RainID 会话)+ 清本站会话 + 丢弃 refresh`post_logout_redirect_uri` 先登记;`state` 原样校验
- [ ] **主动吊销**(可选加固):登出时调 revocation 吊销本站持有的 refresh(双保险)
- [ ] **吊销联动处理**refresh 400 → 重新登录(覆盖改密/换绑/2FA 变更/封禁/撤销授权/client 轮换全场景)
---
## 5. 常见坑清单(RainWeb 实战踩坑汇总)
| # | 现象 | 根因 | 处理 |
|---|---|---|---|
| 1 | discovery 抛 `server must be an instance of URL` | openid-client v6 的 `discovery()` 第一个参数必须传 **URL 实例**,传字符串直接抛错 | `discovery(new URL(discovery_url), clientId, clientSecret)` |
| 2 | 拿到 id_token 却没有 name/email | RainID `conformIdTokenClaims=true`**id_token 只含 sub**profile/email 走 userinfo | 调 `/oauth/me`Bearer access_token),按 sub 校验 userinfo 与 id_token 一致性 |
| 3 | 前端读不到相关设置(白屏/功能缺失) | RainID 侧 `PUBLIC_KEYS` 等配置项漏配,导致 discovery/JWKS 数据不完整 | 接入验收时**系统性核对** client 登记项与 RainID 侧环境变量(PUBLIC_KEYS/OAUTH_CUSTOM_SCOPES/密码直连白名单),不要只测 happy path |
| 4 | 登出跳 RainID 后「卡在确认页」 | **预期行为**:登录态存在时 RainID 渲染退出确认页 → 点确认 → 303 回跳 | UI 文案告知用户"将在 RainID 确认退出";回跳地址必须先登记 |
| 5 | ROPC 返回 `invalid_grant` 但密码没错 | client 不在 Admin「密码直连白名单」(或非机密 client) | 白名单加 client_id;错误文案与凭据失败一致(防枚举,别想着区分) |
| 6 | ROPC 2FA 用户被拒 | 设计决策:2FA 用户一律拒绝 ROPC | UI 固定提示"该账号已开启二次验证,请通过 RainID 登录页登录",引导授权码流 |
| 7 | 换不到 refresh | 没请求 `offline_access` 或未走同意页 | scope 加 `offline_access`,首次走完整同意流程 |
| 8 | refresh 突然全部 400 | 用户改密/换绑/2FA 变更等**吊销事件** | BFF 捕获 → 清会话 → 重新登录(这是设计,不是 bug) |
| 9 | 邮箱绑定被撞 | 未校验 `email_verified` 就按 email 绑定 | 仅验证过的邮箱可绑 + 条件 UPDATE + 唯一索引兜底 |
| 10 | 影子账号锁死 / 后台进不去 | RainID 不可达且无本地逃生通道 | 保留本地 admin 逃生账号(RainWebadmin 保留 bcrypt 密码) |
| 11 | client_credentials 无 scope / invalid_client | 用了公共 client,或业务 scope 未在 `OAUTH_CUSTOM_SCOPES` 登记 | 机密 client + RainID 侧登记 scope |
| 12 | 授权码回调没带 state 或 state 复用 | 漏传/不校验/不删除 | state 必带、回调校验、**一次性消费** |
---
## 6. 验收 Checklist(可直接勾选)
### 6.1 授权码 + PKCE(所有有浏览器的站必过)
- [ ] 完整流程:注册(RainID)→ 登录 → 同意 → 回调 → code 换 token → 验签(iss+aud+exp)→ userinfo → 建影子账号
- [ ] state:回调不带 state / state 错误 / state 复用 → 全部拒绝
- [ ] PKCE:缺 code_challenge → `invalid_request`code_verifier 错误 → 换 token 失败
- [ ] redirect_uri:未登记的 → 400RainID 拒绝)
- [ ] id_token:篡改签名 / 换 iss / 换 aud → 验签失败拒绝登录
- [ ] id_token 只含 sub 时,userinfo 正常取到 name/email/email_verified
- [ ] refresh 轮换:用一次 refresh → 旧 refresh 再发 → `invalid_grant`,新 refresh 可用
- [ ] 2FA 用户:登录两步验证通过
- [ ] 错误路径:同意页拒绝(`access_denied`/ `prompt=none` 未同意(`consent_required`)→ 各站有合理 UI 反馈
### 6.2 ROPC(仅选用的站)
- [ ] 正确凭据 → access + id_token + userinfo
- [ ] 错误密码 → 统一文案(不区分账号不存在)
- [ ] 2FA 用户 → `invalid_grant` + 固定文案 → UI 引导 RainID 登录页
- [ ] 非白名单 client → `invalid_grant`
- [ ] 连续失败 → 429(RainID 侧)+ 各站自限流生效
### 6.3 client_credentialsRainnyaAI 等)
- [ ] 机密 client → access + 正确 scope
- [ ] 公共 client → `invalid_client`
- [ ] 未登记 scope → 拒绝/丢弃
- [ ] 服务端校验 scope 后再执行业务操作
### 6.4 登出
- [ ] 登出 → end_session 确认页 → 303 回跳(`post_logout_redirect_uri` + state 透传)
- [ ] 未登记回跳 → 400
- [ ] 各站自身会话 + refresh 清理
- [ ] 「登出一个站,其他站仍登录」——**确认这是预期**v9 无 front-channel logout,全局登出需各站 BFF 各自调 end_session
### 6.5 吊销联动(最容易被漏,但必须过)
- [ ] RainID 改密 → 该站旧 refresh 立即失效 → 站内自动重新登录流程触发
- [ ] RainID 撤销该应用授权 → 仅本站 refresh 失效(其他 client 不受影响)
- [ ] client secret 轮换后 → 旧 secret 的 refresh 全部失效
- [ ] RainID 封禁/注销 → 登录入口拒绝
### 6.6 安全回归
- [ ] client_secret 未出现在前端代码 / 日志 / 浏览器网络面板
- [ ] token 未出现在 URL
- [ ] 影子账号无本地密码可登录
- [ ] 邮箱自动绑定仅在 email_verified=true 时发生
---
## 附:RainWeb 架构速览(可对照参考)
```
登录入口(auth.js /login
├─ RainID 启用 → admin 逃生(本地 bcrypt| ROPClib/rainid.js → 影子账号)
└─ 未启用 → 本地 bcrypt(影子账号禁止)
OIDC 流(routes/oidc.js
login(302 授权码+PKCE) → callback(验签→userinfo→影子账号→JWT→30s ticket) → exchange(ticket→本地JWT)
logout → end_session 联动
共享逻辑(lib/rainid.js
配置 fail-closedenabled 需 3 项齐全)| discovery 缓存(URL 实例)| 影子账号三查三建 | 错误映射表
```
**关键架构决策提炼(各站可抄)**
1. **fail-closed 配置**:OIDC 配置不全时视为未启用,本地登录不受影响(降级安全)。
2. **ticket 换 JWT**:长 token 不进 URL30 秒一次性 ticket)。
3. **影子账号不可本地登入** + **admin 逃生通道**:双凭证面治理。
4. **email_verified 撞绑闸门** + 条件 UPDATE + 唯一索引并发兜底。
5. **错误映射表集中管理**`invalid_grant` 区分 2FA 文案与防枚举文案;`invalid_client/scope/request` 视为配置错误(500),不暴露给用户。
---
*本文基于 RainID v0.1.4 对接文档 + RainWeb 接入实现撰写。协议细节以 RainID 官方文档为准(端点/token 行为/错误码若有更新,本文相应部分需复核)。*
@@ -0,0 +1,629 @@
# 任意项目接入 RainID(OIDC)实操指南
> 视角:**实现 / 落地**。不是协议科普,而是"从 0 到 1 动手做完 + 线上能跑"的操作手册。
> 素材来源:RainWeb × RainID 真实接入(`lib/rainid.js`、`routes/oidc.js`、`routes/auth.js`、`routes/settings.js`、前端 `Login.jsx`/`Register.jsx`),已上线并排过障。
> 技术栈:Node.js / Express / openid-client v6(函数式 API)。**每个模式旁标注"其他栈同理"**。
---
## 0. 全景图:一个站点接 RainID 需要什么
```
用户 ──→ 你的前端登录页
├─ 方式 A(ROPC):输 RainID 账号密码 → 你的后端 POST rainid /oauth/token (password)
│ → 按 sub 建/找影子账号 → 发你的本地 JWT
└─ 方式 B(授权码+PKCE):点"使用 RainID 登录" → 你的后端 302 到 RainID
→ RainID 登录/同意 → 回跳你的 callback
→ 后端换 token + userinfo → 影子账号 → 一次性 ticket
→ 前端拿 ticket 换本地 JWT(长 token 不进 URL
登出:你的后端 302 到 RainID end_session → RainID 确认销毁 IdP 会话 → 303 回跳你的页面
```
**核心心智模型**
1. **RainID 只认标准 OIDC/OAuth2**,你的项目只需要一个 `discovery_url`
2. **身份归属 RainID,角色归属你**——影子账号按 `sub` 绑定,admin 等角色在你的本地库维护。
3. **access_token 是 opaqueid_token 只有 `sub`**——想要 email/name 必须调 userinfo 端点。
4. **你的会话是 JWT/session(本地),RainID 的会话是它的签名 cookie,两者不共享**。登出联动靠 end_session。
---
## 1. Step-by-step 接入步骤
### Step 1RainID Admin 登记 client(前置,5 分钟)
RainID Admin → 应用管理 → 新增应用,填:
| 字段 | 值 | 说明 |
|---|---|---|
| `name` | 你的站点名(如 RainWeb) | 展示名 |
| `confidential` | ✅ 机密 client | 拿到 `client_secret`;**明文只在创建/轮换时返回一次,立即存好** |
| `redirect_uris` | `https://你的域名/api/auth/oidc/callback` | 授权码回跳白名单,**每行一个,未登记直接拒绝** |
| `grant_types` | `authorization_code` + `refresh_token`+ `password` 若要 ROPC | 按协议选 |
| `require_pkce` | ✅ 强制(默认) | 防授权码注入 |
| `scope` | `openid profile email offline_access` | 白名单必须覆盖你请求的 scope,否则 `invalid_scope` |
| `post_logout_redirect_uris` | `https://你的域名/login.html` | **登出回跳白名单,忘了登记 end_session 就 400** |
创建后**立即生效**(client 热更新,无需重启 RainID)。
> 踩过:登出配置好了但 `post_logout_redirect_uri` 没登记 → RainID 返回 400,用户登出卡死。
### Step 2:装依赖
```bash
npm install openid-client # Node 版;浏览器端等价物见下
npm install jose # 仅当你绕过 SDK 手动验签时(一般不需要,SDK 全包)
```
- **Node**`openid-client` v6(函数式 API,本项目用的就是它)。
- **Python**`authlib`**Go**`coreos/go-oidc`**Java**`spring-security-oauth2-client`。都是标准 OIDC,照着下面模式映射即可。
- **浏览器纯前端(SPA)**:不要 `oidc-client-ts` 全放前端!**SPA 必须 BFF**(后端代理),禁止前端持有 refresh_token。
### Step 3:配置项(先想清楚再写代码)
分两档(详见第 3 章):
- **非敏感(可后台热改、可读)**`rainid_enabled``rainid_client_id``rainid_discovery_url``rainid_register_redirect`
- **机密(只写不读)**`rainid_client_secret`——**优先 `.env.json`gitignored)**,回退后台设置(可写、GET 不返回)。
### Step 4:后端三个路由(授权码流)/ 一个路由(ROPC)
统一挂载(RainWeb 挂 `/api/auth/oidc`):
| 方法 | 路径 | 作用 |
|---|---|---|
| GET | `/login` | 生成 PKCE+state302 到 RainID authorize |
| GET | `/callback` | 验 state → 换 token → userinfo → 影子账号 → 发一次性 ticket → 302 回前端 |
| POST | `/exchange` | 前端用 ticket 换本地 JWT30s 一次性) |
| GET | `/logout` | 302 到 RainID end_session(登出联动) |
ROPC 不需要新路由——**复用你的现有登录 POST**,在处理器里加一个分支转发 RainID。
完整代码模式见第 2 章。
### Step 5:影子账号(数据层)
用户表加一列 `rainid_user_id`(唯一索引),按 `sub` 查/建。见 2.5。
### Step 6:前端入口(登录页/注册页)
- 登录页:`rainid_enabled=1` 时显示「使用 RainID 登录」按钮 → `window.location.href='/api/auth/oidc/login'`
- 回跳收尾:URL 带 `?oidc_ticket=xxx` → POST `/exchange` 换 token → 存 localStorage → 通知登录态变更 → 跳首页;带 `?oidc_error=xxx` → 映射错误文案。
- 注册页:`rainid_register_redirect=1` 时整页跳 `https://rainid.rainnya.asia/register`,本地表单不渲染。
### Step 7:登出联动
你的登出按钮:`rainid_enabled=1` 时跳 `/api/auth/oidc/logout`(后端 302 到 RainID end_session),否则直接清本地会话。
### Step 8:验收(对照 RainID 文档 §15 checklist
完整走一遍:注册 → RainID 登录 → 同意 → 回跳 → 影子账号 → 刷新页面保持登录 → 登出回跳。**再测一遍 2FA 用户、密码错误、未登记回调**三条错误路径。
---
## 2. 关键代码模式(可直接抄)
### 2.1 discovery 缓存(⚠️ v6 第一参数必须是 `new URL()`
```js
const openidClient = require('openid-client'); // v6 函数式 API
const DISCOVERY_DEFAULT = 'https://rainid.rainnya.asia/oauth';
// 模块级缓存:discovery 是一次网络请求 + JWKS 拉取,不能每次登录都打
let configCache = null;
async function getOidcConfig() {
if (configCache) return configCache;
// ⚠️⚠️ v6 的 discovery() 第一个参数必须是 URL 实例!
// 传字符串会抛 "server must be an instance of URL" → 所有登录请求 502(线上踩过的根因,见 §5.1)
configCache = await openidClient.discovery(
new URL(discoveryUrl), // ← 必须是 new URL(),不是字符串
clientId,
clientSecret
);
return configCache;
}
```
要点:
- **缓存副作用:后台改了 discovery_url / client 配置,需重启进程生效**(RainWeb 注释里明确写了这条)。
- JWKS 多 key 轮换 SDK 自动处理,你不需要管。
- 其他栈同理:authlib 的 `discovery(url)`、go-oidc 的 `NewProvider` 接受 URL 字符串,但 Node 的 v6 就是严格。
### 2.2 PKCE + state 无 session 框架怎么存:内存 Map + TTL,取用即删
你的框架若没有 sessionRainWeb 是无状态 JWT),PKCE 的 `code_verifier``state` 不能丢。**内存 Map + 定时清理**即可(单进程够用;多进程部署换 Redis,TTL 语义一样):
```js
const OIDC_STATE_TTL = 10 * 60 * 1000; // 发起登录 → 回调
const oidcStates = new Map(); // state -> { verifier, createdAt }
// 每分钟清扫过期项(防内存泄漏)
setInterval(() => {
const now = Date.now();
for (const [k, v] of oidcStates) if (now - v.createdAt > OIDC_STATE_TTL) oidcStates.delete(k);
}, 60000);
// ① 发起登录
router.get('/login', async (req, res) => {
const config = await getOidcConfig();
const codeVerifier = openidClient.randomPKCECodeVerifier();
const codeChallenge = await openidClient.calculatePKCECodeChallenge(codeVerifier);
const state = openidClient.randomState();
oidcStates.set(state, { verifier: codeVerifier, createdAt: Date.now() });
const url = openidClient.buildAuthorizationUrl(config, {
redirect_uri: siteBase(req) + '/api/auth/oidc/callback',
scope: 'openid profile email', // 要 refresh 就加 offline_access(需用户同意)
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
res.redirect(url.href);
});
```
要点:
- **`state` 一次性,回调里取用即删**——防重放。
- **不要手写 PKCE**,用 SDK 标准函数(`randomPKCECodeVerifier` / `calculatePKCECodeChallenge`)。
### 2.3 回调换 token + 验签 + userinfoid_token 只含 sub!)
```js
router.get('/callback', async (req, res) => {
const state = req.query.state;
const stored = state && oidcStates.get(state);
if (!stored) return res.status(400).send('登录状态已失效,请重新使用 RainID 登录');
oidcStates.delete(state); // 一次性
const config = await getOidcConfig();
const base = siteBase(req);
const currentUrl = new URL(req.originalUrl, base); // 回调的完整 URL(含 query
try {
// authorizationCodeGrant 自动完成:验 state + PKCE + 换 token
// + 自动验 id_token 签名(RS256/JWKS) + iss + aud + exp
const tokens = await openidClient.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: stored.verifier,
expectedState: state,
idTokenExpected: true,
});
const claims = tokens.claims(); // id_token 解码
const sub = claims && claims.sub; // ⚠️ 只有 sub!没有 email/name(见 §5.3
if (!sub) throw new Error('id_token 缺少 sub');
// ⚠️ 必须调 userinfo 拿 email/name/email_verified
// fetchUserInfo 第三个参数传 sub = 校验 userinfo 的 sub 与 id_token 一致(防注入)
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
const user = findOrCreateRainidUser(sub, userinfo); // 见 2.5
const token = jwt.sign({ id: user.id, username: user.username, role: user.role },
SECRET, { expiresIn: '7d' }); // ← 你的本地会话
const ticket = crypto.randomBytes(16).toString('hex');
oidcTickets.set(ticket, { jwt: token, username: user.username, role: user.role,
email: user.email, email_verified: user.email_verified,
createdAt: Date.now() });
res.redirect(frontLoginUrl(req, '?oidc_ticket=' + encodeURIComponent(ticket)));
} catch (err) {
// error 参数:access_denied / consent_required / invalid_grant 等 → 回前端带错误码
const code = err && err.error;
res.redirect(frontLoginUrl(req, '?oidc_error=' + encodeURIComponent(code || 'server_error')));
}
});
```
要点:
- `sub` 是**稳定、永不变更**的绑定键,跨应用唯一。
- 错误码映射表见 §6(前端把 `oidc_error` 码翻译成用户文案)。
### 2.4 一次性 ticket 换本地 JWT(避免长 token 进 URL
JWT 动辄几百字符,塞进 302 URL 会进浏览器历史/反代日志。**用一个 30 秒一次性的随机 ticket 过渡**:
```js
const OIDC_TICKET_TTL = 30 * 1000;
const oidcTickets = new Map(); // ticket -> { jwt, ... }
router.post('/exchange', (req, res) => {
const ticket = req.body && req.body.ticket;
if (!ticket) return res.status(400).json({ error: '缺少 ticket' });
const rec = oidcTickets.get(ticket);
if (!rec) return res.status(401).json({ error: '登录已过期,请重新使用 RainID 登录' });
oidcTickets.delete(ticket); // 一次性
res.json({ token: rec.jwt, username: rec.username, role: rec.role,
email: rec.email, email_verified: rec.email_verified });
});
```
前端收尾(`Login.jsx` 实测模式):
```js
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const ticket = params.get('oidc_ticket');
const oidcError = params.get('oidc_error');
if (ticket) { handleOidcTicket(ticket); return; } // 先兑换,30s 有效期
if (oidcError) { setError(OIDC_ERROR_TEXT[oidcError] || 'RainID 登录失败'); clearOidcParams(); return; }
...
}, []);
const handleOidcTicket = async (ticket) => {
const data = await authApi.oidcExchange(ticket);
setToken(data.token);
notifyAuthChange();
clearOidcParams(); // history.replaceState 清掉 URL 上的 ticket,避免刷新重复兑换
navigate(from || '/');
};
```
### 2.5 影子账号创建/绑定逻辑(含 email_verified 闸门、并发兜底、首用户 admin)
```js
// ① 按 sub 查 → ② email_verified 且本地同邮箱未绑定 → 绑定 → ③ 否则创建
function findOrCreateRainidUser(sub, profile) {
if (!sub) throw new Error('RainID userinfo 缺少 sub');
const email = String(profile.email || '').trim().toLowerCase();
// 1) 已绑定:sub 命中直接返回
let user = db.get('SELECT * FROM users WHERE rainid_user_id = ?', [sub]);
if (user) return user;
// 2) 自动绑定:仅当 RainID 已验证该邮箱(email_verified=true)才允许绑
// 防撞绑:未验证邮箱的 userinfo 绝不能自动绑到本地已有账号
if (email && profile.email_verified) {
const local = db.get('SELECT * FROM users WHERE email = ?', [email]);
if (local && !local.rainid_user_id) {
// 条件 UPDATEWHERE 再带 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); // 冲突加后缀
}
// 影子账号给随机不可登录密码 —— 禁止本地密码登入(见 §4)
const randomHash = bcrypt.hashSync(crypto.randomBytes(24).toString('hex'), 10);
// 首用户 admin:全站无 admin 时第一个绑定的用户自动成为 admin(RainID 不管角色)
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;
}
}
```
配套 DDL(唯一索引是并发兜底的地基,**必须建**):
```sql
ALTER TABLE users ADD COLUMN rainid_user_id TEXT DEFAULT ''; -- 幂等补列
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_rainid
ON users(rainid_user_id) WHERE rainid_user_id <> ''; -- 部分唯一索引
```
要点:
- **email_verified 是自动绑定的闸门**:只有 RainID 验证过的邮箱才能绑本地号,防有人用他人邮箱注册 RainID 来接管本地账号。
- 绑定用**条件 UPDATE**`WHERE rainid_user_id=''`)而非先查后改,并发安全。
### 2.6 ROPC 密码登录(`grant_type=password` + 2FA 拒绝文案)
```js
// 复用一个 getOidcConfig()。genericGrantRequest 自动带 client 认证(HTTP Basic
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 (e) {
if (e && e.code === 'RAINID_NOT_CONFIGURED')
return { ok: false, status: 500, error: 'RainID 客户端未配置' };
return { ok: false, status: 502, error: 'RainID 服务配置错误:Discovery 失败(检查端点是否为 https' };
}
try {
const tokens = await openidClient.genericGrantRequest(config, 'password', {
username, password, scope: 'openid profile email',
});
// id_token 验签 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 状态 + 文案(防枚举!不区分具体原因)
function mapOidcError(err) {
const code = err && err.error;
const desc = (err && err.error_description) || '';
switch (code) {
case 'invalid_grant':
// ⚠️ 2FA 用户 ROPC 被拒 → 固定文案引导走 RainID 登录页
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 服务暂不可用,请稍后再试' };
}
}
```
你的登录路由这样接线(`routes/auth.js` 实测模式):
```js
router.post('/login', loginLimiter, async (req, res) => {
// ...验证码校验略
if (db.getSetting('rainid_enabled') === '1') {
// ① admin 逃生通道:本地 admin 且有本地密码 → 走本地 bcrypt(见 §4)
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
if (localUser && localUser.role === 'admin' && !!localUser.password) {
if (!bcrypt.compareSync(password, localUser.password))
return res.status(401).json({ error: '用户名或密码错误' });
const token = jwt.sign({...}, SECRET, { expiresIn: '7d' });
return res.json({ token, ... });
}
// ② 其余用户:转发 RainIDROPC
const r = await rainidRopcLogin(username, password);
if (!r.ok) return res.status(r.status).json({ error: r.error });
const token = jwt.sign({ id: r.user.id, ... }, SECRET, { expiresIn: '7d' });
return res.json({ token, ... });
}
// ③ RainID 未启用 → 本地 bcrypt 登录(原有逻辑保留)
...
});
```
### 2.7 登出联动 end_session
```js
router.get('/logout', async (req, res) => {
const s = getOidcSettings();
if (!s.enabled) return res.redirect('/');
try {
const config = await getOidcConfig();
const url = openidClient.buildEndSessionUrl(config, {
// ⚠️ 此地址必须在 RainID Admin 的 post_logout_redirect_uris 里登记过,否则 400
post_logout_redirect_uri: siteBase(req) + '/login.html',
});
res.redirect(url.href); // 用户确认后 RainID 303 回跳 login.html
} catch (err) {
console.error('[RainID] logout error:', err.message);
res.redirect('/'); // RainID 故障时至少能回站内
}
});
```
前端(`Layout.jsx` 实测):`rainid_enabled=1` 时登出按钮跳 `/api/auth/oidc/logout`(整页跳,让 RainID 处理完再回来),否则直接清本地 token。
**清本地会话的时点**RainID 回跳 `login.html` 后,前端 `logout()` 清 localStorage token 即可(你的 JWT 是有时效的;要彻底就再调一次 revocation,非必需)。
---
## 3. 配置项设计:哪些进设置、哪些进机密存储
**⚠️ 最易踩的坑:凡前端需要读的 key,必须同时加进"公开设置白名单",否则前端 `getPublicSettings()` 拿不到 → 功能静默失效**(RainWeb 踩过 `rainid_register_redirect` 漏 PUBLIC_KEYS,见 §5.4)。
### 3.1 三个集合(RainWeb 实测结构)
```js
// ① 公开设置白名单:任何未登录用户可读(前端页面依赖)
const PUBLIC_KEYS = [
'site_name', /* ... */,
'rainid_enabled', 'rainid_register_redirect', // ← 前端判断显示 RainID 按钮/注册托管
/* ... */
];
// ② 管理端可读(adminOnly GET):包含全部非敏感配置
const ALL_KEYS = [
/* ... */,
'rainid_enabled', 'rainid_client_id', 'rainid_discovery_url', 'rainid_register_redirect',
/* ... */
];
// ③ 可写白名单:管理端可写(adminOnly PUT)。机密 key 在此,但绝不在 ②
const ALLOWED_SET = [...ALL_KEYS, 'rainid_client_secret']; // ← secret 只写不读!
```
**机密存储原则**
- `rainid_client_secret` **优先 `.env.json`gitignored**,server.js 启动时注入环境变量 `RAINID_CLIENT_SECRET`,运行时还有 `.env.json` 直读和后台设置两档回退(见 `lib/rainid.js getClientSecret()`)。
- 后台设置通道**可写不可读**`ALLOWED_SET` 里有它(能写),`ALL_KEYS` 里没有它(GET `/api/settings` 不返回),浏览器和 API 都读不到明文。
- **fail-closed**`rainid_enabled='1'``client_id`/`client_secret` 任一缺失 → `enabled=false`RainID 按钮不出现、ROPC 拒绝,**本地登录不受影响**。启动时打 warning 日志。
### 3.2 配置清单
| key | 敏感 | 公开可读 | 说明 |
|---|---|---|---|
| `rainid_enabled` | 否 | ✅ | `'1'`/'0',前端据此显示按钮 |
| `rainid_client_id` | 否 | ❌ | 管理端可改 |
| `rainid_client_secret` | ✅ | ❌ | 只写不读,双通道 |
| `rainid_discovery_url` | 否 | ❌ | 默认 `https://rainid.rainnya.asia/oauth` |
| `rainid_register_redirect` | 否 | ✅ | `'1'` 时前端整页跳 RainID 注册页 |
---
## 4. Admin 逃生通道(IdP 故障时的 break-glass
**原则:启用 RainID 后,绝不能出现"RainID 挂了管理员也进不去后台"的死局。**
RainWeb 实践(`routes/auth.js`):
```js
if (db.getSetting('rainid_enabled') === '1') {
// 逃生通道:本地 admin 账号(有本地 bcrypt 密码)始终可用本地密码登录
const localUser = db.get('SELECT * FROM users WHERE username = ?', [username]);
const isAdminEscape = localUser && localUser.role === 'admin' && !!localUser.password;
if (isAdminEscape) {
if (!bcrypt.compareSync(password, localUser.password)) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = jwt.sign({ id: localUser.id, username: localUser.username, role: localUser.role },
SECRET, { expiresIn: '7d' });
return res.json({ token, username: localUser.username, role: localUser.role, ... });
}
// 其余用户 → ROPC 转发 RainID
const r = await rainidRopcLogin(username, password);
...
}
```
规则:
1. **本地 admin + 有本地密码** → 永远走本地 bcrypt,不经 RainID。即使 RainID 完全宕机、discovery 失败,admin 还能登录后台。
2. **影子账号(`rainid_user_id` 非空)给随机不可登录密码** → 在本地登录分支里显式禁止(`user.rainid_user_id && !user.password` 直接拒绝),防止影子账号被猜到密码本地登入。
3. 配套兜底:**首次接入前先建一个本地 admin 并记住密码**(RainWeb 播种的 `admin/admin123` 即为这个角色),再开 `rainid_enabled`
---
## 5. 踩坑实录(按严重程度排序)
### 5.1 [致命] discovery 传字符串 → 全部登录 502
- **现象**:启用 RainID 后,ROPC 登录和授权码登录全部失败;后端日志 `server must be an instance of URL`
- **根因**openid-client **v6 函数式 API**`discovery()` 第一参数必须是 `new URL()` 实例;v5 类式 API 接受字符串,升级后行为变了,网上旧示例都是字符串。传字符串直接抛 TypeError。
- **修复**
```js
// ❌ v5 时代:openidClient.Issuer.discover('https://...')
// ✅ v6:必须 new URL()
configCache = await openidClient.discovery(new URL(discoveryUrl), clientId, clientSecret);
```
- **预防**:升级 openid-client 大版本后跑一遍登录全流程;读 changelog 的 breaking changes。
### 5.2 [高] RainID 端点写成 http:// 被 openid-client 拒绝(RFC 9700
- **现象**:配置 `rainid_discovery_url=http://rainid...`(或反代配错协议)→ discovery 失败,登录 502。
- **根因**openid-client 遵循 RFC 9700OAuth 2.0 over HTTPS),**拒绝 http 端点**localhost 除外)。RainWeb 错误文案就是为此写的:"Discovery 失败(检查端点是否为 https"。
- **修复**:端点统一 `https://rainid.rainnya.asia/oauth`;**确认反代回源协议**——如果你的站点跑在反代后面,回调的 `req.protocol` 会算错,redirect_uri 变成 `http://` 导致 RainID 侧"回调未登记"。处理:优先用 `site_url` 设置拼基址,或用 `app.set('trust proxy', 1)` + 反代带 `X-Forwarded-Proto`。
- **预防**`siteBase(req)` 优先读 `site_url` 设置,回退请求头,且反代必须正确转发协议。
### 5.3 [高] id_token 无 email/name`conformIdTokenClaims`)→ 必须 userinfo
- **现象**:回调里 `tokens.claims()` 只有 `sub``email`/`name` 全是 undefined → 影子账号 email 为空、头像昵称丢。
- **根因**RainID 的 access_token 是 opaque`conformIdTokenClaims=true` 语义下 **id_token 只带 openid scope 的声明(sub**`profile`/`email` 数据在 userinfo 端点(OIDC Core 5.4)。拿 id_token 当唯一数据源是普遍错觉。
- **修复**
```js
const userinfo = await openidClient.fetchUserInfo(config, tokens.access_token, sub);
// userinfo: { sub, name, preferred_username, picture, email, email_verified }
```
并顺手用 `sub` 校验 userinfo 与 id_token 一致(防 userinfo 注入)。
### 5.4 [高] 设置 key 漏 PUBLIC_KEYS → 前端功能静默失效
- **现象**:后台打开了 `rainid_register_redirect`,注册页却仍显示本地表单;无任何报错。
- **根因**:注册页靠 `settingsApi.getPublicSettings()` 读 `rainid_register_redirect`,但该 key 只加了 `ALL_KEYS` 没加 `PUBLIC_KEYS` → 前端拿到 `undefined`,静默走本地注册分支。**公共配置接口返回 undefined 不报错,是最难排查的一类 bug**。
- **修复**`rainid_register_redirect` 加入 `PUBLIC_KEYS`。
- **预防**:凡是"前端根据设置决定显示逻辑"的 key,写完务必在 `/api/settings/public` 响应里确认存在;加新前端依赖的 key 时列 checklist。
### 5.5 [中] end_session 卡确认页不回跳(RainID 侧 issue
- **现象**:登出跳 RainID 后停在"确认退出"页,点了确认不回跳你的站点。
- **根因**`post_logout_redirect_uri` 未在 RainID Admin 登记,或登记的与请求的不完全一致(域名大小写/结尾斜杠/协议)。RainID 侧拒绝回跳时只渲染提示页(oidc-provider 行为),没有 303。
- **修复**:在 RainID Admin 的 `post_logout_redirect_uris` 精确登记你 `buildEndSessionUrl` 传的完整 URL(含路径 `/login.html`);检查域名规范化。
- **预防**:登出回跳地址做成常量与 `siteBase` 拼接,保证与登记一致;登记时复制请求里实际的 URL。
### 5.6 [中] ROPC 未进"密码直连白名单" → invalid_grant 静默失败
- **现象**:密码明明正确,ROPC 登录始终 `400 invalid_grant`,文案统一"用户名/邮箱或密码错误"。
- **根因**RainID 对 `password` grant 有 **Admin 站点设置 → 密码直连白名单**(`system_settings.client_password_grant.allowed`)。client 不在白名单 → `invalid_grant`,**文案与凭据失败完全一致**(防枚举设计)→ 让你误以为密码错了。
- **修复**RainID Admin 把该 client_id 加进密码直连白名单(前置条件:client 必须机密 client、grant_types 含 `password`)。
- **预防**:接入文档 §5.1 四条前置逐条核对;ROPC 配好第一件事就是 curl 一次 token 端点确认不是白名单问题(见 §6)。
### 5.7 [低] 测试端口避开 3k-4kRainID 占 3001
- **现象**:本地开发时浏览器明明打开了 RainID 页面,但跳回登录总失败/串数据。
- **根因**RainWeb 开发端口 3001 与 RainID 本地实例端口**冲突**,你在开自己的服务时把 RainID 顶掉了(或反之)。
- **修复**:自己的开发端口避开 3k-4k 区间(RainID 用 3001vite 代理硬编码 3101);或至少确认本地测试时 RainID 实例可用。
- **预防**:多项目共存时先在项目文档里登记"占用的端口清单"。
---
## 6. 快速排障指南:登录报错按什么顺序查
**自顶向下,每层 30 秒内能判**。RainWeb 线上排障就是这个顺序:
### L1 进程层
- 后端进程还活着吗?`node cli.js status` / `ps aux | grep server`。
- 重启过吗?**改了 discovery_url / client 配置要重启**discovery 有进程级缓存)。
### L2 依赖层
- `openid-client` 版本?v5→v6 是 breaking(§5.1)。
- Node 版本?openid-client v6 需要较新 Node`require('esm')`),RainWeb 要求 Node ≥23 / 22+。
- 启动有没有 require 报错?`node -e "require('openid-client')"` 一把验证。
### L3 配置层
- 后端日志有没有 `RAINID_NOT_CONFIGURED` / "client_id 或 client_secret 未配置"?→ 检查 `.env.json` 的 `rainid_client_secret`、后台 `rainid_client_id`。
- `rainid_enabled='1'` 且三项齐全?**fail-closed 下任一缺失按钮都不会出现**,先看前端到底有没有按钮。
- **管理端 GET /api/settings 里能看到 `rainid_client_secret` 吗?** 能看到就是泄露(正常不该返回)。
- 前端读到的公开设置对不对?`curl /api/settings/public` 确认 `rainid_enabled`/`rainid_register_redirect` 在不在响应里(§5.4)。
### L4 网络层
- `curl -sI https://rainid.rainnya.asia/oauth/.well-known/openid-configuration` 通不通?
- `curl -s https://rainid.rainnya.asia/oauth/.well-known/openid-configuration | head` 看是不是 json
- 本地 dev 端口有没有和 RainID 冲突(§5.7)?反代有没有把协议降级成 http(§5.2)?
### L5 协议层(最细,看 error code
用 curl 直接打 token 端点,绕过前端看原始错误:
```bash
# ROPC 直测(秒杀"白名单还是密码错"之争,§5.6)
curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
-d "grant_type=password&username=test&password=xxx&scope=openid%20profile%20email" \
https://rainid.rainnya.asia/oauth/token
```
| 返回 | 判定 | 处理 |
|---|---|---|
| `400 invalid_grant` | ①凭据错 ②账号锁 ③2FA ④**白名单缺** | 换已确认密码重试;看 error_description 是否含"二次验证";查 Admin 白名单 |
| `401 invalid_client` | client_id/secret 错或非机密 client | 核对 Admin client 配置 |
| `400 invalid_scope` | scope 超白名单 | Admin 扩 scope 白名单 |
| `429 rate_limited` | 限流 | 退避;也检查自己有没有重复请求循环 |
| `400 redirect_uri not registered` | 回调未登记/拼接不一致 | 检查 `siteBase` 拼出的 redirect_uri 与登记是否逐字符一致 |
| `400 post_logout_redirect_uri not registered` | 登出回跳未登记(§5.5 | Admin 登记完整 URL |
| 502 / discovery 失败 | §5.1 / §5.2 | 查 URL 实例化与 https |
### 前端侧速查
- 白屏 + console "MIME type text/html" → 不是 OIDC 问题,是**构建产物缺失**(`npm run build` + 强刷)。
- 地址栏残留 `?oidc_ticket=...` → 前端没兑换成功,看 network 里 POST `/exchange` 的响应。
- `oidc_error=invalid_scope` → 前端按钮正常,后端 scope 配多了,回 L5。
---
## 附:RainWeb 关键文件索引(抄作业对照)
| 需求 | 文件 |
|---|---|
| discovery 缓存 / secret 双通道 / 影子账号 / ROPC | `lib/rainid.js` |
| 授权码三路由 + ticket + 登出联动 | `routes/oidc.js` |
| ROPC 接线 + admin 逃生通道 + 影子账号禁本地登入 | `routes/auth.js` |
| PUBLIC_KEYS / ALL_KEYS / ALLOWED_SET | `routes/settings.js` |
| 前端登录页(oidc_ticket/oidc_error 收尾) | `frontend/src/pages/Login.jsx` |
| 前端注册页(注册托管跳转) | `frontend/src/pages/Register.jsx` |
| 登出联动按钮 | `frontend/src/components/Layout.jsx` |
| 后台设置表单(secret 置空不回显) | `frontend/src/admin/pages/Settings.jsx` |
| 唯一索引迁移 | `db.js` migrations |
---
*本文基于 RainWeb × RainID 线上接入实践整理。协议细节以 RainID `docs/OIDC对接文档.md` 为准。*
+46
View File
@@ -0,0 +1,46 @@
# RainID 接入指南(任意项目通用)
> RainID`https://rainid.rainnya.asia`)是 Rainnya 家族统一身份认证服务(OIDC Provider)。
> 本目录是**任意项目接入 RainID 的完整资料包**,基于 RainWeb 实战接入沉淀(含线上排障)。
> RainID 官方协议文档:`/home/miaomiao/Project/rainid/docs/OIDC对接文档.md`
## 从哪份开始读
| 你的角色 | 读这份 |
|---|---|
| **项目负责人 / 架构师**(先定方向再动手) | `01-onboarding-audit.md`(审计视角:协议选型、账户整合模式 A/B/C、安全清单、验收) |
| **开发 / 实施**(照着写代码) | `02-implementation-practice.md`(实现视角:Step-by-step、可抄代码模式、配置、踩坑、排障) |
| **已接入但在排障** | `02-implementation-practice.md` 第 5 章(踩坑实录)+ 第 6 章(快速排障 L1-L5) |
| **只想知道"注意什么"** | `01-onboarding-audit.md` 第 4 章(安全清单)+ 第 5 章(常见坑) |
## 核心结论(30 秒速览)
1. **RainID 只做标准 OIDC/OAuth2**——你的项目只需要一个 `discovery_url` = `https://rainid.rainnya.asia/oauth`
2. **身份归 RainID,角色归你**——`sub` 是跨应用稳定绑定键,admin/角色/数据归属永远本地维护
3. **授权码 + PKCE 是默认正解**(有浏览器);ROPC 仅服务端代理登录用(2FA 用户会被拒);client_credentials 供服务间
4. **影子账号模式最推荐**(用户表加 `rainid_user_id` 一列),存量站可做混合绑定但必须过 `email_verified` 撞绑闸门
5. **必须留本地 admin 逃生通道**——RainID 故障时管理员仍能登录后台(break-glass
6. **务必登记**redirect_uris、post_logout_redirect_uris、grant_types、scope 白名单、ROPC 密码直连白名单——漏一个登出就卡死/登录静默失败
## 目录文件
- `01-onboarding-audit.md` —— 架构审计视角通用指南(协议选型决策树、账户整合模式对比、安全清单、验收 checklist)
- `02-implementation-practice.md` —— 实现落地实操指南(Step-by-step、可复制代码模式、配置设计、admin 逃生通道、踩坑实录、L1-L5 快速排障)
## RainWeb 参考实现(抄作业对照)
| 需求 | RainWeb 文件 |
|---|---|
| discovery 缓存 / secret 双通道 / 影子账号 / ROPC | `lib/rainid.js` |
| 授权码三路由 + ticket + 登出联动 | `routes/oidc.js` |
| ROPC 接线 + admin 逃生通道 | `routes/auth.js` |
| PUBLIC_KEYS / ALL_KEYS / ALLOWED_SET | `routes/settings.js` |
| 前端登录页(ticket/error 收尾) | `frontend/src/pages/Login.jsx` |
| 前端注册页(注册托管) | `frontend/src/pages/Register.jsx` |
| 登出联动按钮 | `frontend/src/components/Layout.jsx` |
| 后台设置表单(secret 置空不回显) | `frontend/src/admin/pages/Settings.jsx` |
| 唯一索引迁移 | `db.js` migrations |
---
*整理:RainWeb 接入实战(orchestrator 编排 + oracle 审计视角 + fixer 实现视角三方沉淀)*
+58
View File
@@ -0,0 +1,58 @@
# RainWeb 工单系统后续完善计划
## 当前定位
RainWeb 当前已具备登录用户提交和跟踪、管理员队列处理、状态/优先级/负责人、公开回复、内部备注、基础事件时间线、筛选搜索和分页,定位为可用 MVP。
本计划只覆盖审查后确认的后续增强,不影响当前 Bug 修复批次。
## 阶段一:运营上线前(P0
目标:控制滥用风险,保护工单隐私,保证管理员团队可以稳定协作。
| 项目 | 主要内容 | 复杂度 | 依赖 |
| --- | --- | --- | --- |
| 工单反滥用 | 按 IP/用户限流、未关闭工单配额、重复提交幂等键、必要时验证码 | 中 | 现有 tickets、captcha |
| 私有附件 | 工单附件关联表、引用对象校验、私有下载鉴权、配额、孤儿清理、下载审计 | 中高 | upload、权限模型 |
| 通知闭环 | 创建确认、公开回复、状态/负责人变化通知;投递记录、失败重试和管理员告警 | 中 | email、异步任务 |
| 最小权限模型 | 客服、主管、只读审计角色;按队列/分类限制访问范围 | 中高 | 用户角色、工单分类 |
| 自动化测试 | 越权访问、内部备注隔离、状态流转、并发更新、限流、附件访问、迁移兼容 | 中 | 以上后端能力 |
## 阶段二:处理效率(P1
目标:让工单处理可衡量、可搜索、可协作。
- SLA 策略:按优先级/分类设置首次响应和解决目标。
- SLA 计时:支持工作时间、节假日,并在“等待用户”时暂停。
- 超时处理:临界提醒、违约标记、自动升级和主管队列。
- 全文搜索:搜索回复、用户、负责人、来源和日期;数据量增长后接入 SQLite FTS5。
- 工单协作:快捷回复模板、标签、合并/关联工单、重复反馈识别。
- 用户体验:通知偏好、未读数、满意度评价、前台完整分页。
- 完整审计:操作者、IP、请求 ID、前后值、后台查询和导出。
## 阶段三:规模化与治理(P2
目标:支持长期运营、外部渠道和故障恢复。
- 邮件入站转工单、退信处理和邮件线程关联。
- 附件真实 MIME/魔数检查、病毒扫描、图片重编码,必要时迁移对象存储。
- 运营报表:积压、响应时长、解决时长、重开率、SLA 达成率和客服负载。
- 健康检查、就绪检查、结构化日志、指标、告警和磁盘容量保护。
- 数据保留、删除/匿名化、用户数据导出、备份加密、轮换、异地保存和恢复演练。
## 推荐实施顺序
1. 先完成阶段一的权限和附件边界,再接入通知,避免通知泄漏内部数据。
2. 在限流、分页和审计基础上实现 SLA,避免统计建立在不完整事件数据上。
3. 最后实现邮件入站、病毒扫描、对象存储和运营报表等高复杂度能力。
## 暂不纳入本批次
本批次只修复审查发现的代码 Bug:前端请求竞态、记录排序、分页/来源地址校验、状态选项和文案一致性,以及后端限流/响应放大、并发更新、用户存在性、时间字段、事件契约和迁移兼容。上述阶段功能不会在 Bug 修复批次中顺手实现。
## 参考资料
- SQLite FTS5https://sqlite.org/fts5.html
- OWASP 文件上传安全:https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- OWASP 日志安全:https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- Google SRE 监控原则:https://sre.google/sre-book/monitoring/
+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 错误
+1 -1
View File
@@ -12,7 +12,7 @@
<body>
<div id="root"></div>
<div id="musicEmbed"></div>
<div id="snackbar" class="snackbar"></div>
<div id="snackbar" class="snackbar" role="status" aria-live="polite"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+17 -3
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from './theme.jsx';
import Layout from './components/Layout.jsx';
import Home from './pages/Home.jsx';
@@ -8,15 +8,21 @@ 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 ForumCategory from './pages/ForumCategory.jsx';
import ForumDetail from './pages/ForumDetail.jsx';
import ForumManagePanel from './pages/ForumManagePanel.jsx';
import ForumManageCategory from './pages/ForumManageCategory.jsx';
import UserProfile from './pages/UserProfile.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';
import Tickets from './pages/Tickets.jsx';
import TicketCreate from './pages/TicketCreate.jsx';
import TicketDetail from './pages/TicketDetail.jsx';
export default function App() {
return (
@@ -30,13 +36,21 @@ export default function App() {
<Route path="/tag/:name" element={<Tag />} />
<Route path="/archive.html" element={<Archive />} />
<Route path="/forum.html" element={<Forum />} />
<Route path="/forum/c/:id" element={<ForumCategory />} />
<Route path="/forum/:id" element={<ForumDetail />} />
<Route path="/tickets.html" element={<Tickets />} />
<Route path="/tickets/new" element={<TicketCreate />} />
<Route path="/tickets/:id" element={<TicketDetail />} />
<Route path="/forum/manage" element={<ForumManagePanel />} />
<Route path="/forum/manage/:id" element={<ForumManageCategory />} />
<Route path="/u/:id" element={<UserProfile />} />
<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 />} />
{/* 前台论坛管理页已退役 → 后台 /admin/forum(服务端另有 302 */}
<Route path="/forum-manage.html" element={<Navigate to="/admin/forum" replace />} />
<Route path="/embed.html" element={<Embed />} />
<Route path="/setup.html" element={<Setup />} />
</Route>
+244 -56
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useMemo, useRef, 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';
@@ -13,43 +13,38 @@ 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 ListItem from '@mui/material/ListItem';
import ListSubheader from '@mui/material/ListSubheader';
import Collapse from '@mui/material/Collapse';
import MenuItem from '@mui/material/MenuItem';
import Popover from '@mui/material/Popover';
import OutlinedInput from '@mui/material/OutlinedInput';
import InputAdornment from '@mui/material/InputAdornment';
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 SearchIcon from '@mui/icons-material/Search';
import CloseIcon from '@mui/icons-material/Close';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { logout } from '../api/auth.js';
import SnackHost, { showSnack } from './snack.jsx';
import { NAV_GROUPS, searchNav } from './navConfig.js';
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 /> },
];
/** 等元素挂载(跨路由跳转后目标页异步渲染,最多轮询 ~2s) */
function waitForId(id, tries = 40) {
return document.getElementById(id)
? Promise.resolve()
: tries > 0
? new Promise((res) => setTimeout(() => res(waitForId(id, tries - 1)), 50))
: Promise.resolve();
}
/** 后台布局:AppBar(返回前台 + 退出)+ Drawer 导航 + 内容区 Outlet */
/** 顶栏搜索下拉最多展示条数 */
const SEARCH_LIMIT = 5;
/** 后台布局:AppBar(中间设置搜索 + 工作台/返回前台/退出)+ Drawer(分组折叠导航)+ 内容区 Outlet */
export default function AdminLayout() {
const location = useLocation();
const navigate = useNavigate();
@@ -57,19 +52,193 @@ export default function AdminLayout() {
const isMobile = useMediaQuery(muiTheme.breakpoints.down('md'));
const [mobileOpen, setMobileOpen] = useState(false);
// 分组折叠状态:默认展开全部(单成员组平铺,不参与折叠)
const [openGroups, setOpenGroups] = useState(() => (
new Set(NAV_GROUPS.filter((g) => g.items.length > 1).map((g) => g.id))
));
// 设置搜索(顶栏中间;结果最多 SEARCH_LIMIT 条)
const [searchQuery, setSearchQuery] = useState('');
const [searchOpen, setSearchOpen] = useState(false);
const searchRef = useRef(null);
const results = useMemo(() => searchNav(searchQuery, SEARCH_LIMIT), [searchQuery]);
// 当前项所在组自动展开
useEffect(() => {
const current = NAV_GROUPS.find((g) => g.items.some((it) => location.pathname.startsWith(it.path)));
if (current && current.items.length > 1 && !openGroups.has(current.id)) {
setOpenGroups((prev) => new Set(prev).add(current.id));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// Esc 关闭搜索下拉
useEffect(() => {
const onKey = (e) => { if (e.key === 'Escape') setSearchOpen(false); };
if (searchOpen) window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [searchOpen]);
const toggleGroup = (id) => {
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const goMenu = (path) => {
navigate(path);
setMobileOpen(false);
};
const jump = (r) => {
setSearchQuery('');
setSearchOpen(false);
if (r.type === 'menu') { goMenu(r.path); return; }
// 设置区块:跳转页面后定位 + 高亮对应 Paper
const scroll = () => {
const el = document.getElementById(r.anchor);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
const c = muiTheme.palette.primary.main;
el.style.transition = 'box-shadow 0.5s ease';
el.style.boxShadow = `0 0 0 3px ${c}`;
window.setTimeout(() => { el.style.boxShadow = 'none'; el.style.transition = ''; }, 1800);
};
if (location.pathname === r.path) scroll();
else { navigate(r.path); waitForId(r.anchor).then(scroll); }
};
const onSearchChange = (v) => {
setSearchQuery(v);
setSearchOpen(v.trim().length >= 1);
};
const onSearchKeyDown = (e) => {
if (e.key === 'Escape') { setSearchOpen(false); return; }
if (e.key === 'Enter' && results.length === 1) { jump(results[0]); }
};
/** 顶栏搜索框 + 结果下拉(桌面居中 / 移动端全宽第二行) */
const searchField = (
<>
<OutlinedInput
inputRef={searchRef}
size="small"
fullWidth
placeholder="搜索设置…"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
onKeyDown={onSearchKeyDown}
onFocus={() => { if (searchQuery.trim()) setSearchOpen(true); }}
startAdornment={<InputAdornment position="start"><SearchIcon sx={{ fontSize: 18, color: 'text.secondary' }} /></InputAdornment>}
endAdornment={searchQuery ? (
<InputAdornment position="end">
<IconButton size="small" edge="end" onClick={() => onSearchChange('')} title="清空">
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
</InputAdornment>
) : null}
sx={{
height: 38, borderRadius: 3,
bgcolor: 'action.hover',
'& .MuiOutlinedInput-notchedOutline': { borderColor: 'transparent' },
'&:hover .MuiOutlinedInput-notchedOutline': { borderColor: 'divider' },
'&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderColor: 'primary.main' },
}}
/>
{/* 结果下拉:最多 5 条,maxHeight 280 ≈ 5 个列表项卡片高度 */}
<Popover
open={searchOpen}
anchorEl={searchRef.current}
onClose={() => setSearchOpen(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{
paper: {
sx: {
width: 320, maxHeight: 280, mt: 0.5, borderRadius: 2, boxShadow: 8,
overflow: 'auto', border: '1px solid', borderColor: 'divider',
},
},
}}
>
<List dense disablePadding sx={{ py: 0.5 }}>
{results.length === 0 ? (
<ListItem>
<Typography variant="body2" color="text.secondary" sx={{ py: 1, fontSize: 13 }}>没有匹配的设置</Typography>
</ListItem>
) : results.map((r) => (
<MenuItem key={r.type + r.path + (r.anchor || '')} dense onClick={() => jump(r)}>
<ListItemIcon sx={{ minWidth: 34 }}>{r.icon ? <r.icon fontSize="small" /> : <SearchIcon fontSize="small" />}</ListItemIcon>
<ListItemText
primary={r.label}
secondary={r.group}
primaryTypographyProps={{ fontSize: 13.5 }}
slotProps={{ secondary: { fontSize: 11.5 } }}
/>
{r.type === 'section' && (
<Box component="span" sx={{ ml: 1, fontSize: 11, color: 'text.disabled', flexShrink: 0 }}>设置项</Box>
)}
</MenuItem>
))}
</List>
</Popover>
</>
);
/** 单成员组平铺渲染,多成员组显示组头 + Collapse 折叠 */
const renderGroup = (g) => {
const single = g.items.length === 1;
const open = openGroups.has(g.id);
const itemList = (
<List component="div" disablePadding>
{g.items.map((item) => (
<ListItemButton
key={item.path}
selected={location.pathname.startsWith(item.path)}
onClick={() => { goMenu(item.path); }}
sx={{ pl: single ? 2 : 4 }}
>
<ListItemIcon>{item.icon ? <item.icon fontSize="small" /> : null}</ListItemIcon>
<ListItemText primary={item.label} primaryTypographyProps={{ fontSize: 14 }} />
</ListItemButton>
))}
</List>
);
const items = single ? itemList : (
<Collapse in={open} timeout="auto" unmountOnExit>
{itemList}
</Collapse>
);
return (
<Box key={g.id}>
{single ? null : (
<ListSubheader
component="div"
disableSticky
onClick={() => toggleGroup(g.id)}
sx={{
cursor: 'pointer', userSelect: 'none', display: 'flex', alignItems: 'center',
gap: 0.5, fontSize: 12, fontWeight: 700, letterSpacing: 0.06, lineHeight: 2,
}}
>
<Box sx={{ flex: 1 }}>{g.label}</Box>
{open ? <ExpandLessIcon sx={{ fontSize: 16, color: 'text.disabled' }} /> : <ExpandMoreIcon sx={{ fontSize: 16, color: 'text.disabled' }} />}
</ListSubheader>
)}
{items}
</Box>
);
};
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>
<>
<Toolbar />
<List dense>{NAV_GROUPS.map(renderGroup)}</List>
</>
);
const handleLogout = () => {
@@ -81,20 +250,40 @@ export default function AdminLayout() {
return (
<Box sx={{ display: 'flex' }}>
<AppBar position="fixed" sx={{ zIndex: (t) => t.zIndex.drawer + 1 }}>
<Toolbar>
{/* 顶行:汉堡(移动) + 标题(左) + 搜索(桌面中) + 操作按钮(右) */}
<Toolbar sx={{ gap: 1, minHeight: isMobile ? 56 : 64 }}>
{isMobile && (
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 1 }}>
<IconButton color="inherit" edge="start" onClick={() => setMobileOpen(true)} sx={{ mr: 0.5 }}>
<MenuIcon />
</IconButton>
)}
<Typography variant="h6" sx={{ flexGrow: 1 }}>管理后台</Typography>
<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>
<Typography variant="h6" noWrap sx={{ flexShrink: 0, fontSize: { xs: 18, sm: 22 } }}>管理后台</Typography>
{!isMobile && (
<Box sx={{ flexGrow: 1, display: 'flex', justifyContent: 'center', minWidth: 0, px: 2 }}>
<Box sx={{ width: '100%', maxWidth: 480 }}>{searchField}</Box>
</Box>
)}
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0, sm: 0.5 }, ml: 'auto', flexShrink: 0 }}>
<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="返回前台" sx={{ px: { xs: 1, sm: 1.5 } }}>
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>返回前台</Box>
</Button>
<Button color="inherit" onClick={handleLogout} sx={{ px: { xs: 1, sm: 1.5 } }}>
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>退出</Box>
</Button>
</Box>
</Toolbar>
{/* 移动:第二行全宽搜索(顶栏空间不足,收窄到独立一行不拥挤) */}
{isMobile && (
<Toolbar variant="dense" sx={{ pt: 0, pb: 1, gap: 1, minHeight: 48 }}>
{searchField}
</Toolbar>
)}
</AppBar>
<Box component="nav" sx={{ width: { md: 240 }, flexShrink: { md: 0 } }}>
@@ -106,19 +295,18 @@ export default function AdminLayout() {
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 />
<Box component="main" sx={{ flex: '1 1 auto', width: { xs: '100%', md: 'calc(100vw - 240px)' }, maxWidth: { xs: '100%', md: 'calc(100vw - 240px)' }, boxSizing: 'border-box', p: { xs: 2, md: 3 }, minWidth: 0, overflowX: 'hidden' }}>
{/* AppBar 占位:桌面 64 / 移动 56 + 搜索行 48 */}
<Box sx={{ height: isMobile ? 104 : 64 }} />
<Outlet />
</Box>
<SnackHost />
+24 -1
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { lazy, Suspense, useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
@@ -20,11 +20,16 @@ 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 PostManage from './pages/PostManage.jsx';
import RssManage from './pages/RssManage.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';
import TicketManage from './pages/TicketManage.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 自适应为空。
@@ -76,6 +81,21 @@ function AdminApp() {
<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 />} />
@@ -87,11 +107,14 @@ function AdminApp() {
<Route path="/blog" element={<BlogManage />} />
<Route path="/comments" element={<CommentManage />} />
<Route path="/forum" element={<ForumManage />} />
<Route path="/posts" element={<PostManage />} />
<Route path="/rss" element={<RssManage />} />
<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="/tickets" element={<TicketManage />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Route>
</Routes>
+191
View File
@@ -0,0 +1,191 @@
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 HomeIcon from '@mui/icons-material/Home';
import EmailIcon from '@mui/icons-material/Email';
import ArticleIcon from '@mui/icons-material/Article';
import ChatBubbleOutlineOutlinedIcon from '@mui/icons-material/ChatBubbleOutlineOutlined';
import ForumIcon from '@mui/icons-material/Forum';
import ListAltIcon from '@mui/icons-material/ListAlt';
import PeopleIcon from '@mui/icons-material/People';
import CampaignIcon from '@mui/icons-material/Campaign';
import LinkIcon from '@mui/icons-material/Link';
import RssFeedIcon from '@mui/icons-material/RssFeed';
import AttachFileIcon from '@mui/icons-material/AttachFile';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import SupportAgentIcon from '@mui/icons-material/SupportAgent';
/**
* 后台侧边栏导航配置(单一数据源):
* - NAV_GROUPS 分组结构,供侧边栏渲染
* - NAV_SECTIONS 各设置页区块锚点(id 与页面 Paper 的 id 对应),供搜索定位
* - NAV_ALIASES 别名映射,把常用叫法/英文指到现有条目
* - searchNav() 前端本地过滤(无需后端)
*/
export const NAV_GROUPS = [
{
id: 'overview',
label: '概览',
items: [
{ path: '/dashboard', label: '仪表盘', icon: DashboardIcon },
],
},
{
id: 'content',
label: '内容管理',
items: [
{ path: '/blog', label: '博客管理', icon: ArticleIcon },
{ path: '/comments', label: '评论管理', icon: ChatBubbleOutlineOutlinedIcon },
{ path: '/announcements', label: '公告管理', icon: CampaignIcon },
{ path: '/forum', label: '论坛管理', icon: ForumIcon },
{ path: '/posts', label: '帖子管理', icon: ListAltIcon },
],
},
{
id: 'support',
label: '反馈处理',
items: [
{ path: '/tickets', label: '工单管理', icon: SupportAgentIcon },
],
},
{
id: 'users',
label: '用户',
items: [
{ path: '/users', label: '用户管理', icon: PeopleIcon },
],
},
{
id: 'appearance',
label: '外观',
items: [
{ path: '/theme', label: '主题', icon: PaletteIcon },
{ path: '/homepage', label: '首页设置', icon: HomeIcon },
],
},
{
id: 'system',
label: '系统设置',
items: [
{ path: '/settings', label: '站点设置', icon: SettingsIcon },
{ path: '/captcha', label: '验证码', icon: VerifiedUserIcon },
{ path: '/email', label: '邮件配置', icon: EmailIcon },
{ path: '/rss', label: 'RSS 订阅', icon: RssFeedIcon },
],
},
{
id: 'tools',
label: '工具',
items: [
{ path: '/links', label: '面板链接', icon: LinkIcon },
{ path: '/uploads', label: '附件管理', icon: AttachFileIcon },
{ path: '/import', label: '数据导入', icon: UploadFileIcon },
],
},
];
/** 各设置页区块:anchor 必须与页面 Paper/Box 的 id 一致;keywords 为搜索词 */
export const NAV_SECTIONS = [
// 站点设置 /settings
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-basic', label: '基本设置', keywords: ['基本信息', '网站名称', '网站描述', '网站域名', 'favicon', '图标'] },
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-footer', label: '页脚设置', keywords: ['页脚', 'footer', '版权', '分栏导航', '页脚栏目', 'Powered'] },
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-search-verify', label: '搜索引擎验证', keywords: ['SEO', 'Bing', 'Google', 'Yandex', '站长工具', '验证文件'] },
{ page: '/settings', pageLabel: '站点设置', anchor: 'sec-rainid', label: 'RainID 单点登录', keywords: ['SSO', '单点登录', 'OAuth', 'OIDC', 'client', '注册跳转'] },
// 主题 /theme
{ page: '/theme', pageLabel: '主题', anchor: 'sec-primary-color', label: '主题色', keywords: ['颜色', '主色调', '配色', 'primary'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-wallpaper', label: '壁纸背景', keywords: ['wallpaper', '背景图', '背景'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-styles', label: '样式设置', keywords: ['导航栏', '卡片', 'nav', '磨砂'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-glass', label: '玻璃效果', keywords: ['磨砂玻璃', '模糊', '透明度', 'glass', 'blur'] },
{ page: '/theme', pageLabel: '主题', anchor: 'sec-dark', label: '深色模式', keywords: ['暗色', 'dark', '夜间模式'] },
// 首页设置 /homepage
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-profile', label: '个人信息', keywords: ['头像', '简介', 'bio', '联系链接'] },
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-home-content', label: '主页内容', keywords: ['正文', 'markdown', '内容'] },
{ page: '/homepage', pageLabel: '首页设置', anchor: 'sec-music', label: '音乐嵌入', keywords: ['播放器', '网易云', 'music', 'embed'] },
// 邮件配置 /email
{ page: '/email', pageLabel: '邮件配置', anchor: 'sec-smtp', label: 'SMTP 服务器', keywords: ['smtp', '主机', '端口', '服务器'] },
{ page: '/email', pageLabel: '邮件配置', anchor: 'sec-sender', label: '发件人信息', keywords: ['发件人', 'from', '邮箱'] },
// 验证码 /captcha
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-type', label: '验证码类型', keywords: ['captcha', '类型', '内置'] },
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-scope', label: '验证场景', keywords: ['登录验证', '注册验证', '发帖验证'] },
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-recaptcha', label: 'reCAPTCHA 配置', keywords: ['Google', 'recaptcha', 'site key'] },
{ page: '/captcha', pageLabel: '验证码', anchor: 'sec-captcha-turnstile', label: 'Turnstile 配置', keywords: ['Cloudflare', 'turnstile', 'site key'] },
// RSS /rss
{ page: '/rss', pageLabel: 'RSS 订阅', anchor: 'sec-rss-sources', label: '订阅源', keywords: ['源', 'feed', '订阅'] },
{ page: '/rss', pageLabel: 'RSS 订阅', anchor: 'sec-rss-content', label: '内容设置', keywords: ['全文', '摘要', '条目数'] },
// 博客管理 /blog
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-blog-sidebar', label: '博客侧栏', keywords: ['侧边栏', '头像', '简介', '显示'] },
{ page: '/blog', pageLabel: '博客管理', anchor: 'sec-comments', label: '评论设置', keywords: ['评论审核', '评论通知', '审核', '通知', 'moderate', 'UID', '#id', '编号'] },
// 论坛管理 /forum
{ page: '/forum', pageLabel: '论坛管理', anchor: 'sec-forum-settings', label: '论坛设置', keywords: ['访客可见', '游客', '私密', 'guest', '权限'] },
// 面板链接 /links
{ page: '/links', pageLabel: '面板链接', anchor: 'sec-proxy', label: '面板代理', keywords: ['代理', 'proxy', '内网', '白名单', 'SSRF', 'iframe'] },
];
/** 别名映射:把常见叫法指到已有菜单项或设置区块(path + 可选 anchor */
export const NAV_ALIASES = [
{ keywords: ['工单', '反馈', 'bug', '问题', 'ticket'], path: '/tickets' },
{ keywords: ['邮件', 'email', 'smtp'], path: '/email' },
{ keywords: ['验证码', 'captcha', 'reCAPTCHA', 'turnstile'], path: '/captcha' },
{ keywords: ['rss', '订阅', 'feed'], path: '/rss' },
{ keywords: ['favicon', '图标'], path: '/settings', anchor: 'sec-basic' },
{ keywords: ['单点登录', 'sso', 'rainid'], path: '/settings', anchor: 'sec-rainid' },
{ keywords: ['壁纸', 'wallpaper'], path: '/theme', anchor: 'sec-wallpaper' },
{ keywords: ['评论', '审核'], path: '/blog', anchor: 'sec-comments' },
{ keywords: ['代理', '内网'], path: '/links', anchor: 'sec-proxy' },
];
/** 搜索索引:菜单项 + 设置区块 + 别名(模块加载时构建一次) */
function buildIndex() {
const index = [];
NAV_GROUPS.forEach((g) => {
g.items.forEach((item) => {
index.push({
type: 'menu',
label: item.label,
group: g.label,
path: item.path,
icon: item.icon,
keywords: [item.label],
});
});
});
NAV_SECTIONS.forEach((sec) => {
const pageItem = index.find((e) => e.type === 'menu' && e.path === sec.page);
index.push({
type: 'section',
label: sec.label,
group: sec.pageLabel,
path: sec.page,
anchor: sec.anchor,
icon: pageItem ? pageItem.icon : null,
keywords: [sec.label, ...(sec.keywords || [])],
});
});
NAV_ALIASES.forEach((a) => {
const target = index.find((e) =>
e.path === a.path && (a.anchor ? e.anchor === a.anchor : !e.anchor));
if (target) target.keywords.push(...a.keywords);
});
return index;
}
const SEARCH_INDEX = buildIndex();
/**
* 前端本地过滤:输入 ≥1 字符返回结果(大小写不敏感、子串匹配)。
* limit 为可选参数:限制返回条数(顶栏搜索传 5,最多显示 5 条)。
* 相关性排序:命中 label 的条目排在只命中 keywords 的前面
* (同相关度保持构建顺序:菜单项 > 设置区块 > 别名,Array.sort 稳定)。
*/
export function searchNav(query, limit) {
const q = String(query || '').trim().toLowerCase();
if (!q) return [];
const matched = SEARCH_INDEX.filter((e) => e.keywords.some((k) => String(k).toLowerCase().includes(q)));
const ranked = matched.sort((a, b) => {
const aLabel = String(a.label).toLowerCase().includes(q) ? 0 : 1;
const bLabel = String(b.label).toLowerCase().includes(q) ? 0 : 1;
return aLabel - bLabel;
});
return typeof limit === 'number' && limit > 0 ? ranked.slice(0, limit) : ranked;
}
+58 -3
View File
@@ -20,10 +20,13 @@ import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 博客管理:博客侧栏开关 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
/** 博客管理:博客侧栏开关 + 评论设置 + 文章列表(发布开关/编辑跳前台 write.html?edit=id/删除) */
export default function BlogManage() {
const [posts, setPosts] = useState(null);
const [showSidebar, setShowSidebar] = useState(true);
const [commentModerate, setCommentModerate] = useState('0');
const [commentNotify, setCommentNotify] = useState('0');
const [showUidComments, setShowUidComments] = useState('1');
const [confirm, setConfirm] = useState(null); // { id, title }
const [deleting, setDeleting] = useState(false);
@@ -35,7 +38,12 @@ export default function BlogManage() {
useEffect(() => {
load();
getSettings().then((s) => setShowSidebar(s.blog_show_sidebar !== '0')).catch(() => {});
getSettings().then((s) => {
setShowSidebar(s.blog_show_sidebar !== '0');
setCommentModerate(s.comment_moderate === '1' ? '1' : '0');
setCommentNotify(s.comment_notify === '1' ? '1' : '0');
setShowUidComments(s.show_uid_in_comments !== '0' ? '1' : '0');
}).catch(() => {});
}, [load]);
const saveSidebar = async () => {
@@ -47,6 +55,19 @@ export default function BlogManage() {
}
};
const saveCommentSettings = async () => {
try {
await saveSettings({
comment_moderate: commentModerate,
comment_notify: commentNotify,
show_uid_in_comments: showUidComments,
});
showSnack('评论设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
};
const togglePublish = async (p) => {
try {
await updatePost(p.id, {
@@ -84,7 +105,7 @@ export default function BlogManage() {
<Button variant="contained" component="a" href="/write.html" target="_blank" startIcon={<EditIcon />}>写文章</Button>
</Box>
<Paper sx={{ p: 2, mb: 2, maxWidth: 640 }}>
<Paper id="sec-blog-sidebar" 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)} />}
@@ -94,6 +115,40 @@ export default function BlogManage() {
</Box>
</Paper>
<Paper id="sec-comments" sx={{ p: 2, mb: 2, maxWidth: 640 }}>
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>评论设置</Typography>
<FormControlLabel
control={<Switch checked={commentModerate === '1'} onChange={(e) => setCommentModerate(e.target.checked ? '1' : '0')} />}
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={commentNotify === '1'} onChange={(e) => setCommentNotify(e.target.checked ? '1' : '0')} />}
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={showUidComments === '1'} onChange={(e) => setShowUidComments(e.target.checked ? '1' : '0')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>评论显示 UID 编号#id</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>在评论作者名旁显示 UID 后缀关闭后仅帖子/楼主等主作者位显示</Box>
</Box>
)}
/>
<Box sx={{ mt: 1 }}>
<Button variant="outlined" size="small" onClick={saveCommentSettings}>保存评论设置</Button>
</Box>
</Paper>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
+4 -4
View File
@@ -73,7 +73,7 @@ export default function CaptchaSettings() {
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>验证码设置</Typography>
<Paper sx={{ p: 3 }}>
<Paper id="sec-captcha-type" sx={{ p: 3 }}>
<FormControl fullWidth margin="normal">
<InputLabel>验证码类型</InputLabel>
<Select value={form.captcha_type} onChange={set('captcha_type')} label="验证码类型">
@@ -86,7 +86,7 @@ export default function CaptchaSettings() {
</FormControl>
{showScope && (
<FormGroup>
<FormGroup id="sec-captcha-scope">
<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="发帖验证" />
@@ -94,7 +94,7 @@ export default function CaptchaSettings() {
)}
{showRecaptcha && (
<Box sx={{ mt: 2 }}>
<Box id="sec-captcha-recaptcha" 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..." />
@@ -102,7 +102,7 @@ export default function CaptchaSettings() {
)}
{showTurnstile && (
<Box sx={{ mt: 2 }}>
<Box id="sec-captcha-turnstile" 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..." />
+123 -10
View File
@@ -9,24 +9,53 @@ 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 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 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 DeleteIcon from '@mui/icons-material/Delete';
import { listPendingComments, listAllComments, approveComment, rejectComment, deleteComment } from '../../api/blog.js';
import { showSnack } from '../snack.jsx';
const PAGE_SIZE = 20;
/** 时间格式化:yyyy-mm-dd hh:mm */
function fmtTime(t) {
if (!t) return '';
return String(t).replace('T', ' ').slice(0, 16);
}
/** 评论管理:待审核队列 + 全部评论占位(后端暂无全量评论列表接口) */
/** 内容截断(单行预览):换行折叠 + 超长省略 */
function clip(content, max = 60) {
const s = String(content || '').replace(/\s+/g, ' ').trim();
return s.length > max ? s.slice(0, max) + '…' : s;
}
/** 状态 chip */
function StatusChip({ status }) {
if (status === 'approved') return <Chip size="small" label="已通过" color="success" />;
if (status === 'rejected') return <Chip size="small" label="已拒绝" color="error" variant="outlined" />;
return <Chip size="small" label="待审核" color="warning" />;
}
/** 评论管理:待审核队列 + 全部评论分页列表(status=all */
export default function CommentManage() {
const [tab, setTab] = useState(0);
const [pending, setPending] = useState([]);
const [busy, setBusy] = useState(false);
// 全部评论(分页)
const [allList, setAllList] = useState([]);
const [allPage, setAllPage] = useState(1);
const [allTotal, setAllTotal] = useState(0);
const [allTotalPages, setAllTotalPages] = useState(0);
const [allLoading, setAllLoading] = useState(false);
const load = useCallback(() => {
setBusy(true);
listPendingComments()
@@ -37,6 +66,23 @@ export default function CommentManage() {
useEffect(() => { load(); }, [load]);
const loadAll = useCallback((page) => {
setAllLoading(true);
listAllComments({ status: 'all', page, pageSize: PAGE_SIZE })
.then((res) => {
setAllList((res && res.list) || []);
setAllTotal(res ? res.total || 0 : 0);
setAllTotalPages(res ? res.totalPages || 0 : 0);
setAllPage(page);
})
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setAllLoading(false));
}, []);
useEffect(() => {
if (tab === 1) loadAll(1);
}, [tab, loadAll]);
const act = async (id, fn, okMsg) => {
setBusy(true);
try {
@@ -49,17 +95,34 @@ export default function CommentManage() {
setBusy(false);
};
const actAll = async (id, fn, okMsg, reloadPage = allPage) => {
setBusy(true);
try {
await fn(id);
showSnack(okMsg);
// 同步待审核徽标 + 刷新当前页;若删除后本页空且非第一页则回退一页
setPending((list) => list.filter((c) => c.id !== id));
const remaining = allList.filter((c) => c.id !== id).length;
loadAll(remaining === 0 && reloadPage > 1 ? reloadPage - 1 : reloadPage);
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const totalPages = Math.max(1, allTotalPages);
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>
<IconButton onClick={() => (tab === 0 ? load() : loadAll(allPage))} title="刷新" disabled={busy || allLoading}><RefreshIcon /></IconButton>
</Box>
<Paper sx={{ mb: 2 }}>
<Tabs value={tab} onChange={(e, v) => setTab(v)}>
<Tab label={`待审核${pending.length ? ` (${pending.length})` : ''}`} />
<Tab label="全部评论" />
<Tab label={`全部评论${allTotal ? ` (${allTotal})` : ''}`} />
</Tabs>
</Paper>
@@ -114,12 +177,62 @@ export default function CommentManage() {
</Paper>
)
) : (
<Paper sx={{ p: 4, textAlign: 'center' }}>
<Typography variant="body1" sx={{ mb: 0.5 }}>暂未提供全量评论列表接口</Typography>
<Typography variant="body2" color="text.secondary">
当前后端仅提供待审核评论的管理接口已通过 / 已拒绝评论可在对应文章页查看
</Typography>
</Paper>
<>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>作者</TableCell>
<TableCell>文章</TableCell>
<TableCell>内容</TableCell>
<TableCell>状态</TableCell>
<TableCell>时间</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allLoading && allList.length === 0 ? (
<TableRow><TableCell colSpan={6}>加载中...</TableCell></TableRow>
) : allList.length === 0 ? (
<TableRow><TableCell colSpan={6}>暂无评论</TableCell></TableRow>
) : allList.map((c) => (
<TableRow key={c.id}>
<TableCell sx={{ whiteSpace: 'nowrap', fontWeight: 600 }}>
{c.author_name || '匿名'}
</TableCell>
<TableCell sx={{ maxWidth: 200, color: 'text.secondary', fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{c.post_title || `#${c.post_id}`}
</TableCell>
<TableCell sx={{ maxWidth: 320, fontSize: 13, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{clip(c.content)}
</TableCell>
<TableCell><StatusChip status={c.status} /></TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13, whiteSpace: 'nowrap' }}>{fmtTime(c.created_at)}</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
{c.status === 'pending' && (
<>
<Button size="small" startIcon={<CheckIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, approveComment, '已通过审核')}>通过</Button>
<Button size="small" color="error" startIcon={<CloseIcon fontSize="small" />} disabled={busy} onClick={() => actAll(c.id, rejectComment, '已拒绝')}>拒绝</Button>
</>
)}
<IconButton size="small" color="error" title="删除评论" disabled={busy} onClick={() => actAll(c.id, deleteComment, '已删除')}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, mt: 2 }}>
<Button size="small" variant="outlined" disabled={allPage <= 1 || allLoading} onClick={() => loadAll(allPage - 1)}>上一页</Button>
<Typography variant="body2" color="text.secondary">
{allPage} / {totalPages} · {allTotal}
</Typography>
<Button size="small" variant="outlined" disabled={allPage >= totalPages || allLoading} onClick={() => loadAll(allPage + 1)}>下一页</Button>
</Box>
</>
)}
</Box>
);
+2 -2
View File
@@ -66,14 +66,14 @@ export default function EmailSettings() {
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>邮件配置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-smtp" 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 }}>
<Paper id="sec-sender" 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" />
+259 -34
View File
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useState } from 'react';
import Grid from '@mui/material/Grid';
import Paper from '@mui/material/Paper';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Typography from '@mui/material/Typography';
@@ -10,41 +11,90 @@ 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 GroupIcon from '@mui/icons-material/Group';
import CampaignIcon from '@mui/icons-material/Campaign';
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 FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import Select from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import Checkbox from '@mui/material/Checkbox';
import ListItemText from '@mui/material/ListItemText';
import Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import { listCategories, createCategory, updateCategory, deleteCategory, updateAnnouncement, updateModerators } from '../../api/forum.js';
import { listUsers } from '../../api/auth.js';
import { getSettings, saveSettings } from '../../api/settings.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0' };
const EMPTY = { name: '', description: '', announcement: '', sub_categories: '', sort_order: '0', icon: '', icon_color: '' };
/** 论坛管理:板块卡片(帖子数)+ 添加/编辑弹窗 + 删除(MUI 版,同前台 ForumManage API */
// 版块图标色板(MD3 常用色相,供快捷选择)
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
/** 版主名提取:兼容数组 / 逗号字符串 */
function moderatorNames(cat) {
const m = cat && cat.moderators;
if (Array.isArray(m)) {
return m.map((x) => (typeof x === 'string' ? x : (x && x.username) || '')).filter(Boolean);
}
if (typeof m === 'string') return m.split(',').map((s) => s.trim()).filter(Boolean);
return [];
}
/**
* 论坛管理(MUI):板块卡片(聚合帖子数)+ 添加/编辑弹窗(icon/icon_color+
* 版主指派弹窗(多选用户)+ 公告快捷编辑 + 删除。
* 计数来自 GET /forum/categories 聚合(不再全量 listPosts)。
*/
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 [modDialog, setModDialog] = useState(null); // { id, name, ids: [] }
const [users, setUsers] = useState([]);
const [modSaving, setModSaving] = useState(false);
// 公告编辑弹窗
const [annDialog, setAnnDialog] = useState(null); // { id, name, text }
const [annSaving, setAnnSaving] = useState(false);
// 论坛设置(forum_guest_visible):'1'=游客可见(默认)
const [guestVisible, setGuestVisible] = useState(true);
const [settingSaving, setSettingSaving] = 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]);
useEffect(() => {
load();
getSettings().then((s) => setGuestVisible(s.forum_guest_visible !== '0')).catch(() => {});
}, [load]);
const saveForumSettings = async () => {
setSettingSaving(true);
try {
await saveSettings({ forum_guest_visible: guestVisible ? '1' : '0' });
showSnack('论坛设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSettingSaving(false);
};
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (c) => {
@@ -55,6 +105,8 @@ export default function ForumManage() {
announcement: c.announcement || '',
sub_categories: c.sub_categories || '',
sort_order: String(c.sort_order || 0),
icon: c.icon || '',
icon_color: c.icon_color || '',
});
setDialog(true);
};
@@ -68,6 +120,8 @@ export default function ForumManage() {
announcement: form.announcement.trim(),
sub_categories: form.sub_categories.trim(),
sort_order: parseInt(form.sort_order, 10) || 0,
icon: form.icon.trim(),
icon_color: form.icon_color.trim(),
};
try {
if (editingId) await updateCategory(editingId, data);
@@ -93,6 +147,50 @@ export default function ForumManage() {
}
};
// ── 版主指派 ──
// 分类聚合里的 moderators 是用户名逗号串,打开弹窗时按用户名匹配用户 id 作预选
const openMods = async (c) => {
const usernames = moderatorNames(c);
setModDialog({ id: c.id, name: c.name, usernames, ids: [] });
try {
const us = await listUsers();
const list = (us && us.users) || us || [];
setUsers(list);
setModDialog((p) => (p ? { ...p, ids: list.filter((u) => usernames.includes(u.username)).map((u) => u.id) } : p));
} catch (e) {
showSnack(e.message, 'error');
}
};
const saveMods = async () => {
if (!modDialog) return;
setModSaving(true);
try {
await updateModerators(modDialog.id, modDialog.ids);
showSnack('版主已更新');
setModDialog(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setModSaving(false);
};
// ── 公告快捷编辑 ──
const openAnn = (c) => setAnnDialog({ id: c.id, name: c.name, text: c.announcement || '' });
const saveAnn = async () => {
if (!annDialog) return;
setAnnSaving(true);
try {
await updateAnnouncement(annDialog.id, annDialog.text.trim());
showSnack('公告已更新');
setAnnDialog(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
setAnnSaving(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
@@ -100,34 +198,76 @@ export default function ForumManage() {
<Button variant="contained" startIcon={<AddIcon />} onClick={openAdd}>添加板块</Button>
</Box>
<Paper id="sec-forum-settings" sx={{ p: 2.5, mb: 2, maxWidth: 640 }}>
<Typography variant="h6" sx={{ fontSize: 16, mb: 1 }}>论坛设置</Typography>
<FormControlLabel
control={<Switch checked={guestVisible} onChange={(e) => setGuestVisible(e.target.checked)} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>访客可见</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>开启后未登录访客可浏览论坛关闭则需登录私密模式SEO 同时隐藏</Box>
</Box>
)}
/>
<Box sx={{ mt: 1 }}>
<Button variant="outlined" size="small" onClick={saveForumSettings} disabled={settingSaving}>保存论坛设置</Button>
</Box>
</Paper>
{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>
))}
{cats.map((c) => {
const mods = moderatorNames(c);
return (
<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 }}>
<Box
sx={{
width: 36, height: 36, borderRadius: 3, flexShrink: 0, fontWeight: 700, fontSize: 17,
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
background: c.icon_color || 'rgba(0,0,0,0.08)',
color: '#fff',
}}
>
{c.icon ? (
/^https?:\/\//i.test(c.icon)
? <Box component="img" src={c.icon} alt="" sx={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: c.icon
) : (c.name || '?').charAt(0).toUpperCase()}
</Box>
<Typography variant="subtitle1" sx={{ fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{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 }}>
{typeof c.posts_count === 'number' ? `${c.posts_count} 个帖子` : '—'}
{c.today_count ? ` · 今日 ${c.today_count}` : ''}
</Typography>
{mods.length > 0 && (
<Typography variant="caption" color="text.secondary" noWrap sx={{ mb: 1 }}>版主{mods.join('、')}</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5, mt: 'auto' }}>
<IconButton size="small" onClick={() => openMods(c)} title="指派版主"><GroupIcon fontSize="small" /></IconButton>
<IconButton size="small" onClick={() => openAnn(c)} title="编辑公告"><CampaignIcon fontSize="small" /></IconButton>
<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>
@@ -135,7 +275,33 @@ export default function ForumManage() {
<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 }} />
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<TextField label="图标(emoji 或图片 URL" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} margin="normal" placeholder="例: 💬 或 https://…/logo.png" sx={{ flex: 1, minWidth: 200 }} />
<TextField label="排序" type="number" value={form.sort_order} onChange={(e) => setForm((p) => ({ ...p, sort_order: e.target.value }))} margin="normal" sx={{ maxWidth: 120 }} />
</Box>
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>图标底色留空按名称自动配色</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
{COLOR_PALETTE.map((col) => (
<Box
key={col}
onClick={() => setForm((p) => ({ ...p, icon_color: col === form.icon_color ? '' : col }))}
sx={{
width: 28, height: 28, borderRadius: 2, cursor: 'pointer', background: col,
border: col === form.icon_color ? '2px solid #000' : '2px solid transparent',
outline: col === form.icon_color ? '2px solid #fff' : 'none',
}}
/>
))}
<TextField
size="small"
label="自定义"
value={form.icon_color}
onChange={(e) => setForm((p) => ({ ...p, icon_color: e.target.value }))}
sx={{ width: 110 }}
/>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialog(false)}>取消</Button>
@@ -143,6 +309,65 @@ export default function ForumManage() {
</DialogActions>
</Dialog>
{/* 版主指派 */}
<Dialog open={!!modDialog} onClose={() => setModDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>指派版主</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
为版块{modDialog ? modDialog.name : ''}选择版主管理员始终可管理全部版块
</Typography>
<FormControl fullWidth>
<InputLabel>版主</InputLabel>
<Select
multiple
value={modDialog ? modDialog.ids : []}
onChange={(e) => setModDialog((p) => (p ? { ...p, ids: e.target.value } : p))}
label="版主"
renderValue={(selected) => {
const names = users
.filter((u) => selected.includes(u.id))
.map((u) => u.username)
.join('、');
return names || '未选择';
}}
>
{users.map((u) => (
<MenuItem key={u.id} value={u.id} disabled={u.role === 'admin'}>
<Checkbox checked={(modDialog ? modDialog.ids : []).includes(u.id)} />
<ListItemText primary={u.username} secondary={u.role === 'admin' ? '管理员' : ''} />
</MenuItem>
))}
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={() => setModDialog(null)}>取消</Button>
<Button variant="contained" onClick={saveMods} disabled={modSaving}>保存</Button>
</DialogActions>
</Dialog>
{/* 公告快捷编辑 */}
<Dialog open={!!annDialog} onClose={() => setAnnDialog(null)} fullWidth maxWidth="sm">
<DialogTitle>编辑公告</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
版块{annDialog ? annDialog.name : ''}顶部公告
</Typography>
<TextField
fullWidth
multiline
rows={3}
label="公告内容(留空清除)"
value={annDialog ? annDialog.text : ''}
onChange={(e) => setAnnDialog((p) => (p ? { ...p, text: e.target.value } : p))}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setAnnDialog(null)}>取消</Button>
<Button variant="contained" onClick={saveAnn} disabled={annSaving}>保存</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定删除板块「${confirm ? confirm.name : ''}」?板块下的帖子将一并删除`}
+11 -4
View File
@@ -103,14 +103,21 @@ export default function Homepage() {
<Box sx={{ maxWidth: 720 }}>
<Typography variant="h5" sx={{ mb: 2 }}>首页设置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-profile" 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="图标名"
value={c.icon}
onChange={updateContact(i, 'icon')}
sx={{ width: 150 }}
helperText="品牌图标 qq/bilibili/telegram/github/gitea/wechat,或 Material Icons 名(如 email/link/rss_feed"
/>
<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>
@@ -119,7 +126,7 @@ export default function Homepage() {
<Button size="small" startIcon={<AddIcon />} onClick={addContact}>添加链接</Button>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-home-content" 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' }}>
@@ -131,7 +138,7 @@ export default function Homepage() {
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-music" 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 代码" />
+135 -5
View File
@@ -21,22 +21,52 @@ 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 Autocomplete from '@mui/material/Autocomplete';
import { listAdminLinks, createAdminLink, updateAdminLink, deleteAdminLink } from '../../api/adminLinks.js';
import { getSettings, saveSettings } from '../../api/settings.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',
slug: '', trusted: true, permissions: [], scale: '1',
};
/** 面板链接管理:CRUD + 代理嵌入开关(迁移自 v1 面板链接卡片 */
/* 面板代理配置字段的展示与编辑选项(与工作台 PanelFrame 的委托权限一致 */
const PERMISSION_OPTIONS = [
{ value: 'camera', label: '摄像头' },
{ value: 'microphone', label: '麦克风' },
{ value: 'geolocation', label: '定位' },
{ value: 'clipboard-read', label: '剪贴板读取' },
{ value: 'clipboard-write', label: '剪贴板写入' },
{ value: 'payment', label: '支付' },
{ value: 'usb', label: 'USB' },
{ value: 'serial', label: '串口' },
{ value: 'notifications', label: '通知' },
];
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
function parsePermissions(raw) {
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
try {
const a = JSON.parse(raw);
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
} catch { return []; }
}
return [];
}
/** 面板链接管理:CRUD + 代理嵌入开关 + 信任模式/缩放/权限 + 面板代理白名单 */
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 [proxyHosts, setProxyHosts] = useState('');
const [proxySaving, setProxySaving] = useState(false);
const load = useCallback(() => {
listAdminLinks()
@@ -44,7 +74,10 @@ export default function Links() {
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
useEffect(() => {
load();
getSettings().then((s) => setProxyHosts(s.proxy_allowed_hosts || '')).catch(() => {});
}, [load]);
const openAdd = () => { setEditingId(null); setForm(EMPTY); setDialog(true); };
const openEdit = (l) => {
@@ -54,6 +87,10 @@ export default function Links() {
url: l.url,
embed_url: l.embed_url || '',
use_proxy: !!l.use_proxy,
slug: l.slug || '',
trusted: !(l.trusted === 0 || l.trusted === '0'),
permissions: parsePermissions(l.permissions),
scale: String(parseFloat(l.scale) || 1),
description: l.description || '',
icon: l.icon || '',
category: l.category || '默认',
@@ -70,6 +107,10 @@ export default function Links() {
url: form.url.trim(),
embed_url: form.embed_url.trim(),
use_proxy: form.use_proxy ? 1 : 0,
slug: form.slug.trim(),
trusted: form.trusted ? 1 : 0,
permissions: JSON.stringify(form.permissions),
scale: parseFloat(form.scale) || 1,
description: form.description.trim(),
icon: form.icon.trim(),
category: form.category.trim() || '默认',
@@ -99,6 +140,17 @@ export default function Links() {
}
};
const saveProxy = async () => {
setProxySaving(true);
try {
await saveSettings({ proxy_allowed_hosts: proxyHosts });
showSnack('面板代理设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setProxySaving(false);
};
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
@@ -115,24 +167,43 @@ export default function Links() {
<TableCell>版本</TableCell>
<TableCell>URL</TableCell>
<TableCell>嵌入URL</TableCell>
<TableCell>代理配置</TableCell>
<TableCell>分类</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{links === null ? (
<TableRow><TableCell colSpan={7}>加载中...</TableCell></TableRow>
<TableRow><TableCell colSpan={8}>加载中...</TableCell></TableRow>
) : links.length === 0 ? (
<TableRow><TableCell colSpan={7}>暂无面板</TableCell></TableRow>
<TableRow><TableCell colSpan={8}>暂无面板</TableCell></TableRow>
) : links.map((l) => (
<TableRow key={l.id}>
<TableCell>{l.sort_order}</TableCell>
<TableCell><Box sx={{ fontWeight: 600 }}>{l.title}</Box></TableCell>
<TableCell>
<Box sx={{ fontWeight: 600 }}>{l.title}</Box>
{l.slug && (
<Box component="span" sx={{ color: 'text.secondary', fontSize: 12, fontFamily: 'monospace' }}>/{l.slug}/</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>
{l.use_proxy ? (
<Chip
size="small"
label={l.trusted === 0 || l.trusted === '0' ? '代理·安全' : '代理·可信'}
color={l.trusted === 0 || l.trusted === '0' ? 'default' : 'success'}
variant="outlined"
/>
) : <Chip size="small" label="直嵌" variant="outlined" />}
{parseFloat(l.scale) > 0 && parseFloat(l.scale) !== 1 && (
<Box component="span" sx={{ ml: 0.75, color: 'text.secondary', fontSize: 12 }}>缩放 {parseFloat(l.scale)}x</Box>
)}
</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>
@@ -144,6 +215,27 @@ export default function Links() {
</Table>
</TableContainer>
<Paper id="sec-proxy" sx={{ p: 2.5, mt: 2, maxWidth: 720 }}>
<Typography variant="h6" sx={{ fontSize: 16, mb: 0.5 }}>面板代理</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5, fontSize: 13 }}>
通过面板代理嵌入面板绕过 X-Frame-Options 限制把可信内网地址/网段加入白名单
</Typography>
<TextField
fullWidth
label="代理允许的内网地址(白名单)"
value={proxyHosts}
onChange={(e) => setProxyHosts(e.target.value)}
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)与主机名;默认拦截所有内网地址,请谨慎配置。"
/>
<Box sx={{ mt: 1 }}>
<Button variant="contained" onClick={saveProxy} disabled={proxySaving}>保存面板代理设置</Button>
</Box>
</Paper>
<Dialog open={dialog} onClose={() => setDialog(false)} fullWidth maxWidth="sm">
<DialogTitle>{editingId ? '编辑面板' : '添加面板'}</DialogTitle>
<DialogContent>
@@ -151,6 +243,44 @@ export default function Links() {
<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 限制)" />
{form.use_proxy && (
<Box sx={{ mt: 1, mb: 1 }}>
<TextField
fullWidth
label="代理路径标识 slug"
value={form.slug}
onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))}
margin="dense"
placeholder="留空按标题自动生成"
helperText="仅小写字母/数字/连字符,≤32 字符;iframe 走 /proxy/{slug}/"
/>
<FormControlLabel
control={<Switch checked={form.trusted} onChange={(e) => setForm((p) => ({ ...p, trusted: e.target.checked }))} />}
label="可信模式(保留 allow-same-origin,目标站 cookie 登录态可用)"
/>
<TextField
label="缩放(0.13"
type="number"
value={form.scale}
onChange={(e) => setForm((p) => ({ ...p, scale: e.target.value }))}
margin="dense"
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
sx={{ maxWidth: 160 }}
/>
<Autocomplete
multiple
fullWidth
size="small"
options={PERMISSION_OPTIONS}
getOptionLabel={(o) => o.label}
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
renderInput={(params) => (
<TextField {...params} label="委托权限(可选,iframe allow 属性)" margin="dense" placeholder="摄像头 / 麦克风 / 定位等" />
)}
/>
</Box>
)}
<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="默认" />
+188
View File
@@ -0,0 +1,188 @@
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 TextField from '@mui/material/TextField';
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 Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
import PushPinIcon from '@mui/icons-material/PushPin';
import StarIcon from '@mui/icons-material/Star';
import { listPosts, listCategories, setPinned, setEssence, deletePost } from '../../api/forum.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
const PAGE_SIZE = 20;
/**
* 帖子管理(admin,路由 /admin/posts):
* 跨版块搜索(?q=)+ 版块筛选 + 表格(标题/版块/作者/时间/置顶/精华)+ 置顶/加精/删除 + 分页。
*/
export default function PostManage() {
const [cats, setCats] = useState([]);
const [qInput, setQInput] = useState('');
const [catFilter, setCatFilter] = useState('');
const [page, setPage] = useState(1);
const [data, setData] = useState(null); // { list, total, page, pageSize }
const [confirm, setConfirm] = useState(null); // { id, title }
const [busy, setBusy] = useState(true);
const load = useCallback(() => {
setBusy(true);
listPosts({ categoryId: catFilter || undefined, page, q: qInput.trim() || undefined })
.then((d) => setData(Array.isArray(d) ? { list: d, total: d.length, page: 1, pageSize: PAGE_SIZE } : d))
.catch((e) => showSnack(e.message, 'error'))
.finally(() => setBusy(false));
}, [catFilter, page, qInput]);
useEffect(() => {
listCategories()
.then((cs) => setCats(cs || []))
.catch(() => {});
}, []);
useEffect(() => { load(); }, [load]);
const search = () => setPage(1);
const changeCat = (v) => {
setCatFilter(v);
setPage(1);
};
const togglePin = async (p) => {
try {
await setPinned(p.id, !p.is_pinned);
showSnack(p.is_pinned ? '已取消置顶' : '已置顶');
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
const toggleEssence = async (p) => {
try {
await setEssence(p.id, !p.is_essence);
showSnack(p.is_essence ? '已取消加精' : '已加精');
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
const doDelete = async () => {
if (!confirm) return;
try {
await deletePost(confirm.id);
showSnack('已删除');
setConfirm(null);
load();
} catch (e) {
showSnack(e.message, 'error');
}
};
const posts = (data && data.list) || [];
const total = data ? data.total : 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const catNameOf = (cid) => (cats.find((c) => c.id === cid) || {}).name;
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1, mb: 2 }}>
<Typography variant="h5">帖子管理</Typography>
</Box>
<Paper sx={{ p: 2, mb: 2, display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
label="搜索标题/内容"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') search(); }}
sx={{ flex: 1, minWidth: 220, maxWidth: 360 }}
/>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>版块</InputLabel>
<Select value={catFilter} onChange={(e) => changeCat(e.target.value)} label="版块">
<MenuItem value="">全部版块</MenuItem>
{cats.map((c) => <MenuItem key={c.id} value={String(c.id)}>{c.name}</MenuItem>)}
</Select>
</FormControl>
<Button variant="contained" onClick={search}>搜索</Button>
</Paper>
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>标题</TableCell>
<TableCell>版块</TableCell>
<TableCell>作者</TableCell>
<TableCell>时间</TableCell>
<TableCell align="center">📌</TableCell>
<TableCell align="center"></TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{posts.length === 0 ? (
<TableRow><TableCell colSpan={7}>{busy ? '加载中...' : '暂无帖子'}</TableCell></TableRow>
) : posts.map((p) => (
<TableRow key={p.id}>
<TableCell>
<Box sx={{ fontWeight: 600, maxWidth: 360, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.title}</Box>
</TableCell>
<TableCell>
<Chip size="small" label={catNameOf(p.category_id) || `#${p.category_id}`} variant="outlined" />
</TableCell>
<TableCell sx={{ color: 'text.secondary' }}>{p.author_name || '匿名'}</TableCell>
<TableCell sx={{ color: 'text.secondary', fontSize: 13 }}>{p.created_at}</TableCell>
<TableCell align="center">
<IconButton size="small" color={p.is_pinned ? 'primary' : 'default'} onClick={() => togglePin(p)} title={p.is_pinned ? '取消置顶' : '置顶'}>
<PushPinIcon fontSize="small" />
</IconButton>
</TableCell>
<TableCell align="center">
<IconButton size="small" color={p.is_essence ? 'warning' : 'default'} onClick={() => toggleEssence(p)} title={p.is_essence ? '取消加精' : '加精'}>
<StarIcon fontSize="small" />
</IconButton>
</TableCell>
<TableCell align="right" sx={{ whiteSpace: 'nowrap' }}>
<IconButton size="small" color="error" onClick={() => setConfirm({ id: p.id, title: p.title })}><DeleteIcon fontSize="small" /></IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{totalPages > 1 && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 1.5, mt: 2 }}>
<Button size="small" variant="outlined" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>上一页</Button>
<Typography variant="body2" color="text.secondary"> {page} / {totalPages} {total} </Typography>
<Button size="small" variant="outlined" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>下一页</Button>
</Box>
)}
<ConfirmDialog
open={!!confirm}
message={`确定要删除帖子 "${confirm ? confirm.title : ''}" 吗?回复将一并删除`}
onClose={() => setConfirm(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
</Box>
);
}
+240
View File
@@ -0,0 +1,240 @@
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 Switch from '@mui/material/Switch';
import FormControlLabel from '@mui/material/FormControlLabel';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import IconButton from '@mui/material/IconButton';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import RssFeedIcon from '@mui/icons-material/RssFeed';
import { getSettings, saveSettings } from '../../api/settings.js';
import { listCategories, updateCategoryProfile } from '../../api/forum.js';
import { showSnack } from '../snack.jsx';
/** 完整订阅 URL(运行时拼 origin,不硬编码域名) */
function feedUrl(path) {
return window.location.origin + path;
}
/** 版块图标小方块:icon 非 http 渲染 emoji/首字 */
function CatIcon({ c }) {
const isImg = /^https?:\/\//i.test(c.icon || '');
const letter = isImg ? (c.name || '?').charAt(0) : (c.icon || (c.name || '?').charAt(0));
return (
<Box
sx={{
width: 30, height: 30, borderRadius: 2, flexShrink: 0, fontWeight: 700, fontSize: 15,
display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden',
background: c.icon_color || 'rgba(0,0,0,0.08)', color: '#fff',
}}
>
{letter}
</Box>
);
}
/**
* RSS 订阅管理(admin,路由 /admin/rss):
* 1) 源管理:博客(常开只读)/ 论坛全站(feed_forum_enabled/ 各版块(feed_enabled,即时保存)
* 2) 内容设置:feed_show_full(全文/摘要)+ feed_max_items1-100)→ saveSettings
*/
export default function RssManage() {
const [cats, setCats] = useState([]);
const [forumFeed, setForumFeed] = useState(true);
const [showFull, setShowFull] = useState('0');
const [maxItems, setMaxItems] = useState('20');
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
getSettings()
.then((s) => {
setForumFeed(s.feed_forum_enabled !== '0'); // 默认开启
setShowFull(s.feed_show_full === '1' ? '1' : '0');
setMaxItems(s.feed_max_items || '20');
})
.catch((e) => showSnack(e.message, 'error'));
listCategories()
.then((cs) => setCats(cs || []))
.catch((e) => showSnack(e.message, 'error'));
}, []);
useEffect(() => { load(); }, [load]);
const copyUrl = async (url) => {
try {
await navigator.clipboard.writeText(url);
showSnack('链接已复制');
} catch {
showSnack('复制失败,请手动复制', 'error');
}
};
// 论坛全站开关:即时保存
const toggleForumFeed = async (next) => {
setForumFeed(next);
try {
await saveSettings({ feed_forum_enabled: next ? '1' : '0' });
showSnack(next ? '已开启论坛订阅' : '已关闭论坛订阅');
} catch (e) {
setForumFeed(!next);
showSnack(e.message, 'error');
}
};
// 版块级开关:即时保存(feed_enabled 已进 profile 白名单)
const toggleCatFeed = async (cat, next) => {
const prev = cat.feed_enabled !== 0 && cat.feed_enabled !== '0';
setCats((cs) => cs.map((c) => (c.id === cat.id ? { ...c, feed_enabled: next ? 1 : 0 } : c)));
try {
await updateCategoryProfile(cat.id, { feed_enabled: next ? '1' : '0' });
showSnack(next ? `已开启「${cat.name}」订阅` : `已关闭「${cat.name}」订阅`);
} catch (e) {
setCats((cs) => cs.map((c) => (c.id === cat.id ? { ...c, feed_enabled: prev ? 1 : 0 } : c)));
showSnack(e.message, 'error');
}
};
const saveContent = async () => {
const n = parseInt(maxItems, 10);
if (isNaN(n) || n < 1 || n > 100) { showSnack('条目数需为 1-100 的数字', 'error'); return; }
setSaving(true);
try {
await saveSettings({ feed_show_full: showFull, feed_max_items: String(n) });
showSnack('内容设置已保存');
} catch (e) {
showSnack(e.message, 'error');
}
setSaving(false);
};
/** 源行:名称/描述 + URL(复制)+ 开关 */
const SourceRow = ({ name, desc, path, onToggle, checked, disabled, badge }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1.25, borderBottom: '1px solid', borderColor: 'divider', '&:last-of-type': { borderBottom: 'none' } }}>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 14 }}>{name}</Typography>
{badge}
</Box>
{desc && <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>{desc}</Typography>}
<Box component="code" sx={{
display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5,
fontSize: 12, color: 'text.secondary', fontFamily: 'monospace',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{feedUrl(path)}
<IconButton size="small" onClick={() => copyUrl(feedUrl(path))} title="复制链接" sx={{ flexShrink: 0 }}>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
{disabled ? (
<Chip size="small" label="始终启用" variant="outlined" sx={{ flexShrink: 0 }} />
) : (
<FormControlLabel
control={<Switch checked={checked} onChange={(e) => onToggle(e.target.checked)} />}
label=""
sx={{ m: 0, flexShrink: 0 }}
/>
)}
</Box>
);
return (
<Box sx={{ maxWidth: 720 }}>
<Typography variant="h5" sx={{ mb: 0.5 }}>RSS 订阅</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2, fontSize: 13 }}>
管理各位置的订阅源订阅器 Feedly / Inoreader可关注这些链接获取最新内容
</Typography>
{/* 源管理 */}
<Paper id="sec-rss-sources" sx={{ p: 2.5, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<RssFeedIcon fontSize="small" sx={{ color: 'primary.main' }} />
<Typography variant="h6" sx={{ fontSize: 16 }}>订阅源</Typography>
</Box>
<SourceRow
name="博客"
desc="已发布文章(全文/摘要按下方内容设置)"
path="/feed.xml"
disabled
/>
<SourceRow
name="论坛(全站)"
desc="全部版块的最新帖子"
path="/feed/forum.xml"
checked={forumFeed}
onToggle={toggleForumFeed}
/>
{cats.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
版块级订阅关闭后该版块不进入全站源其独立源返回 404
</Typography>
{cats.map((c) => {
const on = c.feed_enabled !== 0 && c.feed_enabled !== '0';
return (
<Box key={c.id} sx={{ display: 'flex', alignItems: 'center', gap: 1.5, py: 1, borderBottom: '1px solid', borderColor: 'divider' }}>
<CatIcon c={c} />
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: 14 }}>{c.name}</Typography>
<Box component="code" sx={{
display: 'flex', alignItems: 'center', gap: 0.5,
fontSize: 12, color: 'text.secondary', fontFamily: 'monospace',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{feedUrl(`/feed/forum/c/${c.id}.xml`)}
<IconButton size="small" onClick={() => copyUrl(feedUrl(`/feed/forum/c/${c.id}.xml`))} title="复制链接" sx={{ flexShrink: 0 }}>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</Box>
<FormControlLabel
control={<Switch checked={on} onChange={(e) => toggleCatFeed(c, e.target.checked)} />}
label=""
sx={{ m: 0, flexShrink: 0 }}
/>
</Box>
);
})}
</Box>
)}
</Paper>
{/* 内容设置 */}
<Paper id="sec-rss-content" sx={{ p: 2.5 }}>
<Typography variant="h6" sx={{ mb: 1, fontSize: 16 }}>内容设置</Typography>
<FormControlLabel
control={<Switch checked={showFull === '1'} onChange={(e) => setShowFull(e.target.checked ? '1' : '0')} />}
label={(
<Box sx={{ py: 0.5 }}>
<Box sx={{ fontSize: 14, fontWeight: 500 }}>输出全文</Box>
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>关闭则仅输出摘要博客用文章摘要论坛用内容截断</Box>
</Box>
)}
/>
<TextField
label="每条源最大条目数"
type="number"
value={maxItems}
onChange={(e) => setMaxItems(e.target.value)}
inputProps={{ min: 1, max: 100 }}
helperText="1-100,默认 20"
sx={{ mt: 1.5, maxWidth: 220 }}
/>
<Box sx={{ mt: 2 }}>
<Button variant="contained" onClick={saveContent} disabled={saving}>
{saving ? '保存中…' : '保存内容设置'}
</Button>
</Box>
</Paper>
</Box>
);
}
+243 -35
View File
@@ -10,8 +10,17 @@ 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 AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import FileUploadIcon from '@mui/icons-material/FileUpload';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import { getSettings, saveSettings } from '../../api/settings.js';
import { uploadVerifyFile, listVerifyFiles, deleteVerifyFile } from '../../api/upload.js';
import { showSnack } from '../snack.jsx';
import { parseFooterColumns } from '../../lib/outlink.js';
/** 页脚样式选项(与前台 Footer.jsx 渲染一致) */
const FOOTER_STYLES = [
@@ -20,7 +29,7 @@ const FOOTER_STYLES = [
{ value: 'glass', label: '玻璃卡片', desc: '磨砂玻璃卡片,与玻璃导航质感呼应' },
];
/** 基本设置 + 页脚设置(v2:页脚样式 / 版权 / Powered by 均支持自定义 */
/** 基本设置 + 页脚设置 + 搜索引擎验证 + RainID 单点登录(v2 */
export default function Settings() {
const [form, setForm] = useState({
site_name: '',
@@ -31,14 +40,64 @@ export default function Settings() {
footer_copyright: '',
footer_powered: '',
footer_desc: '',
comment_moderate: '0',
comment_notify: '0',
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);
// 页脚栏目(footer_columns JSON):独立 state 维护,保存时序列化进 form
const [columns, setColumns] = useState([]);
// 搜索引擎验证文件:列表 / 待上传文件 / 上传状态 / 上传成功结果
const [verifyFiles, setVerifyFiles] = useState([]);
const [verifyFile, setVerifyFile] = useState(null);
const [verifyUploading, setVerifyUploading] = useState(false);
const [verifyResult, setVerifyResult] = useState(null);
const loadVerifyFiles = () => {
listVerifyFiles().then(setVerifyFiles).catch((e) => showSnack(e.message, 'error'));
};
const handleVerifyFileChange = (e) => {
setVerifyFile((e.target.files && e.target.files[0]) || null);
setVerifyResult(null);
e.target.value = ''; // 清空 input 允许重复选择同一文件
};
const handleVerifyUpload = async () => {
if (!verifyFile) { showSnack('请先选择验证文件', 'error'); return; }
setVerifyUploading(true);
try {
const r = await uploadVerifyFile(verifyFile);
setVerifyResult(r);
setVerifyFile(null);
loadVerifyFiles();
showSnack('上传成功');
} catch (e) {
showSnack(e.message, 'error');
}
setVerifyUploading(false);
};
const handleVerifyDelete = async (name) => {
if (!window.confirm(`确认删除验证文件 ${name}`)) return;
try {
await deleteVerifyFile(name);
loadVerifyFiles();
showSnack('已删除');
} catch (e) {
showSnack(e.message, 'error');
}
};
useEffect(() => {
getSettings()
.then((s) => setForm({
.then((s) => {
setForm({
site_name: s.site_name || '',
site_description: s.site_description || '',
site_url: s.site_url || '',
@@ -47,10 +106,17 @@ export default function Settings() {
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',
}))
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',
});
setColumns(parseFooterColumns(s.footer_columns)); // 页脚栏目(同一次响应)
})
.catch((e) => showSnack(e.message, 'error'));
loadVerifyFiles(); // 加载已上传的验证文件
}, []);
const set = (k) => (e) => setForm((p) => ({ ...p, [k]: e.target.value }));
@@ -59,7 +125,10 @@ export default function Settings() {
const save = async () => {
setSaving(true);
try {
await saveSettings(form);
const body = { ...form, footer_columns: JSON.stringify(columns) };
// secret 空值处理:留空 = 不修改(后端不回读、空串会覆盖已有值,故从提交中移除该 key)
if (!body.rainid_client_secret) delete body.rainid_client_secret;
await saveSettings(body);
showSnack('设置已保存');
} catch (e) {
showSnack(e.message, 'error');
@@ -67,44 +136,34 @@ export default function Settings() {
setSaving(false);
};
// ── 页脚栏目编辑器(简单版:无拖拽排序;最多 3 栏并行) ──
const MAX_COLUMNS = 3;
const addColumn = () => {
if (columns.length >= MAX_COLUMNS) { showSnack(`最多添加 ${MAX_COLUMNS}`, 'error'); return; }
setColumns((cs) => [...cs, { title: '', links: [{ label: '', url: '' }] }]);
};
const removeColumn = (i) => setColumns((cs) => cs.filter((_, idx) => idx !== i));
const setColumnTitle = (i, v) => setColumns((cs) => cs.map((c, idx) => (idx === i ? { ...c, title: v } : c)));
const addLink = (ci) => setColumns((cs) => cs.map((c, idx) => (idx === ci ? { ...c, links: [...c.links, { label: '', url: '' }] } : c)));
const removeLink = (ci, li) => setColumns((cs) => cs.map((c, idx) => (idx === ci ? { ...c, links: c.links.filter((_, lIdx) => lIdx !== li) } : c)));
const setLink = (ci, li, k, v) => setColumns((cs) => cs.map((c, idx) => (idx === ci
? { ...c, links: c.links.map((l, lIdx) => (lIdx === li ? { ...l, [k]: v } : l)) }
: c)));
const footStyle = form.footer_style;
return (
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>站点设置</Typography>
<Paper sx={{ p: 3 }}>
<Paper id="sec-basic" 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 }}>
<Paper id="sec-footer" sx={{ p: 3, mt: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>页脚设置</Typography>
<FormControl component="fieldset" sx={{ mb: 1 }}>
@@ -131,8 +190,50 @@ export default function Settings() {
<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>
{/* 页脚栏目编辑器(简单版) */}
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>页脚栏目</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
链接以 / 开头为站内页http(s) 开头为外链经跳转确认页留空使用默认栏目
</Typography>
{columns.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>暂无自定义栏目点击添加栏目创建</Typography>
)}
{columns.map((col, ci) => (
<Box key={ci} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, p: 1.5, mb: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
<TextField size="small" fullWidth label={`栏目 ${ci + 1} 名称`} value={col.title} onChange={(e) => setColumnTitle(ci, e.target.value)} />
<IconButton size="small" color="error" onClick={() => removeColumn(ci)} aria-label={`删除栏目 ${ci + 1}`}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
{(col.links || []).map((l, li) => (
<Box key={li} sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1 }}>
<TextField size="small" label="链接文字" value={l.label} onChange={(e) => setLink(ci, li, 'label', e.target.value)} sx={{ flex: 1, minWidth: 100 }} />
<TextField size="small" label="链接地址" value={l.url} onChange={(e) => setLink(ci, li, 'url', e.target.value)} placeholder="/ 开头站内 或 https://" sx={{ flex: 1.4, minWidth: 160 }} />
<IconButton size="small" color="error" onClick={() => removeLink(ci, li)} aria-label="删除链接">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => addLink(ci)}>添加链接</Button>
</Box>
))}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<AddIcon />}
onClick={addColumn}
disabled={columns.length >= MAX_COLUMNS}
>
添加栏目{columns.length}/{MAX_COLUMNS}
</Button>
</Box>
</Box>
</>
) : (
<>
@@ -142,6 +243,113 @@ export default function Settings() {
)}
</Paper>
<Paper id="sec-search-verify" sx={{ p: 3, mt: 2 }}>
<Typography variant="body2" sx={{ mb: 1.5, color: 'text.secondary', fontSize: 13 }}>
Bing / Google / Yandex 站长工具要求把验证文件上传到网站根目录上传后即可经根路径访问
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 1 }}>
<Button component="label" variant="outlined" startIcon={<FileUploadIcon />} disabled={verifyUploading}>
选择文件
<input type="file" hidden accept=".xml,.html,.txt" onChange={handleVerifyFileChange} />
</Button>
{verifyFile && (
<Typography variant="body2" sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{verifyFile.name}
</Typography>
)}
<Button variant="contained" size="small" onClick={handleVerifyUpload} disabled={!verifyFile || verifyUploading}>
{verifyUploading ? '上传中…' : '上传'}
</Button>
</Box>
{verifyResult && (
<Box sx={{ mb: 1, fontSize: 13, color: 'success.main' }}>
上传成功可访问
<a href={verifyResult.url} target="_blank" rel="noopener noreferrer" style={{ marginLeft: 4, color: 'inherit', textDecoration: 'underline' }}>
{window.location.origin}{verifyResult.url}
</a>
</Box>
)}
{verifyFiles.length > 0 && (
<Box sx={{ mt: 1 }}>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>已上传的验证文件</Typography>
{verifyFiles.map((f) => (
<Box key={f.name} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5 }}>
<a
href={f.url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{f.name}
</a>
<IconButton size="small" color="error" onClick={() => handleVerifyDelete(f.name)} aria-label={`删除 ${f.name}`}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
))}
</Box>
)}
</Paper>
<Paper id="sec-rainid" sx={{ p: 3, mt: 2 }}>
<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>
+13 -5
View File
@@ -19,6 +19,7 @@ export default function ThemeSettings() {
primary_color: '#6750a4',
theme_wallpaper: '',
theme_wallpaper_scale: 'cover',
theme_wallpaper_enabled: true,
nav_style: 'default',
card_style: 'default',
glass_blur: '20',
@@ -34,6 +35,7 @@ export default function ThemeSettings() {
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',
@@ -65,6 +67,7 @@ export default function ThemeSettings() {
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,
@@ -82,7 +85,7 @@ export default function ThemeSettings() {
<Box sx={{ maxWidth: 640 }}>
<Typography variant="h5" sx={{ mb: 2 }}>主题设置</Typography>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-primary-color" 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' }} />
@@ -90,8 +93,13 @@ export default function ThemeSettings() {
</Box>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-wallpaper" 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} />
@@ -111,7 +119,7 @@ export default function ThemeSettings() {
</TextField>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-styles" 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 }}>
@@ -126,7 +134,7 @@ export default function ThemeSettings() {
</ToggleButtonGroup>
</Paper>
<Paper sx={{ p: 3, mb: 2 }}>
<Paper id="sec-glass" 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 }} />
@@ -134,7 +142,7 @@ export default function ThemeSettings() {
<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 }}>
<Paper id="sec-dark" 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 }))} />}
+291
View File
@@ -0,0 +1,291 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Grid from '@mui/material/Grid';
import Typography from '@mui/material/Typography';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import CircularProgress from '@mui/material/CircularProgress';
import Alert from '@mui/material/Alert';
import Avatar from '@mui/material/Avatar';
import RefreshIcon from '@mui/icons-material/Refresh';
import SendIcon from '@mui/icons-material/Send';
import NavigateBeforeIcon from '@mui/icons-material/NavigateBefore';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { normalizeTicketListResponse } from '../../api/tickets.js';
import { request } from '../../api/client.js';
import { showSnack } from '../snack.jsx';
const PAGE_SIZE = 15;
const STATUS = {
open: { label: '待处理', color: 'warning' },
processing: { label: '处理中', color: 'info' },
waiting: { label: '等待用户', color: 'secondary' },
resolved: { label: '已解决', color: 'success' },
closed: { label: '已关闭', color: 'default' },
};
const STATUS_TRANSITIONS = {
open: ['processing', 'closed'],
processing: ['waiting', 'resolved', 'closed'],
waiting: ['processing', 'closed'],
resolved: ['closed', 'processing'],
closed: ['processing'],
};
const PRIORITY = {
low: { label: '低', color: 'default' },
normal: { label: '普通', color: 'info' },
high: { label: '高', color: 'warning' },
urgent: { label: '紧急', color: 'error' },
};
const CATEGORY = {
forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '功能建议',
account: '账号问题', other: '其他',
};
function fmtTime(value) {
return value ? String(value).replace('T', ' ').slice(0, 16) : '—';
}
function clip(value, size = 80) {
const text = String(value || '').replace(/\s+/g, ' ').trim();
return text.length > size ? `${text.slice(0, size)}` : text;
}
function StatusChip({ value }) {
const item = STATUS[value] || { label: value || '未知', color: 'default' };
return <Chip size="small" label={item.label} color={item.color} variant={value === 'closed' ? 'outlined' : 'filled'} />;
}
function PriorityChip({ value }) {
const item = PRIORITY[value] || { label: value || '普通', color: 'default' };
return <Chip size="small" label={item.label} color={item.color} variant="outlined" />;
}
function StatCard({ label, value, tone }) {
return (
<Paper variant="outlined" sx={{ p: 1.5, minWidth: 105, flex: '1 1 130px' }}>
<Typography variant="caption" color="text.secondary">{label}</Typography>
<Typography variant="h5" sx={{ mt: 0.25, fontWeight: 700, color: tone ? `${tone}.main` : 'text.primary' }}>{value}</Typography>
</Paper>
);
}
export default function TicketManage() {
const [filters, setFilters] = useState({ q: '', status: '', priority: '', category: '' });
const [page, setPage] = useState(1);
const [list, setList] = useState([]);
const [total, setTotal] = useState(0);
const [stats, setStats] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [reply, setReply] = useState('');
const [internal, setInternal] = useState('');
const [assignees, setAssignees] = useState([]);
const [saveFeedback, setSaveFeedback] = useState({ type: '', text: '' });
const listRequestRef = useRef(0);
const statsRequestRef = useRef(0);
const detailRequestRef = useRef(0);
const selectedIdRef = useRef(null);
const loadStats = useCallback(() => {
const requestId = ++statsRequestRef.current;
request('/tickets/admin/stats').then((data) => {
if (requestId === statsRequestRef.current) setStats(data || {});
}).catch(() => {});
}, []);
const loadList = useCallback(() => {
const requestId = ++listRequestRef.current;
setLoading(true); setError('');
const params = new URLSearchParams({ page: String(page), pageSize: String(PAGE_SIZE) });
Object.entries(filters).forEach(([key, value]) => { if (value) params.set(key, value); });
request(`/tickets/admin?${params.toString()}`)
.then((data) => {
if (requestId !== listRequestRef.current) return;
const normalized = normalizeTicketListResponse(data, PAGE_SIZE);
setList(normalized.tickets); setTotal(normalized.total);
})
.catch((e) => {
if (requestId === listRequestRef.current) setError(e.message || '工单加载失败');
})
.finally(() => {
if (requestId === listRequestRef.current) setLoading(false);
});
}, [filters, page]);
const loadDetail = useCallback((id) => {
if (!id) return;
const changed = String(selectedIdRef.current) !== String(id);
selectedIdRef.current = id;
const requestId = ++detailRequestRef.current;
setSelectedId(id); setDetailLoading(true);
setDetail(null);
if (changed) {
setReply(''); setInternal('');
setSaveFeedback({ type: '', text: '' });
}
request(`/tickets/${encodeURIComponent(id)}`)
.then((data) => {
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) {
setDetail(data.ticket ? data : { ticket: data, messages: [], events: [] });
}
})
.catch((e) => {
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) showSnack(e.message || '详情加载失败', 'error');
})
.finally(() => {
if (requestId === detailRequestRef.current && String(selectedIdRef.current) === String(id)) setDetailLoading(false);
});
}, []);
useEffect(() => { loadList(); }, [loadList]);
useEffect(() => { loadStats(); }, [loadStats]);
useEffect(() => {
request('/tickets/admin/assignees').then((data) => setAssignees(Array.isArray(data) ? data : (data.users || data.list || []))).catch((e) => setSaveFeedback({ type: 'error', text: e.message || '负责人列表加载失败' }));
}, []);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const selectedTicket = detail && detail.ticket;
const normalizedAssignees = useMemo(() => {
const values = assignees
.filter((user) => user && (user.role === 'admin' || user.role === undefined))
.map((user) => ({
...user,
id: String(user.id),
displayName: user.nickname || user.username || user.name || `管理员 #${user.id}`,
}));
if (selectedTicket?.assignee_id != null && !values.some((user) => user.id === String(selectedTicket.assignee_id))) {
values.push({
id: String(selectedTicket.assignee_id),
displayName: selectedTicket.assignee_name || selectedTicket.assignee_username || `负责人 #${selectedTicket.assignee_id}`,
});
}
return values;
}, [assignees, selectedTicket]);
const updateFilter = (key, value) => { setPage(1); setFilters((old) => ({ ...old, [key]: value })); };
const refresh = () => { loadList(); loadStats(); if (selectedId) loadDetail(selectedId); };
const updateTicket = async (path, body, message, ticketId) => {
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
setSaving(true);
setSaveFeedback({ type: 'saving', text: '正在保存…' });
try {
await request(path, { method: 'PUT', body });
if (String(selectedIdRef.current) !== String(ticketId)) return;
setSaveFeedback({ type: 'success', text: '已保存' });
showSnack(message); loadDetail(ticketId); loadList(); loadStats();
} catch (e) {
if (String(selectedIdRef.current) === String(ticketId)) {
setSaveFeedback({ type: 'error', text: e.message || '保存失败,请重试' });
showSnack(e.message || '保存失败', 'error');
}
} finally { setSaving(false); }
};
const sendMessage = async (internalMessage, ticketId) => {
if (!ticketId || String(selectedIdRef.current) !== String(ticketId)) return;
const content = (internalMessage ? internal : reply).trim();
if (!content) return;
setSaving(true);
try {
await request(`/tickets/${ticketId}/${internalMessage ? 'internal-messages' : 'messages'}`, { method: 'POST', body: { content } });
if (String(selectedIdRef.current) !== String(ticketId)) return;
if (internalMessage) setInternal(''); else setReply('');
showSnack(internalMessage ? '内部备注已添加' : '公开回复已发送');
loadDetail(ticketId); loadList(); loadStats();
} catch (e) {
if (String(selectedIdRef.current) === String(ticketId)) showSnack(e.message || '发送失败', 'error');
}
finally { setSaving(false); }
};
const messages = detail?.messages || [];
const events = detail?.events || [];
const timeline = useMemo(() => [
...messages.map((item) => ({ type: 'message', item })),
...events.map((item) => ({ type: 'event', item })),
].sort((a, b) => {
const timeA = Date.parse(String(a.item.created_at || '').replace(' ', 'T'));
const timeB = Date.parse(String(b.item.created_at || '').replace(' ', 'T'));
const timeDiff = (Number.isNaN(timeA) ? Number.MAX_SAFE_INTEGER : timeA)
- (Number.isNaN(timeB) ? Number.MAX_SAFE_INTEGER : timeB);
if (timeDiff) return timeDiff;
const createdDiff = String(a.item.created_at || '').localeCompare(String(b.item.created_at || ''));
if (createdDiff) return createdDiff;
const idA = Number(a.item.id);
const idB = Number(b.item.id);
if (Number.isFinite(idA) && Number.isFinite(idB) && idA !== idB) return idA - idB;
return a.type === b.type ? 0 : a.type === 'event' ? 1 : -1;
}), [messages, events]);
const currentStatus = selectedTicket?.status || 'open';
const statusOptions = [currentStatus, ...(STATUS_TRANSITIONS[currentStatus] || [])]
.filter((value, index, values) => STATUS[value] && values.indexOf(value) === index);
const statItems = useMemo(() => [
['全部', stats.total || total, null], ['待处理', stats.open || 0, 'warning'],
['处理中', stats.processing || 0, 'info'], ['等待用户', stats.waiting || 0, 'secondary'],
['已解决', stats.resolved || 0, 'success'],
], [stats, total]);
return (
<Box component="main" className="ticket-workspace" aria-labelledby="ticket-page-title">
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Box><Typography id="ticket-page-title" variant="h5" component="h1">工单管理</Typography><Typography variant="body2" color="text.secondary">集中处理论坛和站内问题反馈</Typography></Box>
<IconButton aria-label="刷新工单" title="刷新" onClick={refresh} disabled={loading || saving}><RefreshIcon /></IconButton>
</Stack>
<Stack className="ticket-stats" direction="row" spacing={1.25} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
{statItems.map(([label, value, tone]) => <StatCard key={label} label={label} value={value} tone={tone} />)}
</Stack>
<Paper variant="outlined" sx={{ p: 1.5, mb: 2 }} component="form" onSubmit={(e) => { e.preventDefault(); if (page === 1) loadList(); else setPage(1); }}>
<Box className="ticket-filter-grid">
<TextField className="ticket-filter-search" fullWidth size="small" label="搜索工单" placeholder="编号、标题或内容" value={filters.q} onChange={(e) => updateFilter('q', e.target.value)} />
<TextField fullWidth select size="small" label="状态" value={filters.status} onChange={(e) => updateFilter('status', e.target.value)}><MenuItem value="">全部状态</MenuItem>{Object.entries(STATUS).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
<TextField fullWidth select size="small" label="优先级" value={filters.priority} onChange={(e) => updateFilter('priority', e.target.value)}><MenuItem value="">全部优先级</MenuItem>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField>
<TextField fullWidth select size="small" label="类型" value={filters.category} onChange={(e) => updateFilter('category', e.target.value)}><MenuItem value="">全部类型</MenuItem>{Object.entries(CATEGORY).map(([key, label]) => <MenuItem key={key} value={key}>{label}</MenuItem>)}</TextField>
<Button fullWidth type="submit" variant="contained" sx={{ minHeight: 40 }}>搜索</Button>
</Box>
</Paper>
{error && <Alert severity="error" role="alert" action={<Button color="inherit" size="small" onClick={loadList}>重试</Button>} sx={{ mb: 2 }}>{error}</Alert>}
<Box className="ticket-work-area">
<Box className="ticket-list-pane">
<Paper className="ticket-list-card" variant="outlined" sx={{ height: '100%', overflow: 'hidden' }}>
<Box sx={{ p: 1.5, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><Typography variant="subtitle1" fontWeight={700}>最近工单</Typography><Typography variant="caption" color="text.secondary"> {total} </Typography></Box>
<Divider />
{loading ? <Box role="status" aria-label="正在加载工单" sx={{ p: 4, textAlign: 'center' }}><CircularProgress size={28} /></Box> : list.length === 0 ? <Box sx={{ p: 4, textAlign: 'center' }}><Typography color="text.secondary">暂无匹配工单</Typography></Box> : (
<Stack component="ul" className="ticket-list-scroll" aria-label="工单列表" sx={{ listStyle: 'none', m: 0, p: 0 }}>
{list.map((ticket) => {
const subject = ticket.subject || ticket.title || '无标题工单';
const requester = `${ticket.requester_name || ticket.requester_username || '未知用户'} · ${fmtTime(ticket.updated_at || ticket.created_at)}`;
return <Box component="li" key={ticket.id}><Button fullWidth onClick={() => loadDetail(ticket.id)} aria-pressed={String(selectedId) === String(ticket.id)} sx={{ p: 1.25, textAlign: 'left', textTransform: 'none', justifyContent: 'flex-start', borderRadius: 0, borderBottom: '1px solid', borderColor: 'divider', bgcolor: String(selectedId) === String(ticket.id) ? 'action.selected' : 'transparent' }}><Box className="ticket-list-row" title={`${ticket.ticket_no || `#${ticket.id}`} · ${subject} · ${requester}`}><Typography className="ticket-list-number" variant="caption" color="text.secondary">{ticket.ticket_no || `#${ticket.id}`}</Typography><Typography className="ticket-list-subject" variant="body2" fontWeight={600}>{subject}</Typography><Typography className="ticket-list-requester" variant="caption" color="text.secondary">{requester}</Typography><StatusChip value={ticket.status} /><PriorityChip value={ticket.priority} /></Box></Button></Box>;
})}
</Stack>
)}
<Stack direction="row" alignItems="center" justifyContent="center" spacing={1} sx={{ p: 1.25 }}><IconButton aria-label="上一页" size="small" disabled={page <= 1 || loading} onClick={() => setPage(page - 1)}><NavigateBeforeIcon /></IconButton><Typography variant="caption"> {page} / {totalPages} </Typography><IconButton aria-label="下一页" size="small" disabled={page >= totalPages || loading} onClick={() => setPage(page + 1)}><NavigateNextIcon /></IconButton></Stack>
</Paper>
</Box>
<Box className="ticket-detail-pane">
<Paper className="ticket-detail-card" variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, minHeight: 420 }}>
{!selectedId ? <Box sx={{ py: 10, textAlign: 'center' }}><Typography variant="h6" color="text.secondary">选择一个工单开始处理</Typography><Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>工单详情回复和状态操作会显示在这里</Typography></Box> : detailLoading ? <Box role="status" aria-label="正在加载工单详情" sx={{ py: 10, textAlign: 'center' }}><CircularProgress /></Box> : selectedTicket ? (
<Stack spacing={2}>
<Box><Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap><Typography variant="h6" component="h2" sx={{ overflowWrap: 'anywhere' }}>{selectedTicket.subject || selectedTicket.title}</Typography><StatusChip value={selectedTicket.status} /><PriorityChip value={selectedTicket.priority} /></Stack><Typography variant="caption" color="text.secondary">{selectedTicket.ticket_no || `#${selectedTicket.id}`} · {selectedTicket.requester_name || selectedTicket.requester_username || '未知用户'} · 创建于 {fmtTime(selectedTicket.created_at)}</Typography></Box>
<Box><Grid container spacing={1.25}><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="状态" helperText="管理员可更新处理阶段" value={selectedTicket.status || 'open'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/status`, { status: e.target.value }, '状态已更新', selectedTicket.id)}>{statusOptions.map((key) => <MenuItem key={key} value={key}>{STATUS[key].label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="优先级" helperText="用于安排处理顺序" value={selectedTicket.priority || 'normal'} disabled={saving} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/priority`, { priority: e.target.value }, '优先级已更新', selectedTicket.id)}>{Object.entries(PRIORITY).map(([key, item]) => <MenuItem key={key} value={key}>{item.label}</MenuItem>)}</TextField></Grid><Grid item xs={12} sm={4}><TextField fullWidth select size="small" label="负责人" helperText="可选择管理员或设为未分配" value={selectedTicket.assignee_id == null ? '' : String(selectedTicket.assignee_id)} disabled={saving} SelectProps={{ renderValue: (value) => { const person = normalizedAssignees.find((user) => user.id === String(value)); return <Box component="span" className="ticket-select-value" title={person ? person.displayName : '未分配'}>{person ? person.displayName : '未分配'}</Box>; } }} onChange={(e) => updateTicket(`/tickets/${selectedTicket.id}/assignee`, { assignee_id: e.target.value ? Number(e.target.value) || e.target.value : null }, '负责人已更新', selectedTicket.id)}><MenuItem value="">未分配</MenuItem>{normalizedAssignees.map((user) => <MenuItem key={user.id} value={user.id} sx={{ whiteSpace: 'normal', overflowWrap: 'anywhere' }}>{user.displayName}</MenuItem>)}</TextField></Grid></Grid>{saveFeedback.text && <Typography className={`ticket-save-feedback ticket-save-${saveFeedback.type}`} variant="caption" role={saveFeedback.type === 'error' ? 'alert' : 'status'} sx={{ display: 'block', mt: 0.75 }}>{saveFeedback.text}</Typography>}</Box>
<Paper variant="outlined" sx={{ p: 1.5, bgcolor: 'action.hover' }}><Typography variant="subtitle2" gutterBottom>问题描述</Typography><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{selectedTicket.description || '暂无描述'}</Typography>{selectedTicket.source_url && <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1, overflowWrap: 'anywhere' }}>来源{selectedTicket.source_url}</Typography>}</Paper>
<Box><Typography variant="subtitle2" sx={{ mb: 1 }}>处理记录</Typography><Stack component="ol" spacing={1.25} sx={{ m: 0, pl: 2.5 }}>{timeline.map((entry) => { if (entry.type === 'message') { const message = entry.item; return <Box component="li" key={`m-${message.id}`}><Paper variant="outlined" sx={{ p: 1.25 }}><Stack direction="row" spacing={1} alignItems="center"><Avatar sx={{ width: 26, height: 26, fontSize: 12 }}>{String(message.author_name || message.author_username || '管')[0]}</Avatar><Typography variant="body2" fontWeight={600}>{message.author_name || message.author_username || '管理员'}</Typography>{message.is_internal ? <Chip size="small" label="内部备注" color="warning" /> : <Chip size="small" label="公开回复" variant="outlined" />}<Typography variant="caption" color="text.secondary" sx={{ ml: 'auto' }}>{fmtTime(message.created_at)}</Typography></Stack><Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', mt: 1 }}>{message.content}</Typography></Paper></Box>; } const event = entry.item; return <Box component="li" key={`e-${event.id}`}><Typography variant="caption" color="text.secondary">{fmtTime(event.created_at)} · {event.detail || `${event.field_name || '工单'}已更新`}</Typography></Box>; })}</Stack></Box>
<Divider />
<Grid container spacing={1.5}><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="公开回复" placeholder="回复内容会发送给用户" value={reply} disabled={saving} onChange={(e) => setReply(e.target.value)} /><Button sx={{ mt: 1 }} variant="contained" startIcon={<SendIcon />} disabled={saving || !reply.trim()} onClick={() => sendMessage(false, selectedTicket.id)}>发送公开回复</Button></Grid><Grid item xs={12} md={6}><TextField fullWidth multiline minRows={3} label="内部备注" placeholder="仅管理员可见" value={internal} disabled={saving} onChange={(e) => setInternal(e.target.value)} /><Button sx={{ mt: 1 }} variant="outlined" color="warning" disabled={saving || !internal.trim()} onClick={() => sendMessage(true, selectedTicket.id)}>添加内部备注</Button></Grid></Grid>
</Stack>
) : <Alert severity="error">工单详情不存在或加载失败</Alert>}
</Paper>
</Box>
</Box>
</Box>
);
}
+228 -60
View File
@@ -19,19 +19,31 @@ 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 Divider from '@mui/material/Divider';
import EditIcon from '@mui/icons-material/Edit';
import DeleteIcon from '@mui/icons-material/Delete';
import { listUsers, deleteUser, resetUserPassword, registerByAdmin, updateUser, me } from '../../api/auth.js';
import { showSnack } from '../snack.jsx';
import ConfirmDialog from '../ConfirmDialog.jsx';
/** 用户管理:列表/添加/改密/改权/删除(迁移自 v1 用户卡片 */
/** 头衔色板(MD3 常用色相,与论坛板块图标色板一致 */
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
const EMPTY_EDIT = { nickname: '', title: '', title_color: '', website: '', email: '', role: 'user' };
/** 用户管理:列表 / 添加 / 编辑大弹窗(昵称·头衔·权限·改密·删除危险区) */
export default function Users() {
const [users, setUsers] = useState(null);
const [selfId, setSelfId] = useState(null); // 当前管理员自己的 id(自我保护:禁止改权/删除自己)
const [addOpen, setAddOpen] = useState(false);
const [addForm, setAddForm] = useState({ username: '', password: '', role: 'user' });
const [pwDialog, setPwDialog] = useState(null); // { id, username }
// 编辑大弹窗
const [editUser, setEditUser] = useState(null); // 行数据 { id, username, ... }
const [editForm, setEditForm] = useState(EMPTY_EDIT);
const [pwForm, setPwForm] = useState({ p1: '', p2: '' });
const [roleDialog, setRoleDialog] = useState(null); // { id, username, role }
const [confirm, setConfirm] = useState(null); // { id, username }
const [deleteTarget, setDeleteTarget] = useState(null); // { id, username }
const [busy, setBusy] = useState(false);
const load = useCallback(() => {
@@ -42,6 +54,11 @@ export default function Users() {
useEffect(() => { load(); }, [load]);
// 自我 id(禁止对自己改权/删除)
useEffect(() => {
me().then((u) => setSelfId(u && u.id)).catch(() => setSelfId(null));
}, []);
const addUser = async () => {
if (!addForm.username.trim() || !addForm.password) { showSnack('用户名和密码不能为空', 'error'); return; }
setBusy(true);
@@ -57,15 +74,52 @@ export default function Users() {
setBusy(false);
};
const openEdit = (u) => {
setEditUser(u);
setEditForm({
nickname: u.nickname || '',
title: u.title || '',
title_color: u.title_color || '',
website: u.website || '',
email: u.email || '',
role: u.role || 'user',
});
setPwForm({ p1: '', p2: '' });
};
const closeEdit = () => setEditUser(null);
const saveProfile = async () => {
if (!editUser) return;
if (editForm.nickname.length > 20) { showSnack('昵称最多 20 字', 'error'); return; }
if (editForm.title.length > 20) { showSnack('头衔最多 20 字', 'error'); return; }
setBusy(true);
try {
await updateUser(editUser.id, {
nickname: editForm.nickname.trim(),
title: editForm.title.trim(),
title_color: editForm.title_color.trim(),
website: editForm.website.trim(),
email: editForm.email.trim(),
role: editForm.role,
});
showSnack('资料已保存');
closeEdit();
load();
} catch (e) {
showSnack(e.message, 'error');
}
setBusy(false);
};
const resetPw = async () => {
if (!pwDialog) return;
if (!editUser) 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);
await resetUserPassword(editUser.id, pwForm.p1);
showSnack('密码已重置');
setPwDialog(null);
setPwForm({ p1: '', p2: '' });
} catch (e) {
showSnack(e.message, 'error');
@@ -73,13 +127,14 @@ export default function Users() {
setBusy(false);
};
const changeRole = async () => {
if (!roleDialog) return;
const doDelete = async () => {
if (!deleteTarget) return;
setBusy(true);
try {
await setUserRole(roleDialog.id, roleDialog.role);
showSnack('角色已更新');
setRoleDialog(null);
await deleteUser(deleteTarget.id);
showSnack('已删除');
setDeleteTarget(null);
setEditUser(null); // 删除后关闭编辑弹窗
load();
} catch (e) {
showSnack(e.message, 'error');
@@ -87,19 +142,7 @@ export default function Users() {
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);
};
const isSelf = !!editUser && editUser.id === selfId;
return (
<Box>
@@ -113,7 +156,7 @@ export default function Users() {
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>用户</TableCell>
<TableCell>用户</TableCell>
<TableCell>邮箱</TableCell>
<TableCell>验证</TableCell>
<TableCell>角色</TableCell>
@@ -129,7 +172,12 @@ export default function Users() {
) : users.map((u) => (
<TableRow key={u.id}>
<TableCell>{u.id}</TableCell>
<TableCell><Box sx={{ fontWeight: 600 }}>{u.username}</Box></TableCell>
<TableCell>
<Box sx={{ fontWeight: 600 }}>{u.nickname || u.username}</Box>
{u.nickname && u.nickname !== u.username ? (
<Box sx={{ fontSize: 12, color: 'text.secondary' }}>@{u.username}</Box>
) : null}
</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'} />
@@ -139,9 +187,7 @@ export default function Users() {
</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>
<Button size="small" startIcon={<EditIcon fontSize="small" />} onClick={() => openEdit(u)}>编辑</Button>
</TableCell>
</TableRow>
))}
@@ -169,43 +215,165 @@ export default function Users() {
</DialogActions>
</Dialog>
{/* 重置密码 */}
<Dialog open={!!pwDialog} onClose={() => setPwDialog(null)} fullWidth maxWidth="xs">
<DialogTitle>重置密码</DialogTitle>
{/* 编辑用户大弹窗 */}
<Dialog open={!!editUser} onClose={closeEdit} fullWidth maxWidth="sm">
<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>
{editUser && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 0.5 }}>
{/* 基本信息 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>基本信息</Typography>
<TextField fullWidth label="用户名(不可修改)" value={editUser.username} margin="normal" disabled />
<TextField
fullWidth
label="对外昵称"
value={editForm.nickname}
onChange={(e) => setEditForm((p) => ({ ...p, nickname: e.target.value }))}
margin="normal"
inputProps={{ maxLength: 20 }}
helperText="显示在帖子 / 评论 / 个人主页的作者名,留空则显示用户名"
/>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-start' }}>
<TextField
fullWidth
label="邮箱"
type="email"
value={editForm.email}
onChange={(e) => setEditForm((p) => ({ ...p, email: e.target.value }))}
margin="normal"
placeholder="user@example.com"
/>
<Box sx={{ mt: 3.4, flexShrink: 0 }}>
<Chip size="small" label={editUser.email_verified ? '已验证' : '未验证'} color={editUser.email_verified ? 'primary' : 'default'} variant={editUser.email_verified ? 'filled' : 'outlined'} />
</Box>
</Box>
<TextField
fullWidth
label="个人博客"
type="url"
value={editForm.website}
onChange={(e) => setEditForm((p) => ({ ...p, website: e.target.value }))}
margin="normal"
placeholder="https://example.com"
helperText="显示在个人主页(外链跳确认页)"
/>
</Box>
{/* 修改角色 */}
<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>
<Divider />
{/* 头衔 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>头衔</Typography>
<TextField
fullWidth
label="自定义头衔"
value={editForm.title}
onChange={(e) => setEditForm((p) => ({ ...p, title: e.target.value }))}
margin="normal"
inputProps={{ maxLength: 20 }}
placeholder="如:技术宅 / 站长 / 摸鱼大师"
/>
<Box sx={{ mt: 1.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>头衔颜色点击选择或输入自定义 hex</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', alignItems: 'center' }}>
{COLOR_PALETTE.map((col) => (
<Box
key={col}
onClick={() => setEditForm((p) => ({ ...p, title_color: col === p.title_color ? '' : col }))}
sx={{
width: 26, height: 26, borderRadius: '50%', cursor: 'pointer', background: col,
border: col === editForm.title_color ? '2px solid #fff' : '2px solid transparent',
outline: col === editForm.title_color ? '2px solid var(--md-sys-color-outline, rgba(0,0,0,0.38))' : 'none',
boxSizing: 'border-box',
}}
title={col}
/>
))}
<TextField
size="small"
label="自定义"
value={editForm.title_color}
onChange={(e) => setEditForm((p) => ({ ...p, title_color: e.target.value }))}
placeholder="#6750a4"
sx={{ width: 130 }}
/>
</Box>
</Box>
</Box>
<Divider />
{/* 权限 */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'text.secondary' }}>权限</Typography>
<FormControl fullWidth margin="normal">
<InputLabel>角色</InputLabel>
<Select value={editForm.role} onChange={(e) => setEditForm((p) => ({ ...p, role: e.target.value }))} label="角色" disabled={isSelf}>
<MenuItem value="user">普通用户</MenuItem>
<MenuItem value="admin">管理员</MenuItem>
</Select>
</FormControl>
{isSelf && <Typography variant="caption" color="text.secondary">不能修改自己的角色</Typography>}
</Box>
<Divider />
{/* 账号安全(危险区) */}
<Box sx={{ border: '1px solid', borderColor: 'error.main', borderRadius: 2, p: 2 }}>
<Typography variant="subtitle2" sx={{ mb: 1, color: 'error.main' }}>账号安全</Typography>
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'flex-end' }}>
<TextField
size="small"
label="新密码(至少6位)"
type="password"
value={pwForm.p1}
onChange={(e) => setPwForm((p) => ({ ...p, p1: e.target.value }))}
margin="normal"
sx={{ flex: 1 }}
/>
<TextField
size="small"
label="确认新密码"
type="password"
value={pwForm.p2}
onChange={(e) => setPwForm((p) => ({ ...p, p2: e.target.value }))}
margin="normal"
sx={{ flex: 1 }}
/>
<Button variant="outlined" size="small" onClick={resetPw} disabled={busy} sx={{ mb: 0.5 }}>重置密码</Button>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: 13 }}>
删除后将无法恢复帖子 / 评论 / 附件一并处理
</Typography>
<Button
variant="outlined"
color="error"
size="small"
startIcon={<DeleteIcon />}
disabled={isSelf || busy}
onClick={() => setDeleteTarget({ id: editUser.id, username: editUser.username })}
title={isSelf ? '不能删除自己' : ''}
>
删除用户
</Button>
</Box>
{isSelf && <Typography variant="caption" color="text.secondary">不能删除自己的账号</Typography>}
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setRoleDialog(null)}>取消</Button>
<Button variant="contained" onClick={changeRole} disabled={busy}>确认修改</Button>
<Button onClick={closeEdit}>取消</Button>
<Button variant="contained" onClick={saveProfile} disabled={busy}>保存资料</Button>
</DialogActions>
</Dialog>
<ConfirmDialog
open={!!confirm}
message={`确定要删除 "${confirm ? confirm.username : ''}" 吗?`}
onClose={() => setConfirm(null)}
open={!!deleteTarget}
message={`确定要删除用户 "${deleteTarget ? deleteTarget.username : ''}" 吗?此操作不可恢复`}
onClose={() => setDeleteTarget(null)}
onConfirm={doDelete}
confirmText="确认删除"
/>
+253
View File
@@ -18,6 +18,11 @@ body {
transition: background-color 0.3s ease, color 0.3s ease;
}
/* 设置区块锚点(搜索跳转定位):预留固定 AppBar 高度,避免被遮挡 */
[id^="sec-"] {
scroll-margin-top: 88px;
}
/* 滚动条(对应前台细滚动条) */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -27,6 +32,254 @@ body {
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
/* 工单工作区:占满 AdminLayout 内容区,桌面保持列表/详情主次比例。 */
.ticket-workspace {
width: 100%;
max-width: none;
min-width: 0;
min-height: calc(100vh - 112px);
}
.ticket-workspace .ticket-stats {
width: 100%;
}
.ticket-work-area {
display: grid;
grid-template-columns: minmax(280px, 0.55fr) minmax(0, 1fr);
gap: 16px;
width: 100%;
min-width: 0;
height: max(520px, calc(100vh - 280px));
align-items: stretch;
}
.ticket-list-pane,
.ticket-detail-pane {
min-width: 0;
width: 100%;
}
.ticket-list-pane > .MuiPaper-root,
.ticket-detail-pane > .MuiPaper-root {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.ticket-list-pane > .MuiPaper-root {
min-height: 0;
}
.ticket-detail-pane > .MuiPaper-root {
min-height: 0;
overflow: auto;
}
.ticket-list-card {
display: flex;
flex-direction: column;
}
.ticket-list-card > .MuiDivider-root,
.ticket-list-card > .MuiStack-root:last-child {
flex: 0 0 auto;
}
.ticket-list-scroll {
position: relative;
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: auto;
align-items: stretch;
}
.ticket-list-scroll > li {
flex: 0 0 auto;
width: 100%;
box-sizing: border-box;
}
.ticket-list-scroll > li,
.ticket-list-scroll > li > .MuiButton-root {
min-width: 0;
max-width: 100%;
}
.ticket-list-scroll > li > .MuiButton-root {
display: flex;
box-sizing: border-box;
overflow: hidden;
min-height: 48px;
}
.ticket-list-row {
display: grid;
grid-template-columns: max-content minmax(120px, 1.4fr) minmax(120px, 1fr) max-content max-content;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
overflow: hidden;
}
@media (min-width: 700px) {
.ticket-list-row {
min-width: 500px;
}
}
.ticket-list-row > .MuiTypography-root,
.ticket-list-row > .MuiChip-root {
min-width: 0;
}
.ticket-list-row > .MuiTypography-root {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket-list-row > .MuiChip-root {
flex: 0 0 auto;
}
.ticket-list-scroll > li > .MuiButton-root > .MuiBox-root {
min-width: 0;
}
.ticket-list-scroll .MuiTypography-root {
min-width: 0;
overflow-wrap: anywhere;
}
.ticket-list-scroll .MuiChip-root {
flex: 0 0 auto;
max-width: 100%;
}
.ticket-list-number {
min-width: 0;
overflow-wrap: anywhere;
}
.ticket-detail-card {
overflow: auto;
}
.ticket-filter-grid {
display: grid;
grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(130px, 1fr)) minmax(96px, 0.85fr);
gap: 10px;
align-items: center;
min-width: 0;
}
.ticket-filter-grid > * {
min-width: 0;
}
.ticket-filter-grid .MuiInputBase-root,
.ticket-filter-grid .MuiButton-root {
width: 100%;
min-width: 0;
}
.ticket-select-value {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ticket-save-feedback {
overflow-wrap: anywhere;
}
.ticket-save-saving { color: var(--mui-palette-text-secondary, inherit); }
.ticket-save-success { color: #2e7d32; }
.ticket-save-error { color: #d32f2f; }
[data-theme="dark"] .ticket-save-success { color: #81c784; }
[data-theme="dark"] .ticket-save-error { color: #ef9a9a; }
@media (max-width: 1199px) and (min-width: 700px) {
.ticket-filter-grid {
grid-template-columns: minmax(220px, 1.6fr) repeat(3, minmax(118px, 1fr));
}
.ticket-filter-grid .ticket-filter-search {
grid-column: 1 / -1;
}
.ticket-filter-grid > .MuiButton-root {
grid-column: 1 / -1;
}
}
@media (max-width: 1199px) and (min-width: 700px) {
.ticket-work-area {
grid-template-columns: minmax(250px, 0.62fr) minmax(0, 1fr);
gap: 14px;
}
}
@media (max-width: 699px) {
.ticket-workspace {
min-height: 0;
}
.ticket-work-area {
grid-template-columns: minmax(0, 1fr);
height: auto;
gap: 16px;
}
.ticket-list-pane > .MuiPaper-root,
.ticket-detail-pane > .MuiPaper-root {
min-height: 0;
}
.ticket-list-card {
max-height: 480px;
}
.ticket-list-scroll {
flex: 0 1 auto;
max-height: 350px;
}
.ticket-list-row {
grid-template-columns: max-content minmax(140px, 1.2fr) minmax(120px, 1fr) max-content max-content;
min-width: 560px;
}
.ticket-list-scroll {
overflow-x: auto;
}
.ticket-detail-pane > .MuiPaper-root {
min-height: 480px;
}
.ticket-filter-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.ticket-filter-grid .ticket-filter-search,
.ticket-filter-grid > .MuiButton-root {
grid-column: 1 / -1;
}
}
@media (max-width: 459px) {
.ticket-filter-grid {
grid-template-columns: minmax(0, 1fr);
}
}
/* 减弱动效偏好 */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
+15
View File
@@ -20,6 +20,17 @@ 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);
@@ -42,3 +53,7 @@ export function resetUserPassword(id, newPassword) {
export function setUserRole(id, role) {
return request('/auth/users/' + id + '/role', { method: 'PUT', body: { role } });
}
/** 编辑用户资料(管理员):nickname/title/title_color/website/email/role */
export function updateUser(id, data) {
return request('/auth/users/' + id, { method: 'PUT', body: data });
}
+13
View File
@@ -14,6 +14,14 @@ export function createPost(data) {
export function updatePost(id, data) {
return request('/blog/posts/' + id, { method: 'PUT', body: data });
}
/** 解锁 markdown 锁定块(password 类):{ password? } → { ok, content }(内容仅前端内存态) */
export function unlockLock(postId, index, password) {
return request('/blog/posts/' + postId + '/locks/' + index + '/unlock', {
method: 'POST',
body: password !== undefined && password !== null ? { password } : {},
});
}
export function deletePost(id) {
return request('/blog/posts/' + id, { method: 'DELETE' });
}
@@ -66,3 +74,8 @@ export function approveComment(id) {
export function rejectComment(id) {
return request('/blog/comments/' + id + '/reject', { method: 'POST' });
}
/** 全量评论列表(仅管理员):status=all|pending|approved|rejected,分页 → { list, total, page, pageSize, totalPages } */
export function listAllComments({ status = 'all', page = 1, pageSize = 20 } = {}) {
return request(`/blog/comments?status=${encodeURIComponent(status)}&page=${page}&pageSize=${pageSize}`);
}
+85 -3
View File
@@ -1,9 +1,14 @@
import { request } from './client.js';
// ── 分类 ────────────────────────────────────────
/** 版块列表(含聚合:posts_count / today_count / moderators / icon / icon_color */
export function listCategories() {
return request('/forum/categories');
}
/** 版块详情(含聚合与公告) */
export function getCategory(id) {
return request('/forum/categories/' + id);
}
export function createCategory(data) {
return request('/forum/categories', { method: 'POST', body: data });
}
@@ -13,11 +18,30 @@ export function updateCategory(id, data) {
export function deleteCategory(id) {
return request('/forum/categories/' + id, { method: 'DELETE' });
}
/** 更新版块公告(版主 / 管理员) */
export function updateAnnouncement(id, announcement) {
return request('/forum/categories/' + id + '/announcement', { method: 'PUT', body: { announcement } });
}
/** 版块资料更新(版主 / 管理员):部分更新,只提交有改动的字段。
* fields = { name?, description?, icon?, icon_color? }icon_color 空串表示清除 → 按名称哈希配色 */
export function updateCategoryProfile(id, fields) {
return request('/forum/categories/' + id + '/profile', { method: 'PUT', body: fields });
}
/** 指派版主(管理员);userIds 为用户 id 数组(后端 body 字段 user_ids */
export function updateModerators(id, userIds) {
return request('/forum/categories/' + id + '/moderators', { method: 'PUT', body: { user_ids: userIds } });
}
// ── 帖子 ────────────────────────────────────────
/** 帖子列表;categoryId 可选 */
export function listPosts(categoryId) {
return request('/forum/posts' + (categoryId ? '?category_id=' + encodeURIComponent(categoryId) : ''));
/** 帖子列表;opts = { categoryId, page, subCategory, q },返回 { posts, total, page, pageSize }(兼容裸数组) */
export function listPosts(opts = {}) {
const params = new URLSearchParams();
if (opts.categoryId) params.set('category_id', opts.categoryId);
if (opts.page && opts.page > 1) params.set('page', String(opts.page));
if (opts.subCategory) params.set('sub_category', opts.subCategory);
if (opts.q) params.set('q', opts.q);
const qs = params.toString();
return request('/forum/posts' + (qs ? '?' + qs : ''));
}
export function getPost(id) {
return request('/forum/posts/' + id);
@@ -32,6 +56,64 @@ export function reply(postId, content) {
export function deletePost(id) {
return request('/forum/posts/' + id, { method: 'DELETE' });
}
/** 编辑帖子(作者/版主/admin):body 部分更新 { title?, content?, sub_category?, use_markdown? } → edit_count+1 */
export function updatePost(id, fields) {
return request('/forum/posts/' + id, { method: 'PUT', body: fields });
}
/** 解锁 markdown 锁定块(password 类):{ password? } → { ok, content }(内容仅前端内存态) */
export function unlockLock(postId, index, password) {
return request('/forum/posts/' + postId + '/locks/' + index + '/unlock', {
method: 'POST',
body: password !== undefined && password !== null ? { password } : {},
});
}
export function deleteReply(id) {
return request('/forum/replies/' + id, { method: 'DELETE' });
}
/** 置顶 / 取消置顶(管理员或版主);后端 body 字段 pinned */
export function setPinned(id, value) {
return request('/forum/posts/' + id + '/pin', { method: 'PUT', body: { pinned: value ? 1 : 0 } });
}
/** 加精 / 取消加精(管理员或版主);后端 body 字段 essence */
export function setEssence(id, value) {
return request('/forum/posts/' + id + '/essence', { method: 'PUT', body: { essence: value ? 1 : 0 } });
}
/** 当前用户管理的版块 id 数组(admin 返回全部版块 id;版主返回其管理的) */
export function listModerated() {
return request('/forum/moderated').then((d) => {
const raw = d && (d.category_ids || d.ids);
if (Array.isArray(raw)) return raw.map((x) => Number(x)).filter((n) => !isNaN(n));
const cats = d && d.categories;
if (Array.isArray(cats)) {
return cats.map((c) => (typeof c === 'object' ? c.id : Number(c))).filter((n) => !isNaN(n));
}
return [];
});
}
/** 我管理的版块详情数组(admin 全量 / 版主其管辖),供管理台首页用。
* 兼容三种后端形态:直接返回版块详情数组;{ categories: 详情数组, category_ids };旧 { category_ids } 按 id 过滤全量分类 */
export async function listModeratedCategories(user) {
const d = await request('/forum/moderated');
if (Array.isArray(d)) return d; // 形态1:直接是版块详情数组
if (Array.isArray(d && d.categories)) return d.categories; // 形态2categories 已是详情数组
const ids = (d && (d.category_ids || d.ids)) || []; // 形态3:仅 id 数组
const cats = (await listCategories().catch(() => [])) || [];
if (user && user.role === 'admin') return cats; // admin 的 category_ids 为空 = 全版块可管
const idSet = new Set(ids.map(Number));
return cats.filter((c) => idSet.has(c.id));
}
// ── 禁言(版主/管理员) ──────────────────────────
/** 版块禁言列表;返回 [{ user_id, username, permanent, muted_until, created_at }] */
export function listMutes(categoryId) {
return request('/forum/mutes?category_id=' + encodeURIComponent(categoryId));
}
/** 添加禁言:username 为被禁用户名(后端解析为 user_id),duration 为天数(1/7/30)或 'forever'(永久) */
export function createMute(categoryId, username, duration) {
return request('/forum/mutes', { method: 'PUT', body: { category_id: categoryId, username, duration } });
}
/** 解除禁言:按 (category_id, user_id) 定位(无独立 id */
export function deleteMute(categoryId, userId) {
return request('/forum/mutes', { method: 'DELETE', body: { category_id: categoryId, user_id: userId } });
}
+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' });
}
+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 } });
}
+49
View File
@@ -0,0 +1,49 @@
import { request } from './client.js';
export function normalizeTicketListResponse(data, fallbackPageSize = 20) {
const payload = Array.isArray(data) ? { tickets: data } : (data && typeof data === 'object' ? data : {});
const tickets = Array.isArray(payload.tickets)
? payload.tickets
: (Array.isArray(payload.list) ? payload.list : (Array.isArray(payload.items) ? payload.items : []));
const pageSizeValue = Number(payload.pageSize ?? payload.page_size);
const pageSize = Number.isInteger(pageSizeValue) && pageSizeValue > 0 ? pageSizeValue : fallbackPageSize;
const totalValue = Number(payload.total ?? payload.count);
const total = Number.isFinite(totalValue) && totalValue >= 0 ? totalValue : tickets.length;
const totalPagesValue = Number(payload.totalPages ?? payload.total_pages ?? payload.pages);
const totalPages = Number.isInteger(totalPagesValue) && totalPagesValue > 0
? totalPagesValue
: Math.max(1, Math.ceil(total / pageSize));
return { tickets, total, totalPages };
}
export function listTickets(params = {}) {
const query = new URLSearchParams();
if (params.page) query.set('page', String(params.page));
if (params.pageSize) query.set('pageSize', String(params.pageSize));
if (params.status) query.set('status', params.status);
const qs = query.toString();
return request('/tickets' + (qs ? '?' + qs : ''));
}
export function createTicket(data) {
return request('/tickets', { method: 'POST', body: data });
}
export function getTicket(id) {
return request('/tickets/' + encodeURIComponent(id));
}
export function addMessage(id, content) {
return request('/tickets/' + encodeURIComponent(id) + '/messages', {
method: 'POST',
body: { content },
});
}
export function closeTicket(id) {
return request('/tickets/' + encodeURIComponent(id) + '/close', { method: 'POST' });
}
export function reopenTicket(id) {
return request('/tickets/' + encodeURIComponent(id) + '/reopen', { method: 'POST' });
}
+22 -2
View File
@@ -2,9 +2,9 @@ import { request, getToken } from './client.js';
// client.request 走 JSON,上传必须独立走 fetch + FormData + Bearer
async function upload(path, file, extra) {
async function upload(path, file, extra, fieldName = 'file') {
const fd = new FormData();
fd.append('file', file);
fd.append(fieldName, file);
if (extra) {
for (const [k, v] of Object.entries(extra)) {
if (v !== undefined && v !== null && v !== '') fd.append(k, String(v));
@@ -38,6 +38,11 @@ export function uploadWallpaper(file) {
return upload('/wallpaper', file);
}
/** 版块图标上传(multipart 字段名 icon,≤1MB),返回 { url: '/uploads/icons/xxx.png' } */
export function uploadIcon(file) {
return upload('/icon', file, undefined, 'icon');
}
/** 按 uid 获取头像地址(支持 QQ 自动头像) */
export function avatarUrl(uid) {
return request('/upload/avatar-url?uid=' + encodeURIComponent(uid));
@@ -56,3 +61,18 @@ export function listAttachments(refType, refId) {
export function deleteAttachment(id) {
return request('/upload/' + id, { method: 'DELETE' });
}
/** 搜索引擎验证文件上传(仅管理员,multipart 字段名 file,≤64KB),返回 { url, message } */
export function uploadVerifyFile(file) {
return upload('/verify-file', file);
}
/** 已上传验证文件列表(仅管理员),返回 [{ name, url }] */
export function listVerifyFiles() {
return request('/upload/verify-files');
}
/** 删除验证文件(仅管理员),返回 { message } */
export function deleteVerifyFile(name) {
return request('/upload/verify-file/' + encodeURIComponent(name), { method: 'DELETE' });
}
+21
View File
@@ -0,0 +1,21 @@
import { request } from './client.js';
// ── 公开用户主页(无鉴权) ────────────────────────
/**
* 公开用户资料:{ id, username, role, avatar, bio, created_at, last_active_at, stats }
* 不存在时 404 { error: '用户不存在' }
*/
export function getPublicUser(id) {
return request('/users/' + id);
}
/** 用户帖子列表(分页)→ { list, total, page, pageSize, totalPages };论坛私密时 403 */
export function getUserPosts(id, page = 1, pageSize = 10) {
return request(`/users/${id}/posts?page=${page}&pageSize=${pageSize}`);
}
/** 用户回复列表(分页)→ { list, total, page, pageSize, totalPages };论坛私密时 403 */
export function getUserReplies(id, page = 1, pageSize = 10) {
return request(`/users/${id}/replies?page=${page}&pageSize=${pageSize}`);
}
+62
View File
@@ -0,0 +1,62 @@
import React from 'react';
import { Link } from 'react-router-dom';
/** 名称哈希 → 0..11 色调组(复用 style.css 的 --fi-* 12 色 MD3 变量,深浅色自适应;供 Banner 渐变等复用) */
export function hashTone(name) {
let h = 0;
const k = String(name || '');
for (let i = 0; i < k.length; i += 1) h = (h * 31 + k.charCodeAt(i)) >>> 0;
return h % 12;
}
/**
* 圆形用户头像(MD3):
* - src 非空 → 圆形 <img>object-fit: cover
* - src 空 → 首字兜底(name[0] 大写 + 名称哈希底色)
* - to 非空 → 外层包 <Link to={to}>(点击跳公开主页;内层 stopPropagation 防嵌套 Link 冒泡)
* props: { src, name, size=36, className, to }
*/
export default function Avatar({ src = '', name = '', size = 36, className = '', to }) {
const style = { width: size, height: size, fontSize: Math.round(size * 0.42) };
const base = 'avatar' + (className ? ' ' + className : '');
const alt = name ? `${name} 的头像` : '头像';
const letter = String(name || '?').charAt(0).toUpperCase();
// 跳转版本:Link 作为圆形容器(cursor 指针),fallback 底色放内层 span
if (to) {
return (
<Link
to={to}
className={base + ' avatar-link'}
style={style}
aria-label={alt}
title={name}
onClick={(e) => e.stopPropagation()}
>
{src
? <img src={src} alt={alt} />
: (
<span
className={'avatar-fallback tone-' + hashTone(name)}
style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: style.fontSize }}
>
{letter}
</span>
)}
</Link>
);
}
if (src) {
return (
<span className={base} style={style}>
<img src={src} alt={alt} />
</span>
);
}
return (
<span className={`${base} avatar-fallback tone-${hashTone(name)}`} style={style} role="img" aria-label={alt}>
{letter}
</span>
);
}
+4 -1
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { listPosts } from '../api/blog.js';
import BrandIcon, { BRAND_ICON_NAMES } from './BrandIcon.jsx';
/**
* 前台侧栏(迁移自 index.html HOMEPAGE 侧栏 + blog.js loadSidebar):
@@ -42,7 +43,9 @@ export default function BlogSidebar({ settings = {}, showRecent = false, forceSh
<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>
{BRAND_ICON_NAMES.includes(l.icon)
? <BrandIcon name={l.icon} size={20} className="contact-icon" />
: <span className="material-icons">{l.icon || 'link'}</span>}
</a>
))}
</div>
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
/**
* 品牌图标(读本地彩色 SVG 文件,public/icons/brands/):
* qq / bilibili / telegram / github / gitea / wechat —— 彩色图标,浏览器原生 <img> 渲染,离线可用。
* props: { name, size=24, className, alt }
* 未知 name(不在 BRAND_ICON_NAMES)→ null,调用方以 material-icons 兜底。
*/
/** 支持的品牌图标名(供前台双通道渲染判断) */
export const BRAND_ICON_NAMES = ['qq', 'bilibili', 'telegram', 'github', 'gitea', 'wechat'];
const LABELS = {
qq: 'QQ',
bilibili: '哔哩哔哩',
telegram: 'Telegram',
github: 'GitHub',
gitea: 'Gitea',
wechat: '微信',
};
export default function BrandIcon({ name, size = 24, className = '', alt }) {
if (!BRAND_ICON_NAMES.includes(name)) return null;
return (
<img
src={`/icons/brands/${name}.svg`}
alt={alt || LABELS[name] || name}
width={size}
height={size}
className={className}
loading="lazy"
/>
);
}
+134 -22
View File
@@ -1,5 +1,6 @@
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 }
@@ -30,10 +31,23 @@ export function showCaptcha(action) {
});
}
// 执行并清空排队中的渲染回调(供 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';
@@ -42,6 +56,9 @@ function ensureThirdPartyScript(type) {
}
} 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';
@@ -51,6 +68,66 @@ function ensureThirdPartyScript(type) {
}
}
// 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码
const THIRD_PARTY_LOAD_TIMEOUT = 8000;
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
// 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级;
// 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。
// 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器,
// 供弹窗关闭/重开时防泄漏、防残留降级状态。
function queueRender(type, render, onTimeout) {
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
if (typeof window[lib] !== 'undefined') { render(); return null; }
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
window[key] = window[key] || [];
let done = false; // 已渲染 / 已超时降级 / 已清理:任一后不再执行 render
let raceTimer = null;
let timeoutTimer = null;
const wrappedRender = () => {
if (done) return;
done = true;
clearTimeout(raceTimer);
clearTimeout(timeoutTimer);
render();
};
window[key].push(wrappedRender);
ensureThirdPartyScript(type);
// 300ms 竞态兜底:脚本恰好在「检查就绪」与「入队」之间完成加载、onload 已错过时重放队列
raceTimer = setTimeout(() => {
if (typeof window[lib] !== 'undefined') flushCallbacks(type);
}, 300);
// 8s 加载超时:库仍未就绪 → 降级;就绪但回调错过 → 冲刷队列兜底
timeoutTimer = setTimeout(() => {
if (done) return;
if (typeof window[lib] !== 'undefined') {
flushCallbacks(type);
} else {
done = true;
clearTimeout(raceTimer);
if (onTimeout) onTimeout();
}
}, THIRD_PARTY_LOAD_TIMEOUT);
return () => {
if (done) return;
done = true;
clearTimeout(raceTimer);
clearTimeout(timeoutTimer);
const q = window[key];
if (q) {
const i = q.indexOf(wrappedRender);
if (i >= 0) q.splice(i, 1);
}
};
}
// SHA-256Proof of Work 使用)
async function sha256(str) {
const buf = new TextEncoder().encode(str);
@@ -58,8 +135,8 @@ async function sha256(str) {
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
// 内置 SVG 验证码弹窗
function BuiltinCaptcha({ onFinish }) {
// 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户)
function BuiltinCaptcha({ onFinish, notice }) {
const [svg, setSvg] = useState('');
const [error, setError] = useState('');
const [answer, setAnswer] = useState('');
@@ -128,6 +205,23 @@ function BuiltinCaptcha({ onFinish }) {
return (
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
<h3 style={{ marginBottom: 12 }}>验证码</h3>
{notice && (
<p
role="status"
aria-live="polite"
style={{
color: 'var(--md-ref-on-surface-variant)',
fontSize: 13,
lineHeight: 1.5,
margin: '0 0 12px',
padding: '8px 12px',
background: 'var(--md-ref-surface-container-high)',
borderRadius: 8,
}}
>
{notice}
</p>
)}
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
{svg
? <div dangerouslySetInnerHTML={{ __html: svg }} />
@@ -140,15 +234,16 @@ function BuiltinCaptcha({ onFinish }) {
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="刷新" onClick={loadImage} style={{ flexShrink: 0 }}>
<button type="button" className="btn btn-icon" title="刷新" aria-label="刷新验证码" onClick={loadImage} style={{ flexShrink: 0 }}>
<span className="material-icons">refresh</span>
</button>
</div>
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
{error && <div role="alert" style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
{powDone ? (
<>
@@ -170,12 +265,14 @@ function BuiltinCaptcha({ onFinish }) {
);
}
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
// 脚本 8s 内加载不出来时自动降级到内置 SVG 验证码(proof 路径),不阻塞用户操作。
function ThirdPartyCaptcha({ type, onFinish }) {
const siteKey = type === 'recaptcha'
? (window._recaptchaSiteKey || '')
: (window._turnstileSiteKey || '');
const [status, setStatus] = useState('正在加载...');
const [degraded, setDegraded] = useState(false);
const widgetRef = useRef(null);
const doneRef = useRef(false);
@@ -183,6 +280,7 @@ function ThirdPartyCaptcha({ type, onFinish }) {
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, {
@@ -213,25 +311,31 @@ function ThirdPartyCaptcha({ type, onFinish }) {
setTimeout(() => onFinish(null), 2000);
}
};
if (type === 'recaptcha') {
if (typeof window.grecaptcha !== 'undefined') render();
else {
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
window.recaptchaCallbacks.push(render);
ensureThirdPartyScript(type);
}
} else {
if (typeof window.turnstile !== 'undefined') render();
else {
window.turnstileCallbacks = window.turnstileCallbacks || [];
window.turnstileCallbacks.push(render);
ensureThirdPartyScript(type);
}
}
// 卸载时不触发重复回调
return () => { doneRef.current = true; };
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
// 8s 加载超时 → 自动降级到内置验证码,避免长时间卡「正在加载」阻塞登录
const cleanupQueue = queueRender(type, render, () => {
if (doneRef.current) return;
doneRef.current = true;
setDegraded(true);
});
// 卸载/重开时:防止重复回调,清除超时计时器并移除排队回调(防泄漏/防残留降级状态)
return () => {
doneRef.current = true;
if (cleanupQueue) cleanupQueue();
};
}, []);
// 降级:第三方脚本长时间加载不出来,复用内置 SVG 验证码完整流程
// (校验仍走 loadImage/verify,调用方拿到 { type: 'proof', value }
if (degraded) {
return (
<BuiltinCaptcha
onFinish={onFinish}
notice="第三方验证码加载失败,已切换为内置验证码"
/>
);
}
return (
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
@@ -264,13 +368,21 @@ export default function CaptchaModalHost() {
}
};
// 弹窗键盘/焦点管理(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} />
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
import { useDialog } from '../lib/utils.js';
/**
* 403 等操作被拒错误弹窗(前台 MD3 风格):
* 发帖/回复被禁言时后端返回 403 + 含到期时间的错误文案,用弹窗突出展示(snackbar 2.5s 太短)。
* props: open / message / onClose
*/
export default function ErrorDialog({ open = false, message = '', onClose }) {
const { dialogRef, onKeyDown } = useDialog(open, onClose);
if (!open || !message) 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 && onClose) onClose(); }}
onKeyDown={onKeyDown}
>
<div className="dialog">
<h3> 操作被拒绝</h3>
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12, whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>
{message}
</p>
<div className="actions">
<button className="btn btn-filled" onClick={onClose}>知道了</button>
</div>
</div>
</div>
);
}
+30 -18
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { safeOutUrl, parseFooterColumns } from '../lib/outlink.js';
/**
* 页脚文案渲染:footer_copyright / footer_powered / footer_desc 为管理员字段,
@@ -28,18 +29,35 @@ export default function Footer({ settings = {}, version = '' }) {
const desc = settings.footer_desc || settings.site_description || '';
if (style === 'columns') {
// 导航栏目固定不可编辑(后台提示),首页/博客/论坛走 SPA,管理后台整页跳转
const navCols = [
// 栏目来源:后台 footer_columns 配置(非空优先,最多取前 3 栏);空则回退硬编码三栏导航
const hardcodedCols = [
{ title: '导航', links: [
{ to: '/', label: '首页' },
{ to: '/blog.html', label: '博客' },
{ to: '/forum.html', label: '论坛' },
{ label: '首页', url: '/' },
{ label: '博客', url: '/blog.html' },
{ label: '论坛', url: '/forum.html' },
] },
{ title: '其他', links: [
{ to: '/admin', label: '管理后台', external: true },
{ to: '/profile.html', label: '个人中心' },
{ title: '阅读', links: [
{ label: '归档', url: '/archive.html' },
{ label: 'RSS 订阅', url: '/feed.xml' },
] },
{ title: '账号', links: [
{ label: '个人中心', url: '/profile.html' },
{ label: '管理后台', url: '/admin', fullPage: true },
{ label: '登录', url: '/login.html' },
] },
];
const customCols = parseFooterColumns(settings.footer_columns);
const cols = (customCols.length > 0 ? customCols : hardcodedCols).slice(0, 3);
/** 栏目链接:站内路径走 SPA Link(整页跳转项除外);http(s) 外链走 /out 确认页 */
const colLink = (l, i) => {
const isSite = l.url.startsWith('/');
const isSpa = isSite && !l.fullPage;
return isSpa
? <Link key={i} to={l.url}>{l.label || l.url}</Link>
: <a key={i} href={safeOutUrl(l.url)}>{l.label || l.url}</a>;
};
return (
<footer className="footer-columns">
<div className="fc-grid">
@@ -51,17 +69,11 @@ export default function Footer({ settings = {}, version = '' }) {
</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>
{cols.map((col, ci) => (
<nav key={ci} 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>
)
)}
{(col.links || []).map((l, li) => colLink(l, ci + '-' + li))}
</div>
</nav>
))}
+53
View File
@@ -0,0 +1,53 @@
import React from 'react';
/**
* 论坛版块图标(L1/L2 共用):
* - cat.icon 非空:http(s) 或 / 开头的相对路径(如 /uploads/icons/xxx.png)渲染图片;否则按单个 emoji 字符渲染
* - 无 icon 时取名称首字(大写)
* - 底色:icon_color 自定义 hex(自动按亮度选深/浅文字色);
* 空则按名称哈希选 12 色 MD3 container/onContainer 对(style.css 的 .tone-N,深浅色自适应)
*/
export default function ForumIcon({ icon = '', name = '', iconColor = '', size = 40 }) {
const raw = String(icon || '').trim();
const isUrl = /^https?:\/\//i.test(raw) || raw.startsWith('/');
const isEmoji = !!raw && !isUrl;
const letter = (name || '').trim().charAt(0).toUpperCase() || '?';
// 名称哈希 → 0..11 的色调组
let h = 0;
const key = String(name || '');
for (let i = 0; i < key.length; i += 1) h = (h * 31 + key.charCodeAt(i)) >>> 0;
const style = { width: size, height: size, fontSize: Math.round(size * 0.48), borderRadius: 12 };
// 自定义 icon_colorhex → 按 luma 选深/浅文字色(近似 MD3 on-container
if (iconColor) {
let hex = String(iconColor).replace('#', '');
if (hex.length === 3) hex = hex.split('').map((c) => c + c).join('');
const n = parseInt(hex, 16);
if (!isNaN(n) && hex.length === 6) {
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
const luma = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
style.background = '#' + hex;
style.color = luma > 0.6 ? 'rgba(0,0,0,0.8)' : '#ffffff';
}
}
if (isUrl) {
return (
<span className="forum-icon forum-icon-img" style={{ ...style, background: 'var(--md-ref-surface-container-high)' }}>
<img src={raw} alt="" />
</span>
);
}
if (isEmoji) {
return (
<span className="forum-icon" style={{ ...style, background: 'var(--md-ref-surface-container-high)' }}>
{raw}
</span>
);
}
return <span className={'forum-icon tone-' + (h % 12)} style={style}>{letter}</span>;
}
+128 -7
View File
@@ -1,10 +1,11 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, 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 { isFamilyDomain } from '../lib/outlink.js';
import MusicEmbed from './MusicEmbed.jsx';
import CaptchaModalHost from './CaptchaModal.jsx';
import Footer from './Footer.jsx';
@@ -23,6 +24,48 @@ export default function Layout() {
const [version, setVersion] = useState('');
const [user, setUser] = useState(null);
// ── 移动端汉堡抽屉 ──
const [menuOpen, setMenuOpen] = useState(false);
const menuBtnRef = useRef(null);
const drawerRef = useRef(null);
// 抽屉焦点管理:打开锁定背景滚动并聚焦首个可聚焦项,关闭还原滚动并聚焦汉堡按钮
useEffect(() => {
if (menuOpen) {
document.body.style.overflow = 'hidden';
const first = drawerRef.current && drawerRef.current.querySelector('a, button, [tabindex]');
if (first && typeof first.focus === 'function') first.focus();
else if (drawerRef.current) drawerRef.current.focus();
} else {
document.body.style.overflow = '';
if (menuBtnRef.current && typeof menuBtnRef.current.focus === 'function') menuBtnRef.current.focus();
}
return () => { document.body.style.overflow = ''; };
}, [menuOpen]);
const closeMenu = () => setMenuOpen(false);
// ── 全站外链拦截:http(s) 非本站链接 → /out 确认页(事件委托,覆盖 markdown 渲染出的外链)──
useEffect(() => {
const handler = (e) => {
if (e.defaultPrevented) return;
// 新标签打开(Ctrl/Cmd/Shift/中键)是用户主动分屏意图,不劫持
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
if (!a) return;
const href = (a.getAttribute('href') || '').trim();
if (!/^https?:\/\//i.test(href)) return; // 站内路径(/ 开头)不拦
// 本站同源(www.rainnya.asia)不拦——解析相对链接得到本站 origin 也自然跳过;
// rainnya.asia 家族域名(含子域名)也视为站内,直接跳转不走确认页
if (new URL(href, window.location.origin).origin === window.location.origin) return;
if (isFamilyDomain(href)) return;
e.preventDefault();
window.location.href = '/out?url=' + encodeURIComponent(href);
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, []);
// 初始化向导:未完成安装时跳转 /setup.html(迁移自 nav.js
useEffect(() => {
fetch('/api/setup/status')
@@ -76,7 +119,7 @@ export default function Layout() {
const wallpaper = settings.theme_wallpaper || '';
const scale = settings.theme_wallpaper_scale || 'cover';
if (wallpaper) {
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%' };
@@ -118,6 +161,11 @@ export default function Layout() {
setToken(null);
setUser(null);
notifyAuthChange();
if (settings.rainid_enabled === '1') {
// RainID 单点登录:清本地后联动登出 RainID 端会话(整页跳转,不显示本地提示)
window.location.href = '/api/auth/oidc/logout';
return;
}
showSnackbar('已退出登录');
};
@@ -133,6 +181,7 @@ export default function Layout() {
{ to: '/', label: '首页', end: true, show: true },
{ to: '/blog.html', label: '博客', show: true },
{ to: '/forum.html', label: '论坛', show: !!user },
{ to: '/tickets.html', label: '工单', show: true },
{ to: '/admin', label: '管理后台', show: isAdmin, fullPage: true },
{ to: '/passwords.html', label: '密码箱', show: isAdmin },
];
@@ -144,6 +193,17 @@ export default function Layout() {
<>
<nav className={navClass} id="mainNav">
<div className="nav-left">
<button
ref={menuBtnRef}
type="button"
className="btn-icon nav-hamburger"
onClick={() => setMenuOpen(true)}
aria-label="打开导航菜单"
aria-expanded={menuOpen}
aria-haspopup="dialog"
>
<span className="material-icons">menu</span>
</button>
<Link to="/" className="nav-brand">{siteName}</Link>
<span className="nav-version">v{version}</span>
<div className="nav-tabs">
@@ -167,19 +227,21 @@ export default function Layout() {
<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="个人中心">
{/* 个人中心:桌面显示头像+用户名,移动端只显示头像(.nav-user-name 由 CSS 隐藏) */}
<Link to="/profile.html" className="btn btn-tonal btn-sm nav-user" 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}
? <img src={avatarUrl} alt="" style={{ width: 24, height: 24, borderRadius: '50%', objectFit: 'cover' }} />
: <span className="material-icons" style={{ fontSize: 18 }}>person</span>}
<span className="nav-user-name">{user.username}</span>
</Link>
<button className="btn btn-text btn-sm" onClick={handleLogout}>退出</button>
{/* 退出登录统一在个人中心 / 移动端抽屉中操作,顶栏不再放退出按钮 */}
</>
) : (
<>
@@ -190,6 +252,65 @@ export default function Layout() {
</div>
</nav>
{/* 移动端导航抽屉(汉堡菜单) */}
<div className={'drawer-overlay' + (menuOpen ? ' open' : '')} onClick={closeMenu} aria-hidden="true" />
<aside
ref={drawerRef}
className={'drawer' + (menuOpen ? ' open' : '')}
role="dialog"
aria-modal="true"
aria-label="导航菜单"
aria-hidden={!menuOpen}
tabIndex={-1}
onKeyDown={(e) => { if (e.key === 'Escape') closeMenu(); }}
>
<div className="drawer-header">
<span className="drawer-brand">{siteName}</span>
<button type="button" className="btn-icon" onClick={closeMenu} aria-label="关闭菜单">
<span className="material-icons">close</span>
</button>
</div>
<nav className="drawer-menu" aria-label="页面导航">
{tabs.filter((t) => t.show).map((t) =>
t.fullPage ? (
<a key={t.to} href={t.to} className="drawer-item" onClick={closeMenu}>{t.label}</a>
) : (
<NavLink
key={t.to}
to={t.to}
end={t.end}
className={({ isActive }) => 'drawer-item' + (isActive ? ' active' : '')}
onClick={closeMenu}
>
{t.label}
</NavLink>
)
)}
</nav>
<div className="drawer-section">
<div className="drawer-section-title">账号</div>
{user ? (
<>
<Link to="/profile.html" className="drawer-item" onClick={closeMenu}>个人中心</Link>
<button type="button" className="drawer-item drawer-item-btn" onClick={() => { closeMenu(); handleLogout(); }}>
退出登录
</button>
</>
) : (
<>
<Link to="/login.html" className="drawer-item" onClick={closeMenu}>登录</Link>
<Link to="/register.html" className="drawer-item" onClick={closeMenu}>注册</Link>
</>
)}
</div>
<div className="drawer-footer">
<button type="button" className="btn btn-tonal btn-sm" onClick={() => { closeMenu(); handleToggleTheme(); }}>
<span className="material-icons" style={{ fontSize: 16 }}>{theme === 'dark' ? 'light_mode' : 'dark_mode'}</span>
{theme === 'dark' ? '切换浅色模式' : '切换深色模式'}
</button>
</div>
</aside>
<main className="page">
<Outlet />
</main>
+93
View File
@@ -0,0 +1,93 @@
import React, { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
/**
* markdown 锁定块占位(未解锁时渲染,MD3 风格):
* - login → 「登录后查看」+ 去登录按钮
* - reply → 「评论后可见」+ 去评论按钮(onGoComment 滚动到评论区)
* - password → 密码输入 + 解锁按钮(onUnlock(index, password),失败显示错误态)
* 键盘:密码框 Enter 提交、Esc 清空;Tab 自然可达。
*/
export default function LockBlock({ index, type = 'password', onUnlock, onGoComment }) {
const [pwd, setPwd] = useState('');
const [err, setErr] = useState('');
const [busy, setBusy] = useState(false);
const pwdRef = useRef(null);
// 弹窗/焦点管理不适用这里(块内内联控件),但复用 useDialog 的 Esc 习惯:直接监听
useEffect(() => {
if (type === 'password') {
const h = (e) => {
if (e.key === 'Escape') { setPwd(''); setErr(''); }
};
window.addEventListener('keydown', h);
return () => window.removeEventListener('keydown', h);
}
return undefined;
}, [type]);
const tryUnlock = async () => {
if (busy) return;
if (!pwd) { setErr('请输入密码'); if (pwdRef.current) pwdRef.current.focus(); return; }
setBusy(true);
setErr('');
try {
await onUnlock(index, pwd);
// 成功后父级重渲染(unlocked/lockContent 更新),本组件被内容替换
} catch (e) {
setErr((e && e.message) || '密码错误');
}
setBusy(false);
};
if (type === 'login') {
return (
<div className="lock-placeholder">
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
<div className="lock-title">登录后查看</div>
<div className="lock-desc">登录后可查看此内容</div>
<Link to="/login.html" className="btn btn-filled btn-sm">去登录</Link>
</div>
);
}
if (type === 'reply') {
return (
<div className="lock-placeholder">
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
<div className="lock-title">评论后可见</div>
<div className="lock-desc">发表评论后即可查看此内容</div>
<button type="button" className="btn btn-filled btn-sm" onClick={onGoComment}>去评论</button>
</div>
);
}
// password
return (
<div className="lock-placeholder">
<span className="material-icons lock-icon" aria-hidden="true">lock</span>
<div className="lock-title">密码可见</div>
<div className="lock-desc">输入访问密码解锁内容</div>
<div className="lock-form">
<input
ref={pwdRef}
type="password"
className={'lock-pwd-input' + (err ? ' error' : '')}
value={pwd}
placeholder="访问密码"
aria-label="访问密码"
autoComplete="off"
onChange={(e) => { setPwd(e.target.value); if (err) setErr(''); }}
onKeyDown={(e) => {
if (e.key === 'Enter') tryUnlock();
else if (e.key === 'Escape') { setPwd(''); setErr(''); }
}}
/>
<button type="button" className="btn btn-filled btn-sm" onClick={tryUnlock} disabled={busy}>
{busy ? '解锁中…' : '解锁'}
</button>
</div>
{err && <div className="lock-error" role="alert"><span className="material-icons" style={{ fontSize: 14 }}>error_outline</span> {err}</div>}
</div>
);
}
+298
View File
@@ -0,0 +1,298 @@
import React, { useEffect, useRef, useState } from 'react';
import { uploadFile } from '../api/upload.js';
import { showSnackbar, useDialog } from '../lib/utils.js';
import MarkdownRenderer from './MarkdownRenderer.jsx';
/**
* 标准防抖:value 停止变化 debounceMs 后才更新返回值。
* useEffect 内建 setTimeout,卸载时 clearTimeout 清理 timer。
*/
function useDebounced(value, debounceMs) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), debounceMs);
return () => clearTimeout(timer);
}, [value, debounceMs]);
return debounced;
}
/**
* 共用 Markdown 编辑器(博客写作页 / 论坛发帖弹窗):
* 左侧编辑区(textarea)+ 右侧实时预览区,预览防抖刷新(默认 1200ms,停止输入才重渲染,
* 避免每敲一键都重渲染造成"抖动")。
*
* 工具栏:加粗 / 斜体 / 链接 / 代码块 / 上传图片或附件(复用 uploadFile → [image:]/[file:] 标签)。
* 附件标签在编辑区以明文显示,预览区由 MarkdownRendererDOMPurify 净化)渲染成 /uploads/ 链接。
*
* props:
* value / onChange — 受控内容
* placeholder — textarea 占位文案
* label — textarea 无障碍标签(默认 "Markdown 内容"
* compact — 紧凑模式(弹窗内使用,更小的字号/边距/最小高度)
* debounceMs — 预览防抖毫秒数,默认 1200
* className — 附加到根节点的 class
*/
export default function MarkdownEditor({
value = '',
onChange,
placeholder = '支持 Markdown 语法',
label = 'Markdown 内容',
compact = false,
debounceMs = 1200,
className = '',
}) {
const textareaRef = useRef(null);
const fileInputRef = useRef(null);
const [dragging, setDragging] = useState(false);
const [uploadStatus, setUploadStatus] = useState('');
// 锁定块插入弹窗
const [lockOpen, setLockOpen] = useState(false);
const [lockType, setLockType] = useState('login');
const [lockPwd, setLockPwd] = useState('');
const { dialogRef: lockDialogRef, onKeyDown: lockDialogKey } = useDialog(lockOpen, () => setLockOpen(false));
const LOCK_TYPES = [
{ type: 'login', label: '登录可见', icon: 'person' },
{ type: 'reply', label: '评论后可见', icon: 'chat_bubble' },
{ type: 'password', label: '密码可见', icon: 'lock' },
];
// 右侧预览的防抖值:停止输入 debounceMs 后才刷新
const previewValue = useDebounced(value, debounceMs);
const pending = value !== previewValue;
/** 在光标处插入文本,并在重渲染后把光标移到插入内容末尾 */
const setValueAtCaret = (text) => {
const v = value || '';
const ta = textareaRef.current;
if (ta && typeof ta.selectionStart === 'number') {
const s = ta.selectionStart;
const e = ta.selectionEnd;
const next = v.slice(0, s) + text + v.slice(e);
const caret = s + text.length;
onChange(next);
requestAnimationFrame(() => {
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
} else {
onChange(v + text);
}
};
/** 工具栏包装:选中文本用 before/after 包裹(无选中时用 fallback 占位) */
const wrapSelection = (before, after, fallback) => {
const v = value || '';
const ta = textareaRef.current;
if (ta && typeof ta.selectionStart === 'number') {
const s = ta.selectionStart;
const e = ta.selectionEnd;
const inner = v.slice(s, e) || fallback || '';
const next = v.slice(0, s) + before + inner + after + v.slice(e);
const caret = s + before.length + inner.length + after.length;
onChange(next);
requestAnimationFrame(() => {
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
} else {
onChange(v + before + (fallback || '') + after);
}
};
/** 插入链接:光标定位到 url 位置方便直接输入 */
const insertLink = () => {
const v = value || '';
const ta = textareaRef.current;
if (ta && typeof ta.selectionStart === 'number') {
const s = ta.selectionStart;
const e = ta.selectionEnd;
const text = v.slice(s, e) || '链接文字';
const url = 'https://';
const next = v.slice(0, s) + `[${text}](${url})` + v.slice(e);
const caret = s + 1 + text.length + 2 + url.length; // 落在 (https:// 之后
onChange(next);
requestAnimationFrame(() => {
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
} else {
onChange(v + '[链接文字](https://)');
}
};
/** 上传附件/图片 → 插入 [image:]/[file:] 标签(复用 Write.jsx 的 uploadFile 模式) */
const doUpload = async (file) => {
try {
const data = await uploadFile(file);
setValueAtCaret('\n' + data.tag + '\n');
setUploadStatus('已插入: ' + data.tag);
} catch (e) {
showSnackbar(e.message);
}
};
/**
* 插入锁定块:有选中文本则包裹,无选中插入占位文本。
* 语法约定:`[lock:login]` / `[lock:reply]` / `[lock:password:密码]`(密码型带明文密码参数,后端解析存库)
*/
const doInsertLock = () => {
const tag = lockType === 'password'
? `[lock:password:${lockPwd.trim()}]`
: `[lock:${lockType}]`;
wrapSelection(tag + '\n', '\n[/lock]', '锁定内容');
setLockOpen(false);
setLockPwd('');
};
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();
setDragging(false);
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);
};
return (
<div className={'md-editor' + (compact ? ' compact' : '') + (className ? ' ' + className : '')}>
{/* 工具栏 */}
<div className="md-editor-toolbar">
<button type="button" className="btn-icon" title="加粗" aria-label="加粗" onClick={() => wrapSelection('**', '**', '加粗文字')}>
<span className="material-icons">format_bold</span>
</button>
<button type="button" className="btn-icon" title="斜体" aria-label="斜体" onClick={() => wrapSelection('*', '*', '斜体文字')}>
<span className="material-icons">format_italic</span>
</button>
<button type="button" className="btn-icon" title="插入链接" aria-label="插入链接" onClick={insertLink}>
<span className="material-icons">link</span>
</button>
<button type="button" className="btn-icon" title="代码块" aria-label="插入代码块" onClick={() => wrapSelection('```\n', '\n```', '代码')}>
<span className="material-icons">code</span>
</button>
<button type="button" className="btn-icon" title="上传图片或附件" aria-label="上传图片或附件" onClick={() => fileInputRef.current && fileInputRef.current.click()}>
<span className="material-icons">upload</span>
</button>
<button type="button" className="btn-icon" title="插入锁定内容(登录/评论/密码可见)" aria-label="插入锁定内容" onClick={() => setLockOpen(true)}>
<span className="material-icons">lock</span>
</button>
{uploadStatus && <span className="md-editor-upload-status">{uploadStatus}</span>}
</div>
{/* 左编辑 / 右预览 */}
<div className="md-editor-panes">
<div
className={'md-editor-pane md-editor-input' + (dragging ? ' dragover' : '')}
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false); }}
onDrop={handleDrop}
>
<div className="md-editor-pane-head">
<span className="material-icons" style={{ fontSize: 15 }}>edit_note</span> 内容
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
aria-label={label}
spellCheck="false"
/>
{dragging && (
<div className="md-editor-drop-hint">
<span className="material-icons" style={{ fontSize: 20 }}>image</span> 松开以插入图片
</div>
)}
</div>
<div className="md-editor-pane md-editor-preview">
<div className="md-editor-pane-head">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> 预览
<span className={'md-editor-sync' + (pending ? ' pending' : '')}>
{pending ? '待刷新…' : '已同步'}
</span>
</div>
<div className="md-editor-preview-body" role="region" aria-label="Markdown 实时预览">
{previewValue.trim() ? (
<MarkdownRenderer content={previewValue} useMarkdown previewMode />
) : (
<div className="md-editor-preview-empty">预览区 输入内容后稍候自动更新</div>
)}
</div>
</div>
</div>
<input ref={fileInputRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
{/* 锁定块插入弹窗 */}
{lockOpen && (
<div
ref={lockDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="插入锁定内容"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setLockOpen(false); }}
onKeyDown={lockDialogKey}
>
<div className="dialog">
<h3>插入锁定内容</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
选择可见条件满足条件后读者才可查看锁定部分
</p>
<div className="lock-type-options" role="radiogroup" aria-label="可见条件">
{LOCK_TYPES.map((o) => (
<button
key={o.type}
type="button"
role="radio"
aria-checked={lockType === o.type}
className={'lock-type-option' + (lockType === o.type ? ' active' : '')}
onClick={() => setLockType(o.type)}
>
<span className="material-icons" style={{ fontSize: 18 }}>{o.icon}</span> {o.label}
</button>
))}
</div>
{lockType === 'password' && (
<div className="form-group" style={{ marginTop: 12 }}>
<label htmlFor="lockPwdInput">访问密码</label>
<input
id="lockPwdInput"
type="text"
value={lockPwd}
onChange={(e) => setLockPwd(e.target.value)}
placeholder="读者解锁时输入的密码"
autoComplete="off"
/>
</div>
)}
<div className="actions">
<button className="btn btn-text" onClick={() => setLockOpen(false)}>取消</button>
<button
className="btn btn-filled"
onClick={doInsertLock}
disabled={lockType === 'password' && !lockPwd.trim()}
>
插入
</button>
</div>
</div>
</div>
)}
</div>
);
}
+236 -16
View File
@@ -3,32 +3,143 @@ import { marked } from 'marked';
import DOMPurify from 'dompurify';
import { escapeHtml } from '../lib/utils.js';
import { getToken } from '../api/client.js';
import LockBlock from './LockBlock.jsx';
/** [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">`;
/**
* 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验,
* 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。
*/
function lockedAttachmentUrl(refType, refId, blockIndex, filename, token) {
if (!refType || refId == null || !token) return null;
return `/api/upload/locked?ref_type=${encodeURIComponent(refType)}&ref_id=${refId}&block=${encodeURIComponent(blockIndex)}&file=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}`;
}
/** [file:文件名] → 带 token 的下载链接 */
function fileTag(filename) {
/** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */
function imageTag(filename, lockedUrl) {
if (!filename) return '';
const src = lockedUrl || `/uploads/${encodeURIComponent(filename)}`;
return `<img src="${src}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
}
/** [file:文件名] → 下载链接;lockedUrl 存在则 href 走鉴权接口(保留 target=_blank 与样式),否则原下载逻辑 */
function fileTag(filename, lockedUrl) {
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">
const href = lockedUrl || `/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}`;
return `<a href="${href}" 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>`;
}
/**
* 剥离内联 [lock:] 成对标签,保留块内内容(只去标签不去内容)。
* 保留导出供外部/预览使用;renderContent 不再调用——组件层已用切段方案接管(保留锁块容器)。
*/
export function stripLockTags(content) {
return String(content).replace(/\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\]/g, '$1');
}
/** 从整块标签提取 lock 类型参数:'login' / 'reply' / 'password'(无参数返回 '' */
function extractLockType(block) {
const m = String(block).match(/^\[lock(?::([^\]]*))?\]/);
const param = (m && m[1]) || '';
return param.split(':')[0] || '';
}
/**
* 按内联 [lock:] 成对标签切段(split 带捕获组):
* 交替产出 [文本, 整块(含标签), 块内内容, 文本, …](i%3:0=文本 / 1=整块 / 2=块内内容)
*/
const INLINE_LOCK_RE = /(\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\])/g;
/** 统计文本段内内联 [lock:] 成对标签数量(全文锁块索引游标推进用) */
function countInlineLocks(text) {
const m = String(text).match(INLINE_LOCK_RE);
return m ? m.length : 0;
}
/**
* 渲染一段文本:文本段走 renderContent;内联 [lock:] 段渲染「已解锁内容」容器。
* 已解锁块(admin/作者/登录或评论后可见)后端在 content 里原样保留 [lock:...]...[/lock] 标签,
* 这里按标签切成锁块容器(同款背景+边框 + 🔓 已解锁头部),内容递归 MarkdownRenderer 渲染。
* rest.inlineStartIdx:本段起始内联块在全文中的索引(buildNodes 用锁块游标给出),
* 用于查 locks 数组拿该块的附件 token(块内 [image:]/[file:] 走鉴权接口)。
*/
function renderSegments(text, useMarkdown, rest) {
const { locks, refType, refId, blockCtx: parentCtx } = rest;
const parts = String(text).split(INLINE_LOCK_RE);
const nodes = [];
let textIdx = 0;
let lockIdx = 0;
let tagIdx = rest.inlineStartIdx || 0; // 内联块在全文中的索引游标
for (let i = 0; i < parts.length; i += 1) {
const p = parts[i];
if (i % 3 === 1) {
// 整块(含标签),紧随其后 i+1 是对应块内内容
const inner = parts[i + 1] || '';
const type = extractLockType(p);
const idx = tagIdx++;
// 内联块附件上下文:已处锁块内(parentCtx)时沿用父块 token(嵌套块文件同属父块);
// 顶层则按本段索引查 locks 数组拿该块的 token(无 token 保持直链向后兼容)
let childCtx = null;
if (parentCtx) {
childCtx = parentCtx;
} else {
const meta = (Array.isArray(locks) && locks.find((l) => l.index === idx)) || {};
if (meta.token) childCtx = { index: idx, token: meta.token };
}
nodes.push(
<div key={'reveal' + lockIdx++} className="lock-reveal">
<div className="lock-placeholder lock-revealed">
<div className="lock-revealed-head">
<span className="material-icons" style={{ fontSize: 16 }} aria-hidden="true">lock_open</span>
已解锁内容
{type ? <span className="lock-revealed-type">{type}</span> : null}
</div>
<div className="lock-revealed-body">
<MarkdownRenderer
content={inner}
useMarkdown={useMarkdown}
locks={locks}
unlocked={rest.unlocked}
lockContent={rest.lockContent}
onUnlock={rest.onUnlock}
onGoComment={rest.onGoComment}
refType={refType}
refId={refId}
blockCtx={childCtx}
/>
</div>
</div>
</div>
);
i += 1; // 跳过内容段(已作为 inner 渲染)
} else if (p) {
// 文本段:仅当外层处于已解锁锁块内(parentCtx)时附件才改走鉴权接口
const attach = parentCtx ? { refType, refId, blockIndex: parentCtx.index, token: parentCtx.token || '' } : null;
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown, attach)} />);
}
}
return nodes;
}
/**
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
* attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文;
* 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。
*/
export function renderContent(content, useMarkdown) {
export function renderContent(content, useMarkdown, attach) {
if (content == null) return '';
// 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑
const lockedUrl = (filename) => (attach
? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token)
: null);
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(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f)));
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f)));
html = html.replace(/\n/g, '<br>');
return DOMPurify.sanitize(html);
}
@@ -41,12 +152,121 @@ export function renderContent(content, useMarkdown) {
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)]));
return DOMPurify.sanitize(html);
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)], lockedUrl(images[parseInt(i)])));
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(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 }} />;
/** 单段文本块:dangerouslySetInnerHTML 包装(renderContent 已 DOMPurify 净化) */
function RawBlock({ html }) {
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
/**
* 构建渲染节点(两层分段):
* 1. @@LOCK<n>@@ 占位分段(详情页 locks 非空)——lock 段:已解锁+有内容 → 递归渲染(.lock-reveal 淡入),
* 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)。
* 文本段内联块索引用全文游标(nextIdx)衔接,保证与后端 parseLocks 的 index 一致。
* 2. locks 为空(含 previewMode 编辑预览):整段按内联 [lock:] 切段,作者视角同样显示已解锁容器
*/
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }) {
if (content == null) return null;
const str = String(content);
const hasLocks = Array.isArray(locks) && locks.length > 0;
if (hasLocks) {
// 服务端已把未解锁块替换为 @@LOCK<n>@@split 交替产出 [文本, 索引, 文本, 索引…]
const parts = str.split(/@@LOCK(\d+)@@/);
const nodes = [];
let nextIdx = 0; // 全文锁块索引游标:内联块与 @@LOCK 占位按文档顺序递增
for (let i = 0; i < parts.length; i += 1) {
const p = parts[i];
if (i % 2 === 1) {
const n = parseInt(p, 10);
nextIdx = n + 1; // 占位符自带索引,同步游标
const meta = locks.find((l) => l.index === n) || {};
const type = meta.type || 'password';
const lockEntry = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
if (lockEntry) {
// lockContent 兼容纯字符串或 { content, token }token 优先取 locks 数组项(后端按块签发)
const raw = typeof lockEntry === 'object' ? lockEntry.content : lockEntry;
const entryToken = typeof lockEntry === 'object' && lockEntry.token ? lockEntry.token : '';
const token = (meta.token || entryToken) || '';
nodes.push(
<div key={'lock' + n} className="lock-reveal">
<MarkdownRenderer
content={raw}
useMarkdown={useMarkdown}
locks={locks}
unlocked={unlocked}
lockContent={lockContent}
onUnlock={onUnlock}
onGoComment={onGoComment}
refType={refType}
refId={refId}
blockCtx={{ index: n, token }}
/>
</div>
);
} else {
nodes.push(<LockBlock key={'lock' + n} index={n} type={type} onUnlock={onUnlock} onGoComment={onGoComment} />);
}
} else if (p) {
// 文本段:可能仍含内联 [lock:](已解锁块)→ 切段渲染已解锁容器
nodes.push(...renderSegments(p, useMarkdown, {
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
inlineStartIdx: nextIdx,
}));
nextIdx += countInlineLocks(p);
}
}
return nodes;
}
// locks 为空:整段按内联 [lock:] 切段(previewMode 作者预览同样显示「已解锁内容」容器)
return renderSegments(str, useMarkdown, {
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
inlineStartIdx: 0,
});
}
/**
* Markdown 渲染器(前台统一入口):
* props:
* content / useMarkdown — 同原有
* locks — [{index, type}](新后端带 {index, type, token})详情接口返回的锁定块元信息
* unlocked — Set<number> 已解锁索引(login/reply 由服务端判定后内容直接给原文;
* password 类解锁后内容存 lockContent
* lockContent — Map<number, string | {content, token}> password 块解锁拿到的 markdown(仅内存态)
* onUnlock — async (index, password?) → 解锁;失败 throw
* onGoComment — reply 块「去评论」滚动回调
* previewMode — 编辑器作者预览:锁定标签直接展开
* refType — 'blog' | 'forum'(可选):提供后锁块内 [image:]/[file:] 附件改走鉴权接口
* refId — 详情 id(可选,配合 refType
* blockCtx — {index, token}(可选):当前渲染上下文处于已解锁锁块内,附件用该块的 token 鉴权
*/
export default function MarkdownRenderer({
content = '',
useMarkdown = true,
locks,
unlocked,
lockContent,
onUnlock,
onGoComment,
previewMode = false,
refType,
refId,
blockCtx,
}) {
const nodes = useMemo(
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }),
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx]
);
return <div className="md-body">{nodes}</div>;
}
+2 -2
View File
@@ -49,9 +49,9 @@ export default function MusicEmbed({ settings }) {
>
{/* 保持挂载以保证折叠后音乐继续播放(display 由 .music-embed.collapsed 规则控制) */}
<div className="music-embed-player" dangerouslySetInnerHTML={{ __html: code }} />
<div className="music-embed-icon" onClick={expand} title="展开播放器">
<button type="button" className="music-embed-icon" onClick={expand} title="展开播放器" aria-label="展开播放器">
<span className="material-icons">music_note</span>
</div>
</button>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import React from 'react';
/**
* 全站作者名渲染(前台):
* - name 显示名(后端 author_name 已归一 = nickname||username,调用方负责兜底匿名/游客)
* - uid 用户 idshowUid 时显示 #id 后缀
* - title 自定义头衔文案(非空才渲染 chip)
* - titleColor 头衔 chip 背景色(hex,可空 → 默认中性色)
* - role 'admin' → 系统身份 chip「管理员」(固定系统配色)
* - moderatorLabel 版主 chip 文案(如「版主·闲聊」);接口未提供时留空则不渲染
* - showUid UID 后缀开关:帖子发布者/楼主/博客作者/个人主页恒显;
* 评论作者/回复楼层由 show_uid_in_comments 设置控制
*
* 渲染 = 名字 + 系统身份 chip(管理员/版主)+ 自定义头衔 chip + UID 后缀,
* 样式命名空间 .username-* / .title-chip(见 public/css/style.css)。
*/
export default function UserName({
name = '',
uid,
title = '',
titleColor = '',
role = '',
showUid = false,
moderatorLabel = '',
className = '',
}) {
return (
<span className={'username' + (className ? ' ' + className : '')}>
<span className="username-name">{name || '匿名'}</span>
{role === 'admin' ? <span className="username-chip username-chip-role">管理员</span> : null}
{moderatorLabel ? <span className="username-chip username-chip-moderator">{moderatorLabel}</span> : null}
{title ? (
<span
className={'username-chip title-chip' + (titleColor ? ' title-chip-colored' : '')}
style={titleColor ? { background: titleColor } : undefined}
>
{title}
</span>
) : null}
{showUid && uid != null ? <span className="username-uid">#{uid}</span> : null}
</span>
);
}
+56
View File
@@ -0,0 +1,56 @@
/**
* 外链跳转确认页工具(配合后端 GET /out?url=<encoded>):
* - 站内路径(/ 开头)原样返回
* - http(s) 外链 → /out?url=<encodeURIComponent(url)>(确认页 5s 倒计时后跳转)
* - 其他协议(javascript: 等)→ 返回 '#' 并 console 警告(防 XSS 链接)
*/
export function safeOutUrl(url) {
const u = String(url || '').trim();
if (!u) return '#';
if (u.startsWith('/')) return u; // 站内绝对/相对路径(含 /uploads、/out 本身)
if (/^https?:\/\//i.test(u)) {
// 本站家族域名(rainnya.asia 及子域名)直接跳转,不走确认页
if (isFamilyDomain(u)) return u;
return '/out?url=' + encodeURIComponent(u);
}
console.warn('[outlink] 不支持的链接协议,已拦截:', u);
return '#';
}
/** 判断链接是否为站内链接(/ 开头) */
export function isInternalUrl(url) {
return String(url || '').startsWith('/');
}
/** 本站家族域名:rainnya.asia 及所有子域名(www.rainnya.asia / rainid.rainnya.asia 等)视为站内,直接跳转不走确认页 */
export function isFamilyDomain(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
return hostname === 'rainnya.asia' || hostname.endsWith('.rainnya.asia');
} catch {
return false;
}
}
/**
* footer_columnsJSON 字符串)解析为 [{ title, links: [{ label, url }] }]
* 解析失败或非数组返回 [](前端回退硬编码栏目)。
*/
export function parseFooterColumns(raw) {
try {
const arr = JSON.parse(raw || '[]');
if (!Array.isArray(arr)) return [];
return arr
.filter((c) => c && typeof c === 'object')
.map((c) => ({
title: String(c.title || ''),
links: Array.isArray(c.links)
? c.links
.filter((l) => l && typeof l === 'object')
.map((l) => ({ label: String(l.label || ''), url: String(l.url || '') }))
: [],
}));
} catch {
return [];
}
}
+56
View File
@@ -1,3 +1,5 @@
import { useCallback, useEffect, useRef } from 'react';
/** HTML 转义:& < > " ' 全转义 */
export function escapeHtml(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, (ch) => {
@@ -12,6 +14,21 @@ export function escapeHtml(str) {
});
}
/** 分页接口返回归一化:兼容裸数组与 { posts|list|items, total, page, pageSize } 两种形态 */
export function normalizePagedList(data) {
if (Array.isArray(data)) return { items: data, total: data.length, page: 1, pageSize: data.length };
if (data && typeof data === 'object') {
const items = data.posts || data.list || data.items || [];
return {
items,
total: typeof data.total === 'number' ? data.total : items.length,
page: data.page || 1,
pageSize: typeof data.pageSize === 'number' ? data.pageSize : (items.length || 20),
};
}
return { items: [], total: 0, page: 1, pageSize: 20 };
}
/** 格式化时间为 YYYY-MM-DD HH:mm */
export function formatDate(input) {
const d = input instanceof Date ? input : new Date(input);
@@ -45,3 +62,42 @@ export function showSnackbar(msg) {
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 };
}
+18 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import BlogSidebar from '../components/BlogSidebar.jsx';
import { listPosts, searchPosts, getTags } from '../api/blog.js';
import { getPublicSettings } from '../api/settings.js';
@@ -13,10 +13,23 @@ function excerptOf(p) {
/** 博客列表页(迁移自 blog.html + blog.js loadPosts):瀑布流卡片,点击进详情;
* 顶部搜索框(/api/blog/search+ 标签云(/api/blog/tags,点击进 /tag/:name+ 归档入口 */
export default function Blog() {
const navigate = useNavigate();
const [settings, setSettings] = useState({});
const [posts, setPosts] = useState(null); // null=加载中
const [error, setError] = useState('');
// 分享链接跳转 SPA 完整版:/blog/:id/share → 302 到此页 ?share=<id>
// 检测到后客户端 navigate 到详情(React Router 内部跳转,不触发整页刷新 → 完整 SPA UI)。
// 只在首次挂载处理一次;replaceState 清掉 share 参数避免刷新重复跳。
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const shareId = params.get('share');
if (shareId && /^\d+$/.test(shareId)) {
history.replaceState({}, '', window.location.pathname);
navigate('/blog/' + shareId);
}
}, [navigate]);
// 搜索状态
const [query, setQuery] = useState('');
const [results, setResults] = useState(null); // null=未搜索
@@ -88,6 +101,7 @@ export default function Blog() {
return (
<div className="homepage-layout blog-layout">
<h1 className="sr-only">博客</h1>
<BlogSidebar settings={settings} />
<div className="homepage-content">
{/* 工具条:搜索框 + 归档 */}
@@ -113,6 +127,9 @@ export default function Blog() {
<Link to="/archive.html" className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>archive</span> 归档
</Link>
<a href="/feed.xml" target="_blank" rel="noopener" className="btn btn-text btn-sm" title="RSS 订阅" aria-label="RSS 订阅">
<span className="material-icons" style={{ fontSize: 16 }}>rss_feed</span> RSS
</a>
</form>
{/* 标签云 */}
+188 -50
View File
@@ -4,7 +4,10 @@ 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';
import Avatar from '../components/Avatar.jsx';
import UserName from '../components/UserName.jsx';
import * as settingsApi from '../api/settings.js';
import { showSnackbar, useDialog } from '../lib/utils.js';
/** 字数统计:剥离 markdown 符号与 [image:]/[file:] 标签后,中文字符 + 英文单词数 */
function countWords(content) {
@@ -33,8 +36,48 @@ export default function BlogDetail() {
const [prevnext, setPrevnext] = useState(null);
const [like, setLike] = useState({ liked: false, count: 0 });
const [toc, setToc] = useState([]);
const [tocOpen, setTocOpen] = useState(true);
const [replyTo, setReplyTo] = useState(null); // {id, name}
const [shareOpen, setShareOpen] = useState(false);
// 评论作者 UID 显示开关(site_settings.show_uid_in_comments,默认显示)
const [showCommentUid, setShowCommentUid] = useState(true);
// markdown 锁定块:locks 元信息 / 已解锁索引(Set/ password 块内容(Map,仅内存态)/ viewer 判定结果
const [locks, setLocks] = useState([]);
const [unlocked, setUnlocked] = useState(() => new Set());
const [lockContent, setLockContent] = useState(() => new Map());
const [viewer, setViewer] = useState(null);
// 分享链接:/blog/:id/share → 服务端 302 到 SPA 完整版(运行时拼接,不硬编码域名)
const shareUrl = typeof window !== 'undefined'
? window.location.origin + '/blog/' + id + '/share'
: '';
// 分享弹窗键盘/焦点管理(Esc 关闭 + 焦点还给触发按钮)
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
const copyShareLink = async () => {
const ok = await (async () => {
try {
await navigator.clipboard.writeText(shareUrl);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = shareUrl;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const res = document.execCommand('copy');
document.body.removeChild(ta);
return res;
} catch { return false; }
}
})();
if (ok) showSnackbar('链接已复制');
else showSnackbar('复制失败,请手动复制');
};
const load = useCallback(async () => {
setLoading(true);
@@ -51,6 +94,11 @@ export default function BlogDetail() {
setComments(cs || []);
setPrevnext(pn);
setLike(lk || { liked: false, count: 0 });
// 锁定块:详情接口返回 locks/unlocked/viewer(旧数据无则空)
setLocks((p && p.locks) || []);
setUnlocked(new Set((p && p.unlocked) || []));
setLockContent(new Map());
setViewer((p && p.viewer) || null);
} catch (e) {
setError(e.message || '加载失败');
} finally {
@@ -60,6 +108,10 @@ export default function BlogDetail() {
useEffect(() => {
load();
// 评论 UID 开关(公开设置;后端未加字段时默认显示)
settingsApi.getPublicSettings()
.then((s) => setShowCommentUid(s.show_uid_in_comments !== '0'))
.catch(() => setShowCommentUid(true));
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
} else {
@@ -67,22 +119,44 @@ export default function BlogDetail() {
}
}, [load]);
// 目录:从已渲染的 .md-body 提取 h2/h3 并打锚点 id
useEffect(() => {
if (!post) return;
const body = document.querySelector('.blog-article .md-body');
if (!body) { setToc([]); return; }
const items = [...body.querySelectorAll('h2, h3')].map((h, i) => {
if (!h.id) h.id = 'toc-' + i;
return { id: h.id, text: (h.textContent || '').trim(), level: h.tagName === 'H2' ? 2 : 3 };
});
setToc(items);
}, [post]);
const canEdit = user && post && (user.role === 'admin' || user.id === post.author_id);
const 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]);
// ── markdown 锁定块 ──
// 解锁 password 块:调解锁接口,内容 + 附件 tokenlockToken)仅存内存 state(刷新失效)
const unlockLock = async (index, password) => {
const res = await blogApi.unlockLock(id, index, password);
if (!res || !res.ok || !res.content) throw new Error((res && res.error) || '解锁失败');
setLockContent((prev) => {
const m = new Map(prev);
m.set(index, { content: res.content, token: res.lockToken || '' });
return m;
});
setUnlocked((prev) => { const s = new Set(prev); s.add(index); return s; });
};
// reply 块「去评论」:滚动到评论区(锁定块在正文中,评论区在下方)
const scrollToComment = () => {
const el = document.querySelector('.comment-form') || document.querySelector('.comment-list');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
};
// ── 点赞(乐观更新)──
const toggleLike = async () => {
if (!user) { showSnackbar('登录后可以点赞'); return; }
@@ -106,7 +180,15 @@ export default function BlogDetail() {
return (
<div key={c.id} className={'reply-item' + (depth > 0 ? ' reply-child' : '')}>
<div className="reply-meta">
<strong>{c.author_name || '游客'}</strong> · {c.created_at}
<Avatar src={c.author_avatar} name={c.author_name} size={22} className="avatar-sm" to={c.author_id ? `/u/${c.author_id}` : undefined} />
<UserName
name={c.author_name || '游客'}
uid={c.author_id}
title={c.author_title}
titleColor={c.author_title_color}
role={c.author_role}
showUid={showCommentUid && !!c.author_id}
/> · {c.created_at}
{kids.length > 0 && <span className="reply-count">回复 {kids.length}</span>}
</div>
<div className="reply-body">{c.content}</div>
@@ -161,7 +243,8 @@ export default function BlogDetail() {
}
return (
<div className="blog-article">
<div className="article-layout">
<div className="blog-article article-main">
<Link to="/blog.html" className="btn btn-text btn-sm" style={{ marginBottom: 16 }}>
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回列表
</Link>
@@ -169,7 +252,15 @@ export default function BlogDetail() {
<h1 className="article-title">{post.title}</h1>
<div className="article-meta">
{post.author_name || '管理员'} · {post.created_at}
<Avatar src={post.author_avatar} name={post.author_name} size={24} to={post.author_id ? `/u/${post.author_id}` : undefined} />
<UserName
name={post.author_name || '管理员'}
uid={post.author_id}
title={post.author_title}
titleColor={post.author_title_color}
role={post.author_role}
showUid={!!post.author_id}
/> · {post.created_at}
<span className="meta-stat" title="阅读量">
<span className="material-icons" style={{ fontSize: 15 }}>visibility</span> {post.views || 0}
</span>
@@ -185,46 +276,29 @@ export default function BlogDetail() {
)}
<div className="article-actions">
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞">
<button className={'btn like-btn' + (like.liked ? ' liked' : '')} onClick={toggleLike} title="点赞" aria-label="点赞" aria-pressed={like.liked}>
<span className="material-icons" style={{ fontSize: 18 }}>{like.liked ? 'favorite' : 'favorite_border'}</span>
<span className="like-count">{like.count}</span>
</button>
<button className="btn btn-tonal btn-sm" onClick={() => setShareOpen(true)} aria-label="分享文章">
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
</button>
{canEdit && (
<Link to={`/write.html?edit=${post.id}`} className="btn btn-tonal btn-sm">编辑</Link>
)}
</div>
{/* 目录(正文顶部,可折叠) */}
{toc.length > 0 && (
<div className="toc-block">
<button className="toc-toggle" onClick={() => setTocOpen((v) => !v)}>
<span className="material-icons" style={{ fontSize: 17 }}>format_list_bulleted</span>
目录
<span className="toc-count">{toc.length}</span>
<span className={'material-icons toc-arrow' + (tocOpen ? ' open' : '')} style={{ fontSize: 18 }}>expand_more</span>
</button>
{tocOpen && (
<ul className="toc-list">
{toc.map((t) => (
<li key={t.id} className={'toc-item toc-lv' + t.level}>
<a
href={'#' + t.id}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(t.id);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
>
{t.text}
</a>
</li>
))}
</ul>
)}
</div>
)}
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
<MarkdownRenderer
content={post.content}
useMarkdown={post.use_markdown}
locks={locks}
unlocked={unlocked}
lockContent={lockContent}
onUnlock={unlockLock}
onGoComment={scrollToComment}
refType="blog"
refId={post.id}
/>
{/* 上一篇 / 下一篇 */}
{prevnext && (prevnext.prev || prevnext.next) && (
@@ -253,7 +327,7 @@ export default function BlogDetail() {
)}
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '32px 0' }} />
<h4 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h4>
<h2 style={{ fontWeight: 500, marginBottom: 16 }}>评论 ({comments.length})</h2>
{comments.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无评论</p>
@@ -294,6 +368,70 @@ export default function BlogDetail() {
<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>
)}
{/* 分享弹窗(与全站 dialog 风格一致:Esc/遮罩关闭、焦点管理) */}
{shareOpen && (
<div
ref={shareDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="分享文章"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
onKeyDown={shareDialogKey}
>
<div className="dialog">
<h3>分享文章</h3>
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
复制链接分享好友打开直达完整阅读页面
</p>
<div className="form-group">
<label htmlFor="shareUrlInput">分享链接</label>
<input
id="shareUrlInput"
type="text"
readOnly
value={shareUrl}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
<button className="btn btn-filled" onClick={copyShareLink}>
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
</button>
</div>
</div>
</div>
)}
</div>
);
}
+9 -1
View File
@@ -44,6 +44,7 @@ export default function Embed() {
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 }}
@@ -67,7 +68,14 @@ export default function Embed() {
Ignore X-Frame-Headers
</a>{' '}
扩展或点击右上角原站在新标签页打开
<span onClick={() => setShowHint(false)} style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600 }}></span>
<button
type="button"
onClick={() => setShowHint(false)}
aria-label="关闭提示"
style={{ cursor: 'pointer', marginLeft: 8, fontWeight: 600, height: 'auto', padding: '2px 8px', fontSize: 'inherit', fontFamily: 'inherit', background: 'transparent', border: 'none', color: 'inherit' }}
>
</button>
</div>
)}
+142 -237
View File
@@ -1,269 +1,174 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import * as settingsApi from '../api/settings.js';
import * as announcementsApi from '../api/announcements.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 } from '../lib/utils.js';
import ForumIcon from '../components/ForumIcon.jsx';
/** 板块的子分类列表 */
function subCatsOf(cat) {
if (!cat || !cat.sub_categories) return [];
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
/** 版主名提取:兼容 ["a","b"] / [{username:"a"}] / "a,b" 三种后端形态 */
function moderatorsOf(cat) {
const m = cat && cat.moderators;
if (Array.isArray(m)) {
return m
.map((x) => (typeof x === 'string' ? x : (x && x.username) || ''))
.filter(Boolean)
.slice(0, 3);
}
if (typeof m === 'string') {
return m.split(',').map((s) => s.trim()).filter(Boolean).slice(0, 3);
}
return [];
}
/** 论坛首页:分类导航(侧栏 + 子分类 chips 筛选)+ 帖子列表 + 发帖弹窗(含验证码) */
/**
* 论坛首页路由 /forum.htmlL1 版块索引
* 全站公告区 + 页头标题/发帖+ 版块卡片网格聚合统计
* 分享链接处理?share=<id> 帖子详情?share_c=<id> 版块页replaceState 清参
* 游客预览开关forum_guest_visible==='0' 且未登录 登录提示
*/
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 [categories, setCategories] = useState(null); // null=
const [announcements, setAnnouncements] = useState([]);
const [loadError, setLoadError] = useState('');
const [user, setUser] = useState(null);
const [moderatedIds, setModeratedIds] = useState([]);
const [guestLocked, setGuestLocked] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
//
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([]);
// SPA ?share=<id> / ?share_c=<id> navigate
// replaceState
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const shareC = params.get('share_c');
if (shareC && /^\d+$/.test(shareC)) {
history.replaceState({}, '', window.location.pathname);
navigate('/forum/c/' + shareC);
return;
}
}, []);
const shareId = params.get('share');
if (shareId && /^\d+$/.test(shareId)) {
history.replaceState({}, '', window.location.pathname);
navigate('/forum/' + shareId);
}
}, [navigate]);
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);
// '0'
settingsApi.getPublicSettings()
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
.catch(() => { setSettingsLoaded(true); });
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
// admin role
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
} else {
navigate('/login.html');
setUser(null);
}
};
//
announcementsApi.listActive().then(setAnnouncements).catch(() => {});
}, []);
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);
// 401
useEffect(() => {
if (!settingsLoaded) return;
if (getToken() || !guestLocked) {
forumApi.listCategories()
.then((cs) => setCategories(cs || []))
.catch((e) => { setLoadError(e.message || '加载失败'); setCategories([]); });
}
}, [settingsLoaded, guestLocked]);
// Write
const handleNewPostBtn = () => {
if (user) window.location.href = '/write.html?type=forum';
else navigate('/login.html');
};
const handleFileSelect = (e) => {
const f = e.target.files && e.target.files[0];
if (f) doUpload(f);
e.target.value = '';
};
// /
const canManage = !!user && (user.role === 'admin' || moderatedIds.length > 0);
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);
//
const locked = settingsLoaded && guestLocked && !getToken();
if (locked) {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见登录后即可浏览与发帖</p>
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
</div>
);
}
return (
<div className="forum-layout">
<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}>
<div className="forum-home">
{announcements.length > 0 && (
<div className="announcement-bar">
<span className="material-icons ann-icon">campaign</span>
<span className="ann-content">
{announcements.map((a) => (
<span key={a.id} className="ann-item">
{a.title ? <strong>{a.title}</strong> : null}
{a.title ? '' : ''}{a.content}
</span>
))}
</span>
</div>
)}
<div className="forum-home-head">
<h1 className="page-title" style={{ margin: 0 }}>论坛</h1>
<div className="forum-home-actions">
<a className="btn-icon" href="/feed/forum.xml" target="_blank" rel="noopener" title="RSS 订阅(论坛全站)" aria-label="RSS 订阅">
<span className="material-icons">rss_feed</span>
</a>
{canManage && (
<Link to="/forum/manage" className="btn btn-tonal">
<span className="material-icons" style={{ fontSize: 16 }}>manage_accounts</span> 管理我的版块
</Link>
)}
<button className="btn btn-filled" onClick={handleNewPostBtn}>
{user ? '发帖' : '登录发帖'}
</button>
</div>
<div className="forum-cat-list">
{categories.map((c) => (
<div
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}
</div>
))}
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
<div
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
onClick={showAllPosts}
style={{ fontWeight: 500 }}
>
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
</div>
</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" style={{ cursor: 'default', fontSize: 12, padding: '1px 8px', color: 'var(--md-ref-on-surface-variant)', border: '1px solid var(--md-ref-outline-variant)' }}>板块</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 }}>
<span className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</span>
{subCats.map((s) => (
<span key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</span>
))}
</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" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'transparent', border: '1px solid var(--md-ref-outline-variant)', color: 'var(--md-ref-on-surface-variant)' }}>{catName}</span>
{p.sub_category ? (
<span className="chip" style={{ cursor: 'default', fontSize: 11, padding: '1px 8px', background: 'var(--md-ref-secondary-container)', color: 'var(--md-ref-on-secondary-container)' }}>{p.sub_category}</span>
) : null}
</div>
</Link>
);
})}
</div>
)}
</div>
{/* 发帖弹窗 */}
{showNewPost && (
<div
className="dialog-overlay active"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
>
<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>
{categories === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
{loadError && <div className="empty-state"><div className="empty-icon"></div><p>加载失败</p></div>}
{categories !== null && !loadError && categories.length === 0 && (
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无版块</p></div>
)}
{categories !== null && categories.length > 0 && (
<div className="forum-grid">
{categories.map((c) => {
const mods = moderatorsOf(c);
const postsCount = typeof c.post_count === 'number' ? c.post_count : null;
const todayCount = typeof c.today_count === 'number' ? c.today_count : null;
return (
<Link key={c.id} to={'/forum/c/' + c.id} className="card board-card">
<div className="board-card-top">
<ForumIcon icon={c.icon} name={c.name} iconColor={c.icon_color} size={44} />
<div className="board-card-title">
<span className="board-name">{c.name}</span>
{c.announcement ? <span className="board-badge" title="版块公告">📢</span> : null}
</div>
</div>
<p className="board-desc">{c.description || '暂无描述'}</p>
<div className="board-meta">
<span>{postsCount != null ? `${postsCount}` : ''}</span>
{todayCount ? <span className="board-today">今日 {todayCount}</span> : null}
{!postsCount && !todayCount ? <span>暂无帖子</span> : null}
</div>
{mods.length > 0 && (
<div className="board-mods">版主{mods.join('、')}</div>
)}
</Link>
);
})}
</div>
)}
</div>
+402
View File
@@ -0,0 +1,402 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams, useNavigate, useSearchParams } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import * as settingsApi from '../api/settings.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import { showSnackbar, useDialog, normalizePagedList } from '../lib/utils.js';
import ForumIcon from '../components/ForumIcon.jsx';
import Avatar from '../components/Avatar.jsx';
import UserName from '../components/UserName.jsx';
const PAGE_SIZE = 20;
/**
* 论坛版块页路由 /forum/c/:idL2
* 面包屑 + 版块头icon/统计/分享/发帖/编辑公告+ 版块公告 +
* 子分类 chips 筛选?sub_category=+ 置顶区 / 精华区 + 帖子列表 + 分页
* 版主/管理员可编辑公告置顶/加精删除帖子canManage
* 游客锁定forum_guest_visible==='0' 且未登录 登录提示
*/
export default function ForumCategory() {
const { id } = useParams();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const catId = parseInt(id, 10);
const [cat, setCat] = useState(null); //
const [pageData, setPageData] = useState(null); //
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [user, setUser] = useState(null);
const [moderatedIds, setModeratedIds] = useState([]);
const [guestLocked, setGuestLocked] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
// URL page / sub_category
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10) || 1);
const filterSub = searchParams.get('sub_category') || '';
// BlogDetail
const shareUrl = typeof window !== 'undefined'
? window.location.origin + '/forum/c/' + catId + '/share'
: '';
const [shareOpen, setShareOpen] = useState(false);
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
//
const [annOpen, setAnnOpen] = useState(false);
const [annText, setAnnText] = useState('');
const [annSaving, setAnnSaving] = useState(false);
const { dialogRef: annDialogRef, onKeyDown: annDialogKey } = useDialog(annOpen, () => setAnnOpen(false));
const loadPosts = useCallback(async (catId_, page_, sub) => {
setLoading(true);
setError('');
try {
const data = await forumApi.listPosts({ categoryId: catId_, page: page_, subCategory: sub });
setPageData(normalizePagedList(data));
} catch (e) {
setError(e.message || '加载失败');
setPageData({ items: [], total: 0, page: page_, pageSize: PAGE_SIZE });
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
settingsApi.getPublicSettings()
.then((s) => { setGuestLocked(s.forum_guest_visible === '0'); setSettingsLoaded(true); })
.catch(() => { setSettingsLoaded(true); });
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
// api id admin role
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
} else {
setUser(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// +
useEffect(() => {
if (!settingsLoaded) return;
const canLoad = getToken() || !guestLocked;
if (!canLoad) return;
forumApi.getCategory(catId)
.then((c) => setCat(c || null))
.catch(() => setCat(null));
loadPosts(catId, page, filterSub);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded, guestLocked, catId, page, filterSub]);
const canManage = !!user && (user.role === 'admin' || moderatedIds.includes(catId));
// / /
const items = (pageData && pageData.items) || [];
const pinned = useMemo(() => items.filter((p) => p.is_pinned), [items]);
const essence = useMemo(() => items.filter((p) => !p.is_pinned && p.is_essence), [items]);
const rest = useMemo(() => items.filter((p) => !p.is_pinned && !p.is_essence), [items]);
const totalPages = pageData ? Math.max(1, Math.ceil(pageData.total / (pageData.pageSize || PAGE_SIZE))) : 1;
const goPage = (p) => {
if (p < 1 || p > totalPages || p === page) return;
const next = new URLSearchParams(searchParams);
if (p <= 1) next.delete('page');
else next.set('page', String(p));
setSearchParams(next);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const pickSub = (sub) => {
const next = new URLSearchParams(searchParams);
next.delete('page');
if (sub) next.set('sub_category', sub);
else next.delete('sub_category');
setSearchParams(next);
};
// ?type=forum&category=id
const handleNewPostBtn = () => {
if (user) window.location.href = `/write.html?type=forum&category=${catId}`;
else navigate('/login.html');
};
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
const copyShareLink = async () => {
const ok = await (async () => {
try {
await navigator.clipboard.writeText(shareUrl);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = shareUrl;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const res = document.execCommand('copy');
document.body.removeChild(ta);
return res;
} catch { return false; }
}
})();
if (ok) showSnackbar('链接已复制');
else showSnackbar('复制失败,请手动复制');
};
const openAnnEdit = () => {
setAnnText((cat && cat.announcement) || '');
setAnnOpen(true);
};
const saveAnnouncement = async () => {
setAnnSaving(true);
try {
await forumApi.updateAnnouncement(catId, annText.trim());
showSnackbar('公告已更新');
setAnnOpen(false);
const c = await forumApi.getCategory(catId);
setCat(c || null);
} catch (e) {
showSnackbar(e.message);
}
setAnnSaving(false);
};
//
if (settingsLoaded && guestLocked && !getToken()) {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见</p>
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
</div>
);
}
return (
<div className="forum-category">
{/* 面包屑 */}
<nav className="breadcrumbs" aria-label="面包屑">
<Link to="/">首页</Link>
<span className="sep">/</span>
<Link to="/forum.html">论坛</Link>
<span className="sep">/</span>
<span>{cat ? cat.name : '版块'}</span>
</nav>
{cat && (
<>
{/* 版块头 */}
<div className="card category-head">
<ForumIcon icon={cat.icon} name={cat.name} iconColor={cat.icon_color} size={48} />
<div className="category-head-main">
<h1>{cat.name}</h1>
<p className="category-head-desc">{cat.description || '暂无描述'}</p>
<div className="category-head-stats">
<span>{typeof cat.post_count === 'number' ? `${cat.post_count} 帖子` : ''}</span>
{cat.today_count ? <span>今日 {cat.today_count}</span> : null}
</div>
</div>
<div className="category-head-actions">
<a className="btn-icon" href={'/feed/forum/c/' + catId + '.xml'} target="_blank" rel="noopener" title="RSS 订阅(本版块)" aria-label="RSS 订阅">
<span className="material-icons">rss_feed</span>
</a>
<button className="btn btn-text btn-sm" onClick={() => setShareOpen(true)}>
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
</button>
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
<span className="material-icons" style={{ fontSize: 16 }}>edit</span> 发帖
</button>
{canManage && (
<button className="btn btn-tonal btn-sm" onClick={openAnnEdit}>
<span className="material-icons" style={{ fontSize: 16 }}>campaign</span> 编辑公告
</button>
)}
</div>
</div>
{/* 版块公告 */}
{cat.announcement && (
<div className="announcement-bar">
<span className="material-icons ann-icon">campaign</span>
<span className="ann-content">{cat.announcement}</span>
{canManage && (
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-on-primary-container)', flexShrink: 0 }} onClick={openAnnEdit}>编辑</button>
)}
</div>
)}
{/* 子分类 chips */}
{(cat.sub_categories || '').split(',').map((s) => s.trim()).filter(Boolean).length > 0 && (
<div className="chips" style={{ marginBottom: 16 }}>
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => pickSub('')}>全部</button>
{(cat.sub_categories || '').split(',').map((s) => s.trim()).filter(Boolean).map((s) => (
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => pickSub(s)}>{s}</button>
))}
</div>
)}
</>
)}
{/* 帖子列表 */}
{loading && <div className="loading"><div className="spinner"></div></div>}
{error && <div className="empty-state"><div className="empty-icon"></div><p>加载失败</p></div>}
{!loading && !error && (
<>
{pinned.length > 0 && (
<section className="forum-section">
<div className="section-label"><span>📌</span> 置顶</div>
{pinned.map((p) => <PostCard key={p.id} p={p} />)}
</section>
)}
{essence.length > 0 && (
<section className="forum-section">
<div className="section-label"><span></span> 精华</div>
{essence.map((p) => <PostCard key={p.id} p={p} />)}
</section>
)}
{rest.length > 0 && (
<section className="forum-section">
{pinned.length === 0 && essence.length === 0 && rest.length > 0
? null
: <div className="section-label"><span>💬</span> 最新</div>}
{rest.map((p) => <PostCard key={p.id} p={p} />)}
</section>
)}
{items.length === 0 && (
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
)}
{/* 分页条 */}
{pageData && totalPages > 1 && (
<div className="pagination">
<button
type="button"
className="btn btn-tonal btn-sm"
disabled={page <= 1}
onClick={() => goPage(page - 1)}
>
<span className="material-icons" style={{ fontSize: 16 }}>chevron_left</span> 上一页
</button>
<span className="page-info"> {page} / {totalPages} </span>
<button
type="button"
className="btn btn-tonal btn-sm"
disabled={page >= totalPages}
onClick={() => goPage(page + 1)}
>
下一页 <span className="material-icons" style={{ fontSize: 16 }}>chevron_right</span>
</button>
</div>
)}
</>
)}
{/* 分享弹窗 */}
{shareOpen && (
<div
ref={shareDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="分享版块"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
onKeyDown={shareDialogKey}
>
<div className="dialog">
<h3>分享版块</h3>
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
复制链接分享好友打开直达版块页面
</p>
<div className="form-group">
<label htmlFor="shareCatInput">分享链接</label>
<input
id="shareCatInput"
type="text"
readOnly
value={shareUrl}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
<button className="btn btn-filled" onClick={copyShareLink}>
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
</button>
</div>
</div>
</div>
)}
{/* 编辑公告弹窗(版主/管理员) */}
{annOpen && (
<div
ref={annDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="编辑版块公告"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setAnnOpen(false); }}
onKeyDown={annDialogKey}
>
<div className="dialog">
<h3>编辑版块公告</h3>
<div className="form-group">
<label htmlFor="annInput">公告内容留空清除</label>
<textarea
id="annInput"
value={annText}
onChange={(e) => setAnnText(e.target.value)}
placeholder="展示在版块顶部的公告"
style={{ minHeight: 80 }}
/>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setAnnOpen(false)}>取消</button>
<button className="btn btn-filled" onClick={saveAnnouncement} disabled={annSaving}>
{annSaving ? '保存中…' : '保存'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
/** 帖子行卡片:标题 + 徽章 + meta */
function PostCard({ p }) {
return (
<Link to={'/forum/' + p.id} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
<div className="post-title">
{p.title}
{p.is_pinned ? <span className="post-badge pin">📌</span> : null}
{p.is_essence ? <span className="post-badge essence"></span> : null}
</div>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Avatar src={p.author_avatar} name={p.author_name} size={22} className="avatar-sm" to={p.author_id ? `/u/${p.author_id}` : undefined} />
<UserName
name={p.author_name || '匿名'}
uid={p.author_id}
title={p.author_title}
titleColor={p.author_title_color}
role={p.author_role}
showUid={!!p.author_id}
/>
<span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span>
{p.sub_category ? (
<span className="chip chip-tonal">{p.sub_category}</span>
) : null}
</div>
</Link>
);
}
+303 -37
View File
@@ -1,15 +1,28 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import * as settingsApi from '../api/settings.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';
import ErrorDialog from '../components/ErrorDialog.jsx';
import Avatar from '../components/Avatar.jsx';
import UserName from '../components/UserName.jsx';
import { showSnackbar, useDialog } from '../lib/utils.js';
/** 后端时间 'YYYY-MM-DD HH:MM:SS' → 'YYYY年M月D日 HH:MM'(编辑标记用,与创建时间同源) */
function formatEditTime(s) {
const m = String(s || '').match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
if (!m) return s || '';
return `${m[1]}${parseInt(m[2], 10)}${parseInt(m[3], 10)}${m[4]}:${m[5]}`;
}
/**
* 论坛帖子详情路由 /forum/:idSPA 内路由
* 标题/meta/正文MarkdownRenderer+ 回复列表/回复框登录检查+ 删除按钮作者/admin
* 论坛帖子详情路由 /forum/:idL3
* 面包屑首页/论坛/版块/标题+ 楼主标识 + 楼层楼主1回复i+2+ 置顶/加精徽章 +
* 管理操作置顶/加精/删除admin 或版主+ 分享弹窗 + 回复列表/回复框
* 回复无需验证码 v1仅发帖走 captcha_forum
* 游客预览开关forum_guest_visible==='0' 且未登录 登录提示
*/
export default function ForumDetail() {
const { id } = useParams();
@@ -17,10 +30,27 @@ export default function ForumDetail() {
const [post, setPost] = useState(null);
const [replies, setReplies] = useState([]);
const [user, setUser] = useState(null);
const [moderatedIds, setModeratedIds] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [replyText, setReplyText] = useState('');
const [submitting, setSubmitting] = useState(false);
const [guestLocked, setGuestLocked] = useState(false);
const [settingsLoaded, setSettingsLoaded] = useState(false);
// UID site_settings.show_uid_in_comments
const [showReplyUid, setShowReplyUid] = useState(true);
const [shareOpen, setShareOpen] = useState(false);
const [adminBusy, setAdminBusy] = useState(false);
const [submitError, setSubmitError] = useState(''); // 403
// markdown
const [locks, setLocks] = useState([]);
const [unlocked, setUnlocked] = useState(() => new Set());
const [lockContent, setLockContent] = useState(() => new Map());
const shareUrl = typeof window !== 'undefined'
? window.location.origin + '/forum/' + id + '/share'
: '';
const { dialogRef: shareDialogRef, onKeyDown: shareDialogKey } = useDialog(shareOpen, () => setShareOpen(false));
const load = useCallback(async () => {
setLoading(true);
@@ -29,6 +59,12 @@ export default function ForumDetail() {
const data = await forumApi.getPost(id);
setPost(data.post);
setReplies(data.replies || []);
// post
const lp = (data.post && data.post.locks) || data.locks;
const up = (data.post && data.post.unlocked) || data.unlocked;
setLocks(Array.isArray(lp) ? lp : []);
setUnlocked(new Set(Array.isArray(up) ? up : []));
setLockContent(new Map());
} catch (e) {
setError(e.message || '加载失败');
} finally {
@@ -37,12 +73,65 @@ export default function ForumDetail() {
}, [id]);
useEffect(() => {
load();
if (getToken()) me().then(setUser).catch(() => setUser(null));
else setUser(null);
}, [load]);
// '0' + UID
settingsApi.getPublicSettings()
.then((s) => {
setGuestLocked(s.forum_guest_visible === '0');
setShowReplyUid(s.show_uid_in_comments !== '0');
setSettingsLoaded(true);
})
.catch(() => { setSettingsLoaded(true); });
if (getToken()) {
me().then(setUser).catch(() => setUser(null));
// api id admin role
forumApi.listModerated().then(setModeratedIds).catch(() => setModeratedIds([]));
} else {
setUser(null);
}
}, []);
const canDeletePost = user && post && (user.id === post.author_id || user.role === 'admin');
// 401
useEffect(() => {
if (!settingsLoaded) return;
if (!(guestLocked && !getToken())) load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settingsLoaded, guestLocked, load]);
const catId = post ? post.category_id : null;
// username='admin'/ admin 403
const isOwnerPost = !!post && (post.author_username || '') === 'admin';
const isSelf = !!user && !!post && user.id === post.author_id;
// /admin
const canManage = !!user && (user.role === 'admin' || (catId != null && moderatedIds.includes(catId)))
&& !(isOwnerPost && !isSelf);
// / / / admin
const canDeletePost = (isSelf || canManage) && !!post;
const canEdit = canDeletePost;
/** 剪贴板写入:优先 Async Clipboard,失败降级 execCommand(非安全上下文/LAN 部署) */
const copyShareLink = async () => {
const ok = await (async () => {
try {
await navigator.clipboard.writeText(shareUrl);
return true;
} catch {
try {
const ta = document.createElement('textarea');
ta.value = shareUrl;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
const res = document.execCommand('copy');
document.body.removeChild(ta);
return res;
} catch { return false; }
}
})();
if (ok) showSnackbar('链接已复制');
else showSnackbar('复制失败,请手动复制');
};
const submitReply = async () => {
const content = replyText.trim();
@@ -54,7 +143,9 @@ export default function ForumDetail() {
setReplyText('');
await load();
} catch (e) {
showSnackbar(e.message);
// 403
if (e.status === 403) setSubmitError(e.message);
else showSnackbar(e.message);
}
setSubmitting(false);
};
@@ -81,6 +172,64 @@ export default function ForumDetail() {
}
};
// markdown
const unlockLock = async (index, password) => {
const res = await forumApi.unlockLock(id, index, password);
if (!res || !res.ok || !res.content) throw new Error((res && res.error) || '解锁失败');
// + tokenlockTokentoken [image:]/[file:]
setLockContent((prev) => {
const m = new Map(prev);
m.set(index, { content: res.content, token: res.lockToken || '' });
return m;
});
setUnlocked((prev) => { const s = new Set(prev); s.add(index); return s; });
};
// reply
const scrollToComment = () => {
const el = document.querySelector('.post-detail textarea') || document.querySelector('.post-detail .reply-item');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
else window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
};
const togglePin = async () => {
if (!canManage) return;
setAdminBusy(true);
try {
await forumApi.setPinned(post.id, !post.is_pinned);
showSnackbar(post.is_pinned ? '已取消置顶' : '已置顶');
await load();
} catch (e) {
showSnackbar(e.message);
}
setAdminBusy(false);
};
const toggleEssence = async () => {
if (!canManage) return;
setAdminBusy(true);
try {
await forumApi.setEssence(post.id, !post.is_essence);
showSnackbar(post.is_essence ? '已取消加精' : '已加精');
await load();
} catch (e) {
showSnackbar(e.message);
}
setAdminBusy(false);
};
// loading
if (settingsLoaded && guestLocked && !getToken()) {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>登录后查看论坛</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>论坛内容仅登录用户可见</p>
<Link to="/login.html" className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
</div>
);
}
if (loading) {
return <div className="loading"><div className="spinner"></div></div>;
}
@@ -98,33 +247,95 @@ export default function ForumDetail() {
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>
<h3 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h3>
{/* 面包屑:首页 / 论坛 / 版块 / 标题 */}
<nav className="breadcrumbs" aria-label="面包屑">
<Link to="/">首页</Link>
<span className="sep">/</span>
<Link to="/forum.html">论坛</Link>
<span className="sep">/</span>
{post.category_id != null ? (
<>
<Link to={'/forum/c/' + post.category_id}>{post.category_name || '版块'}</Link>
<span className="sep">/</span>
</>
) : null}
<span className="breadcrumb-title">{post.title}</span>
</nav>
{/* 标题 + 置顶/加精徽章 */}
<h1 style={{ fontSize: 22, fontWeight: 600, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{post.title}</span>
{post.is_pinned ? <span className="post-badge pin">📌 置顶</span> : null}
{post.is_essence ? <span className="post-badge essence"> 精华</span> : null}
</h1>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{post.author_name || '匿名'}</span>
<Avatar src={post.author_avatar} name={post.author_name} size={28} to={post.author_id ? `/u/${post.author_id}` : undefined} />
<UserName
name={post.author_name || '匿名'}
uid={post.author_id}
title={post.author_title}
titleColor={post.author_title_color}
role={post.author_role}
showUid={!!post.author_id}
/>
<span className="lz-chip">楼主</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>
<span className="floor-num">#1 </span>
<span className="chip chip-static">{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>
<span className="chip chip-tonal">{post.sub_category}</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
{canEdit && (
<Link to={`/write.html?type=forum&edit=${post.id}`} className="btn btn-text btn-sm" aria-label="编辑帖子">
<span className="material-icons" style={{ fontSize: 16 }}>edit</span> 编辑
</Link>
)}
<button className="btn btn-text btn-sm" onClick={() => setShareOpen(true)}>
<span className="material-icons" style={{ fontSize: 16 }}>share</span> 分享
</button>
</div>
</div>
{/* 编辑标记:已编辑过 → 展示更新时间与次数 */}
{post.edit_count > 0 && (
<div className="post-edit-info">
<span className="material-icons" style={{ fontSize: 15 }}>edit_note</span>
本文章于 {formatEditTime(post.updated_at)} 更新已更新 {post.edit_count}
</div>
)}
{/* 管理操作区:置顶 / 加精 / 删除 */}
{canManage && (
<div className="post-admin-actions">
<button type="button" className="btn btn-tonal btn-sm" onClick={togglePin} disabled={adminBusy}>
📌 {post.is_pinned ? '取消置顶' : '置顶'}
</button>
<button type="button" className="btn btn-tonal btn-sm" onClick={toggleEssence} disabled={adminBusy}>
{post.is_essence ? '取消加精' : '加精'}
</button>
{canDeletePost && (
<button className="btn btn-text btn-sm" style={{ color: 'var(--md-ref-error)' }} onClick={deletePost} disabled={adminBusy}>
删除
</button>
)}
</div>
)}
</div>
<div className="post-body">
<MarkdownRenderer content={post.content} useMarkdown={post.use_markdown} />
<MarkdownRenderer
content={post.content}
useMarkdown={post.use_markdown}
locks={locks}
unlocked={unlocked}
lockContent={lockContent}
onUnlock={unlockLock}
onGoComment={scrollToComment}
refType="forum"
refId={post.id}
/>
</div>
<hr style={{ border: 'none', borderTop: '2px solid var(--md-ref-primary-container)', margin: '24px 0', borderRadius: 2 }} />
@@ -151,26 +362,81 @@ export default function ForumDetail() {
{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');
replies.map((r, i) => {
const canDeleteReply = user && (user.id === r.author_id || user.role === 'admin' || (catId != null && moderatedIds.includes(catId)));
// 1 i+2 floor
const floor = r.floor != null ? r.floor : i + 2;
return (
<div className="reply-item" key={r.id}>
<div className="reply-meta">
<strong>{r.author_name || '匿名'}</strong> · {r.created_at}
<div className="floor-item" key={r.id}>
<div className="floor-head">
<Avatar src={r.author_avatar} name={r.author_name} size={24} to={r.author_id ? `/u/${r.author_id}` : undefined} />
<UserName
name={r.author_name || '匿名'}
uid={r.author_id}
title={r.author_title}
titleColor={r.author_title_color}
role={r.author_role}
showUid={showReplyUid && !!r.author_id}
/>
{r.author_id === post.author_id ? <span className="lz-chip">楼主</span> : null}
<span className="floor-num">#{floor} </span>
<span className="floor-time">{r.created_at}</span>
{canDeleteReply && (
<span
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
<button
type="button"
aria-label="删除回复"
className="floor-del"
onClick={() => deleteReply(r.id)}
>
删除
</span>
</button>
)}
</div>
<div className="reply-body">{r.content}</div>
<div className="floor-body">{r.content}</div>
</div>
);
})
)}
{/* 分享弹窗(复用 BlogDetail 模式:Esc/遮罩关闭、焦点管理) */}
{shareOpen && (
<div
ref={shareDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="分享帖子"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShareOpen(false); }}
onKeyDown={shareDialogKey}
>
<div className="dialog">
<h3>分享帖子</h3>
<p className="text-muted" style={{ fontSize: 14, marginBottom: 12 }}>
复制链接分享好友打开直达完整阅读页面
</p>
<div className="form-group">
<label htmlFor="sharePostInput">分享链接</label>
<input
id="sharePostInput"
type="text"
readOnly
value={shareUrl}
onFocus={(e) => e.target.select()}
/>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setShareOpen(false)}>关闭</button>
<button className="btn btn-filled" onClick={copyShareLink}>
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span> 复制链接
</button>
</div>
</div>
</div>
)}
{/* 403 禁言等拒绝操作提示 */}
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
</div>
);
}
-191
View File
@@ -1,191 +0,0 @@
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>
);
}
+698
View File
@@ -0,0 +1,698 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import * as forumApi from '../api/forum.js';
import { uploadIcon } from '../api/upload.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import { showSnackbar, normalizePagedList } from '../lib/utils.js';
import ForumIcon from '../components/ForumIcon.jsx';
import Avatar from '../components/Avatar.jsx';
const PAGE_SIZE = 20;
const DURATIONS = [
{ value: 1, label: '1 天' },
{ value: 7, label: '7 天' },
{ value: 30, label: '30 天' },
{ value: 'forever', label: '永久' },
];
const TABS = [
{ key: 'posts', label: '帖子管理', icon: 'forum' },
{ key: 'announcement', label: '公告编辑', icon: 'campaign' },
{ key: 'mutes', label: '用户禁言', icon: 'block' },
{ key: 'profile', label: '版块设置', icon: 'tune' },
];
// ForumManage 12 =
const COLOR_PALETTE = ['#6750a4', '#00639b', '#006a60', '#387002', '#7d5260', '#b3261e',
'#8f4c38', '#5d4037', '#c0008f', '#386a20', '#005ac1', '#6d4fc8'];
/** 版主名提取:兼容数组 / 逗号字符串 */
function moderatorNamesOf(cat) {
const m = cat && cat.moderators;
if (Array.isArray(m)) {
return m.map((x) => (typeof x === 'string' ? x : (x && x.username) || '')).filter(Boolean);
}
if (typeof m === 'string') return m.split(',').map((s) => s.trim()).filter(Boolean);
return [];
}
/**
* 单版块管理台路由 /forum/manage/:id版主/管理员
* Tab 1 帖子管理搜索 + 分页 + 置顶/加精切换/删除
* Tab 2 公告编辑PUT /categories/:id/announcement
* Tab 3 用户禁言列表 / 添加固定时长 1·7·30·永久 / 解除版主只读展示
* Tab 4 版块设置名称/描述/图标/图标底色 PUT /categories/:id/profile 部分更新
* 权限非版主且非 admin 无权限提示
*/
export default function ForumManageCategory() {
const { id } = useParams();
const navigate = useNavigate();
const catId = parseInt(id, 10);
const [user, setUser] = useState(null);
const [managedIds, setManagedIds] = useState([]);
const [permission, setPermission] = useState('checking'); // checking | ok | denied
const [cat, setCat] = useState(null); // /
const [catMissing, setCatMissing] = useState(false);
const [tab, setTab] = useState('posts');
// Tab1
const [qInput, setQInput] = useState(''); // /
const [q, setQ] = useState(''); //
const [page, setPage] = useState(1);
const [pageData, setPageData] = useState(null);
const [postsBusy, setPostsBusy] = useState(false);
const [acting, setActing] = useState(false);
// Tab2
const [annText, setAnnText] = useState('');
const [annSaving, setAnnSaving] = useState(false);
// Tab3
const [mutes, setMutes] = useState([]);
const [muteUsername, setMuteUsername] = useState('');
const [muteDuration, setMuteDuration] = useState(7);
const [muteBusy, setMuteBusy] = useState(false);
// Tab4
const [profile, setProfile] = useState({ name: '', description: '', icon: '', icon_color: '' });
const [profileSaving, setProfileSaving] = useState(false);
const [iconUploading, setIconUploading] = useState(false);
const iconFileRef = useRef(null);
const canManage = !!user && (user.role === 'admin' || managedIds.includes(catId));
// +
useEffect(() => {
if (!getToken()) { navigate('/login.html'); return; }
let mounted = true;
(async () => {
try {
const u = await me();
const ids = await forumApi.listModerated();
if (!mounted) return;
setUser(u);
setManagedIds(ids);
if (u.role === 'admin' || ids.includes(catId)) {
setPermission('ok');
const c = await forumApi.getCategory(catId).catch(() => null);
if (!mounted) return;
if (c) {
setCat(c);
setAnnText(c.announcement || '');
setProfile({
name: c.name || '',
description: c.description || '',
icon: c.icon || '',
icon_color: c.icon_color || '',
});
}
else setCatMissing(true);
} else {
setPermission('denied');
}
} catch (e) {
if (mounted) setPermission('denied');
}
})();
return () => { mounted = false; };
}, [catId, navigate]);
const loadPosts = useCallback(async () => {
if (!catId) return;
setPostsBusy(true);
try {
const data = await forumApi.listPosts({ categoryId: catId, page, q: q || undefined });
setPageData(normalizePagedList(data));
} catch (e) {
showSnackbar(e.message);
setPageData({ items: [], total: 0, page, pageSize: PAGE_SIZE });
} finally {
setPostsBusy(false);
}
}, [catId, page, q]);
const loadMutes = useCallback(async () => {
if (!catId) return;
try {
const ms = await forumApi.listMutes(catId);
setMutes(Array.isArray(ms) ? ms : []);
} catch (e) {
showSnackbar(e.message);
setMutes([]);
}
}, [catId]);
//
useEffect(() => {
if (permission === 'ok') loadPosts();
}, [permission, loadPosts]);
// Tab
useEffect(() => {
if (permission === 'ok' && tab === 'mutes') loadMutes();
}, [permission, tab, loadMutes]);
const items = (pageData && pageData.items) || [];
const totalPages = pageData ? Math.max(1, Math.ceil(pageData.total / (pageData.pageSize || PAGE_SIZE))) : 1;
const moderatorNames = useMemo(() => (cat ? moderatorNamesOf(cat) : []), [cat]);
const goPage = (p) => {
if (p < 1 || p > totalPages || p === page) return;
setPage(p);
};
const search = () => { setQ(qInput.trim()); setPage(1); };
//
const togglePin = async (p) => {
if (acting) return;
setActing(true);
try {
await forumApi.setPinned(p.id, !p.is_pinned);
showSnackbar(p.is_pinned ? '已取消置顶' : '已置顶');
await loadPosts();
} catch (e) {
showSnackbar(e.message);
}
setActing(false);
};
const toggleEssence = async (p) => {
if (acting) return;
setActing(true);
try {
await forumApi.setEssence(p.id, !p.is_essence);
showSnackbar(p.is_essence ? '已取消加精' : '已加精');
await loadPosts();
} catch (e) {
showSnackbar(e.message);
}
setActing(false);
};
const confirmDeletePost = async (p) => {
if (!window.confirm(`确定删除帖子「${p.title}」?回复将一并删除`)) return;
if (acting) return;
setActing(true);
try {
await forumApi.deletePost(p.id);
showSnackbar('已删除');
if (items.length === 1 && page > 1) setPage((cur) => cur - 1);
else await loadPosts();
} catch (e) {
showSnackbar(e.message);
}
setActing(false);
};
//
const saveAnnouncement = async () => {
setAnnSaving(true);
try {
await forumApi.updateAnnouncement(catId, annText.trim());
showSnackbar('公告已更新');
const c = await forumApi.getCategory(catId).catch(() => null);
if (c) setCat(c);
} catch (e) {
showSnackbar(e.message);
}
setAnnSaving(false);
};
//
// png/jpg/jpeg/gif/webp svg 1MB url icon
const handleIconFile = async (e) => {
const f = e.target.files && e.target.files[0];
e.target.value = ''; //
if (!f) return;
const IMG_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!IMG_TYPES.includes(f.type)) { showSnackbar('仅支持 png/jpg/jpeg/gif/webp 图片'); return; }
if (f.size > 1024 * 1024) { showSnackbar('图片不能超过 1MB'); return; }
setIconUploading(true);
try {
const data = await uploadIcon(f);
if (data && data.url) {
setProfile((p) => ({ ...p, icon: data.url })); //
showSnackbar('图标已上传,记得保存设置');
} else {
showSnackbar('上传失败,请重试');
}
} catch (err) {
showSnackbar(err.message);
}
setIconUploading(false);
};
const saveProfile = async () => {
const name = profile.name.trim();
if (!name) { showSnackbar('名称不能为空'); return; }
// icon_color =
const fields = {};
if (name !== (cat.name || '')) fields.name = name;
if (profile.description.trim() !== (cat.description || '')) fields.description = profile.description.trim();
if (profile.icon.trim() !== (cat.icon || '')) fields.icon = profile.icon.trim();
if ((profile.icon_color || '') !== (cat.icon_color || '')) fields.icon_color = profile.icon_color.trim();
if (Object.keys(fields).length === 0) { showSnackbar('没有改动'); return; }
setProfileSaving(true);
try {
const res = await forumApi.updateCategoryProfile(catId, fields);
showSnackbar((res && res.message) || '已保存');
// cat
if (res && res.category) {
setCat((prev) => (prev ? { ...prev, ...res.category } : prev));
setProfile((p) => ({
...p,
name: res.category.name,
description: res.category.description || '',
icon: res.category.icon || '',
icon_color: res.category.icon_color || '',
}));
}
} catch (e) {
showSnackbar(e.message);
}
setProfileSaving(false);
};
//
const addMute = async () => {
const name = muteUsername.trim();
if (!name) { showSnackbar('请输入用户名'); return; }
setMuteBusy(true);
try {
await forumApi.createMute(catId, name, muteDuration);
showSnackbar('已禁言');
setMuteUsername('');
loadMutes();
} catch (e) {
showSnackbar(e.message);
}
setMuteBusy(false);
};
const unmute = async (m) => {
if (!window.confirm(`确定解除「${m.username || '用户#' + m.user_id}」的禁言?`)) return;
try {
await forumApi.deleteMute(catId, m.user_id);
showSnackbar('已解除');
loadMutes();
} catch (e) {
showSnackbar(e.message);
}
};
//
if (permission === 'checking') {
return <div className="loading"><div className="spinner"></div></div>;
}
//
if (permission === 'denied') {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>没有管理权限</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>你不是该版块的版主无法进入管理台</p>
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回论坛</Link>
</div>
);
}
//
if (catMissing || !cat) {
return (
<div className="empty-state">
<div className="empty-icon"></div>
<p>版块不存在</p>
<Link to="/forum/manage" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>返回我的版块</Link>
</div>
);
}
return (
<div className="forum-manage-cat">
{/* 面包屑:论坛 / 管理 / 版块名 */}
<nav className="breadcrumbs" aria-label="面包屑">
<Link to="/forum.html">论坛</Link>
<span className="sep">/</span>
<Link to="/forum/manage">管理</Link>
<span className="sep">/</span>
<span>{cat.name}</span>
</nav>
{/* 版块信息 */}
<div className="card category-head">
<ForumIcon icon={cat.icon} name={cat.name} iconColor={cat.icon_color} size={48} />
<div className="category-head-main">
<h1>{cat.name} · 管理台</h1>
<p className="category-head-desc">{cat.description || '暂无描述'}</p>
<div className="category-head-stats">
<span>{typeof cat.post_count === 'number' ? `${cat.post_count} 帖子` : ''}</span>
{cat.today_count ? <span>今日 {cat.today_count}</span> : null}
{moderatorNames.length > 0 ? <span>版主{moderatorNames.join('、')}</span> : null}
</div>
</div>
<div className="category-head-actions">
<Link to={'/forum/c/' + catId} className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>visibility</span> 查看版块
</Link>
<Link to="/forum/manage" className="btn btn-text btn-sm">全部版块</Link>
</div>
</div>
{/* Tab 栏 */}
<div className="manage-tabs" role="tablist" aria-label="管理功能">
{TABS.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={tab === t.key}
className={'manage-tab' + (tab === t.key ? ' active' : '')}
onClick={() => setTab(t.key)}
>
<span className="material-icons" aria-hidden="true">{t.icon}</span> {t.label}
{t.key === 'mutes' && mutes.length > 0 ? <span className="manage-tab-count">{mutes.length}</span> : null}
</button>
))}
</div>
{/* Tab 1:帖子管理 */}
{tab === 'posts' && (
<div className="card manage-card">
<div className="manage-search">
<input
type="text"
placeholder="搜索帖子标题…"
value={qInput}
onChange={(e) => setQInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') search(); }}
aria-label="搜索帖子标题"
/>
<button className="btn btn-tonal btn-sm" onClick={search}>搜索</button>
</div>
{postsBusy && <div className="loading" style={{ padding: 24 }}><div className="spinner"></div></div>}
{!postsBusy && items.length === 0 && (
<div className="empty-state" style={{ padding: '24px 0' }}>
<div className="empty-icon">📝</div><p>暂无帖子</p>
</div>
)}
{!postsBusy && items.map((p) => {
// username='admin'
const isOwnerPost = (p.author_name || '') === 'admin';
const canActOnPost = !isOwnerPost || (user && p.author_id === user.id);
return (
<div className="manage-post-row" key={p.id}>
<div className="manage-post-main">
<div className="manage-post-title">
<span className="manage-post-title-text">{p.title}</span>
{p.is_pinned ? <span className="post-badge pin">📌</span> : null}
{p.is_essence ? <span className="post-badge essence"></span> : null}
</div>
<div className="manage-post-meta">
<Avatar src={p.author_avatar} name={p.author_name} size={22} className="avatar-sm" to={p.author_id ? `/u/${p.author_id}` : undefined} />
<span>{p.author_name || '匿名'}</span>
<span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span>
{p.sub_category ? <span className="chip chip-tonal">{p.sub_category}</span> : null}
</div>
</div>
{canActOnPost ? (
<div className="manage-post-actions">
<button
type="button"
className={'manage-action' + (p.is_pinned ? ' on' : '')}
title={p.is_pinned ? '取消置顶' : '置顶'}
aria-label={p.is_pinned ? '取消置顶' : '置顶'}
aria-pressed={!!p.is_pinned}
disabled={acting}
onClick={() => togglePin(p)}
>
<span className="material-icons" style={{ fontSize: 18 }}>push_pin</span>
</button>
<button
type="button"
className={'manage-action' + (p.is_essence ? ' on' : '')}
title={p.is_essence ? '取消加精' : '加精'}
aria-label={p.is_essence ? '取消加精' : '加精'}
aria-pressed={!!p.is_essence}
disabled={acting}
onClick={() => toggleEssence(p)}
>
<span className="material-icons" style={{ fontSize: 18 }}>star</span>
</button>
<button
type="button"
className="manage-action danger"
title="删除帖子"
aria-label="删除帖子"
disabled={acting}
onClick={() => confirmDeletePost(p)}
>
<span className="material-icons" style={{ fontSize: 18 }}>delete</span>
</button>
</div>
) : (
<span className="chip chip-static" title="站长帖子仅站长本人可操作">🔒 站长帖</span>
)}
</div>
);
})}
{totalPages > 1 && (
<div className="pagination">
<button type="button" className="btn btn-tonal btn-sm" disabled={page <= 1} onClick={() => goPage(page - 1)}>
<span className="material-icons" style={{ fontSize: 16 }}>chevron_left</span> 上一页
</button>
<span className="page-info"> {page} / {totalPages} </span>
<button type="button" className="btn btn-tonal btn-sm" disabled={page >= totalPages} onClick={() => goPage(page + 1)}>
下一页 <span className="material-icons" style={{ fontSize: 16 }}>chevron_right</span>
</button>
</div>
)}
</div>
)}
{/* Tab 2:公告编辑 */}
{tab === 'announcement' && (
<div className="card manage-card" style={{ maxWidth: 640 }}>
<h3 className="manage-card-title">板块公告</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 12 }}>
展示在版块顶部留空可清除当前公告
</p>
<div className="form-group">
<label htmlFor="annText">公告内容</label>
<textarea
id="annText"
value={annText}
onChange={(e) => setAnnText(e.target.value)}
style={{ minHeight: 100 }}
/>
</div>
<button className="btn btn-filled" onClick={saveAnnouncement} disabled={annSaving}>
{annSaving ? '保存中…' : '保存公告'}
</button>
</div>
)}
{/* Tab 3:用户禁言 */}
{tab === 'mutes' && (
<>
<div className="card manage-card" style={{ maxWidth: 640 }}>
<h3 className="manage-card-title">添加禁言</h3>
<div className="mute-form">
<input
type="text"
placeholder="输入要禁言的用户名"
value={muteUsername}
onChange={(e) => setMuteUsername(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') addMute(); }}
aria-label="被禁言用户名"
/>
<select
value={muteDuration}
onChange={(e) => {
const v = e.target.value;
setMuteDuration(v === 'forever' ? v : parseInt(v, 10));
}}
aria-label="禁言时长"
>
{DURATIONS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
<button className="btn btn-filled btn-sm" onClick={addMute} disabled={muteBusy || !muteUsername.trim()}>
{muteBusy ? '提交中…' : '禁言'}
</button>
</div>
<p className="text-muted" style={{ fontSize: 12 }}>被禁用户在本版块发帖 / 回复将被拒绝到期自动解除</p>
</div>
<div className="card manage-card">
<h3 className="manage-card-title">禁言列表 ({mutes.length})</h3>
{mutes.length === 0 ? (
<p className="text-muted" style={{ fontSize: 14 }}>暂无禁言</p>
) : (
mutes.map((m) => {
const permanent = m.permanent || m.muted_until == null || m.muted_until === '永久';
return (
<div className="mute-row" key={m.user_id}>
<div className="mute-main">
<div className="mute-user">{m.username || '用户#' + m.user_id}</div>
<div className={'mute-expiry' + (permanent ? ' permanent' : '')}>
{permanent ? '永久禁言' : `到期:${m.muted_until}`}
</div>
</div>
<button
className="btn btn-text btn-sm"
style={{ color: 'var(--md-ref-error)' }}
onClick={() => unmute(m)}
>
解除
</button>
</div>
);
})
)}
</div>
{moderatorNames.length > 0 && (
<div className="card manage-card">
<h3 className="manage-card-title">版主</h3>
<p className="text-muted" style={{ fontSize: 13 }}>
当前版主仅管理员可更改{moderatorNames.join('、')}
</p>
</div>
)}
</>
)}
{/* Tab 4:版块设置 */}
{tab === 'profile' && (
<div className="card manage-card profile-form">
<h3 className="manage-card-title">版块设置</h3>
<p className="text-muted" style={{ fontSize: 13, marginBottom: 16 }}>
修改版块的名称描述与图标保存后前台版块索引与版块页即时生效
</p>
{/* 实时预览 */}
<div className="profile-preview">
<ForumIcon icon={profile.icon} name={profile.name} iconColor={profile.icon_color} size={48} />
<div className="profile-preview-hint">
预览{profile.name || '(未填写名称)'}
{!profile.icon && !profile.icon_color ? ' · 图标与底色按名称自动生成' : ''}
</div>
</div>
<div className="form-group">
<label htmlFor="profileName">名称 *</label>
<input
id="profileName"
type="text"
value={profile.name}
onChange={(e) => setProfile((p) => ({ ...p, name: e.target.value }))}
placeholder="版块名称(≤50 字)"
maxLength={50}
/>
</div>
<div className="form-group">
<label htmlFor="profileDesc">描述</label>
<textarea
id="profileDesc"
value={profile.description}
onChange={(e) => setProfile((p) => ({ ...p, description: e.target.value }))}
placeholder="版块简介,展示在版块头与卡片上"
style={{ minHeight: 80 }}
maxLength={500}
/>
</div>
<div className="form-group">
<label htmlFor="profileIcon">图标</label>
<div className="icon-input-row">
<input
id="profileIcon"
type="text"
value={profile.icon}
onChange={(e) => setProfile((p) => ({ ...p, icon: e.target.value }))}
placeholder="支持上传 ≤1MB 图片,或输入 emoji / 图片 URL"
maxLength={100}
/>
<button
type="button"
className="btn btn-tonal btn-sm icon-upload-btn"
onClick={() => iconFileRef.current && iconFileRef.current.click()}
disabled={iconUploading}
aria-label="上传版块图标图片"
>
<span className="material-icons" style={{ fontSize: 16 }}>upload</span>
{iconUploading ? '上传中…' : '上传图片'}
</button>
</div>
<input
ref={iconFileRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
aria-label="选择版块图标图片(png/jpg/jpeg/gif/webp,≤1MB"
style={{ display: 'none' }}
onChange={handleIconFile}
/>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
支持上传 1MB 图片png/jpg/jpeg/gif/webp或输入单个 emoji / 图片链接留空取名称首字
</div>
</div>
<div className="form-group">
<label>图标底色</label>
<div className="profile-palette" role="group" aria-label="选择图标底色">
<button
type="button"
className={'profile-swatch clear' + (!profile.icon_color ? ' selected' : '')}
aria-pressed={!profile.icon_color}
aria-label="自动配色(留空,按名称哈希)"
title="自动配色(留空)"
onClick={() => setProfile((p) => ({ ...p, icon_color: '' }))}
>
A
</button>
{COLOR_PALETTE.map((col) => (
<button
type="button"
key={col}
className={'profile-swatch' + (profile.icon_color === col ? ' selected' : '')}
style={{ background: col }}
aria-pressed={profile.icon_color === col}
aria-label={'底色 ' + col}
title={col}
onClick={() => setProfile((p) => ({ ...p, icon_color: p.icon_color === col ? '' : col }))}
/>
))}
</div>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>
留空时按版块名称哈希自动配色深浅色主题自适应
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button className="btn btn-filled" onClick={saveProfile} disabled={profileSaving}>
{profileSaving ? '保存中…' : '保存设置'}
</button>
<button
className="btn btn-text"
onClick={() => setProfile({
name: cat.name || '',
description: cat.description || '',
icon: cat.icon || '',
icon_color: cat.icon_color || '',
})}
disabled={profileSaving}
>
撤销修改
</button>
</div>
</div>
)}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import React, { useEffect, 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 ForumIcon from '../components/ForumIcon.jsx';
/**
* 版主子管理页路由 /forum/manage我管理的版块列表卡片 进入单版块管理台
* 权限版主/forum/moderated 非空 admin全版块无权限显示提示 + 返回论坛
*/
export default function ForumManagePanel() {
const navigate = useNavigate();
const [cats, setCats] = useState(null); // null=
const [permission, setPermission] = useState('checking'); // checking | ok | denied
const [error, setError] = useState('');
useEffect(() => {
if (!getToken()) { navigate('/login.html'); return; }
let mounted = true;
(async () => {
try {
const u = await me();
const cs = await forumApi.listModeratedCategories(u);
if (!mounted) return;
setCats(cs || []);
setPermission(u.role === 'admin' || (cs && cs.length > 0) ? 'ok' : 'denied');
} catch (e) {
if (!mounted) return;
setError(e.message || '加载失败');
setPermission('denied');
}
})();
return () => { mounted = false; };
}, [navigate]);
// /
if (permission === 'denied') {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '48px auto' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>{error ? '加载失败' : '没有管理权限'}</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>
{error || '只有版主或管理员可以管理版块'}
</p>
<Link to="/forum.html" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回论坛</Link>
</div>
);
}
return (
<div className="forum-manage-panel">
<div className="forum-home-head">
<h1 className="page-title" style={{ margin: 0 }}>我的版块</h1>
<Link to="/forum.html" className="btn btn-text btn-sm">
<span className="material-icons" style={{ fontSize: 16 }}>arrow_back</span> 返回论坛
</Link>
</div>
{cats === null && <div className="loading"><div className="spinner"></div></div>}
{cats !== null && cats.length === 0 && (
<div className="empty-state"><div className="empty-icon">📋</div><p>暂无版块</p></div>
)}
{cats !== null && cats.length > 0 && (
<div className="forum-grid">
{cats.map((c) => (
<Link key={c.id} to={'/forum/manage/' + c.id} className="card board-card">
<div className="board-card-top">
<ForumIcon icon={c.icon} name={c.name} iconColor={c.icon_color} size={44} />
<div className="board-card-title">
<span className="board-name">{c.name}</span>
</div>
</div>
{c.description ? <p className="board-desc">{c.description}</p> : null}
<div className="board-meta">
<span>{typeof c.post_count === 'number' ? `${c.post_count}` : '暂无帖子'}</span>
<span className="board-today">进入管理台 </span>
</div>
{c.announcement ? (
<div className="board-mods" title="当前公告">📢 {c.announcement}</div>
) : null}
</Link>
))}
</div>
)}
</div>
);
}
+1
View File
@@ -19,6 +19,7 @@ export default function Home() {
return (
<div className="homepage-layout">
<h1 className="sr-only">首页</h1>
<BlogSidebar settings={settings} showRecent forceShow />
<div className="homepage-content">
<div className="card">
+83 -2
View File
@@ -1,14 +1,28 @@
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();
@@ -21,19 +35,69 @@ export default function Login() {
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(() => {});
}, [navigate]);
// 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; }
@@ -46,8 +110,13 @@ export default function Login() {
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);
}
};
@@ -107,7 +176,7 @@ export default function Login() {
height: 44,
...(capDone
? { background: '#e8f5e9', borderColor: '#4caf50', color: '#2e7d32' }
: { background: '#fff', color: '#333', border: '1px solid #333' }),
: { background: 'var(--md-ref-surface-container)', color: 'var(--md-ref-on-surface)', border: '1px solid var(--md-ref-outline-variant)' }),
}}
>
<span className="material-icons" style={{ fontSize: 20 }}>{capDone ? 'check_circle' : 'verified_user'}</span>
@@ -115,6 +184,18 @@ export default function Login() {
</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>
+109 -79
View File
@@ -3,7 +3,7 @@ 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 } from '../lib/utils.js';
import { showSnackbar, useDialog } from '../lib/utils.js';
/**
* 密码学安全的随机密码生成器crypto.getRandomValues替代 v1 Math.random
@@ -250,93 +250,113 @@ export default function Passwords() {
const detailEntry = entries ? entries.find((x) => x.id === detailId) : null;
// /B3Esc
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>;
}
// PIN
if (phase === 'setup' || phase === 'locked') {
return (
<div className="pin-overlay">
<div className="pin-icon"><span className="material-icons" style={{ fontSize: 40 }}>lock</span></div>
<div style={{ fontSize: 18, fontWeight: 500 }}>
{phase === 'setup' ? '首次使用,请设置 PIN 码' : '请输入 PIN 码解锁'}
</div>
{pinError && <div style={{ color: 'var(--md-ref-error)', fontSize: 14, marginBottom: 8 }}>{pinError}</div>}
{phase === 'locked' && (
<input
type="password"
className="pin-input"
maxLength={6}
inputMode="numeric"
autoComplete="off"
value={pinInput}
onChange={(e) => setPinInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') submitPin(); }}
/>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
<button className="btn btn-filled" onClick={phase === 'setup' ? openPinDialog : submitPin} disabled={pinBusy}>
{phase === 'setup' ? '设置' : (pinBusy ? '解锁中...' : '解锁')}
</button>
</div>
</div>
);
}
//
return (
<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>
<>
{/* 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>
{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="card password-card" onClick={() => setDetailId(p.id)}>
<div className="pw-title">{p.title}</div>
<div className="pw-username">{p.username || '无用户名'}</div>
<div className="pw-actions">
<button
className="btn-icon"
style={{ width: 32, height: 32, fontSize: 16 }}
title="复制密码"
onClick={(e) => { e.stopPropagation(); copyToClipboard(p.password, '密码'); }}
>
<span className="material-icons" style={{ fontSize: 16 }}>content_copy</span>
</button>
</div>
) : (
/* 已解锁:密码箱 */
<div>
<div 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>
@@ -360,9 +380,14 @@ export default function Passwords() {
{/* 添加/编辑条目弹窗 */}
{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>
@@ -400,9 +425,14 @@ export default function Passwords() {
{/* 详情弹窗 */}
{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>
@@ -410,16 +440,16 @@ export default function Passwords() {
<div className="pw-field">
<span className="pw-label">用户名</span>
<span className="pw-value">{detailEntry.username || ''}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
<button type="button" className="pw-copy" aria-label="复制用户名" onClick={() => copyToClipboard(detailEntry.username || '', '用户名')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span>
</button>
</div>
<div className="pw-field">
<span className="pw-label">密码</span>
<span className="pw-value">{detailEntry.password}</span>
<span className="pw-copy" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
<button type="button" className="pw-copy" aria-label="复制密码" onClick={() => copyToClipboard(detailEntry.password, '密码')}>
<span className="material-icons" style={{ fontSize: 18 }}>content_copy</span>
</span>
</button>
</div>
{detailEntry.url ? (
<div className="pw-field">
@@ -447,7 +477,7 @@ export default function Passwords() {
{/* 密码生成器弹窗 */}
{genDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
<div ref={genDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="生成随机密码" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={genDlgKey}>
<div className="dialog" style={{ maxWidth: 380 }}>
<h3>生成随机密码</h3>
<div className="form-group">
@@ -476,7 +506,7 @@ export default function Passwords() {
{/* 修改 PIN 警告确认弹窗 */}
{pinChangeWarn && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
<div ref={warnDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认修改 PIN" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={warnDlgKey}>
<div className="dialog">
<h3>确认修改 PIN</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}> 修改 PIN 现有密码条目将无法解密需要重新添加确定继续</p>
@@ -490,7 +520,7 @@ export default function Passwords() {
{/* 删除确认弹窗 */}
{confirmDialog && (
<div className="dialog-overlay active" style={{ display: 'flex', zIndex: 9999 }}>
<div ref={confirmDlgRef} className="dialog-overlay active" role="dialog" aria-modal="true" aria-label="确认操作" style={{ display: 'flex', zIndex: 9999 }} onKeyDown={confirmDlgKey}>
<div className="dialog">
<h3>确认操作</h3>
<p style={{ marginBottom: 24, fontSize: 16 }}>确定删除此密码记录</p>
@@ -501,6 +531,6 @@ export default function Passwords() {
</div>
</div>
)}
</div>
</>
);
}
+132 -4
View File
@@ -2,9 +2,10 @@ 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 * as settingsApi from '../api/settings.js';
import { avatarUrl, uploadAvatar } from '../api/upload.js';
import { getToken, notifyAuthChange } from '../api/client.js';
import { showSnackbar } from '../lib/utils.js';
import { showSnackbar, useDialog, focusDialog } from '../lib/utils.js';
/** FileReader + Image 加载图片(头像裁剪前读取) */
function loadImage(file) {
@@ -29,6 +30,17 @@ export default function Profile() {
const [error, setError] = useState('');
const fileRef = useRef(null);
// RainID / RainIDrainid_enabled
const [rainidEnabled, setRainidEnabled] = useState(false);
// QQ rainweb qq QQ
const [qq, setQq] = useState('');
// bio
const [bio, setBio] = useState('');
// + /
const [nickname, setNickname] = useState('');
const [website, setWebsite] = useState('');
const [infoBusy, setInfoBusy] = useState(false);
// /
const [pwDialog, setPwDialog] = useState(false);
const [pwStep, setPwStep] = useState(1);
@@ -38,15 +50,30 @@ export default function Profile() {
const [confirmPw, setConfirmPw] = useState('');
const [pwBusy, setPwBusy] = useState(false);
// /B3Esc
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);
setQq(u.qq || ''); // qq
setBio(u.bio || ''); //
setNickname(u.nickname || ''); //
setWebsite(u.website || ''); //
// QQ avatar-url
const av = await avatarUrl(u.id);
setAvatar(av.url || '');
// RainID
settingsApi.getPublicSettings()
.then((s) => setRainidEnabled(s.rainid_enabled === '1'))
.catch(() => setRainidEnabled(false));
} catch (e) {
setError(e.message || '加载失败');
if (e.status === 401) { navigate('/login.html'); return; }
@@ -93,6 +120,20 @@ export default function Profile() {
navigate('/');
};
// + + QQ +
const saveAll = async () => {
if (nickname.trim().length > 20) { showSnackbar('昵称最多 20 字'); return; }
if (website.trim() && !/^https?:\/\//i.test(website.trim())) { showSnackbar('个人博客需以 http(s):// 开头'); return; }
setInfoBusy(true);
try {
await profileApi.updateProfile({ nickname: nickname.trim(), website: website.trim(), qq: qq.trim(), bio: bio.trim() });
showSnackbar('资料已保存');
} catch (e) {
showSnackbar(e.message);
}
setInfoBusy(false);
};
const sendPwCode = async () => {
setPwBusy(true);
try {
@@ -171,9 +212,91 @@ export default function Profile() {
</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>
{/* 对外昵称 + 个人博客:显示在帖子/评论/个人主页 */}
<div className="form-group">
<label htmlFor="profileNickname">对外昵称</label>
<input
id="profileNickname"
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="显示在帖子 / 评论 / 个人主页的作者名"
maxLength={20}
/>
</div>
<div className="form-group">
<label htmlFor="profileWebsite">个人博客</label>
<input
id="profileWebsite"
type="url"
value={website}
onChange={(e) => setWebsite(e.target.value)}
placeholder="https://example.com"
/>
</div>
{/* QQ 号:rainweb 层字段(后端返回 qq 时显示;用于 QQ 头像渲染优先级) */}
{typeof user.qq !== 'undefined' && (
<div className="form-group">
<label htmlFor="profileQq">QQ 用于 QQ 头像</label>
<input id="profileQq" type="text" value={qq} onChange={(e) => setQq(e.target.value)} placeholder="QQ 号码" />
</div>
)}
{/* 个性签名:公开主页展示 */}
<div className="form-group">
<label htmlFor="profileBio">个性签名</label>
<textarea
id="profileBio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder="写一句自我介绍,会展示在公开主页上"
style={{ minHeight: 72 }}
maxLength={200}
/>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4, display: 'flex', justifyContent: 'space-between' }}>
<span>展示在个人主页的签名区最多 200 </span>
<span>{bio.length}/200</span>
</div>
</div>
<p style={{ fontSize: 12, color: 'var(--md-ref-on-surface-variant)', margin: '4px 0 12px' }}>
昵称留空则显示用户名博客会以个人博客链接展示在主页外链跳确认页
</p>
{/* 统一保存按钮 */}
<button className="btn btn-tonal" onClick={saveAll} disabled={infoBusy}>
{infoBusy ? '保存中…' : '保存全部'}
</button>
{/* RainID 开启:改密/资料交 RainID,本地只保留头像与 QQ */}
{rainidEnabled && (
<div className="rainid-notice">
<span className="material-icons" style={{ fontSize: 22 }}>badge</span>
<div className="rainid-notice-body">
<div className="rainid-notice-title">账号由 RainID 统一管理</div>
<div className="rainid-notice-desc">资料与密码请在 RainID 个人中心修改头像与 QQ 仍在本站管理</div>
</div>
<a
className="btn btn-tonal btn-sm"
href="https://rainid.rainnya.asia/account"
target="_blank"
rel="noopener"
aria-label="前往 RainID 个人中心(新窗口打开)"
>
前往 RainID 个人中心
</a>
</div>
)}
{!rainidEnabled && (
<>
<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>
@@ -186,9 +309,14 @@ export default function Profile() {
{/* 修改密码弹窗 */}
{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>
+60 -3
View File
@@ -2,15 +2,21 @@ 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();
@@ -25,12 +31,27 @@ export default function Register() {
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; }
captchaRequired('register')
.then((r) => { if (r && r.required) setCapRequired(true); })
.catch(() => {});
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 () => {
@@ -38,6 +59,11 @@ export default function 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; }
@@ -60,6 +86,9 @@ export default function Register() {
}
} catch (e) {
setError(e.message || '注册失败');
// proof
setCapDone(false);
setCapResult(null);
}
setBusy(false);
};
@@ -85,6 +114,21 @@ export default function Register() {
}
};
// 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' }}>
@@ -167,6 +211,19 @@ export default function Register() {
<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 }}>
+61
View File
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import * as ticketsApi from '../api/tickets.js';
import { getToken } from '../api/client.js';
import { safeSourceUrl } from './Tickets.jsx';
const categories = [['forum_bug', '论坛 Bug'], ['site_bug', '站内 Bug'], ['feature', '功能建议'], ['account', '账号问题'], ['other', '其他问题']];
const priorities = [['low', '低'], ['normal', '普通'], ['high', '高'], ['urgent', '紧急']];
export default function TicketCreate() {
const navigate = useNavigate();
const location = useLocation();
const search = new URLSearchParams(location.search);
const sourceUrl = safeSourceUrl((location.state && location.state.sourceUrl) || search.get('source_url') || search.get('from') || window.location.pathname);
const [form, setForm] = useState({ subject: '', description: '', category: 'site_bug', priority: 'normal', source_url: sourceUrl });
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const update = (key) => (e) => setForm((prev) => ({ ...prev, [key]: e.target.value }));
if (!getToken()) return <div className="empty-state" style={{ maxWidth: 460, margin: '48px auto' }}><div className="empty-icon" aria-hidden="true">🔒</div><h1 style={{ fontSize: 22 }}>登录后提交工单</h1><p className="text-muted">请先登录再反馈论坛或站内问题</p><Link to="/login.html" state={{ from: location.pathname }} className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link></div>;
const submit = async (e) => {
e.preventDefault();
const subject = form.subject.trim(); const description = form.description.trim();
if (subject.length < 2) { setError('请填写问题标题(至少 2 个字)'); return; }
if (description.length < 10) { setError('请详细描述问题(至少 10 个字)'); return; }
setError(''); setBusy(true);
try {
const sourceValue = String(form.source_url ?? '');
const sourceUrl = safeSourceUrl(sourceValue);
const hasInvalidSource = sourceValue.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(sourceValue) || sourceValue.includes('\\');
if (hasInvalidSource && !sourceUrl) {
setError('来源地址不合法,请填写站内路径或 http/https 地址');
return;
}
const data = await ticketsApi.createTicket({
...form,
subject,
description,
source: form.category === 'forum_bug' ? 'forum' : 'site',
source_url: sourceUrl,
browser_info: [navigator.userAgent, `${window.innerWidth}x${window.innerHeight}`].join(' | ').slice(0, 1000),
});
if (!data.ticket || !data.ticket.id) throw new Error('工单创建成功但未返回编号');
navigate('/tickets/' + data.ticket.id, { replace: true });
} catch (err) { setError(err.message || '提交失败,请稍后重试'); }
finally { setBusy(false); }
};
return <div style={{ maxWidth: 760, margin: '0 auto' }}>
<div style={{ marginBottom: 22 }}><Link to="/tickets.html" className="btn btn-text btn-sm"> 返回工单</Link><h1 className="page-title" style={{ margin: '12px 0 6px' }}>提交问题</h1><p className="text-muted" style={{ margin: 0 }}>描述得越具体越有助于快速定位问题</p></div>
<form className="card" onSubmit={submit} noValidate style={{ padding: 22 }}>
{error && <div role="alert" className="empty-state" style={{ padding: 12, marginBottom: 18, textAlign: 'left' }}><p style={{ margin: 0 }}>{error}</p></div>}
<div className="form-group"><label htmlFor="ticket-subject">问题标题 <span aria-hidden="true">*</span></label><input id="ticket-subject" value={form.subject} onChange={update('subject')} maxLength={120} required aria-describedby="ticket-subject-help" placeholder="例如:论坛帖子无法回复" /><p id="ticket-subject-help" className="text-muted" style={{ fontSize: 13 }}>请用一句话概括遇到的问题最多 120 </p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 16 }}><div className="form-group"><label htmlFor="ticket-category">问题类型 <span aria-hidden="true">*</span></label><select id="ticket-category" value={form.category} onChange={update('category')} required>{categories.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div><div className="form-group"><label htmlFor="ticket-priority">优先级</label><select id="ticket-priority" value={form.priority} onChange={update('priority')}>{priorities.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></div></div>
<div className="form-group"><label htmlFor="ticket-description">问题描述 <span aria-hidden="true">*</span></label><textarea id="ticket-description" value={form.description} onChange={update('description')} maxLength={20000} required rows={8} aria-describedby="ticket-description-help" placeholder="请描述发生了什么、如何复现,以及你期望的结果。" /><p id="ticket-description-help" className="text-muted" style={{ fontSize: 13 }}>至少 10 个字最多 20000 </p></div>
<div className="form-group"><label htmlFor="ticket-source-url">发现问题的页面地址</label><input id="ticket-source-url" type="url" value={form.source_url} onChange={update('source_url')} maxLength={1000} aria-describedby="ticket-source-help" /><p id="ticket-source-help" className="text-muted" style={{ fontSize: 13 }}>已自动记录当前页面地址如果问题来自其他页面可以在这里修改</p></div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', flexWrap: 'wrap' }}><Link to="/tickets.html" className="btn btn-tonal">取消</Link><button type="submit" className="btn btn-filled" disabled={busy}>{busy ? '提交中…' : '提交工单'}</button></div>
</form>
</div>;
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import * as ticketsApi from '../api/tickets.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import { categoryLabel, formatTime, priorityLabel, safeSourceUrl, statusInfo } from './Tickets.jsx';
const STEPS = ['open', 'processing', 'waiting', 'resolved', 'closed'];
const PUBLIC_EVENTS = new Set(['ticket_created', 'message_added', 'status_changed', 'ticket_closed', 'ticket_reopened']);
function eventLabel(event) {
if (event.event_type === 'ticket_created') return '工单已创建';
if (event.event_type === 'message_added') return '新增公开回复';
if (event.event_type === 'ticket_closed') return '工单已关闭';
if (event.event_type === 'ticket_reopened') return '工单已重新打开';
return event.new_value ? `状态更新为“${statusInfo(event.new_value).label}` : '工单状态已更新';
}
function TicketProgress({ status }) {
const current = STEPS.indexOf(status);
return (
<ol className="ticket-detail-progress" aria-label={`工单处理进度,当前为${statusInfo(status).label}`}>
{STEPS.map((step, index) => {
const info = statusInfo(step);
const state = index === current ? 'current' : index < current ? 'done' : 'upcoming';
return <li key={step} className={`ticket-detail-progress-item is-${state}`} aria-current={state === 'current' ? 'step' : undefined}>
<span className="ticket-detail-progress-line" aria-hidden="true" />
<span className="ticket-detail-progress-dot" aria-hidden="true">{state === 'done' ? '✓' : index + 1}</span>
<span className="ticket-detail-progress-label">{info.label}</span>
</li>;
})}
</ol>
);
}
function StatusChip({ status }) {
const info = statusInfo(status);
return <span className={`chip ticket-status ticket-status--${info.tone}`}><span className="material-icons" aria-hidden="true">{info.icon}</span>{info.label}</span>;
}
export default function TicketDetail() {
const { id } = useParams();
const [user, setUser] = useState(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [error, setError] = useState('');
const [reply, setReply] = useState(''); const [busy, setBusy] = useState(false);
const detailRequestRef = useRef(0);
const activeIdRef = useRef(id);
activeIdRef.current = id;
const load = useCallback(async () => {
if (String(activeIdRef.current) !== String(id)) return;
const requestId = ++detailRequestRef.current;
setLoading(true); setError(''); setData(null);
try {
const nextData = await ticketsApi.getTicket(id);
if (requestId === detailRequestRef.current) setData(nextData);
} catch (e) {
if (requestId === detailRequestRef.current) setError(e.message || '工单加载失败');
} finally {
if (requestId === detailRequestRef.current) setLoading(false);
}
}, [id]);
useEffect(() => { if (!getToken()) { setLoading(false); return; } me().then(setUser).catch(() => { setUser(null); setLoading(false); }); }, []);
useEffect(() => { if (user) load(); }, [user, load]);
if (!getToken()) return <div className="empty-state ticket-detail-guard" role="status"><div className="empty-icon" aria-hidden="true">🔒</div><h1>登录后查看工单</h1><p className="text-muted">登录后才能查看工单详情和回复</p><Link to="/login.html" state={{ from: window.location.pathname }} className="btn btn-filled">去登录</Link></div>;
if (loading) return <div className="loading" role="status" aria-label="正在加载工单"><div className="spinner" /></div>;
if (error || !data || !data.ticket) return <div className="empty-state" role="alert"><div className="empty-icon" aria-hidden="true"></div><p>{error || '工单不存在或无权访问'}</p><button type="button" className="btn btn-tonal btn-sm" onClick={load}>重试</button><Link to="/tickets.html" className="btn btn-text btn-sm">返回工单</Link></div>;
const { ticket } = data; const messages = Array.isArray(data.messages) ? data.messages : [];
const events = (Array.isArray(data.events) ? data.events : []).filter((event) => PUBLIC_EVENTS.has(event.event_type));
const sourceUrl = safeSourceUrl(ticket.source_url); const canReply = ticket.status !== 'closed';
const runAction = async (action, confirmation) => {
if (!window.confirm(confirmation)) return;
const actionId = id;
setBusy(true); setError('');
try {
await action(actionId);
if (String(activeIdRef.current) === String(actionId)) await load();
} catch (e) {
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '操作失败');
} finally { setBusy(false); }
};
const sendReply = async (e) => {
e.preventDefault();
const content = reply.trim();
if (!content) { setError('回复内容不能为空'); return; }
const actionId = id;
setBusy(true); setError('');
try {
await ticketsApi.addMessage(actionId, content);
if (String(activeIdRef.current) === String(actionId)) {
setReply('');
await load();
}
} catch (e) {
if (String(activeIdRef.current) === String(actionId)) setError(e.message || '回复失败');
} finally { setBusy(false); }
};
return <div className="ticket-detail-page">
<Link to="/tickets.html" className="btn btn-text btn-sm ticket-detail-back"> 返回工单列表</Link>
<div className="ticket-detail-layout">
<div className="ticket-detail-main">
<article className="card ticket-detail-header"><div className="ticket-detail-kicker">{ticket.ticket_no || `工单 #${ticket.id}`}</div><h1 className="ticket-detail-title">{ticket.subject || ticket.title || '未命名工单'}</h1><div className="ticket-detail-tags"><StatusChip status={ticket.status} /><span className="chip chip-static">{categoryLabel(ticket.category)}</span><span className="chip chip-static">{priorityLabel(ticket.priority)}</span></div><TicketProgress status={ticket.status} /></article>
<article className="card ticket-detail-description"><h2>问题描述</h2><p>{ticket.description}</p>{sourceUrl && <p className="ticket-detail-source"><span className="text-muted">发现页面</span><a href={sourceUrl} target="_blank" rel="noopener noreferrer">{sourceUrl}</a></p>}</article>
<section className="ticket-detail-conversation" aria-labelledby="ticket-messages-heading"><div className="ticket-detail-section-heading"><h2 id="ticket-messages-heading">沟通记录</h2><span className="text-muted">{messages.length} 条公开回复</span></div>{messages.length === 0 ? <div className="card ticket-detail-empty"><span className="material-icons" aria-hidden="true">forum</span><p>暂无回复提交后管理员会在这里跟进</p></div> : <ol className="ticket-detail-messages">{messages.map((message) => <li key={message.id} className="card ticket-detail-message"><div className="ticket-detail-message-meta"><strong>{message.author_username || message.author_name || '用户'}</strong><time className="text-muted" dateTime={message.created_at}>{formatTime(message.created_at)}</time></div><p>{message.content || message.body}</p></li>)}</ol>}</section>
{events.length > 0 && <section className="ticket-detail-events" aria-labelledby="ticket-events-heading"><h2 id="ticket-events-heading">处理记录</h2><ol>{events.map((event) => <li key={event.id}><span>{eventLabel(event)}</span><time className="text-muted" dateTime={event.created_at}>{formatTime(event.created_at)}</time></li>)}</ol></section>}
{canReply ? <form className="card ticket-detail-reply" onSubmit={sendReply}><label htmlFor="ticket-reply">追加回复</label><textarea id="ticket-reply" value={reply} onChange={(e) => setReply(e.target.value)} rows={5} maxLength={20000} placeholder="补充信息或回复处理结果" aria-describedby="ticket-reply-help" /><div className="ticket-detail-reply-footer"><p id="ticket-reply-help" className="text-muted">回复将对工单参与者公开</p><button type="submit" className="btn btn-filled" disabled={busy}>{busy ? '发送中…' : '发送回复'}</button></div></form> : <div className="card ticket-detail-closed" role="status">工单已关闭如需继续反馈请提交新的工单</div>}
</div>
<aside className="card ticket-detail-sidebar" aria-label="工单信息"><div className="ticket-detail-sidebar-status"><span className="text-muted">当前状态</span><StatusChip status={ticket.status} /></div><dl><div><dt>问题类型</dt><dd>{categoryLabel(ticket.category)}</dd></div><div><dt>创建时间</dt><dd>{formatTime(ticket.created_at)}</dd></div><div><dt>最近更新</dt><dd>{formatTime(ticket.updated_at)}</dd></div></dl><div className="ticket-detail-actions">{ticket.status === 'resolved' && <button type="button" className="btn btn-filled" disabled={busy} onClick={() => runAction(ticketsApi.closeTicket, '确认将此工单标记为已关闭吗?')}>确认已解决</button>}{ticket.status !== 'closed' && ticket.status !== 'resolved' && <button type="button" className="btn btn-tonal" disabled={busy} onClick={() => runAction(ticketsApi.closeTicket, '确认关闭此工单吗?')}>关闭工单</button>}{ticket.status === 'closed' && <button type="button" className="btn btn-tonal" disabled={busy} onClick={() => runAction(ticketsApi.reopenTicket, '确认重新打开此工单吗?')}>重新打开</button>}</div></aside>
</div>
{error && <div className="ticket-detail-error" role="alert">{error}</div>}
</div>;
}
+122
View File
@@ -0,0 +1,122 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import * as ticketsApi from '../api/tickets.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
const STATUS = {
open: { label: '待处理', icon: 'inbox', tone: 'warning' },
processing: { label: '处理中', icon: 'sync', tone: 'primary' },
waiting: { label: '等待用户', icon: 'schedule', tone: 'secondary' },
resolved: { label: '已解决', icon: 'check_circle', tone: 'success' },
closed: { label: '已关闭', icon: 'lock', tone: 'neutral' },
};
const CATEGORY = { forum_bug: '论坛 Bug', site_bug: '站内 Bug', feature: '功能建议', account: '账号问题', report: '内容举报', other: '其他问题' };
export function statusInfo(status) { return STATUS[status] || { label: status || '未知状态', icon: 'help', tone: 'neutral' }; }
export function categoryLabel(category) { return CATEGORY[category] || category || '其他问题'; }
export function priorityLabel(priority) {
return { low: '低', normal: '普通', high: '高', urgent: '紧急' }[priority] || '普通';
}
/** 工单来源只允许 http(s) 或本站相对路径,避免把用户可控值直接作为危险链接。 */
export function safeSourceUrl(value) {
const valueText = String(value ?? '');
if (!valueText || valueText.length > 1000) return '';
if (/[\u0000-\u001f\u007f-\u009f]/.test(valueText) || valueText.includes('\\')) return '';
const raw = valueText.trim();
if (!raw || raw.startsWith('//')) return '';
if (raw.startsWith('/')) return raw;
try {
const parsed = new URL(raw);
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : '';
} catch { return ''; }
}
export function formatTime(value) {
if (!value) return '';
const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})/);
return m ? `${m[1]}${Number(m[2])}${Number(m[3])}${m[4]}:${m[5]}` : String(value);
}
function StatusChip({ status }) {
const info = statusInfo(status);
return <span className={'chip ticket-status ticket-status--' + info.tone}><span className="material-icons" aria-hidden="true" style={{ fontSize: 16 }}>{info.icon}</span>{info.label}</span>;
}
function LoginGuide() {
return <div className="empty-state" style={{ maxWidth: 460, margin: '48px auto' }}>
<div className="empty-icon" aria-hidden="true">🎫</div>
<h1 style={{ fontSize: 22, margin: '0 0 8px' }}>登录后使用工单</h1>
<p className="text-muted">登录后可以提交论坛或站内问题并随时查看处理进度</p>
<Link to="/login.html" state={{ from: '/tickets.html' }} className="btn btn-filled" style={{ marginTop: 16 }}>去登录</Link>
</div>;
}
export default function Tickets() {
const [user, setUser] = useState(null);
const [tickets, setTickets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [status, setStatus] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const listRequestRef = useRef(0);
const load = useCallback(async () => {
const requestId = ++listRequestRef.current;
setLoading(true); setError('');
try {
const data = await ticketsApi.listTickets({ page, pageSize: 20, status });
if (requestId !== listRequestRef.current) return;
const normalized = ticketsApi.normalizeTicketListResponse(data, 20);
setTickets(normalized.tickets);
setTotal(normalized.total);
setTotalPages(normalized.totalPages);
} catch (e) {
if (requestId !== listRequestRef.current) return;
setError(e.message || '工单加载失败');
} finally {
if (requestId === listRequestRef.current) setLoading(false);
}
}, [page, status]);
useEffect(() => {
if (!getToken()) { setLoading(false); return; }
me().then(setUser).catch(() => { setUser(null); setLoading(false); });
}, []);
useEffect(() => { if (user) load(); }, [user, load]);
const updateStatus = (value) => { setStatus(value); setPage(1); };
if (!getToken() || (!user && !loading)) return <LoginGuide />;
return <div style={{ maxWidth: 960, margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap', marginBottom: 20 }}>
<div><h1 className="page-title" style={{ margin: 0 }}>工单中心</h1><p className="text-muted" style={{ margin: '6px 0 0' }}>反馈论坛或站内问题查看处理进度</p></div>
<Link to="/tickets/new" className="btn btn-filled"><span className="material-icons" aria-hidden="true" style={{ fontSize: 18 }}>add</span>提交问题</Link>
</div>
<div className="card" style={{ padding: 12, marginBottom: 16 }}>
<label htmlFor="ticket-status-filter" style={{ marginRight: 10 }}>筛选状态</label>
<select id="ticket-status-filter" value={status} onChange={(e) => updateStatus(e.target.value)} style={{ minHeight: 40, padding: '0 10px', borderRadius: 8 }}>
<option value="">全部工单</option><option value="open">待处理</option><option value="processing">处理中</option><option value="waiting">等待用户</option><option value="resolved">已解决</option><option value="closed">已关闭</option>
</select>
</div>
{loading && <div className="loading" role="status" aria-label="正在加载工单"><div className="spinner" /></div>}
{error && <div className="empty-state" role="alert"><div className="empty-icon" aria-hidden="true"></div><p>{error}</p><button type="button" className="btn btn-tonal btn-sm" onClick={load}>重试</button></div>}
{!loading && !error && tickets.length === 0 && <div className="empty-state"><div className="empty-icon" aria-hidden="true">📭</div><p>还没有工单</p><p className="text-muted">如果你在论坛或站内遇到问题可以提交一条反馈</p><Link to="/tickets/new" className="btn btn-tonal btn-sm" style={{ marginTop: 12 }}>提交第一个问题</Link></div>}
{!loading && !error && tickets.length > 0 && <section aria-labelledby="recent-tickets-heading">
<h2 id="recent-tickets-heading" style={{ fontSize: 18, margin: '24px 0 12px' }}>最近创建的工单</h2>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: 12 }}>
{tickets.map((ticket) => <li key={ticket.id}><Link to={'/tickets/' + ticket.id} className="card" style={{ display: 'block', textDecoration: 'none', padding: 18 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}><div style={{ minWidth: 0 }}><div className="text-muted" style={{ fontSize: 13 }}>{ticket.ticket_no || `工单 #${ticket.id}`}</div><h3 style={{ margin: '5px 0 8px', overflowWrap: 'anywhere' }}>{ticket.subject || ticket.title || '未命名工单'}</h3></div><StatusChip status={ticket.status} /></div>
<div className="text-muted" style={{ fontSize: 13, display: 'flex', gap: 12, flexWrap: 'wrap' }}><span>{categoryLabel(ticket.category)}</span><span>创建于 {formatTime(ticket.created_at)}</span><span>更新于 {formatTime(ticket.updated_at)}</span></div>
</Link></li>)}
</ul>
</section>}
{!loading && !error && totalPages > 1 && <nav className="ticket-pagination" aria-label="工单分页" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 12, marginTop: 20 }}>
<button type="button" className="btn btn-text btn-sm" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>上一页</button>
<span className="text-muted" aria-live="polite"> {page} / {totalPages} {total} </span>
<button type="button" className="btn btn-text btn-sm" disabled={page >= totalPages} onClick={() => setPage((current) => current + 1)}>下一页</button>
</nav>}
</div>;
}
+363
View File
@@ -0,0 +1,363 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import * as usersApi from '../api/users.js';
import { me } from '../api/auth.js';
import { getToken } from '../api/client.js';
import Avatar, { hashTone } from '../components/Avatar.jsx';
import { safeOutUrl } from '../lib/outlink.js';
const PAGE_SIZE = 10;
/** 后端时间 'YYYY-MM-DD HH:MM:SS'UTC)→ 相对时间(刚刚 / n 分钟前 / n 小时前 / n 天前 / n 个月前 / n 年前) */
function relativeTime(ts) {
if (!ts) return '';
const d = new Date(String(ts).includes('T') ? ts : String(ts).replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return String(ts);
const diff = Date.now() - d.getTime();
const MIN = 60e3;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
if (diff < MIN) return '刚刚';
if (diff < HOUR) return Math.floor(diff / MIN) + ' 分钟前';
if (diff < DAY) return Math.floor(diff / HOUR) + ' 小时前';
if (diff < 30 * DAY) return Math.floor(diff / DAY) + ' 天前';
if (diff < 365 * DAY) return Math.floor(diff / (30 * DAY)) + ' 个月前';
return Math.floor(diff / (365 * DAY)) + ' 年前';
}
/** 注册时间只到年月:'2026-08-12 10:00' → '2026-08' */
function regDate(s) {
return String(s || '').slice(0, 7);
}
/** 骨架块 */
function Skel({ w, h, r = 8, style }) {
return <div className="profile-skel" style={{ width: w, height: h, borderRadius: r, ...style }} />;
}
/** 统计格(2×2 */
function StatCell({ label, value }) {
return (
<div className="profile-stat">
<div className="profile-stat-num">{value ?? 0}</div>
<div className="profile-stat-label">{label}</div>
</div>
);
}
/**
* 公开个人主页路由 /u/:id
* Banner 渐变条 + 身份卡头像/用户名/签名/元信息/统计 2×2+ 内容流帖子/回复 Tab + 加载更多
* 自己视角显示这是你的公开主页+ 编辑资料入口他人视角零操作
* 404 用户不存在或已注销论坛私密 内容区 403 提示
*/
export default function UserProfile() {
const { id } = useParams();
const uid = parseInt(id, 10);
const [user, setUser] = useState(null);
const [status, setStatus] = useState('loading'); // loading | ok | notfound | error
const [loadError, setLoadError] = useState('');
const [meUser, setMeUser] = useState(null);
const [tab, setTab] = useState('posts'); // posts | replies
const [list, setList] = useState([]);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [listLoading, setListLoading] = useState(false);
const [listError, setListError] = useState('');
const [private403, setPrivate403] = useState(false);
// +
useEffect(() => {
let mounted = true;
setStatus('loading');
setUser(null);
setList([]);
setPage(1);
setTotal(0);
setTotalPages(0);
setPrivate403(false);
setListError('');
usersApi.getPublicUser(uid)
.then((u) => { if (mounted) { setUser(u); setStatus('ok'); } })
.catch((e) => {
if (!mounted) return;
if (e.status === 404) setStatus('notfound');
else { setLoadError(e.message || '加载失败'); setStatus('error'); }
});
if (getToken()) me().then(setMeUser).catch(() => setMeUser(null));
else setMeUser(null);
return () => { mounted = false; };
}, [uid]);
// tab / page
const loadList = useCallback(async (uid_, tab_, page_) => {
setListLoading(true);
setListError('');
setPrivate403(false);
try {
const res = tab_ === 'posts'
? await usersApi.getUserPosts(uid_, page_, PAGE_SIZE)
: await usersApi.getUserReplies(uid_, page_, PAGE_SIZE);
const items = (res && res.list) || [];
setList((prev) => (page_ === 1 ? items : [...prev, ...items]));
setTotal(res ? res.total || 0 : 0);
setTotalPages(res ? res.totalPages || 0 : 0);
} catch (e) {
if (e.status === 403) {
// 403
setPrivate403(true);
setList([]);
setTotal(0);
setTotalPages(0);
} else {
setListError(e.message || '加载失败');
}
}
setListLoading(false);
}, []);
useEffect(() => {
if (status === 'ok') loadList(uid, tab, page);
}, [status, uid, tab, page, loadList]);
const switchTab = (t) => {
if (t === tab) return;
setTab(t);
setPage(1);
setList([]);
setTotal(0);
setTotalPages(0);
};
const isSelf = !!meUser && meUser.id === user?.id;
//
if (status === 'loading') {
return (
<div className="profile-page" aria-busy="true" aria-label="正在加载个人主页">
<Skel w="100%" h={180} r={0} style={{ marginBottom: 0 }} />
<div className="profile-grid">
<div className="profile-identity">
<div className="card profile-card profile-card-skeleton">
<Skel w={80} h={80} r="50%" />
<Skel w={140} h={24} r={6} />
<Skel w="100%" h={14} r={4} />
<Skel w="70%" h={14} r={4} />
</div>
</div>
<div className="profile-content">
<Skel w={140} h={36} r={20} />
{[0, 1, 2].map((i) => (
<div className="card profile-list-item profile-item-skeleton" key={i}>
<Skel w="75%" h={16} r={4} />
<Skel w="40%" h={12} r={4} style={{ marginTop: 10 }} />
</div>
))}
</div>
</div>
</div>
);
}
if (status === 'notfound') {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '64px auto' }}>
<div className="empty-icon">🔍</div>
<p style={{ fontWeight: 500 }}>用户不存在或已注销</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>这个公开主页可能已被删除</p>
<Link to="/" className="btn btn-tonal btn-sm" style={{ marginTop: 16 }}>返回首页</Link>
</div>
);
}
if (status === 'error') {
return (
<div className="empty-state" style={{ maxWidth: 420, margin: '64px auto' }}>
<div className="empty-icon"></div>
<p style={{ fontWeight: 500 }}>{loadError || '加载失败'}</p>
<button
type="button"
className="btn btn-tonal btn-sm"
style={{ marginTop: 16 }}
onClick={() => window.location.reload()}
>
重试
</button>
</div>
);
}
// Banner + + ""
const displayName = user.display_name || user.nickname || user.username || '';
const tone = hashTone(user.username || String(user.id || ''));
const h = tone * 30;
const bannerGrad = `linear-gradient(135deg, hsl(${h} 48% 82%), hsl(${(h + 40) % 360} 58% 68%))`;
const stats = (user && user.stats) || {};
const listEmpty = !listLoading && list.length === 0 && !listError && !private403;
return (
<div className="profile-page">
<div className="profile-banner" style={{ background: bannerGrad }} aria-hidden="true">
<span className="profile-banner-char">{displayName.charAt(0)}</span>
<span className="profile-banner-name">{displayName}</span>
</div>
<div className="profile-grid">
{/* 左列:身份卡 */}
<aside className="profile-identity">
<div className="card profile-card">
<Avatar src={user.avatar} name={displayName || user.username} size={80} />
<div className="profile-name-row">
<h1 className="profile-name">{displayName || user.username}</h1>
{user.role === 'admin' ? <span className="profile-role-chip">管理员</span> : null}
{user.title ? (
<span
className={'username-chip title-chip' + (user.title_color ? ' title-chip-colored' : '')}
style={user.title_color ? { background: user.title_color } : undefined}
>
{user.title}
</span>
) : null}
</div>
{user.bio ? <p className="profile-bio">{user.bio}</p> : null}
<div className="profile-meta-line">
<span>UID {user.id}</span>
{user.created_at ? <span>注册于 {regDate(user.created_at)}</span> : null}
{user.last_active_at ? <span>{relativeTime(user.last_active_at)}活跃</span> : null}
{user.qq ? <span>QQ{user.qq}</span> : null}
{user.website ? (
<a href={safeOutUrl(user.website)} target="_blank" rel="noopener noreferrer" title="个人博客">
<span className="material-icons" style={{ fontSize: 13 }}>link</span> 个人博客
</a>
) : null}
</div>
<div className="profile-stats">
<StatCell label="帖子" value={stats.posts} />
<StatCell label="回复" value={stats.replies} />
<StatCell label="文章" value={stats.articles} />
<StatCell label="获赞" value={stats.likes} />
</div>
{isSelf && (
<div className="profile-self-area">
<div className="profile-self-notice">这是你的公开主页</div>
<Link to="/profile.html" className="btn btn-tonal btn-sm">编辑资料</Link>
</div>
)}
</div>
</aside>
{/* 右列:内容流 */}
<div className="profile-content">
<div className="profile-tabs" role="tablist" aria-label="内容筛选">
<button
type="button"
role="tab"
id="profile-tab-posts"
aria-selected={tab === 'posts'}
aria-controls="profile-panel"
className={'nav-tab' + (tab === 'posts' ? ' active' : '')}
onClick={() => switchTab('posts')}
>
帖子
</button>
<button
type="button"
role="tab"
id="profile-tab-replies"
aria-selected={tab === 'replies'}
aria-controls="profile-panel"
className={'nav-tab' + (tab === 'replies' ? ' active' : '')}
onClick={() => switchTab('replies')}
>
回复
</button>
</div>
<div
id="profile-panel"
role="tabpanel"
aria-labelledby={tab === 'posts' ? 'profile-tab-posts' : 'profile-tab-replies'}
>
{private403 && (
<div className="empty-state" style={{ padding: '40px 16px' }}>
<div className="empty-icon">🔒</div>
<p style={{ fontWeight: 500 }}>论坛已设为私密</p>
<p className="text-muted" style={{ fontSize: 14, marginTop: 4 }}>登录后可查看该用户的论坛内容</p>
</div>
)}
{listError && (
<div className="empty-state" style={{ padding: '40px 16px' }}>
<div className="empty-icon"></div>
<p>{listError}</p>
</div>
)}
{listLoading && page === 1 && !private403 && (
<div className="profile-list-skeleton" aria-label="正在加载">
{[0, 1, 2].map((i) => (
<div className="card profile-list-item profile-item-skeleton" key={i}>
<Skel w="70%" h={16} r={4} />
<Skel w="45%" h={12} r={4} style={{ marginTop: 10 }} />
</div>
))}
</div>
)}
{listEmpty && (
<div className="empty-state" style={{ padding: '48px 16px' }}>
<div className="empty-icon">{tab === 'posts' ? '📝' : '💬'}</div>
<p>{tab === 'posts' ? '还没有发过帖子' : '还没有回复过'}</p>
</div>
)}
{!listLoading && !listError && !private403 && list.map((item) => (
tab === 'posts' ? (
<Link key={item.id} to={'/forum/' + item.id} className="card profile-list-item">
<div className="profile-item-title">
{item.is_pinned ? <span className="post-badge pin">📌</span> : null}
<span className="profile-item-title-text">{item.title}</span>
</div>
<div className="profile-item-meta">
{item.category_name ? <span className="chip chip-static">{item.category_name}</span> : null}
<span>{relativeTime(item.created_at)}</span>
<span>{item.reply_count || 0} 回复</span>
</div>
</Link>
) : (
<Link key={item.id} to={'/forum/' + item.post_id} className="card profile-list-item">
<div className="profile-item-preview">{item.content}</div>
<div className="profile-item-meta">
<span className="profile-reply-at">回复于 {item.post_title || '帖子'}</span>
<span>{relativeTime(item.created_at)}</span>
</div>
</Link>
)
))}
{/* 加载更多 / 到底 */}
{!listError && !private403 && list.length > 0 && totalPages > page && (
<div className="profile-load-more">
<button
type="button"
className="btn btn-tonal btn-sm"
onClick={() => setPage((p) => p + 1)}
disabled={listLoading}
>
{listLoading ? '加载中…' : '加载更多'}
</button>
</div>
)}
{!listError && !private403 && list.length > 0 && totalPages > 0 && page >= totalPages && (
<div className="profile-end-hint">已经到底啦</div>
)}
</div>
</div>
</div>
</div>
);
}
+202 -113
View File
@@ -1,112 +1,181 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, 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 * as forumApi from '../api/forum.js';
import { required as captchaRequired, applyCaptchaResult } from '../api/captcha.js';
import { showCaptcha } from '../components/CaptchaModal.jsx';
import MarkdownEditor from '../components/MarkdownEditor.jsx';
import ErrorDialog from '../components/ErrorDialog.jsx';
import { showSnackbar } from '../lib/utils.js';
/** 板块的子分类列表(论坛模式;照搬原 Forum.jsx 发帖弹窗) */
function subCatsOf(cat) {
if (!cat || !cat.sub_categories) return [];
return cat.sub_categories.split(',').filter(Boolean).map((t) => t.trim());
}
/**
* 写文章页路由 /write.html?edit=id 进入编辑模式
* 标题/摘要/内容(Markdown)/发布开关附件上传用 uploadFile + [image:]/[file:] 标签插入
* 支持拖拽图片上传与预览迁移自 write.html 内联脚本
* 写文章 / 发帖路由 /write.html
* - 默认博客模式?edit=id 进入编辑标题/摘要/标签/发布开关 /api/blog
* - ?type=forum 论坛模式?type=forum&edit=id 进入编辑标题 + 板块/子分类选择 /api/forum/posts
* - 发帖走验证码captcha_forum编辑不需要验证码且不改版块下拉只读
* 内容编辑统一用共用 MarkdownEditor左编辑右实时预览 + 防抖刷新
*/
export default function Write() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const mode = searchParams.get('type') === 'forum' ? 'forum' : 'blog';
const isForum = mode === 'forum';
const editId = searchParams.get('edit') ? parseInt(searchParams.get('edit'), 10) : null;
const isForumEdit = isForum && !!editId;
// ?category=id
const categoryParam = searchParams.get('category');
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 [submitError, setSubmitError] = useState(''); // 403
const fileRef = useRef(null);
const contentRef = useRef(null);
// +
const [categories, setCategories] = useState([]);
const [fpCategory, setFpCategory] = useState('');
const [fpSub, setFpSub] = useState('');
//
//
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);
if (!isForum && editId) {
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 || '加载文章失败'));
}
};
// title/category/sub_category/content
if (isForum && editId) {
let mounted = true;
forumApi.getPost(editId)
.then((data) => {
const p = data && data.post;
if (!mounted || !p) return;
setTitle(p.title);
setContent(p.content || '');
setFpCategory(String(p.category_id || ''));
setFpSub(p.sub_category || '');
})
.catch((e) => { if (mounted) setLoadError(e.message || '加载帖子失败'); });
return () => { mounted = false; };
}
return undefined;
}, [isForum, editId]);
const handleFileSelect = (e) => {
const f = e.target.files && e.target.files[0];
if (f) doUpload(f);
e.target.value = '';
};
// ?category=
useEffect(() => {
if (!isForum) return;
let mounted = true;
forumApi.listCategories()
.then((cs) => {
if (!mounted) return;
setCategories(cs || []);
if (editId || !cs || !cs.length) return; //
setFpCategory((cur) => {
if (cur) return cur;
if (categoryParam && cs.some((c) => String(c.id) === String(categoryParam))) {
return String(categoryParam);
}
return String(cs[0].id);
});
})
.catch((e) => { if (mounted) setLoadError(e.message || '加载板块失败'); });
return () => { mounted = false; };
}, [isForum, editId]);
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 handleCategoryChange = (e) => {
setFpCategory(e.target.value);
setFpSub(''); //
};
const submit = async () => {
if (!title.trim() || !content.trim()) { showSnackbar('标题和内容不能为空'); return; }
if (isForum && !fpCategory) { 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('已更新');
if (isForum) {
if (editId) {
// title/content/sub_category
await forumApi.updatePost(editId, {
title: title.trim(),
content: content.trim(),
sub_category: fpSub,
use_markdown: 1,
});
showSnackbar('已更新');
navigate('/forum/' + editId);
} else {
const base = {
category_id: parseInt(fpCategory, 10),
title: title.trim(),
content: content.trim(),
sub_category: fpSub,
use_markdown: 1,
};
// captcha_forum showCaptcha proof / token
// /api/forum/posts
const cap = await captchaRequired('forum');
if (cap && cap.required) {
const result = await showCaptcha('forum');
if (result === null) { setSaving(false); return; } //
applyCaptchaResult(base, result);
}
const post = await forumApi.createPost(base);
showSnackbar('发布成功');
navigate('/forum/' + post.id);
}
} else {
const created = await blogApi.createPost(data);
id = created.id;
showSnackbar('已发布');
const data = {
title: title.trim(),
content: content.trim(),
excerpt: excerpt.trim(),
tags: tags.trim(),
published,
use_markdown: 1,
};
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);
}
navigate('/blog/' + id);
} catch (e) {
showSnackbar(e.message);
// 403
if (e.status === 403) setSubmitError(e.message);
else showSnackbar(e.message);
}
setSaving(false);
};
const heading = isForum ? (editId ? '编辑帖子' : '发帖') : (editId ? '编辑文章' : '写文章');
const backHref = isForum ? '/forum.html' : '/blog.html';
const backText = isForum ? '返回论坛' : '返回博客';
return (
<div className="write-page" style={{ maxWidth: 800, margin: '0 auto', padding: '24px 16px' }}>
<div className="write-page" style={{ maxWidth: 960, 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> 返回博客
<h1 style={{ fontSize: 24, fontWeight: 500, flex: 1, margin: 0 }}>{heading}</h1>
<a href={backHref} className="btn btn-text btn-sm">
<span className="material-icons">arrow_back</span> {backText}
</a>
</div>
@@ -118,61 +187,81 @@ export default function Write() {
</div>
) : (
<>
{isForum && (
<>
<div className="form-group">
<label>板块 *</label>
{isForumEdit ? (
<>
<select value={fpCategory} disabled aria-label="板块(编辑时不可修改)">
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<div className="text-muted" style={{ fontSize: 12, marginTop: 4 }}>编辑帖子不支持修改版块</div>
</>
) : (
<select value={fpCategory} 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={fpSub} onChange={(e) => setFpSub(e.target.value)}>
<option value=""></option>
{subCatsOf(categories.find((c) => c.id === parseInt(fpCategory, 10))).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</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}
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isForum ? '帖子标题' : '文章标题'}
/>
</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>
{!isForum && (
<>
<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>
</>
)}
{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 className="form-group">
<label>内容 *</label>
<MarkdownEditor
value={content}
onChange={setContent}
placeholder={isForum ? '支持 Markdown 语法,可插入附件/图片' : '支持 Markdown 语法,拖入图片自动上传'}
label={isForum ? '帖子内容' : '文章内容'}
/>
</div>
{!isForum && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
<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>
)}
<button className="btn btn-filled" onClick={submit} disabled={saving}>
{editId ? '保存修改' : '发布文章'}
{isForum ? (editId ? '保存修改' : '发布') : (editId ? '保存修改' : '发布文章')}
</button>
</>
)}
</div>
{/* 403 禁言等拒绝操作提示 */}
<ErrorDialog open={!!submitError} message={submitError} onClose={() => setSubmitError('')} />
</div>
);
}
+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 };
/** pxRRP 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=48pxdelta 被其 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,263 @@
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复用现有 APItrusted/scale/permissions 为代理新字段
* ============================================================ */
const MODE_META = [
{ mode: OPEN_MODE_EMBED, label: '内嵌', Icon: WebAssetIcon },
{ mode: OPEN_MODE_TAB, label: '新标签', Icon: OpenInNewIcon },
{ mode: OPEN_MODE_MODAL, label: '弹窗', Icon: OpenInFullIcon },
];
/* iframe 委托权限候选(对应 Permissions Policy 的 allow 属性 token */
const PERMISSION_OPTIONS = [
{ value: 'camera', label: '摄像头' },
{ value: 'microphone', label: '麦克风' },
{ value: 'geolocation', label: '定位' },
{ value: 'clipboard-read', label: '剪贴板读取' },
{ value: 'clipboard-write', label: '剪贴板写入' },
{ value: 'payment', label: '支付' },
{ value: 'usb', label: 'USB' },
{ value: 'serial', label: '串口' },
{ value: 'notifications', label: '通知' },
];
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: '',
trusted: true, scale: '1', permissions: [],
});
const [err, setErr] = useState('');
const resetForm = () => setForm({
title: '', url: '', category: '', openMode: OPEN_MODE_EMBED,
use_proxy: false, embed_url: '',
trusted: true, scale: '1', permissions: [],
});
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,
trusted: form.trusted ? 1 : 0,
scale: parseFloat(form.scale) || 1,
permissions: JSON.stringify(form.permissions),
description: '',
icon: '',
sort_order: 0,
version: '',
});
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
setErr('');
resetForm();
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 sx={{ mt: 2, pt: 1.5, borderTop: 1, borderColor: 'divider' }}>
<Typography variant="caption" sx={{ display: 'block', mb: 0.5, color: 'text.secondary' }}>
信任模式影响 iframe 沙箱
</Typography>
<ToggleButtonGroup
exclusive
fullWidth
size="small"
value={form.trusted ? 'trusted' : 'safe'}
onChange={(e, v) => { if (v) setForm((p) => ({ ...p, trusted: v === 'trusted' })); }}
aria-label="信任模式"
>
<ToggleButton value="trusted" sx={{ py: 0.75 }}>可信保留登录态</ToggleButton>
<ToggleButton value="safe" sx={{ py: 0.75 }}>安全隔离沙箱</ToggleButton>
</ToggleButtonGroup>
<Typography variant="caption" sx={{ display: 'block', mt: 0.5, color: 'text.secondary' }}>
{form.trusted
? '可信:iframe 保留 allow-same-origin,目标站 cookie 登录态可用'
: '安全:剥离 allow-same-originopaque origin),被代理页无法访问本站'}
</Typography>
<TextField
fullWidth
label="缩放(0.13,默认 1"
type="number"
value={form.scale}
onChange={set('scale')}
margin="dense"
inputProps={{ min: 0.1, max: 3, step: 0.1 }}
helperText="小于 1 缩小(看得更多),大于 1 放大(看得更清)"
/>
<Autocomplete
multiple
fullWidth
size="small"
options={PERMISSION_OPTIONS}
getOptionLabel={(o) => o.label}
value={PERMISSION_OPTIONS.filter((o) => form.permissions.includes(o.value))}
onChange={(e, v) => setForm((p) => ({ ...p, permissions: v.map((o) => o.value) }))}
renderInput={(params) => (
<TextField {...params} label="委托权限(可选)" margin="dense" placeholder="选择 iframe 可调用的设备权限" />
)}
/>
</Box>
</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,359 @@
import React, { useCallback, 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
* 拒绝 / 目标无响应显示失败提示 + 在新标签页打开回退
* - 代理模式use_proxy=1 HTTPS 页内嵌 HTTP 目标
* /proxy/{slug}/ 前缀代理后端 HTML 改写 + shim 注入 + WebSocket
* + 全方法透传鉴权由后端处理首次加载用 Authorization header
* 预热一次触发下发 HttpOnly rwp_{slug} 面板 cookiePath 限定
* /proxy/{slug}/12h 有效iframe src 本身不带任何 token
* 被代理页脚本读不到凭据旧面板无 slug 时回退 /api/proxy/fetch
* URL 透传保留兼容 TTL token 5 分钟
* - 信任模型admin_links.trusted sandbox
* trusted=1默认保留 allow-same-origin保目标站 cookie 登录态
* 追加 allow-modals / allow-downloads
* trusted=0剥离 allow-same-originopaque origin 安全模式
* 站内 URL/ 开头直嵌一律剥离 allow-same-origin本站同源伺服
* 保留即等同 sandbox 逃逸与信任开关无关
* - permissions 委托面板配置的权限数组 iframe allow 属性
* - scale 缩放外盒 1/scale + transform: scale 实现任意缩放0.1-3
* - LRU 由父级 Workbench 控制挂载/卸载此组件不自行卸载
* ============================================================ */
const LOAD_TIMEOUT_MS = 15000;
const HINT_TTL_MS = 5000; // 5
const WARM_TTL_MS = 10 * 60 * 60 * 1000; // cookie 12h 10h
const TOKEN_TTL_MS = 4 * 60 * 1000; // /fetch token 4
/* 面板预热缓存:slug -> { p, at }(并发去重 + 定时过期) */
const warmCache = new Map();
/* 短 TTL proxy token 的模块级缓存(仅旧 /api/proxy/fetch 兼容回退使用) */
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;
}
/* /proxy/{slug}/ token fetch rwp_{slug} cookie
* HttpOnly面板脚本不可读之后 iframe 同源加载自动携带该 cookie 鉴权 */
function warmProxy(slug) {
const now = Date.now();
const hit = warmCache.get(slug);
if (hit && now - hit.at < WARM_TTL_MS) return hit.p;
const p = fetch('/proxy/' + slug + '/', {
headers: { Authorization: 'Bearer ' + (getToken() || '') },
})
.then((res) => {
if (!res.ok) throw new Error('面板代理鉴权失败');
return true;
})
.catch((e) => { warmCache.delete(slug); throw e; });
warmCache.set(slug, { p, at: now });
return p;
}
/* permissions 字段:后端存 JSON 数组字符串,兼容已解析数组 */
function parsePermissions(raw) {
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
if (typeof raw === 'string' && raw.trim()) {
try {
const a = JSON.parse(raw);
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
} catch { return []; }
}
return [];
}
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:'));
// slug [a-z0-9-]32 退 /fetch
const slug = String(panel.slug || '').toLowerCase();
const hasSlug = /^[a-z0-9-]{1,32}$/.test(slug);
const proxyPrefix = hasSlug ? '/proxy/' + slug + '/' : '';
// admin_links.trusted1=0=
const trusted = !(panel.trusted === 0 || panel.trusted === '0');
// sandbox
// trusted=1 allow-same-origin cookie
// trusted=0 opaque origin/ DOM
// /
// trusted=1 allow-same-origin cookie
const stripSameOrigin = needsProxy
? !trusted
: (targetUrl.startsWith('/') || !trusted);
const sandbox = stripSameOrigin
? '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 allow-modals allow-downloads';
// permissions + allow
const baseAllow = 'fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
const delegated = parsePermissions(panel.permissions);
const allowAttr = delegated.length ? [baseAllow, ...delegated].join('; ') : baseAllow;
// scale 0.1-3 11/scale + transform scale
const scale = (() => {
const s = parseFloat(panel.scale);
return Number.isFinite(s) ? Math.min(3, Math.max(0.1, s)) : 1;
})();
// slug token src
// alive setState退
const bootProxy = useCallback((alive = () => true) => {
if (!needsProxy) { setProxySrc(targetUrl); return; }
setProxySrc('');
if (hasSlug) {
warmProxy(slug)
.then(() => { if (alive()) setProxySrc(proxyPrefix); })
.catch(() => {
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
});
} else {
fetchProxyToken()
.then((tok) => {
if (alive()) setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
})
.catch(() => {
if (alive()) { statusRef.current = 'timeout'; setStatus('timeout'); }
});
}
}, [needsProxy, hasSlug, slug, proxyPrefix, targetUrl]);
useEffect(() => {
let alive = true;
bootProxy(() => alive);
return () => { alive = false; };
}, [bootProxy]);
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]);
// /token
// cookie reload
const performReload = () => {
statusRef.current = 'loading';
setStatus('loading');
setShowHint(false);
startTimer();
if (!src) { bootProxy(); return; }
const reloadNow = () => {
const iframe = iframeRef.current;
if (!iframe || !src) return;
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
};
if (needsProxy && hasSlug) warmProxy(slug).then(reloadNow).catch(reloadNow);
else reloadNow();
};
//
const lastNonce = useRef(refreshNonce);
useEffect(() => {
if (!active || refreshNonce === lastNonce.current) return;
lastNonce.current = refreshNonce;
performReload();
// 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 = () => {
performReload();
};
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 && (
/* scale 1/scaletransform scale(scale)
* 恰好铺满容器scale=1 时恒等无副作用 */
<Box
sx={{
position: 'absolute', top: 0, left: 0,
width: `${100 / scale}%`, height: `${100 / scale}%`,
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<iframe
ref={iframeRef}
title={panel.title || '面板'}
src={src}
onLoad={handleLoad}
sandbox={sandbox}
allow={allowAttr}
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
/>
</Box>
)}
{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,273 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { listAdminLinks } from '../../../api/adminLinks.js';
/* ============================================================
* 工作台面板状态Lane A 核心 hook
*
* 管理面板列表admin_links打开集合当前面板历史栈
* 固定pin分组折叠打开方式侧边栏折叠搜索
* 打开集合 / 当前面板 / 历史栈 / 折叠态等持久化到 localStoragekey: workbench.*
*
* 面板对象直接来自后端 admin_links 全行SELECT *已含代理新字段
* slug唯一标识/ trusted信任模型 0|1/ permissionsJSON 数组字符串/
* scale缩放 0.1-3加载与 panelOverride 场景统一经 normalizePanel 归一化
* 兜底旧库行缺失字段保证 PanelFrame 拿到的字段类型稳定
* ============================================================ */
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 { /* 隐私模式等场景忽略 */ }
}
/*
* slug 空串trusted 默认 1permissions 原样保留scale 默认 1 */
function normalizePanel(p) {
if (!p || typeof p !== 'object') return p;
const scale = parseFloat(p.scale);
return {
...p,
slug: String(p.slug || ''),
trusted: p.trusted === 0 || p.trusted === '0' ? 0 : 1,
permissions: p.permissions || '',
scale: Number.isFinite(scale) ? Math.min(3, Math.max(0.1, scale)) : 1,
};
}
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.map(normalizePanel) : [];
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 = normalizePanel(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';
+40
View File
@@ -0,0 +1,40 @@
// lib/author.js —— 作者显示信息组装(论坛/博客/评论/公开主页共用)
//
// SQL 侧需为作者 JOIN 补选(见 routes/forum.js / routes/blog.js):
// u.username AS author_name, u.nickname AS author_nickname, u.title AS author_title,
// u.title_color AS author_title_color, u.role AS author_role
//u.title 用 AS 别名,避免与帖子/文章的 title 列冲突)
// 前端最终拿到的作者字段:author_id, author_name(=nickname||username),
// author_username(原始登录名,恒存在——站长帖判断等需用它), author_avatar,
// author_title, author_title_color, author_role。
const { authorAvatar } = require('./avatar');
// 在 JOIN 行上直接写入作者显示字段(幂等:已有 author_avatar / author_username 则保留)
function attachAuthor(p) {
if (!p) return p;
if (p.author_avatar === undefined) p.author_avatar = authorAvatar(p);
// 覆盖 author_name 前先保存原始登录名(前端「站长帖」判断等依赖原始 username)
if (p.author_username === undefined) p.author_username = String(p.author_name || '').trim();
p.author_name = String(p.author_nickname || '').trim() || p.author_name || p.username || '';
p.author_title = String(p.author_title || '').trim();
p.author_title_color = String(p.author_title_color || '').trim();
p.author_role = p.author_role || 'user';
return p;
}
// 组装作者显示信息(返回独立对象,供测试或需要独立作者对象的场景)
function authorInfo(p) {
if (!p) return null;
return {
id: p.author_id != null ? p.author_id : p.uid,
username: String(p.author_username || p.author_name || '').trim(),
name: String(p.author_nickname || '').trim() || p.author_name || p.username || '',
avatar: p.author_avatar || '',
title: String(p.author_title || '').trim(),
title_color: String(p.author_title_color || '').trim(),
role: p.author_role || 'user',
};
}
module.exports = { attachAuthor, authorInfo };
+31
View File
@@ -0,0 +1,31 @@
// lib/avatar.js —— 用户头像解析(博客/论坛/评论等各处共用)
//
// 优先级(用户确认):
// 1. RainWeb 自己上传的头像(users.avatar 以 /uploads/ 开头)
// 2. QQ 头像(users.qq 字段 → https://q1.qlogo.cn/g?b=qq&nk=<QQ号>&s=640
// 3. QQ 邮箱前缀(users.qq_from_email —— SQL 内 CASE 提取的数字前缀,见 routes/forum.js、routes/blog.js
// 4. @qq.com 邮箱匹配(users.email 本体,仅本地直查场景可用)
// 5. RainID 获取的头像(users.avatar 里的 http(s) URL
// 6. 均无 → 空串(前端显示默认头像)
function resolveAvatar(user) {
if (!user) return '';
if (user.avatar && String(user.avatar).startsWith('/uploads/')) return user.avatar;
// QQ 头像:users.qq 字段 → qq_from_emailSQL 提取)→ @qq.com 邮箱前缀
let qq = String(user.qq || '').trim();
if (!qq && user.qq_from_email) qq = String(user.qq_from_email).trim();
if (!qq && user.email) {
const m = String(user.email).toLowerCase().match(/^(\d{5,12})@qq\.com$/);
if (m) qq = m[1];
}
if (qq) return 'https://q1.qlogo.cn/g?b=qq&nk=' + qq + '&s=640';
if (user.avatar && /^https?:/i.test(user.avatar)) return user.avatar;
return '';
}
// 兼容缺行(匿名作者/已注销用户等 author_id 为空的行)
function authorAvatar(authorRow) {
return resolveAvatar(authorRow || {});
}
module.exports = { resolveAvatar, authorAvatar };
+152
View File
@@ -0,0 +1,152 @@
// lib/locks.js —— markdown [lock:] 成对块锁定核心共享模块(博客/论坛共用)
//
// 语法(多类型):
// [lock:login]...[/lock] 需登录
// [lock:reply]...[/lock] 评论后解锁(含待审核评论)
// [lock:password 密码]...[/lock] 密码解锁(bcrypt 成本 10 存储校验)
// [lock]...[/lock] 默认 login
//
// 真锁原则:未解锁内容不进 HTML/API——剥离的块整体替换为 @@LOCK<index>@@ 占位符,
// 块内文本与 [image:]/[file:] 附件标签一律不输出;password 块内容必须经 unlock 接口
// bcrypt 校验后才返回。未闭合 [lock: 不匹配正则,按普通文本原样保留(不破坏文档)。
const bcrypt = require('bcryptjs');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const { SECRET } = require('../middleware/auth');
const LOCK_RE = /\[lock(?::([^\]]*))?\]([\s\S]*?)\[\/lock\]/gi;
const PASSWORD_TAG_RE = /\[lock:password\s*[:\s]\s*([^\]]*)\]/gi;
// 解锁接口限流:15 分钟窗口内每 IP 最多 10 次(照 loginLimiter 模式,防密码爆破)
const unlockLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: '尝试次数过多,请 15 分钟后再试' },
});
// 参数解析:空 → loginlogin/reply → 对应;'password:<密码>'(冒号分隔,兼容 'password 密码')→ password
// 冒号分隔优于空格:密码本身可含空格(如 "my secret"
function parseLockParams(param) {
const p = String(param || '').trim();
if (!p || p === 'login') return { type: 'login' };
if (p === 'reply') return { type: 'reply' };
const m = p.match(/^password\s*[:\s]\s*(.+)$/i) || p.match(/^password$/i);
if (m && m[1] && m[1].trim()) return { type: 'password', plain: m[1].trim() };
if (p.toLowerCase() === 'password') return { type: 'password', plain: '' }; // 无密码参数按 login 兜底在下方
// 未知参数容错按 login 处理(不破坏文档)
return { type: 'login' };
}
// 提取锁定块:index=解析顺序(0 起);raw=块原文含标签;inner=块内原文。
// 注意:本函数不做 bcrypt 计算(读路径零开销)——hash 由 saveLocks 落库、
// unlock 接口取库内 hash 校验,两块 index 顺序一致(同一正则遍历)。
function parseLocks(content) {
const blocks = [];
const re = new RegExp(LOCK_RE.source, 'gi');
String(content || '').replace(re, (m, param, inner) => {
const { type, plain } = parseLockParams(param);
blocks.push({ index: blocks.length, type, plain, raw: m, inner });
return '';
});
return { blocks, stripped: '' };
}
// 依据 viewer 视角剥离锁定块,返回 { stripped, unlocked }
// stripped 中保留块原样输出(含 [lock:] 标签),剥离块替换为 @@LOCK<index>@@ 占位符;
// unlocked = 本次视角已解锁的块索引数组。
// viewer: { userId, isAdmin, isAuthor, hasCommented }
// login 块:任何登录用户可见;reply 块:已评论或 admin/作者;
// password 块:仅 admin/作者可见(其余一律剥离,必须经 unlock 接口 bcrypt 校验;
// unlockedSet 参数不豁免 password 块,防止前端伪造解锁)。
// maskPasswords:对输出中残留的 [lock:password 明文] 参数打码为 ***SSR 用,
// 覆盖未闭合/异常块按文本输出时明文进 HTML 的路径)。
function stripLocks(content, opts = {}) {
const { viewer = {}, unlockedSet = [], maskPasswords = false } = opts;
const blocks = parseLocks(content).blocks;
const unlocked = [];
let matchIdx = -1;
const stripped = String(content || '').replace(new RegExp(LOCK_RE.source, 'gi'), (m) => {
matchIdx++;
const b = blocks[matchIdx];
const extra = Array.isArray(unlockedSet) && unlockedSet.map(Number).includes(b.index);
// 判定:admin/作者恒全解锁(password 含明文也仅限作者可见);login 登录即见;
// reply 已评论即见;password 非作者一律剥离(unlockedSet 不豁免)
const keep = !!(
viewer.isAdmin || viewer.isAuthor ||
(b.type === 'login' && !!viewer.userId) ||
(b.type === 'reply' && (viewer.hasCommented || extra)) ||
(b.type === 'password' && extra && (viewer.isAdmin || viewer.isAuthor))
);
if (keep) { unlocked.push(b.index); return m; }
return '@@LOCK' + b.index + '@@';
});
let out = stripped;
if (maskPasswords) {
out = out.replace(PASSWORD_TAG_RE, (m, p) => '[lock:password ' + (p.trim() ? '***' : '') + ']');
}
return { stripped: out, unlocked };
}
// 保存锁定元数据:parseLocks → JSON.stringify(blocks.map(b => ({type, hash})))(不含 raw/inner)。
// password 块在此处 bcrypt 哈希(成本 10,全站一致);login/reply 块无 hash 字段。
function saveLocks(content) {
const { blocks } = parseLocks(content);
return JSON.stringify(blocks.map(b => ({
type: b.type,
...(b.type === 'password' ? { hash: bcrypt.hashSync(b.plain, 10) } : {}),
})));
}
// 解锁校验(blog/forum 共用):
// blocks=parseLocks(content).blockslocksMeta=库内 JSON(取 password hash);
// viewer={userId,isAdmin,isAuthor}replyQualified=是否已评论。
// 返回 { ok, status, inner? };失败统一 401(密码错/块不存在不区分,防探测);
// reply 未评论返回 403。
function verifyUnlock({ blocks, locksMeta = [], index, password, viewer = {}, replyQualified = false }) {
const b = (blocks || []).find(x => x.index === index);
if (!b) return { ok: false, status: 401 };
if (viewer.isAdmin || viewer.isAuthor) return { ok: true, status: 200, inner: b.inner };
if (b.type === 'login') {
if (!viewer.userId) return { ok: false, status: 401 };
return { ok: true, status: 200, inner: b.inner };
}
if (b.type === 'reply') {
if (!replyQualified) return { ok: false, status: 403 };
return { ok: true, status: 200, inner: b.inner };
}
if (b.type === 'password') {
const meta = locksMeta[index] || null;
const hash = meta && meta.hash;
if (!hash || !password || !bcrypt.compareSync(String(password), hash)) {
return { ok: false, status: 401 };
}
return { ok: true, status: 200, inner: b.inner };
}
return { ok: false, status: 401 };
}
// 解锁附件 token:详情接口对已解锁块、unlock 接口对解锁成功块签发,
// 前端用它加载块内 [image:]/[file:] 附件(/api/upload/locked 鉴权)。
// 有效期 1hpayload 绑定 refType/refId/blockIndex——只能取该帖该块的附件。
function makeLockToken(refType, refId, blockIndex) {
return jwt.sign({ purpose: 'lock-attachment', refType, refId, blockIndex }, SECRET, { expiresIn: '1h' });
}
// 校验解锁附件 token:合法且 purpose 匹配返回 payload,否则 null
function verifyLockToken(token) {
if (!token) return null;
try {
const payload = jwt.verify(token, SECRET);
if (!payload || payload.purpose !== 'lock-attachment') return null;
return payload;
} catch {
return null;
}
}
module.exports = { parseLocks, stripLocks, saveLocks, verifyUnlock, unlockLimiter, makeLockToken, verifyLockToken };
+186
View File
@@ -0,0 +1,186 @@
// 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;
}
// v6 的 discovery() 第一个参数必须是 URL 实例(传字符串会抛 "server must be an instance of URL"
configCache = await openidClient.discovery(new URL(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(/\/+$/, '');
// M7:Host 头不可信——攻击者可构造 Host: evil.com 把 redirect_uri 变成开放重定向/授权回调投毒。
// 仅接受合法域名/IP 格式(允许字母数字、点、连字符、冒号、方括号、下划线),
// 拒绝含 @、/、空白、控制字符的 Host;缺失或异常时回退固定值 localhost。
const host = req.get('host');
const valid = !!host && host.length <= 255
&& !/[@\s\x00-\x1f\x7f]/.test(host)
&& /^[a-zA-Z0-9._\-:\[\]]+$/.test(host);
return (req.protocol || 'http') + '://' + (valid ? 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 (e) {
// 区分:配置缺失 vs discovery 失败(端点协议/不可达——RainID 端点必须是 https
if (e && e.code === 'RAINID_NOT_CONFIGURED') {
return { ok: false, status: 500, error: 'RainID 客户端未配置(client_id / client_secret' };
}
console.error('[RainID] discovery 失败:', e && e.message);
return { ok: false, status: 502, error: 'RainID 服务配置错误:Discovery 失败(检查端点是否为 https' };
}
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,
};
+16
View File
@@ -3,6 +3,20 @@ const db = require('../db');
const SECRET = process.env.JWT_SECRET;
// 最后活跃记录:模块级缓存 <userId, 'YYYY-MM-DD'>,仅当用户上次记录日期 ≠ 今天时
// 才写库(避免每个请求都执行 UPDATE)。写入失败静默忽略(best-effort,不影响鉴权)。
const activeDates = new Map();
function touchLastActive(userId) {
if (!userId) return;
const today = new Date().toISOString().slice(0, 10);
if (activeDates.get(userId) === today) return;
activeDates.set(userId, today);
try {
db.run("UPDATE users SET last_active_at = datetime('now') WHERE id = ?", [userId]);
} catch { /* 忽略活跃记录失败 */ }
}
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
if (!header || !header.startsWith('Bearer ')) {
@@ -11,6 +25,8 @@ function authMiddleware(req, res, next) {
try {
const payload = jwt.verify(header.slice(7), SECRET);
req.user = payload;
// 记录最后活跃(跨天节流)
touchLastActive(payload.id);
next();
} catch {
return res.status(401).json({ error: '登录已过期' });

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