67 Commits
Author SHA1 Message Date
miaomiao 390c4ac53f 统一前后台主题颜色管理 2026-09-05 23:12:17 +08:00
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
121 changed files with 15852 additions and 1775 deletions
+6 -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)。
@@ -40,6 +43,7 @@ node cli.js <cmd> # status | start | stop | restart | port [N] | password [pw
- 路由保持 v1 的 `.html` 后缀路径(`/blog.html``/forum.html` 等),SPA fallback 已支持,链接不用改。
- `frontend/index.html``${site_name}` / `${site_description}` / `${site_favicon}` 占位符,构建后由 server.js `serveIndex` 按 site_settings **运行时替换**(务必保留这 3 个占位符)。
- 主题:`data-theme="dark"` 属性在 `<html>` 上(`public/css/style.css``[data-theme="dark"]` 选择器),非 `.dark` 类,非 prefers-color-scheme。切换逻辑在 `src/theme.jsx`
- 主题颜色:`frontend/src/themeTokens.js` 是前后台共享的 Token 派生入口;前台通过 `applyThemeTokens` 写入 CSS 变量,后台 `admin/theme.js` 通过 `buildThemeTokens` 生成 MUI palette。主题设置保存后使用 `rainweb:theme-update` 事件和 localStorage storage 事件同步;颜色、玻璃透明度和主题策略由 `routes/settings.js` 校验。
- 构建:`npm run build``public/dist/`gitignored,不入库)。**生产部署必须构建**,否则页面 500/白屏。
- 博文/帖子内容中的 `[image:文件名]` / `[file:文件名]` 标签:前台由 `MarkdownRenderer`marked + DOMPurify 净化)、SEO 页由 `ssr.js` 渲染为 `/uploads/` 链接。**所有 markdown 渲染必须过 DOMPurify**。
- 登录态:token 存 localStoragekey `token`),变更通过 `authchange` 事件通知 Layout 刷新(`api/client.js``notifyAuthChange`)。
@@ -52,7 +56,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.1.0
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;
});
+259 -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 {}
@@ -168,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); }
@@ -209,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: '',
@@ -219,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])) {
@@ -269,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;
@@ -303,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/
+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>
+243 -60
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,44 +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();
@@ -58,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 = () => {
@@ -82,24 +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" onClick={() => navigate('/workbench')} title="面板工作台" sx={{ minWidth: 0, px: { xs: 1, sm: 1.5 } }}>
<GridViewIcon sx={{ fontSize: 18, mr: { xs: 0, sm: 0.5 } }} />
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>工作台</Box>
</Button>
<Button color="inherit" href="/" title="返回前台">
<HomeIcon sx={{ mr: 0.5, fontSize: 18 }} />返回前台
</Button>
<Button color="inherit" onClick={handleLogout}>
<LogoutIcon sx={{ mr: 0.5, fontSize: 18 }} />退出
</Button>
<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 } }}>
@@ -111,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 />
+29 -9
View File
@@ -1,4 +1,4 @@
import React, { lazy, Suspense, useEffect, useState } from 'react';
import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ThemeProvider } from '@mui/material/styles';
@@ -6,6 +6,7 @@ import CssBaseline from '@mui/material/CssBaseline';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import { createMD3Theme } from './theme.js';
import { THEME_SETTINGS_EVENT } from '../themeTokens.js';
import './theme.css';
import { getToken } from '../api/client.js';
import { me } from '../api/auth.js';
@@ -20,11 +21,14 @@ 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'));
@@ -35,14 +39,13 @@ const basename = (pathname === '/admin' || pathname.startsWith('/admin/')) ? '/a
function AdminApp() {
const [status, setStatus] = useState('checking'); // checking | ok
const [primary, setPrimary] = useState('#6750a4');
const [themeSettings, setThemeSettings] = useState({ primary_color: '#6750a4', glass_opacity: '0.6', theme_force_dark: '0' });
// 深色模式跟随前台偏好:整页跳转后 data-theme 属性不保留,
// 以 localStorage 的 theme 为兜底(前台 theme.jsx 持久化同一 key)。
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|| localStorage.getItem('theme') === 'dark';
const preferredTheme = localStorage.getItem('theme') === 'dark' ? 'dark' : 'light';
const dark = themeSettings.theme_force_dark === '1' || preferredTheme === 'dark';
useEffect(() => {
if (dark) document.documentElement.setAttribute('data-theme', 'dark');
else document.documentElement.removeAttribute('data-theme');
}, [dark]);
useEffect(() => {
@@ -59,10 +62,26 @@ function AdminApp() {
})
.catch(() => { window.location.href = '/login.html'; });
getSettings()
.then((s) => { if (s.primary_color) setPrimary(s.primary_color); })
.then((s) => setThemeSettings((current) => ({ ...current, ...s })))
.catch(() => {});
}, []);
useEffect(() => {
const handleThemeUpdate = (event) => {
if (event.detail && typeof event.detail === 'object') {
setThemeSettings((current) => ({ ...current, ...event.detail }));
}
};
window.addEventListener(THEME_SETTINGS_EVENT, handleThemeUpdate);
return () => window.removeEventListener(THEME_SETTINGS_EVENT, handleThemeUpdate);
}, []);
const theme = useMemo(() => createMD3Theme({
mode: dark ? 'dark' : 'light',
primary: themeSettings.primary_color || '#6750a4',
glassOpacity: themeSettings.glass_opacity,
}), [dark, themeSettings.primary_color, themeSettings.glass_opacity]);
if (status !== 'ok') {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
@@ -71,8 +90,6 @@ function AdminApp() {
);
}
const theme = createMD3Theme({ mode: dark ? 'dark' : 'light', primary });
return (
<ThemeProvider theme={theme}>
<CssBaseline />
@@ -104,11 +121,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>
+26 -10
View File
@@ -13,6 +13,15 @@ import { getSettings, saveSettings } from '../../api/settings.js';
import { uploadWallpaper } from '../../api/upload.js';
import { showSnack } from '../snack.jsx';
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
function validateThemeForm(form) {
if (!HEX_COLOR_RE.test(String(form.primary_color).trim())) return '主题色必须是有效的 HEX 色值(如 #6750a4';
const opacity = Number(form.glass_opacity);
if (!Number.isFinite(opacity) || opacity < 0.1 || opacity > 0.95) return '玻璃透明度必须在 0.1 到 0.95 之间';
return '';
}
/** 主题设置:主题色/壁纸(上传或URL)/导航卡片样式/玻璃模糊透明度/强制深色(迁移自 v1 主题卡片) */
export default function ThemeSettings() {
const [form, setForm] = useState({
@@ -61,10 +70,15 @@ export default function ThemeSettings() {
};
const save = async () => {
const validationError = validateThemeForm(form);
if (validationError) {
showSnack(validationError, 'error');
return;
}
setSaving(true);
try {
await saveSettings({
primary_color: form.primary_color,
const settings = {
primary_color: form.primary_color.trim().toLowerCase(),
theme_wallpaper: form.theme_wallpaper.trim(),
theme_wallpaper_scale: form.theme_wallpaper_scale,
theme_wallpaper_enabled: form.theme_wallpaper_enabled ? '1' : '0',
@@ -72,8 +86,10 @@ export default function ThemeSettings() {
nav_style: form.nav_style,
card_style: form.card_style,
glass_blur: form.glass_blur,
glass_opacity: form.glass_opacity,
});
glass_opacity: String(Number(form.glass_opacity)),
};
await saveSettings(settings);
window.dispatchEvent(new CustomEvent('rainweb:theme-update', { detail: settings }));
showSnack('主题设置已保存');
} catch (e) {
showSnack(e.message, 'error');
@@ -85,15 +101,15 @@ 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' }} />
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" sx={{ width: 160 }} />
<TextField label="主色调" value={form.primary_color} onChange={set('primary_color')} size="small" error={form.primary_color !== '' && !HEX_COLOR_RE.test(form.primary_color.trim())} helperText="支持 3 位或 6 位 HEX 色值" sx={{ width: 190 }} />
</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 }))} />}
@@ -119,7 +135,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 }}>
@@ -134,7 +150,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 }} />
@@ -142,7 +158,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="确认删除"
/>
+257 -5
View File
@@ -1,6 +1,6 @@
/* ============================================================
* 后台 MD3 全局微调与前台public/css/style.css一致的氛围细节
* 前台 token 变量未注入后台页面这里按明暗各写一份
* 颜色焦点色和玻璃透明度由后台 MUI Theme 注入 --admin-* 语义变量
* ============================================================ */
/* 内容区入场动效(对应前台 contentFadeIn */
@@ -18,14 +18,266 @@ 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; }
::-webkit-scrollbar-thumb { background: #cac4d0; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #79747e; }
::-webkit-scrollbar-thumb { background: var(--admin-outline-variant, #cac4d0); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--admin-outline, #79747e); }
[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #49454f; }
[data-theme="dark"] ::-webkit-scrollbar-thumb:hover { background: #938f99; }
:focus-visible {
outline: 2px solid var(--admin-primary, #6750a4);
outline-offset: 2px;
}
/* 工单工作区:占满 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: var(--admin-success, #2e7d32); }
.ticket-save-error { color: var(--admin-error, #d32f2f); }
@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) {
+26 -5
View File
@@ -1,4 +1,5 @@
import { createTheme, alpha } from '@mui/material/styles';
import { buildThemeTokens, DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY } from '../themeTokens.js';
/* ============================================================
* 后台 Material Design 3 主题
@@ -10,7 +11,7 @@ import { createTheme, alpha } from '@mui/material/styles';
* surface 色阶弱边框 + 低阴影surface tint
* ============================================================ */
const DEFAULT_SEED = '#6750a4';
const DEFAULT_SEED = DEFAULT_PRIMARY_COLOR;
/* ---------- 颜色工具 ---------- */
@@ -242,9 +243,10 @@ function md3Shadows() {
/* ---------- 主题工厂 ---------- */
export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {}) {
const dark = mode === 'dark';
const p = buildPalette(primary, dark);
export function createMD3Theme({ mode = 'light', primary = DEFAULT_PRIMARY_COLOR, glassOpacity = DEFAULT_GLASS_OPACITY } = {}) {
const p = buildThemeTokens({ mode, primary_color: primary, glass_opacity: glassOpacity });
const safeGlassOpacity = p.glassOpacity;
const dark = p.mode === 'dark';
const shadows = md3Shadows();
const fontFamily = "'Segoe UI', 'Roboto', system-ui, -apple-system, sans-serif";
@@ -301,6 +303,25 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {})
},
shadows,
components: {
MuiCssBaseline: {
styleOverrides: {
':root': {
'--admin-primary': p.primary,
'--admin-on-primary': p.onPrimary,
'--admin-surface': p.surface,
'--admin-surface-container': p.surfaceContainer,
'--admin-surface-container-low': p.surfaceContainerLow,
'--admin-surface-container-high': p.surfaceContainerHigh,
'--admin-on-surface': p.onSurface,
'--admin-on-surface-variant': p.onSurfaceVariant,
'--admin-outline': p.outline,
'--admin-outline-variant': p.outlineVariant,
'--admin-error': p.error,
'--admin-success': p.success,
'--admin-glass-opacity': safeGlassOpacity,
},
},
},
/* MD3 顶栏:surfaceContainer + 细边框,去硬阴影 */
MuiAppBar: {
defaultProps: { color: 'inherit', elevation: 0 },
@@ -396,7 +417,7 @@ export function createMD3Theme({ mode = 'light', primary = DEFAULT_SEED } = {})
styleOverrides: {
rounded: { borderRadius: 12 },
elevation1: {
backgroundColor: p.surfaceContainerLow,
backgroundColor: alpha(p.surfaceContainerLow, safeGlassOpacity),
border: `1px solid ${p.outlineVariant}`,
boxShadow: 'none',
backgroundImage: 'none',
+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 } });
}
+24 -3
View File
@@ -1,16 +1,37 @@
import { request } from './client.js';
import { THEME_SETTINGS_EVENT, THEME_SETTINGS_SYNC_KEY } from '../themeTokens.js';
const THEME_SETTING_KEYS = new Set([
'primary_color', 'theme_wallpaper', 'theme_wallpaper_scale', 'theme_wallpaper_enabled',
'nav_style', 'card_style', 'glass_blur', 'glass_opacity', 'theme_force_dark',
]);
function noStorePath(path) {
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}_ts=${Date.now()}`;
}
function broadcastThemeSettings(settings) {
if (typeof window === 'undefined' || !settings || !Object.keys(settings).some((key) => THEME_SETTING_KEYS.has(key))) return;
const detail = { ...settings, updated_at: Date.now() };
window.dispatchEvent(new CustomEvent(THEME_SETTINGS_EVENT, { detail }));
try { window.localStorage.setItem(THEME_SETTINGS_SYNC_KEY, JSON.stringify(detail)); } catch {}
}
/** 公开设置(无需登录):site_name、theme_wallpaper、homepage_* 等 */
export function getPublicSettings() {
return request('/settings/public');
return request(noStorePath('/settings/public'), { auth: false });
}
/** 全量设置(仅管理员) */
export function getSettings() {
return request('/settings');
return request(noStorePath('/settings'));
}
/** 保存设置(仅管理员) */
export function saveSettings(data) {
return request('/settings', { method: 'PUT', body: data });
return request('/settings', { method: 'PUT', body: data }).then((result) => {
broadcastThemeSettings(data);
return result;
});
}
+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"
/>
);
}
+122 -20
View File
@@ -31,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';
@@ -43,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';
@@ -52,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);
@@ -59,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('');
@@ -129,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 }} />
@@ -172,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);
@@ -185,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, {
@@ -215,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>
+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>;
}
+154 -13
View File
@@ -1,10 +1,12 @@
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 { getThemePreference, useTheme } from '../theme.jsx';
import { applyThemeTokens, THEME_SETTINGS_EVENT, THEME_SETTINGS_SYNC_KEY } from '../themeTokens.js';
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 +25,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')
@@ -38,9 +82,13 @@ export default function Layout() {
// / / authchange /退/
useEffect(() => {
const updateSettings = (next) => {
if (!next || typeof next !== 'object') return;
setSettings((previous) => ({ ...previous, ...next }));
};
settingsApi.getPublicSettings()
.then((s) => {
setSettings(s);
updateSettings(s);
// reCAPTCHA/Turnstile nav.js
window._recaptchaSiteKey = s.recaptcha_site_key || '';
window._turnstileSiteKey = s.turnstile_site_key || '';
@@ -59,17 +107,33 @@ export default function Layout() {
};
loadUser();
window.addEventListener('authchange', loadUser);
return () => window.removeEventListener('authchange', loadUser);
const onThemeSettings = (event) => updateSettings(event.detail);
const onStorage = (event) => {
if (event.key !== THEME_SETTINGS_SYNC_KEY || !event.newValue) return;
try { updateSettings(JSON.parse(event.newValue)); } catch {}
};
window.addEventListener(THEME_SETTINGS_EVENT, onThemeSettings);
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('authchange', loadUser);
window.removeEventListener(THEME_SETTINGS_EVENT, onThemeSettings);
window.removeEventListener('storage', onStorage);
};
}, []);
// nav.js applyTheme
useEffect(() => {
if (settings.theme_force_dark === '1') {
setTheme('dark');
document.documentElement.setAttribute('data-theme', 'dark');
}
setTheme(settings.theme_force_dark === '1' ? 'dark' : getThemePreference(), { persist: false });
}, [settings.theme_force_dark, setTheme]);
useEffect(() => {
applyThemeTokens({
primary_color: settings.primary_color,
mode: theme,
glass_opacity: settings.glass_opacity,
});
}, [settings.primary_color, settings.glass_opacity, theme]);
// + nav.js applyTheme body
useEffect(() => {
const body = document.body;
@@ -118,6 +182,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 +202,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 +214,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">
@@ -174,13 +255,14 @@ export default function Layout() {
</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>
{/* 退出登录统一在个人中心 / 移动端抽屉中操作,顶栏不再放退出按钮 */}
</>
) : (
<>
@@ -191,6 +273,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>
);
}
+229 -15
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%30=文本 / 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,8 +152,8 @@ 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)]));
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
@@ -52,7 +163,110 @@ export function renderContent(content, useMarkdown) {
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>;
}
+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 [];
}
}
+15
View File
@@ -14,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);
+17 -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=
@@ -114,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>
{/* 标签云 */}
+143 -4
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) {
@@ -34,6 +37,47 @@ export default function BlogDetail() {
const [like, setLike] = useState({ liked: false, count: 0 });
const [toc, setToc] = useState([]);
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);
@@ -50,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 {
@@ -59,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 {
@@ -84,6 +137,26 @@ export default function BlogDetail() {
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; }
@@ -107,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>
@@ -171,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>
@@ -191,12 +280,25 @@ export default function BlogDetail() {
<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>
<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) && (
@@ -293,6 +395,43 @@ export default function BlogDetail() {
</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>
);
}
+142 -248
View File
@@ -1,280 +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, useDialog } 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);
// B3Esc
const { dialogRef: npDialogRef, onKeyDown: npDialogKey } = useDialog(showNewPost, () => setShowNewPost(false));
//
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">
<h1 className="sr-only">论坛</h1>
<aside className="forum-sidebar">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
<span style={{ fontWeight: 600, fontSize: 16 }}>板块</span>
<button className="btn btn-filled btn-sm" onClick={handleNewPostBtn}>
<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) => (
<button
type="button"
key={c.id}
className={'forum-cat-item' + (c.id === currentCatId ? ' active' : '')}
onClick={() => selectCategory(c.id)}
>
<span className="material-icons" style={{ fontSize: 18 }}>forum</span> {c.name}
{c.announcement ? (
<span className="material-icons" style={{ fontSize: 14, color: 'var(--md-ref-primary)' }}>campaign</span>
) : null}
</button>
))}
</div>
<hr style={{ border: 'none', borderTop: '1px solid var(--md-ref-outline-variant)', margin: '12px 0' }} />
<button
type="button"
className={'forum-cat-item' + (currentCatId === null ? ' active' : '')}
onClick={showAllPosts}
style={{ fontWeight: 500 }}
>
<span className="material-icons" style={{ fontSize: 18 }}>dynamic_feed</span> 全部最新
</button>
</aside>
<div className="forum-content">
{currentCat && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<h3 style={{ fontWeight: 600, fontSize: 20, margin: 0 }}>{currentCat.name}</h3>
<span className="chip chip-static">板块</span>
</div>
<p className="text-muted" style={{ fontSize: 14 }}>{currentCat.description || ''}</p>
{currentCat.announcement && (
<div className="announcement-bar">
<span className="material-icons ann-icon">campaign</span>
<span className="ann-content">{currentCat.announcement}</span>
</div>
)}
{subCats.length > 0 && (
<div className="chips" style={{ marginBottom: 12, marginTop: 12 }}>
<button type="button" className={'chip' + (!filterSub ? ' active' : '')} onClick={() => { setFilterSub(''); load(currentCatId, ''); }}>全部</button>
{subCats.map((s) => (
<button type="button" key={s} className={'chip' + (filterSub === s ? ' active' : '')} onClick={() => { setFilterSub(s); load(currentCatId, s); }}>{s}</button>
))}
</div>
)}
</div>
)}
{posts === null && !loadError && <div className="loading"><div className="spinner"></div></div>}
{loadError && <div className="empty-state"><div className="empty-icon"></div><p>加载失败</p></div>}
{posts !== null && !loadError && posts.length === 0 && (
<div className="empty-state"><div className="empty-icon">📝</div><p>暂无帖子</p></div>
)}
{posts && posts.length > 0 && (
<div className="forum-post-list">
{posts.map((p) => {
const catName = categories.find((c) => c.id === p.category_id)?.name || '';
return (
<Link key={p.id} to={`/forum/${p.id}`} className="card forum-post-card" style={{ textDecoration: 'none', display: 'block' }}>
<div className="post-title">{p.title}</div>
<div className="post-meta" style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{p.author_name || '匿名'}</span>
<span>{p.created_at}</span>
<span>{p.reply_count || 0} 回复</span>
<span style={{ color: 'var(--md-ref-outline)', margin: '0 4px' }}>|</span>
<span className="chip chip-static">{catName}</span>
{p.sub_category ? (
<span className="chip chip-tonal">{p.sub_category}</span>
) : null}
</div>
</Link>
);
})}
</div>
)}
</div>
{/* 发帖弹窗 */}
{showNewPost && (
<div
ref={npDialogRef}
className="dialog-overlay active"
role="dialog"
aria-modal="true"
aria-label="发布新帖"
style={{ display: 'flex', zIndex: 9999 }}
onMouseDown={(e) => { if (e.target === e.currentTarget) setShowNewPost(false); }}
onKeyDown={npDialogKey}
>
<div className="dialog">
<h3>发布新帖</h3>
<div className="form-group">
<label>板块</label>
<select value={npCategory} onChange={handleCategoryChange}>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>帖子分类可选</label>
<select value={npSub} onChange={(e) => setNpSub(e.target.value)}>
<option value=""></option>
{subCatsOf(categories.find((c) => c.id === parseInt(npCategory, 10))).map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
<div className="form-group">
<label>标题 *</label>
<input type="text" value={npTitle} onChange={(e) => setNpTitle(e.target.value)} />
</div>
<div className="form-group">
<label>内容 *</label>
<textarea value={npContent} onChange={(e) => setNpContent(e.target.value)} style={{ minHeight: 150, fontFamily: 'monospace' }} />
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12 }}>
<button className="btn btn-tonal btn-sm" onClick={() => fileRef.current && fileRef.current.click()}>
<span className="material-icons">upload</span> 上传附件
</button>
<input ref={fileRef} type="file" style={{ display: 'none' }} onChange={handleFileSelect} />
<span className="text-muted" style={{ fontSize: 13 }}>{npStatus}</span>
</div>
<div className="actions">
<button className="btn btn-text" onClick={() => setShowNewPost(false)}>取消</button>
<button className="btn btn-filled" onClick={submitPost} disabled={submitting}>发布</button>
</div>
</div>
{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>
);
}
+299 -35
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>
<h1 style={{ fontSize: 22, fontWeight: 600 }}>{post.title}</h1>
{/* 面包屑:首页 / 论坛 / 版块 / 标题 */}
<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,28 +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 && (
<button
type="button"
aria-label="删除回复"
style={{ float: 'right', color: 'var(--md-ref-error)', cursor: 'pointer', fontSize: 13 }}
className="floor-del"
onClick={() => deleteReply(r.id)}
>
删除
</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>
);
}
+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>
+119 -3
View File
@@ -2,6 +2,7 @@ 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, useDialog, focusDialog } from '../lib/utils.js';
@@ -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);
@@ -51,9 +63,17 @@ export default function Profile() {
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; }
@@ -100,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 {
@@ -178,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>
+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>
);
}
+16 -8
View File
@@ -1,10 +1,17 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { applyThemeTokens } from './themeTokens.js';
// v1 document.documentElement data-theme="dark"
// public/css/style.css 使 [data-theme="dark"] .dark
const THEME_KEY = 'theme';
const ThemeContext = createContext(null);
export function getThemePreference() {
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
@@ -14,21 +21,22 @@ function applyTheme(theme) {
}
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem(THEME_KEY);
if (saved === 'dark' || saved === 'light') return saved;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
const [theme, setThemeState] = useState(getThemePreference);
const setTheme = useCallback((nextTheme, { persist = true } = {}) => {
if (nextTheme !== 'dark' && nextTheme !== 'light') return;
if (persist) localStorage.setItem(THEME_KEY, nextTheme);
setThemeState(nextTheme);
}, []);
useEffect(() => {
applyTheme(theme);
localStorage.setItem(THEME_KEY, theme);
applyThemeTokens({ mode: theme });
}, [theme]);
const value = useMemo(
() => ({
theme,
toggleTheme: () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')),
toggleTheme: () => setTheme(theme === 'dark' ? 'light' : 'dark'),
setTheme,
}),
[theme]
+358
View File
@@ -0,0 +1,358 @@
const DEFAULT_PRIMARY_COLOR = '#6750a4';
const DEFAULT_GLASS_OPACITY = 0.6;
const HEX_COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
function clamp(value, min = 0, max = 255) {
return Math.min(max, Math.max(min, Math.round(value)));
}
export function normalizeHexColor(value, fallback = DEFAULT_PRIMARY_COLOR) {
const raw = String(value ?? '').trim();
const safeFallback = HEX_COLOR_RE.test(String(fallback).trim()) ? String(fallback).trim() : DEFAULT_PRIMARY_COLOR;
const color = HEX_COLOR_RE.test(raw) ? raw : safeFallback;
const hex = color.slice(1).toLowerCase();
return `#${hex.length === 3 ? hex.split('').map((part) => part + part).join('') : hex}`;
}
function hexToRgb(hex) {
const value = normalizeHexColor(hex).slice(1);
const number = parseInt(value, 16);
return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
}
function rgbToHex(red, green, blue) {
const toHex = (value) => clamp(value).toString(16).padStart(2, '0');
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`;
}
function mix(first, second, weight) {
const a = hexToRgb(first);
const b = hexToRgb(second);
return rgbToHex(
a[0] + (b[0] - a[0]) * weight,
a[1] + (b[1] - a[1]) * weight,
a[2] + (b[2] - a[2]) * weight
);
}
function hexToHsl(hex) {
const [red, green, blue] = hexToRgb(hex).map((value) => value / 255);
const max = Math.max(red, green, blue);
const min = Math.min(red, green, blue);
const lightness = (max + min) / 2;
let hue = 0;
let saturation = 0;
if (max !== min) {
const delta = max - min;
saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
if (max === red) hue = (green - blue) / delta + (green < blue ? 6 : 0);
else if (max === green) hue = (blue - red) / delta + 2;
else hue = (red - green) / delta + 4;
hue *= 60;
}
return [hue, saturation, lightness];
}
function hslToHex(hue, saturation, lightness) {
const normalizedHue = ((hue % 360) + 360) % 360;
const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = chroma * (1 - Math.abs(((normalizedHue / 60) % 2) - 1));
const match = lightness - chroma / 2;
let rgb;
if (normalizedHue < 60) rgb = [chroma, x, 0];
else if (normalizedHue < 120) rgb = [x, chroma, 0];
else if (normalizedHue < 180) rgb = [0, chroma, x];
else if (normalizedHue < 240) rgb = [0, x, chroma];
else if (normalizedHue < 300) rgb = [x, 0, chroma];
else rgb = [chroma, 0, x];
return rgbToHex((rgb[0] + match) * 255, (rgb[1] + match) * 255, (rgb[2] + match) * 255);
}
function grayscale(hex) {
const [red, green, blue] = hexToRgb(hex);
const value = 0.299 * red + 0.587 * green + 0.114 * blue;
return rgbToHex(value, value, value);
}
function tone(seed, value) {
if (value >= 40) return mix(seed, '#ffffff', (value - 40) / 60);
return mix(seed, '#000000', (40 - value) / 40);
}
function tonal(seed, value, { hue = 0, saturation = 1 } = {}) {
let base = seed;
if (hue || saturation < 1) {
const [currentHue, currentSaturation, lightness] = hexToHsl(seed);
base = hslToHex(currentHue + hue, currentSaturation * saturation, lightness);
}
return tone(base, value);
}
function relativeLuminance(hex) {
return hexToRgb(hex).reduce((total, channel, index) => {
const value = channel / 255;
const linear = value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
return total + linear * [0.2126, 0.7152, 0.0722][index];
}, 0);
}
function contrastText(background) {
const luminance = relativeLuminance(background);
const whiteContrast = 1.05 / (luminance + 0.05);
const darkContrast = (luminance + 0.05) / 0.05;
return whiteContrast >= darkContrast ? '#ffffff' : '#1c1b1f';
}
function safeOpacity(value) {
const number = Number(value);
return Number.isFinite(number)
? Math.min(0.95, Math.max(0.1, number))
: DEFAULT_GLASS_OPACITY;
}
function rgba(hex, alpha) {
const [red, green, blue] = hexToRgb(hex);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const LIGHT_SURFACES = {
surface: '#fffbfe',
onSurface: '#1c1b1f',
surfaceVariant: '#e7e0ec',
onSurfaceVariant: '#49454f',
surfaceContainerLow: '#f7f2fa',
surfaceContainer: '#f3edf7',
surfaceContainerHigh: '#ece6f0',
outline: '#79747e',
outlineVariant: '#cac4d0',
error: '#b3261e',
onError: '#ffffff',
inverseSurface: '#313033',
inverseOnSurface: '#f4eff4',
};
const DARK_SURFACES = {
surface: '#1c1b1f',
onSurface: '#e6e1e5',
surfaceVariant: '#49454f',
onSurfaceVariant: '#cac4d0',
surfaceContainerLow: '#211f26',
surfaceContainer: '#25232a',
surfaceContainerHigh: '#2b2930',
outline: '#938f99',
outlineVariant: '#49454f',
error: '#f2b8b5',
onError: '#601410',
inverseSurface: '#e6e1e5',
inverseOnSurface: '#313033',
};
const DEFAULT_LIGHT_PRIMARY = {
primary: DEFAULT_PRIMARY_COLOR,
onPrimary: '#ffffff',
primaryContainer: '#eadfff',
onPrimaryContainer: '#21005d',
secondary: '#625b71',
onSecondary: '#ffffff',
secondaryContainer: '#e8def8',
onSecondaryContainer: '#1d192b',
tertiary: '#7d5260',
onTertiary: '#ffffff',
tertiaryContainer: '#ffd8e4',
onTertiaryContainer: '#31111d',
};
const DEFAULT_DARK_PRIMARY = {
primary: '#d0bcff',
onPrimary: '#381e72',
primaryContainer: '#4f378b',
onPrimaryContainer: '#eadfff',
secondary: '#ccc2dc',
onSecondary: '#332d41',
secondaryContainer: '#4a4458',
onSecondaryContainer: '#e8def8',
tertiary: '#efb8c8',
onTertiary: '#492532',
tertiaryContainer: '#61343f',
onTertiaryContainer: '#ffd8e4',
};
function buildPalette(seed, dark) {
if (seed === DEFAULT_PRIMARY_COLOR) {
return { ...(dark ? DEFAULT_DARK_PRIMARY : DEFAULT_LIGHT_PRIMARY), ...(dark ? DARK_SURFACES : LIGHT_SURFACES) };
}
const surfaces = dark ? DARK_SURFACES : LIGHT_SURFACES;
const primary = tone(seed, dark ? 80 : 40);
const primaryContainer = tone(seed, dark ? 30 : 90);
const secondary = tonal(seed, dark ? 80 : 40, { saturation: 0.55 });
const secondaryContainer = tonal(seed, dark ? 30 : 90, { saturation: 0.55 });
const tertiary = tonal(seed, dark ? 80 : 40, { hue: 32 });
const tertiaryContainer = tonal(seed, dark ? 30 : 90, { hue: 32 });
return {
...surfaces,
primary,
onPrimary: dark ? tone(seed, 20) : contrastText(primary),
primaryContainer,
onPrimaryContainer: dark ? tone(seed, 90) : contrastText(primaryContainer),
secondary,
onSecondary: dark ? tone(seed, 20) : contrastText(secondary),
secondaryContainer,
onSecondaryContainer: dark ? tone(seed, 90) : contrastText(secondaryContainer),
tertiary,
onTertiary: dark ? tone(seed, 20) : contrastText(tertiary),
tertiaryContainer,
onTertiaryContainer: dark ? tone(seed, 90) : contrastText(tertiaryContainer),
};
}
function resolveMode(options) {
const requested = options.mode || options.lightDark || options.light_dark || options['light-dark'];
return requested === 'dark' ? 'dark' : 'light';
}
export function buildThemeTokens(options = {}) {
const mode = resolveMode(options);
const dark = mode === 'dark';
const primaryColor = normalizeHexColor(options.primaryColor ?? options.primary_color);
const glassOpacity = safeOpacity(options.glassOpacity ?? options.glass_opacity);
const palette = buildPalette(primaryColor, dark);
const primaryRgb = hexToRgb(palette.primary).join(', ');
const primaryColorRgb = hexToRgb(primaryColor).join(', ');
const glassSurface = palette.surface;
const glassHeavyOpacity = Math.min(0.98, glassOpacity + 0.18);
const glassWallpaperOpacity = Math.min(0.98, glassOpacity + 0.22);
return {
...palette,
mode,
primaryColor,
primary_color: primaryColor,
primaryRgb,
primary_rgb: primaryRgb,
primaryColorRgb,
background: palette.surface,
foreground: palette.onSurface,
card: palette.surfaceContainerLow,
cardForeground: palette.onSurface,
muted: palette.surfaceContainer,
mutedForeground: palette.onSurfaceVariant,
accent: palette.primaryContainer,
accentForeground: palette.onPrimaryContainer,
border: palette.outlineVariant,
input: palette.outlineVariant,
ring: palette.primary,
destructive: palette.error,
destructiveForeground: palette.onError,
errorContainer: dark ? '#93000a' : '#f9dedc',
onErrorContainer: dark ? '#ffdad6' : '#410e0b',
success: dark ? '#81c784' : '#2e7d32',
onSuccess: dark ? '#123d16' : '#ffffff',
successContainer: dark ? '#214d29' : '#d9f2df',
onSuccessContainer: dark ? '#b8efbd' : '#1b5e20',
warning: dark ? '#ffdda0' : '#7a5900',
onWarning: '#ffffff',
warningContainer: dark ? '#5a420d' : '#fff1d6',
onWarningContainer: dark ? '#ffdda0' : '#6b4300',
info: dark ? '#a8c7fa' : '#245b9e',
glassOpacity,
glass_opacity: String(glassOpacity),
glassBg: rgba(glassSurface, glassOpacity),
glassBgHeavy: rgba(glassSurface, glassHeavyOpacity),
glassBgWallpaper: rgba(glassSurface, glassWallpaperOpacity),
glassBgHeavyWallpaper: rgba(glassSurface, Math.min(0.99, glassHeavyOpacity + 0.04)),
glassBorder: rgba(dark ? '#ffffff' : '#ffffff', dark ? 0.1 : 0.2),
};
}
export function tokensToCssVars(tokens) {
const safeTokens = buildThemeTokens(tokens || {});
const vars = {
'--md-source': safeTokens.primaryColor,
'--md-ref-primary': safeTokens.primary,
'--md-ref-on-primary': safeTokens.onPrimary,
'--md-ref-primary-container': safeTokens.primaryContainer,
'--md-ref-on-primary-container': safeTokens.onPrimaryContainer,
'--md-ref-secondary': safeTokens.secondary,
'--md-ref-on-secondary': safeTokens.onSecondary,
'--md-ref-secondary-container': safeTokens.secondaryContainer,
'--md-ref-on-secondary-container': safeTokens.onSecondaryContainer,
'--md-ref-tertiary': safeTokens.tertiary,
'--md-ref-on-tertiary': safeTokens.onTertiary,
'--md-ref-tertiary-container': safeTokens.tertiaryContainer,
'--md-ref-on-tertiary-container': safeTokens.onTertiaryContainer,
'--md-ref-surface': safeTokens.surface,
'--md-ref-on-surface': safeTokens.onSurface,
'--md-ref-surface-variant': safeTokens.surfaceVariant,
'--md-ref-on-surface-variant': safeTokens.onSurfaceVariant,
'--md-ref-outline': safeTokens.outline,
'--md-ref-outline-variant': safeTokens.outlineVariant,
'--md-ref-error': safeTokens.error,
'--md-ref-on-error': safeTokens.onError,
'--md-ref-error-container': safeTokens.errorContainer,
'--md-ref-on-error-container': safeTokens.onErrorContainer,
'--md-ref-success': safeTokens.success,
'--md-ref-on-success': safeTokens.onSuccess,
'--md-ref-success-container': safeTokens.successContainer,
'--md-ref-on-success-container': safeTokens.onSuccessContainer,
'--md-ref-warning': safeTokens.warning,
'--md-ref-on-warning': safeTokens.onWarning,
'--md-ref-warning-container': safeTokens.warningContainer,
'--md-ref-on-warning-container': safeTokens.onWarningContainer,
'--md-ref-background': safeTokens.background,
'--md-ref-on-background': safeTokens.foreground,
'--md-ref-surface-container-low': safeTokens.surfaceContainerLow,
'--md-ref-surface-container': safeTokens.surfaceContainer,
'--md-ref-surface-container-high': safeTokens.surfaceContainerHigh,
'--md-ref-inverse-surface': safeTokens.inverseSurface,
'--md-ref-inverse-on-surface': safeTokens.inverseOnSurface,
'--md-card-bg': safeTokens.card,
'--md-ref-primary-rgb': safeTokens.primaryRgb,
'--primary-color': safeTokens.primaryColor,
'--primary-rgb': safeTokens.primaryRgb,
'--primary-color-rgb': safeTokens.primaryColorRgb,
'--background': safeTokens.background,
'--foreground': safeTokens.foreground,
'--card': safeTokens.card,
'--card-foreground': safeTokens.cardForeground,
'--muted': safeTokens.muted,
'--muted-foreground': safeTokens.mutedForeground,
'--accent': safeTokens.accent,
'--accent-foreground': safeTokens.accentForeground,
'--border': safeTokens.border,
'--input': safeTokens.input,
'--ring': safeTokens.ring,
'--destructive': safeTokens.destructive,
'--destructive-foreground': safeTokens.destructiveForeground,
'--glass-opacity': String(safeTokens.glassOpacity),
'--glass-opacity-heavy': String(Math.min(0.98, safeTokens.glassOpacity + 0.18)),
'--glass-bg': safeTokens.glassBg,
'--glass-bg-heavy': safeTokens.glassBgHeavy,
'--glass-bg-wallpaper': safeTokens.glassBgWallpaper,
'--glass-bg-heavy-wallpaper': safeTokens.glassBgHeavyWallpaper,
'--glass-border': safeTokens.glassBorder,
'--theme-primary-gradient': `linear-gradient(135deg, ${safeTokens.primary}, ${safeTokens.primaryContainer})`,
'--theme-primary-gradient-wide': `linear-gradient(90deg, ${safeTokens.primary}, ${safeTokens.primaryContainer}, ${safeTokens.tertiaryContainer})`,
'--theme-primary-gradient-soft': `linear-gradient(135deg, color-mix(in srgb, ${safeTokens.primary} 16%, transparent), color-mix(in srgb, ${safeTokens.secondary} 9%, transparent) 50%, color-mix(in srgb, ${safeTokens.tertiary} 10%, transparent))`,
'--theme-primary-shadow': `0 0 8px rgba(${safeTokens.primaryRgb}, 0.7)`,
'--theme-primary-glow': `0 0 16px rgba(${safeTokens.primaryRgb}, 0.45), 0 0 36px rgba(${safeTokens.primaryRgb}, 0.25)`,
};
return vars;
}
export const THEME_SETTINGS_EVENT = 'rainweb:theme-update';
export const THEME_SETTINGS_SYNC_KEY = 'rainweb:theme-settings';
/** 将设置派生出的 token 写入前台根节点,供 CSS 与非 React 内容共同使用。 */
export function applyThemeTokens(options = {}) {
const tokens = buildThemeTokens(options);
if (typeof document === 'undefined') return tokens;
Object.entries(tokensToCssVars(tokens)).forEach(([name, value]) => {
document.documentElement.style.setProperty(name, value);
});
return tokens;
}
export { DEFAULT_PRIMARY_COLOR, DEFAULT_GLASS_OPACITY };
+110 -42
View File
@@ -39,10 +39,10 @@ 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 TerminalPanel from './components/TerminalPanel.jsx';
import StatusBar from './components/StatusBar.jsx';
import PanelFrame from './components/PanelFrame.jsx';
import AddPanelDialog from './components/AddPanelDialog.jsx';
@@ -61,6 +61,21 @@ import SnackHost, { showSnack } from '../../admin/snack.jsx';
* ============================================================ */
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() {
@@ -196,23 +211,38 @@ function WorkbenchContent() {
const [aboutOpen, setAboutOpen] = useState(false);
const [mainRefresh, setMainRefresh] = useState(0);
const [modalRefresh, setModalRefresh] = useState(0);
const [activeMainKey, setActiveMainKey] = useState(null);
// 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 termRef = 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: Math.max(5, Math.min(90, savedLayout.current.main)),
// 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,
}
@@ -238,13 +268,44 @@ function WorkbenchContent() {
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) => {
const ref = side === 'left' ? leftRef.current : rightRef.current;
if (!ref) return;
if (ref.isCollapsed()) ref.expand();
else ref.collapse();
setTimeout(saveLayoutNow, 0);
}, [saveLayoutNow]);
// 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');
@@ -318,20 +379,27 @@ function WorkbenchContent() {
};
const mainTabs = buildMainTabs();
// 退
// 退
const effectiveActiveKey = mainTabs.some((t) => t.key === activeMainKey)
? activeMainKey
: (mainTabs[0]?.key ?? null);
// / iframe
// / iframe
// tab
// activeId
useEffect(() => {
if (activeId != null) setActiveMainKey('panel:' + activeId);
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) => {
@@ -339,6 +407,14 @@ function WorkbenchContent() {
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 = () => {
@@ -371,10 +447,11 @@ function WorkbenchContent() {
mode={layout.mode[viewId]}
onSetMode={(m) => setViewModeSafely(viewId, m)}
onFloat={() => showSnack('浮动模式即将上线', 'info')}
closeTitle="关闭侧边栏"
onClose={() => {
// X = 0
const side = layout.sideOf[viewId];
const ref = side === 'left' ? leftRef.current : rightRef.current;
ref?.collapse();
toggleSidebar(side);
}}
/>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
@@ -401,17 +478,16 @@ function WorkbenchContent() {
);
};
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起 */
/** 活动栏图标点击:激活视图 + 展开侧栏;已激活则收起VSCode 语义) */
const handleActivateView = (viewId, side) => {
const ref = side === 'left' ? leftRef.current : rightRef.current;
const collapsed = side === 'left' ? leftCollapsed : rightCollapsed;
const alreadyActive = layout.activeViewOf(side) === viewId;
setViewModeSafely(viewId, VIEW_MODE_SIDEBAR);
layout.activateView(viewId);
if (ref) {
if (alreadyActive && !ref.isCollapsed()) ref.collapse();
else ref.expand();
setTimeout(saveLayoutNow, 0);
}
// setLayout / 0 pivot
if (alreadyActive && !collapsed) applySidebarSize(side, 0);
else applySidebarSize(side, SIDEBAR_DEFAULT_WIDTH[side]);
setTimeout(saveLayoutNow, 0);
};
// ---- ----
@@ -444,8 +520,8 @@ function WorkbenchContent() {
},
{
label: '终端', items: [
{ label: '切换终端面板', Icon: TerminalIcon, action: () => layout.toggleTerminal() },
{ label: '新建终端会话', Icon: AddIcon, action: () => { layout.openTerminal(); termRef.current?.newSession(); } },
// 线TerminalPanel archive/
{ label: '终端功能即将回归', Icon: TerminalIcon },
],
},
{
@@ -579,7 +655,7 @@ function WorkbenchContent() {
</Panel>
<ResizeSeparator disabled />
{/* 左侧侧边栏 */}
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef}>
<Panel id="sidebar-l" defaultSize={240} minSize={180} maxSize={360} collapsible collapsedSize={0} panelRef={leftRef} onResize={onSidebarResize('left')}>
<SidebarPane
side="left"
views={leftPaneViews}
@@ -590,8 +666,10 @@ function WorkbenchContent() {
</Panel>
<ResizeSeparator />
{/* 主区:标签条 + 内容 + 底部终端 */}
<Panel id="main" minSize={320}>
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<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}
@@ -604,23 +682,15 @@ function WorkbenchContent() {
<Box sx={{ flex: 1, minHeight: 0, display: 'flex', position: 'relative', overflow: 'hidden' }}>
{mainBody}
</Box>
{/* 底部终端:默认隐藏,keep-alive 不销毁会话 */}
<Box
sx={{
height: layout.terminalOpen ? 240 : 0,
flexShrink: 0, overflow: 'hidden',
transition: 'height .18s ease',
display: 'flex', flexDirection: 'column',
borderTop: layout.terminalOpen ? 1 : 0, borderColor: 'divider',
}}
>
<TerminalPanel ref={termRef} open={layout.terminalOpen} />
</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}>
<Panel id="sidebar-r" defaultSize={300} minSize={200} maxSize={480} collapsible collapsedSize={0} panelRef={rightRef} onResize={onSidebarResize('right')}>
<SidebarPane
side="right"
views={rightPaneViews}
@@ -637,8 +707,6 @@ function WorkbenchContent() {
views={rightBarViews}
activeId={rightActive}
onActivate={(id) => handleActivateView(id, 'right')}
terminalOpen={layout.terminalOpen}
onToggleTerminal={layout.toggleTerminal}
/>
</Panel>
</Group>
@@ -415,23 +415,37 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
if (!inst || !el) return;
if (inst.term) {
requestAnimationFrame(() => {
try { inst.fit?.fit(); manager.current.sendResize(id); } catch {}
try {
if (el.clientWidth > 0 && el.clientHeight > 0) { inst.fit?.fit(); manager.current.sendResize(id); }
} catch {}
});
return;
}
const term = new Terminal({
fontSize: 13,
fontFamily: FONT,
lineHeight: 1.35,
cursorBlink: true,
scrollback: 3000,
theme: themeObj,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
term.open(el);
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;
@@ -449,11 +463,12 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
}
});
ro.observe(el);
inst.term = term;
inst.fit = fit;
inst.ro = ro;
// rAF fit 0
requestAnimationFrame(() => {
try { fit.fit(); manager.current.sendResize(id); } catch {}
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
@@ -672,6 +687,26 @@ const TerminalPanel = forwardRef(function TerminalPanel({ open = false }, ref) {
}}
>
<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
@@ -1,7 +1,6 @@
import React, { useRef } from 'react';
import Box from '@mui/material/Box';
import Tooltip from '@mui/material/Tooltip';
import TerminalIcon from '@mui/icons-material/Terminal';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
@@ -16,7 +15,6 @@ import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-
* - 无障碍role=toolbar仅活动项 tabIndex=0roving
* /Home/End 移动焦点Enter/空格 激活活动项 aria-controls 指向侧栏容器
* 侧栏内由 aria-live 播报当前视图 SidebarPane
* - 右侧底部固定终端呼出按钮不参与拖拽
* ============================================================ */
function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }) {
@@ -73,33 +71,8 @@ function ActivityTab({ view, active, onActivate, side, index, moveFocus, count }
);
}
function TerminalButton({ open, onToggle }) {
return (
<Tooltip title="终端" placement="right">
<Box
component="button"
type="button"
onClick={onToggle}
aria-label="终端"
aria-pressed={open}
sx={{
width: 48, height: 48, flexShrink: 0, border: 'none', background: 'transparent',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer',
color: open ? 'primary.main' : 'text.secondary',
bgcolor: open ? 'action.selected' : 'transparent',
'&:hover': { bgcolor: open ? 'action.selected' : 'action.hover' },
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: -3 },
}}
>
<TerminalIcon sx={{ fontSize: 22 }} />
</Box>
</Tooltip>
);
}
export default function ActivityBar({
side, views, activeId, onActivate,
terminalOpen = false, onToggleTerminal,
}) {
const right = side === 'right';
const { setNodeRef, isOver } = useDroppable({ id: 'activity:' + side });
@@ -146,7 +119,6 @@ export default function ActivityBar({
</Box>
</SortableContext>
<Box sx={{ flex: 1 }} />
{right && <TerminalButton open={terminalOpen} onToggle={onToggleTerminal} />}
</Box>
);
}
@@ -21,7 +21,8 @@ import { OPEN_MODE_EMBED, OPEN_MODE_TAB, OPEN_MODE_MODAL } from '../hooks/usePan
/* ============================================================
* AddPanelDialog添加面板
* URL + 标题 + 分组 + favicon 实时预览 + 打开方式内嵌/新标签/弹窗
* + 嵌入 URL + 代理开关保存到 admin_links复用现有 API
* + 嵌入 URL + 代理开关 + 信任模式 + 缩放 + 权限委托
* 保存到 admin_links复用现有 APItrusted/scale/permissions 为代理新字段
* ============================================================ */
const MODE_META = [
@@ -30,6 +31,19 @@ const MODE_META = [
{ 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 '';
@@ -40,9 +54,16 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
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]);
@@ -61,6 +82,9 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
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,
@@ -68,7 +92,7 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
});
if (form.openMode !== OPEN_MODE_EMBED) onSetMode(panel.id, form.openMode);
setErr('');
setForm({ title: '', url: '', category: '', openMode: OPEN_MODE_EMBED, use_proxy: false, embed_url: '' });
resetForm();
onSaved(panel);
} catch (e) {
setErr(e.message || '保存失败');
@@ -176,6 +200,53 @@ export default function AddPanelDialog({ open, onClose, categories = [], onSaved
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>
)}
@@ -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>
);
}
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
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';
@@ -17,20 +17,33 @@ import { getToken } from '../../../api/client.js';
* - 懒加载首次激活才创建 iframe DOM之后 display:none 保留状态
* - 超时 + load 双保险加载中显示覆盖层超时X-Frame-Options
* 拒绝 / 目标无响应显示失败提示 + 在新标签页打开回退
* - proxy 模式use_proxy=1 HTTPS 页内嵌 HTTP 目标
* /api/proxy/fetch?url=&token=proxy X-Frame-Options/CSP
* token /api/proxy/token 签发的 5 分钟短 TTL JWT避免把 7
* token 暴露在 iframe URL 模块级缓存4 分钟刷新一次
* - 站内 URL/ 开头为同源内容剥离 allow-same-origin 防其访问
* 父页面 DOM跨源面板保留 allow-same-origin多数站点依赖
* - 代理模式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 TOKEN_TTL_MS = 4 * 60 * 1000; // 4 token 5
const HINT_TTL_MS = 5000; // 5
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
/* 短 TTL proxy token 的模块级缓存:多个 PanelFrame 共享,避免每帧都请求 */
/* 面板预热缓存:slug -> { p, at }(并发去重 + 定时过期) */
const warmCache = new Map();
/* 短 TTL proxy token 的模块级缓存(仅旧 /api/proxy/fetch 兼容回退使用) */
let proxyTokenCache = { promise: null, expiresAt: 0 };
function fetchProxyToken() {
@@ -53,6 +66,36 @@ function fetchProxyToken() {
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
@@ -66,31 +109,65 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
const targetUrl = panel.embed_url || panel.url || '';
const isHttpsPage = typeof window !== 'undefined' && window.location.protocol === 'https:';
const needsProxy = !!panel.use_proxy || (isHttpsPage && targetUrl.startsWith('http:'));
// URL allow-same-origin DOM
const isInternal = targetUrl.startsWith('/');
const sandbox = isInternal
? 'allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox'
: 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox';
// proxy 5 TTL token iframe URL退
// 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;
if (!needsProxy) { setProxySrc(targetUrl); return () => { alive = false; }; }
setProxySrc('');
fetchProxyToken()
.then((tok) => {
if (alive) {
setProxySrc('/api/proxy/fetch?url=' + encodeURIComponent(targetUrl) + '&token=' + encodeURIComponent(tok));
}
})
.catch(() => {
if (alive) {
statusRef.current = 'timeout';
setStatus('timeout');
}
});
bootProxy(() => alive);
return () => { alive = false; };
}, [needsProxy, targetUrl]);
}, [bootProxy]);
const src = needsProxy ? proxySrc : targetUrl;
@@ -119,18 +196,30 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
return () => clearTimeout(timerRef.current);
}, [mounted, src]);
// reload
// /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;
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
performReload();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refreshNonce]);
@@ -148,13 +237,7 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
};
const handleRetry = () => {
const iframe = iframeRef.current;
if (!iframe || !src) return;
statusRef.current = 'loading';
setStatus('loading');
startTimer();
try { iframe.contentWindow.location.reload(); }
catch { iframe.src = iframe.src; }
performReload();
};
useEffect(() => () => { clearTimeout(hintTimerRef.current); }, []);
@@ -167,15 +250,25 @@ export default function PanelFrame({ panel, active = false, refreshNonce = 0 })
}}
>
{mounted && (
<iframe
ref={iframeRef}
title={panel.title || '面板'}
src={src}
onLoad={handleLoad}
sandbox={sandbox}
allow="fullscreen; accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
style={{ width: '100%', height: '100%', border: 'none', display: 'block', background: 'transparent' }}
/>
/* 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' && (
@@ -1,11 +1,28 @@
import React, { useState } from 'react';
import Box from '@mui/material/Box';
import LanguageIcon from '@mui/icons-material/Language';
/* ============================================================
* 面板图标Google favicon 服务 + 失败回退首字母 / 地球图标
* 面板图标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;
@@ -17,19 +34,24 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
const [error, setError] = useState(false);
const host = hostOf(url);
if (!host) {
// +
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: '50%', flexShrink: 0,
width: size, height: size, borderRadius: size / 4, flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
bgcolor: 'primary.container', color: 'primary.onContainer',
bgcolor: bg, color: fg,
fontSize: size * 0.55, fontWeight: 600, lineHeight: 1,
...sx,
}}
>
{(title[0] || '?').toUpperCase()}
{letter}
</Box>
);
}
@@ -43,19 +65,15 @@ export default function PanelIcon({ url, title = '', size = 20, sx = {} }) {
bgcolor: 'background.paper', ...sx,
}}
>
{error ? (
<LanguageIcon sx={{ fontSize: size * 0.7, color: 'text.disabled' }} />
) : (
<img
src={`https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`}
alt=""
width={size}
height={size}
loading="lazy"
onError={() => setError(true)}
style={{ display: 'block' }}
/>
)}
<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>
);
}
@@ -70,11 +70,11 @@ export default function PanelsView({
return m;
}, [links]);
const pinnedPanels = links.filter((p) => pinned.includes(p.id));
const pinnedPanels = links.filter((p) => pinned.includes(String(p.id)));
const renderItem = (p) => {
const selected = String(p.id) === String(activeId);
const isPinned = pinned.includes(p.id);
const isPinned = pinned.includes(String(p.id));
const mode = modes[p.id] || OPEN_MODE_EMBED;
return (
<Box
@@ -17,7 +17,7 @@ import { VIEW_MODE_SIDEBAR, VIEW_MODE_TAB } from '../hooks/useWorkbenchLayout.js
* - 纯展示组件无内部状态模式/关闭行为由父级注入
* ============================================================ */
export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onClose, actions = null, children }) {
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
@@ -65,11 +65,11 @@ export default function ToolWindow({ icon, title, mode, onSetMode, onFloat, onCl
)}
{actions}
{onClose && (
<Tooltip title="关闭">
<Tooltip title={closeTitle}>
<IconButton
size="small"
onClick={onClose}
aria-label="关闭"
aria-label={closeTitle}
sx={{ width: 26, height: 26, borderRadius: 1 }}
>
<CloseIcon sx={{ fontSize: 15 }} />
@@ -18,8 +18,9 @@ import PanelIcon from './PanelIcon.jsx';
/* ============================================================
* ToolbarVSCode 风格顶栏高度 56px
*
* Rain Work(渐变字标) | 菜单栏 | | 缩小地址栏(只读) | 搜索 | 返回
* - 地址栏仅展示当前面板 URL嵌入地址优先只读可点开新标签
* Rain Work(渐变字标) | 菜单栏 | | 地址栏(自适应填满) | 搜索 | 返回
* - 地址栏仅展示当前面板 URL嵌入地址优先只读可点开新标签
* flex:1 填满顶栏剩余空间左侧内容变宽时自然缩减
* - 搜索框面板名/URL/分组Enter 直达逻辑由 Workbench 注入
* ============================================================ */
@@ -51,7 +52,7 @@ export default function Toolbar({
component="span"
aria-label="Rain Work"
sx={{
fontSize: 15, fontWeight: 700, letterSpacing: 0.3, mr: 0.75, flexShrink: 0,
fontSize: 19.5, fontWeight: 700, letterSpacing: 0.4, mr: 1, flexShrink: 0,
background: logoGradient, WebkitBackgroundClip: 'text', backgroundClip: 'text',
WebkitTextFillColor: 'transparent', color: 'transparent',
userSelect: 'none',
@@ -75,12 +76,13 @@ export default function Toolbar({
<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: 360, display: 'flex', alignItems: 'center', gap: 0.75,
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' },
+17 -15
View File
@@ -51,6 +51,23 @@ export default function useNotes() {
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(() => {
@@ -94,21 +111,6 @@ export default function useNotes() {
}
}, [activeName]);
// ---- 保存(PUT ----
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));
}, []);
// ---- 打开文件 ----
const open = useCallback((name) => {
setBusy(true);
+50 -10
View File
@@ -7,6 +7,11 @@ import { listAdminLinks } from '../../../api/adminLinks.js';
* 管理面板列表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
@@ -33,17 +38,48 @@ 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('');
const [openIds, setOpenIds] = useState(() => read(STORE.open, []));
const [activeId, setActiveId] = useState(() => read(STORE.active, null));
// 初始化即清洗(治愈已污染的 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);
return (h && Array.isArray(h.ids)) ? h : { ids: [], index: -1 };
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(() => read(STORE.pinned, []));
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('');
@@ -65,14 +101,15 @@ export default function usePanels() {
setLinks(null);
return listAdminLinks()
.then((ls) => {
const arr = Array.isArray(ls) ? ls : [];
const arr = Array.isArray(ls) ? ls.map(normalizePanel) : [];
setLinks(arr);
const valid = new Set(arr.map((p) => String(p.id)));
setOpenIds((prev) => prev.filter((id) => valid.has(id)));
setPinned((prev) => prev.filter((id) => valid.has(id)));
// 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.filter((id) => valid.has(id));
const ids = prev.ids.map(String).filter((id) => valid.has(id));
return { ids, index: Math.min(prev.index, ids.length - 1) };
});
return arr;
@@ -99,7 +136,8 @@ export default function usePanels() {
// ---- 打开面板:按打开方式分发(tab 新标签 / modal 弹窗 / embed 内嵌) ----
// panelOverride:新建面板保存后(links 尚未包含)时直接传入对象
const openPanel = useCallback((id, panelOverride) => {
const panel = panelOverride || (links && links.find((p) => String(p.id) === String(id)));
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;
@@ -108,7 +146,7 @@ export default function usePanels() {
return false;
}
if (mode === OPEN_MODE_MODAL) { setModalId(id); return false; }
if (String(activeId) === String(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);
@@ -143,6 +181,7 @@ export default function usePanels() {
// ---- 关闭面板(当前面板被关时自动切到最近历史/其他打开面板) ----
const closePanel = useCallback((id) => {
id = String(id); // 统一字符串:此前数字/字符串混杂导致 filter 漏删、标签关不掉
const nextOpen = openIds.filter((x) => x !== id);
setOpenIds(nextOpen);
delete recency.current[id];
@@ -157,6 +196,7 @@ export default function usePanels() {
}, [history, openIds]);
const togglePin = useCallback((id) => {
id = String(id); // 防止固定集合再次被数字污染
setPinned((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}, []);
@@ -49,17 +49,48 @@ function write(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 隐私模式忽略 */ }
}
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧 */
/** 校验 order 合法性:过滤未知/重复视图,遗漏的补到右侧,且保证每侧至少一个视图 */
function sanitizeOrder(saved) {
if (!saved || !Array.isArray(saved.left) || !Array.isArray(saved.right)) return DEFAULT_ORDER;
const seen = new Set();
const clean = (arr) => arr.filter((id) => VIEW_IDS.includes(id) && !seen.has((seen.add(id), id)));
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 };
@@ -73,10 +104,7 @@ function sanitizeMode(saved) {
export default function useWorkbenchLayout() {
const [order, setOrder] = useState(() => sanitizeOrder(read(STORE.order, null)));
const [active, setActive] = useState(() => {
const saved = read(STORE.active, null);
return saved && saved.left && saved.right ? saved : DEFAULT_ACTIVE;
});
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));
+2 -1
View File
@@ -21,7 +21,8 @@ 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';
export { default as TerminalPanel } from './components/TerminalPanel.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';
+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: '登录已过期' });
+539 -11
View File
@@ -1,12 +1,13 @@
{
"name": "rainweb-links",
"version": "2.1.0",
"version": "2.3.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rainweb-links",
"version": "2.1.0",
"version": "2.3.2",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -19,16 +20,19 @@
"@xterm/xterm": "^6.0.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^13.0.3",
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"dompurify": "^3.4.13",
"express": "^4.18.2",
"express-rate-limit": "^8.6.2",
"http-proxy-middleware": "^4.2.0",
"jsdom": "^30.0.1",
"jsonwebtoken": "^9.0.2",
"marked": "^18.0.5",
"multer": "^2.2.0",
"node-pty": "^1.0.0",
"nodemailer": "^9.0.1",
"openid-client": "^6.8.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-resizable-panels": "^4.12.2",
@@ -567,7 +571,7 @@
}
},
"node_modules/@emotion/utils": {
"version": "2.1.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz",
"integrity": "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==",
"license": "MIT"
@@ -1322,6 +1326,24 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
@@ -1392,6 +1414,90 @@
"node": ">=6"
}
},
"node_modules/cheerio": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/cheerio/-/cheerio-1.2.0.tgz",
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
"license": "MIT",
"dependencies": {
"cheerio-select": "^2.1.0",
"dom-serializer": "^2.0.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"encoding-sniffer": "^0.2.1",
"htmlparser2": "^10.1.0",
"parse5": "^7.3.0",
"parse5-htmlparser2-tree-adapter": "^7.1.0",
"parse5-parser-stream": "^7.1.2",
"undici": "^7.19.0",
"whatwg-mimetype": "^4.0.0"
},
"engines": {
"node": ">=20.18.1"
},
"funding": {
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
}
},
"node_modules/cheerio-select": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/cheerio-select/-/cheerio-select-2.1.0.tgz",
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-select": "^5.1.0",
"css-what": "^6.1.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/cheerio/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/cheerio/node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/cheerio/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/cheerio/node_modules/whatwg-mimetype": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1402,7 +1508,7 @@
}
},
"node_modules/concat-stream": {
"version": "2.1.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
@@ -1500,6 +1606,22 @@
"node": ">= 6"
}
},
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmmirror.com/css-select/-/css-select-5.2.2.tgz",
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0",
"css-what": "^6.1.0",
"domhandler": "^5.0.2",
"domutils": "^3.0.1",
"nth-check": "^2.0.1"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
@@ -1513,6 +1635,18 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css-what": {
"version": "6.2.2",
"resolved": "https://registry.npmmirror.com/css-what/-/css-what-6.2.2.tgz",
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">= 6"
},
"funding": {
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1562,7 +1696,7 @@
"license": "MIT"
},
"node_modules/depd": {
"version": "2.1.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
@@ -1600,6 +1734,59 @@
"csstype": "^3.0.2"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.4.13",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
@@ -1609,6 +1796,20 @@
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1639,7 +1840,7 @@
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.1.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
@@ -1647,6 +1848,31 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-sniffer": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
"license": "MIT",
"dependencies": {
"iconv-lite": "^0.6.3",
"whatwg-encoding": "^3.1.1"
},
"funding": {
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
}
},
"node_modules/encoding-sniffer/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -1831,6 +2057,18 @@
}
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -1997,6 +2235,37 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -2017,6 +2286,45 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/http-proxy-middleware": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz",
"integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"httpxy": "^0.5.4",
"is-glob": "^4.0.3",
"is-plain-obj": "^4.1.0",
"micromatch": "^4.0.8"
},
"engines": {
"node": "^22.15.0 || ^24.0.0 || >=26.0.0"
}
},
"node_modules/http-proxy-middleware/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/httpxy": {
"version": "0.5.5",
"resolved": "https://registry.npmmirror.com/httpxy/-/httpxy-0.5.5.tgz",
"integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==",
"license": "MIT"
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -2090,12 +2398,63 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmmirror.com/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"license": "MIT"
},
"node_modules/jose": {
"version": "6.2.8",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
"integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2155,7 +2514,7 @@
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
@@ -2605,6 +2964,31 @@
"node": ">= 0.6"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@@ -2639,13 +3023,13 @@
}
},
"node_modules/ms": {
"version": "2.1.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.2.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
"license": "MIT",
@@ -2725,6 +3109,27 @@
"node": ">=6.0.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.6",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
"integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2758,6 +3163,19 @@
"node": ">= 0.8"
}
},
"node_modules/openid-client": {
"version": "6.8.4",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.4.tgz",
"integrity": "sha512-QSw0BA08piujetEwfZsHoTrDpMEha7GDZDicQqVwX4u0ChCjefvjDB++TZ8BTg76UpwhzIQgdvvfgfl3HpCSAw==",
"license": "MIT",
"dependencies": {
"jose": "^6.2.2",
"oauth4webapi": "^3.8.5"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2800,6 +3218,79 @@
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
"license": "MIT",
"dependencies": {
"domhandler": "^5.0.3",
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream": {
"version": "7.1.2",
"resolved": "https://registry.npmmirror.com/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
"license": "MIT",
"dependencies": {
"parse5": "^7.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5-parser-stream/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parse5-parser-stream/node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -2909,7 +3400,7 @@
}
},
"node_modules/punycode": {
"version": "2.3.1",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
@@ -3434,6 +3925,18 @@
"integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -3633,6 +4136,31 @@
"node": ">=20"
}
},
"node_modules/whatwg-encoding": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
"license": "MIT",
"dependencies": {
"iconv-lite": "0.6.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/whatwg-encoding/node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
@@ -3687,7 +4215,7 @@
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
+6 -2
View File
@@ -1,6 +1,6 @@
{
"name": "rainweb-links",
"version": "2.1.0",
"version": "2.3.2",
"description": "链接聚合管理平台",
"main": "server.js",
"scripts": {
@@ -22,16 +22,19 @@
"@xterm/xterm": "^6.0.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^13.0.3",
"cheerio": "^1.2.0",
"cors": "^2.8.5",
"dompurify": "^3.4.13",
"express": "^4.18.2",
"express-rate-limit": "^8.6.2",
"http-proxy-middleware": "^4.2.0",
"jsdom": "^30.0.1",
"jsonwebtoken": "^9.0.2",
"marked": "^18.0.5",
"multer": "^2.2.0",
"node-pty": "^1.0.0",
"nodemailer": "^9.0.1",
"openid-client": "^6.8.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-resizable-panels": "^4.12.2",
@@ -41,5 +44,6 @@
"devDependencies": {
"@vitejs/plugin-react": "^6.0.5",
"vite": "^8.2.1"
}
},
"license": "SEE LICENSE IN LICENSE"
}
+1583 -43
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#120A22"/>
<text x="32" y="46" text-anchor="middle" font-family="Anybody, 'Arial Black', Arial, sans-serif" font-weight="900" font-style="italic" font-size="42" fill="#F4F1EB">R</text>
</svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553242408" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8564" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M306.005333 117.632L444.330667 256h135.296l138.368-138.325333a42.666667 42.666667 0 1 1 60.373333 60.373333l-78.037333 77.952L789.333333 256A149.333333 149.333333 0 0 1 938.666667 405.333333v341.333334a149.333333 149.333333 0 0 1-149.333334 149.333333h-554.666666A149.333333 149.333333 0 0 1 85.333333 746.666667v-341.333334A149.333333 149.333333 0 0 1 234.666667 256h88.96L245.632 177.962667a42.666667 42.666667 0 0 1 60.373333-60.373334zM789.333333 341.333333h-554.666666a64 64 0 0 0-63.701334 57.856L170.666667 405.333333v341.333334a64 64 0 0 0 57.856 63.701333L234.666667 810.666667h554.666666a64 64 0 0 0 63.701334-57.813334L853.333333 746.666667v-341.333334A64 64 0 0 0 789.333333 341.333333zM341.333333 469.333333a42.666667 42.666667 0 0 1 42.666667 42.666667v85.333333a42.666667 42.666667 0 1 1-85.333333 0v-85.333333a42.666667 42.666667 0 0 1 42.666666-42.666667z m341.333334 0a42.666667 42.666667 0 0 1 42.666666 42.666667v85.333333a42.666667 42.666667 0 1 1-85.333333 0v-85.333333a42.666667 42.666667 0 0 1 42.666667-42.666667z" p-id="8565"></path></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553278064" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="11557" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M178.602667 231.296C99.882667 231.125333-5.546667 281.173333 0.256 406.656c9.088 196.010667 209.92 214.186667 290.176 215.765333 8.789333 36.778667 103.253333 163.584 173.184 170.24h306.346667c183.722667-12.202667 321.28-555.605333 219.306666-557.653333-168.661333 7.936-268.629333 11.946667-354.346666 12.629333v169.6l-26.709334-11.818666-0.170666-157.696c-98.389333-0.042667-185.002667-4.608-349.397334-12.714667-20.565333-0.128-49.237333-3.626667-80.042666-3.712z m11.136 69.333333h9.386666c11.178667 100.48 29.354667 159.232 66.133334 249.002667-93.866667-11.093333-173.738667-38.357333-188.416-140.16-7.594667-52.693333 18.005333-107.690667 112.896-108.885333z m365.098666 98.773334c6.4 0.085333 12.928 1.28 19.072 4.096l31.957334 13.781333-22.912 41.770667a28.672 25.472 0 0 0-10.282667 1.621333 28.672 25.472 0 0 0-17.28 32.597333 28.672 25.472 0 0 0 4.778667 7.424l-39.509334 71.936a28.672 25.472 0 0 0-9.472 1.621334 28.672 25.472 0 0 0-17.28 32.597333 28.672 25.472 0 0 0 36.693334 15.36 28.672 25.472 0 0 0 17.237333-32.64 28.672 25.472 0 0 0-6.741333-9.386667l38.485333-70.058666a28.672 25.472 0 0 0 12.501333-1.28 28.672 25.472 0 0 0 9.088-4.778667c14.848 6.229333 27.008 11.306667 35.754667 15.616 13.141333 6.485333 17.792 10.794667 19.2 15.573333 1.408 4.693333-0.128 13.738667-7.552 29.610667-5.546667 11.818667-14.72 28.586667-25.557333 48.341333a28.672 25.472 0 0 0-10.709334 1.621334 28.672 25.472 0 0 0-17.28 32.597333 28.672 25.472 0 0 0 36.693334 15.36 28.672 25.472 0 0 0 17.237333-32.597333 28.672 25.472 0 0 0-5.845333-8.618667c10.709333-19.541333 19.925333-36.352 25.856-48.981333 8.021333-17.152 12.202667-29.909333 8.533333-42.24-3.669333-12.330667-14.933333-20.352-29.866667-27.733334-9.813333-4.821333-22.058667-9.941333-36.693333-16.085333a28.672 25.472 0 0 0-1.621333-10.197333 28.672 25.472 0 0 0-6.186667-8.917334l22.528-41.088 124.757333 53.888c22.528 9.770667 31.829333 33.706667 20.906667 53.76l-85.76 157.013334c-10.965333 20.010667-37.888 28.288-60.416 18.56l-176.512-76.288c-22.528-9.728-31.872-33.706667-20.906667-53.76l85.76-156.970667c7.509333-13.781333 22.613333-21.973333 38.613334-22.613333h2.730666z" p-id="11558"></path></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553263659" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="10560" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M511.6 76.3C264.3 76.2 64 276.4 64 523.5 64 718.9 189.3 885 363.8 946c23.5 5.9 19.9-10.8 19.9-22.2v-77.5c-135.7 15.9-141.2-73.9-150.3-88.9C215 726 171.5 718 184.5 703c30.9-15.9 62.4 4 98.9 57.9 26.4 39.1 77.9 32.5 104 26 5.7-23.5 17.9-44.5 34.7-60.8-140.6-25.2-199.2-111-199.2-213 0-49.5 16.3-95 48.3-131.7-20.4-60.5 1.9-112.3 4.9-120 58.1-5.2 118.5 41.6 123.2 45.3 33-8.9 70.7-13.6 112.9-13.6 42.4 0 80.2 4.9 113.5 13.9 11.3-8.6 67.3-48.8 121.3-43.9 2.9 7.7 24.7 58.3 5.5 118 32.4 36.8 48.9 82.7 48.9 132.3 0 102.2-59 188.1-200 212.9 23.5 23.2 38.1 55.4 38.1 91v112.5c0.8 9 0 17.9 15 17.9 177.1-59.7 304.6-227 304.6-424.1 0-247.2-200.4-447.3-447.5-447.3z" p-id="10561"></path></svg>

After

Width:  |  Height:  |  Size: 1017 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553215331" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7559" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M824.8 613.2c-16-51.4-34.4-94.6-62.7-165.3C766.5 262.2 689.3 112 511.5 112 331.7 112 256.2 265.2 261 447.9c-28.4 70.8-46.7 113.7-62.7 165.3-34 109.5-23 154.8-14.6 155.8 18 2.2 70.1-82.4 70.1-82.4 0 49 25.2 112.9 79.8 159-26.4 8.1-85.7 29.9-71.6 53.8 11.4 19.3 196.2 12.3 249.5 6.3 53.3 6 238.1 13 249.5-6.3 14.1-23.8-45.3-45.7-71.6-53.8 54.6-46.2 79.8-110.1 79.8-159 0 0 52.1 84.6 70.1 82.4 8.5-1.1 19.5-46.4-14.5-155.8z" p-id="7560"></path></svg>

After

Width:  |  Height:  |  Size: 780 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553254765" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9589" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M822.909 852.197l134.083-652.039c13.426-54.928-20.117-82.37-60.351-68.672L105.164 440.35c-53.706 20.615-53.706 54.928-6.736 68.626l201.26 68.672L775.94 268.784c20.117-13.743 40.234-6.871 26.853 6.827L420.39 632.576l-13.427 212.795c20.117 0 33.544-6.872 40.234-20.615l100.63-96.068 207.95 157.869c33.544 20.57 60.35 6.826 67.087-34.313l0.045-0.047z" fill="#999999" p-id="9590"></path></svg>

After

Width:  |  Height:  |  Size: 722 B

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786553089135" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6536" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M690.1 377.4c5.9 0 11.8 0.2 17.6 0.5-24.4-128.7-158.3-227.1-319.9-227.1C209 150.8 64 271.4 64 420.2c0 81.1 43.6 154.2 111.9 203.6 5.5 3.9 9.1 10.3 9.1 17.6 0 2.4-0.5 4.6-1.1 6.9-5.5 20.3-14.2 52.8-14.6 54.3-0.7 2.6-1.7 5.2-1.7 7.9 0 5.9 4.8 10.8 10.8 10.8 2.3 0 4.2-0.9 6.2-2l70.9-40.9c5.3-3.1 11-5 17.2-5 3.2 0 6.4 0.5 9.5 1.4 33.1 9.5 68.8 14.8 105.7 14.8 6 0 11.9-0.1 17.8-0.4-7.1-21-10.9-43.1-10.9-66 0-135.8 132.2-245.8 295.3-245.8z m-194.3-86.5c23.8 0 43.2 19.3 43.2 43.1s-19.3 43.1-43.2 43.1c-23.8 0-43.2-19.3-43.2-43.1s19.4-43.1 43.2-43.1z m-215.9 86.2c-23.8 0-43.2-19.3-43.2-43.1s19.3-43.1 43.2-43.1 43.2 19.3 43.2 43.1-19.4 43.1-43.2 43.1z" p-id="6537"></path><path d="M866.7 792.7c56.9-41.2 93.2-102 93.2-169.7 0-124-120.8-224.5-269.9-224.5-149 0-269.9 100.5-269.9 224.5S540.9 847.5 690 847.5c30.8 0 60.6-4.4 88.1-12.3 2.6-0.8 5.2-1.2 7.9-1.2 5.2 0 9.9 1.6 14.3 4.1l59.1 34c1.7 1 3.3 1.7 5.2 1.7 2.4 0 4.7-0.9 6.4-2.6 1.7-1.7 2.6-4 2.6-6.4 0-2.2-0.9-4.4-1.4-6.6-0.3-1.2-7.6-28.3-12.2-45.3-0.5-1.9-0.9-3.8-0.9-5.7 0.1-5.9 3.1-11.2 7.6-14.5zM600.2 587.2c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c0 19.8-16.2 35.9-36 35.9z m179.9 0c-19.9 0-36-16.1-36-35.9 0-19.8 16.1-35.9 36-35.9s36 16.1 36 35.9c-0.1 19.8-16.2 35.9-36 35.9z" p-id="6538"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630">
<rect width="1200" height="630" fill="#120A22"/>
<text x="600" y="385" text-anchor="middle" font-family="Anybody, 'Arial Black', Arial, sans-serif" font-weight="900" font-style="italic" font-size="150" fill="#F4F1EB" letter-spacing="4">RAINBLOG</text>
</svg>

After

Width:  |  Height:  |  Size: 327 B

+107 -6
View File
@@ -4,6 +4,89 @@ const { authMiddleware, adminOnly } = require('../middleware/auth');
const router = express.Router();
// slug 生成:name 小写、空格转连字符、去特殊字符(仅 [a-z0-9-]),空则随机后缀
function makeSlug(name) {
const base = String(name || '')
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 32);
return base || ('panel-' + Math.random().toString(36).slice(2, 8));
}
// 确保 slug 唯一:存在同名则追加 -2/-3/...
function uniqueSlug(slug, excludeId) {
let s = slug;
let i = 1;
const exists = (x) => {
if (excludeId) return db.get('SELECT id FROM admin_links WHERE slug = ? AND id != ?', [x, excludeId]);
return db.get('SELECT id FROM admin_links WHERE slug = ?', [x]);
};
while (exists(s)) { s = slug + '-' + (++i); }
return s;
}
// 校验面板代理配置字段(返回 { ok, data?, error? }
function validateProxyFields(body, current) {
const data = {};
// proxy_headers:合法 JSON 对象(空串允许)
if (body.proxy_headers !== undefined) {
const raw = String(body.proxy_headers);
if (raw.trim() !== '') {
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return { ok: false, error: 'proxy_headers 需为 JSON 对象' };
data.proxy_headers = raw;
} catch { return { ok: false, error: 'proxy_headers 不是合法 JSON' }; }
} else {
data.proxy_headers = '';
}
}
// permissionsJSON 数组
if (body.permissions !== undefined) {
const raw = String(body.permissions);
if (raw.trim() !== '') {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return { ok: false, error: 'permissions 需为 JSON 数组' };
data.permissions = raw;
} catch { return { ok: false, error: 'permissions 不是合法 JSON' }; }
} else {
data.permissions = '';
}
}
// scale0.1-3
if (body.scale !== undefined) {
const s = parseFloat(body.scale);
if (!Number.isFinite(s) || s < 0.1 || s > 3) return { ok: false, error: 'scale 需在 0.1-3 之间' };
data.scale = s;
}
// trusted0/1
if (body.trusted !== undefined) {
const t = Number(body.trusted);
if (t !== 0 && t !== 1) return { ok: false, error: 'trusted 需为 0 或 1' };
data.trusted = t;
}
// proxy_skip_tls_verify0/1
if (body.proxy_skip_tls_verify !== undefined) {
data.proxy_skip_tls_verify = Number(body.proxy_skip_tls_verify) ? 1 : 0;
}
// slug:唯一([a-z0-9-]),空则自动生成
if (body.slug !== undefined) {
const raw = String(body.slug || '').trim().toLowerCase();
if (raw !== '') {
if (!/^[a-z0-9-]{1,32}$/.test(raw)) return { ok: false, error: 'slug 仅限小写字母/数字/连字符,≤32 字符' };
if (['token', 'fetch'].includes(raw)) return { ok: false, error: 'slug 为保留名,请更换' };
data.slug = uniqueSlug(raw, current && current.id);
} else {
data.slug = uniqueSlug(makeSlug((body.title || (current && current.title) || '')), current && current.id);
}
}
return { ok: true, data };
}
router.get('/', authMiddleware, adminOnly, (req, res) => {
res.json(db.all('SELECT * FROM admin_links ORDER BY sort_order ASC, id ASC'));
});
@@ -11,18 +94,36 @@ router.get('/', authMiddleware, adminOnly, (req, res) => {
router.post('/', authMiddleware, adminOnly, (req, res) => {
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
if (!title || !url) return res.status(400).json({ error: '标题和链接不能为空' });
const vf = validateProxyFields(req.body);
if (!vf.ok) return res.status(400).json({ error: vf.error });
const d = vf.data;
const id = db.run(
'INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order, use_proxy, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[title, url, embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '']);
`INSERT INTO admin_links (title, url, embed_url, description, icon, category, sort_order, use_proxy, version,
proxy_headers, proxy_skip_tls_verify, permissions, scale, trusted, slug) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[title, url, embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '',
d.proxy_headers || '', d.proxy_skip_tls_verify || 0, d.permissions || '', d.scale !== undefined ? d.scale : 1.0, d.trusted !== undefined ? d.trusted : 1,
d.slug || uniqueSlug(makeSlug(title))]);
res.json(db.get('SELECT * FROM admin_links WHERE id = ?', [id]));
});
router.put('/:id', authMiddleware, adminOnly, (req, res) => {
if (!db.get('SELECT id FROM admin_links WHERE id = ?', [req.params.id]))
return res.status(404).json({ error: '链接不存在' });
const existing = db.get('SELECT * FROM admin_links WHERE id = ?', [req.params.id]);
if (!existing) return res.status(404).json({ error: '链接不存在' });
const { title, url, embed_url, description, icon, category, sort_order, use_proxy, version } = req.body;
db.run('UPDATE admin_links SET title=?, url=?, embed_url=?, description=?, icon=?, category=?, sort_order=?, use_proxy=?, version=? WHERE id=?',
[title || '', url || '', embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '', req.params.id]);
const vf = validateProxyFields(req.body, existing);
if (!vf.ok) return res.status(400).json({ error: vf.error });
const d = vf.data;
db.run(
`UPDATE admin_links SET title=?, url=?, embed_url=?, description=?, icon=?, category=?, sort_order=?, use_proxy=?, version=?,
proxy_headers=?, proxy_skip_tls_verify=?, permissions=?, scale=?, trusted=?, slug=? WHERE id=?`,
[title || '', url || '', embed_url || '', description || '', icon || '', category || '默认', sort_order || 0, use_proxy ? 1 : 0, version || '',
d.proxy_headers !== undefined ? d.proxy_headers : existing.proxy_headers,
d.proxy_skip_tls_verify !== undefined ? d.proxy_skip_tls_verify : existing.proxy_skip_tls_verify,
d.permissions !== undefined ? d.permissions : existing.permissions,
d.scale !== undefined ? d.scale : existing.scale,
d.trusted !== undefined ? d.trusted : existing.trusted,
d.slug !== undefined ? d.slug : (existing.slug || uniqueSlug(makeSlug(existing.title))),
req.params.id]);
res.json(db.get('SELECT * FROM admin_links WHERE id = ?', [req.params.id]));
});

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