diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 948e056..767ccea 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,10 +6,10 @@ **要求:** Go 1.26+、Node.js 18+ -```powershell -# Windows:一键启动后端 + 前端热更新 -.\build.ps1 -Target dev -# 浏览器访问 http://localhost:5173 +```bat +REM Windows:一键启动后端 + 前端热更新(请用 build.bat) +build.bat -Target dev +REM 浏览器访问 http://localhost:5173 ``` ```bash @@ -27,21 +27,25 @@ make dev 发布单二进制前需先构建前端并 embed: -```powershell -.\build.ps1 # Windows +```bat +build.bat REM Windows +``` + +```bash make build # Linux / macOS ``` ## 报告问题 -在 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 中描述(也可使用仓库自带的 Issue 模板): +在本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)): 1. 复现步骤 2. 期望行为 vs 实际行为 3. 环境信息(系统、浏览器、Go/Node 版本) 4. 截图或日志(如有) -已知问题与计划功能见 [ROADMAP.md](../ROADMAP.md)。 +演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) +已知问题与计划功能见 [ROADMAP.md](ROADMAP.md)。 ## 行为准则 diff --git a/README.md b/README.md index d7057af..1d31973 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,20 @@ # 姜十三论坛 Jiang13 Forum -**轻量 · 好看 · 单文件部署的现代化论坛** +**能聊 · 好看 · 好装** -面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite,开箱即用。 +面向小圈子、团队与同好社群的轻量现代化论坛。 +编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
+[![在线演示](https://img.shields.io/badge/Demo-bbs.iioio.com-18a058?style=flat-square)](https://bbs.iioio.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-18a058?style=flat-square)](LICENSE) [![Go](https://img.shields.io/badge/Go-1.26-00ADD8?style=flat-square&logo=go&logoColor=white)](go.mod) [![React](https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react&logoColor=white)](frontend/package.json) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#) +[在线演示](https://bbs.iioio.com/) · [快速开始](#-快速开始) · [界面预览](#-界面预览) · [功能亮点](#-功能亮点) · @@ -23,27 +26,40 @@ 姜十三论坛首页 - 浅色主题三栏布局 -浅色主题 · 三栏布局 · Feed 排序 · 虚拟滚动帖列表 +浅色主题 · 三栏布局 · Feed 排序 · 板块导航 · 标签云
-> **开发状态:** 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎参与共建。 -> 查看 [路线图 ROADMAP.md](ROADMAP.md) · [Issues 反馈](https://git.iioio.com/freefire/jiang13-forum/issues) +> **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/) +> 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎提 Issue / PR 共建。 --- +## 它是什么 + +姜十三论坛不做大而全的社区平台,只做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。 + +| 场景 | 说明 | +|------|------| +| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 | +| 兴趣小圈子 | 同好交流、作品分享、活动组织 | +| 项目配套社区 | 可与 Gitea 等通过 OIDC(开放身份连接)做 SSO(单点登录) | +| 个人站长 | 单机可跑,无需云数据库与一堆微服务 | + +--- + ## 界面预览 -> 论坛用户第一眼看到的是界面。姜十三论坛采用清新绿色主题、高密度信息布局,兼顾桌面与移动端体验。 +截图来自演示站 [bbs.iioio.com](https://bbs.iioio.com/)。
浅色主题首页
浅色主题
- 左栏板块导航 · Feed 排序切换 · 右栏热门/评论 + 左栏板块 · Feed 排序 · 右栏热门 / 标签 / 评论
暗色主题首页 @@ -55,19 +71,19 @@ 帖子详情页
帖子详情
- TipTap 富文本渲染 · 标签展示 · 点赞收藏互动 + 文章目录 · 标签 · 作者卡片 · 修订信息
- 移动端首页 -
移动端适配
- 板块快捷筛选 · Feed 排序 · 触控友好列表 + 富文本与代码高亮 +
富文本渲染
+ TipTap 排版 · 图片 · 代码高亮 · 目录导航

- 发帖页 -
发帖 — 板块胶囊选择 · TipTap 工具栏 · 本地上传图片 + 移动端首页 +
移动端 — 板块快捷筛选 · Feed 排序 · 触控友好列表

--- @@ -78,32 +94,28 @@ | 特性 | 说明 | |------|------| -| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/最新评论 | -| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 | -| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 | -| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 | +| **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 | +| **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 | +| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 | +| **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 | | **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 | -| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 | ### 社区功能 -- 用户注册 / 登录(bcrypt + JWT Cookie) -- 普通用户 / 管理员两级权限,**首个注册用户自动成为管理员** -- 板块管理、发帖、TipTap 富文本编辑、正文图片本地上传、标签、置顶 -- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看 -- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口 -- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复 -- 点赞、收藏、热门帖、最新评论 -- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份 -- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配) +- 用户注册 / 登录(bcrypt + JWT Cookie);**首个注册用户自动成为管理员** +- 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华 +- 帖子修订历史与 diff(差异)对比;可配置普通用户编辑时限 +- 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控 +- 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页 +- 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份 +- 可选:邮件验证码、OIDC Provider、Gitea 仓库同步(开源码桶)、S3 兼容对象存储 ### 部署体验 -- **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源 +- **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源 - **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 -- **配置文件** — 工作目录下 `app.ini`(类似 Gitea),启动可省略一长串参数 - **跨平台** — Windows / Linux / macOS 一键编译 -- **系统服务** — 内置注册:Linux systemd / Windows Service,一条命令安装与启停 +- **系统服务** — 内置 Linux systemd / Windows Service 注册 --- @@ -113,11 +125,12 @@ **Windows(推荐):** -```powershell -.\build.ps1 -# 或双击 build.bat +```bat +build.bat ``` +> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy)。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。 + **Linux / macOS:** ```bash @@ -131,19 +144,17 @@ cd frontend && npm install && npm run build cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 ``` -> Windows 自带的 `make` 通常是 Embarcadero MAKE,不能识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make 后再用 `make build`。 - 跨平台编译: -```powershell -.\build.ps1 -Target build-windows -.\build.ps1 -Target build-linux -.\build.ps1 -Target build-all +```bat +build.bat -Target build-windows +build.bat -Target build-linux +build.bat -Target build-all ``` ### 2. 启动 -把二进制放到目标目录后直接运行即可(首次会在同目录生成 `app.ini`): +把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`): ```bash # Windows @@ -153,7 +164,7 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 ./dist/jiang13 ``` -也可先复制示例配置再改端口/数据目录: +也可先复制示例配置再改端口 / 数据目录: ```bash cp app.ini.example /opt/jiang13/app.ini @@ -165,11 +176,11 @@ cp app.ini.example /opt/jiang13/app.ini 1. 浏览器打开 `http://localhost:3000/register` 注册账号 2. **第一个注册的用户自动成为管理员** -3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台 +3. 登录后访问 `http://localhost:3000/admin` 进入后台 ### 配置文件(`app.ini`) -默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录;`go run` 开发时回退为当前目录)。 +默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。 ```ini [server] @@ -182,7 +193,7 @@ DATA = data JWT_SECRET = ``` -完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。 +完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。 **优先级:** 命令行显式参数 > `app.ini` > 内置默认值。 @@ -192,39 +203,25 @@ JWT_SECRET = |------|--------|------| | `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) | | `--config` | `{work-path}/app.ini` | 配置文件路径 | -| `--port` | (读配置 / `3000`) | HTTP 监听端口,覆盖配置文件 | -| `--data` | (读配置 / `data`) | 数据目录,覆盖配置文件 | +| `--port` | (读配置 / `3000`) | HTTP 监听端口 | +| `--data` | (读配置 / `data`) | 数据目录 | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) | -| `--service` | (空) | 系统服务控制:`install` / `uninstall` / `start` / `stop` / `restart` / `status` | +| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` | ### 4. 注册为系统服务(可选) -将二进制与 `app.ini` 放到同一目录后注册即可。服务会绑定 `--work-path` 与 `--config`;之后改端口或数据目录只需编辑 `app.ini` 并重启服务,**不必重新安装**。 +将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。 **Ubuntu / Linux(systemd,需 root):** ```bash sudo mkdir -p /opt/jiang13 sudo cp jiang13 /opt/jiang13/ -# 可选:先写好配置 -# sudo cp app.ini.example /opt/jiang13/app.ini sudo /opt/jiang13/jiang13 --service install sudo /opt/jiang13/jiang13 --service start sudo systemctl enable jiang13 ``` -常用管理: - -```bash -sudo /opt/jiang13/jiang13 --service status -sudo /opt/jiang13/jiang13 --service stop -sudo /opt/jiang13/jiang13 --service restart -sudo /opt/jiang13/jiang13 --service uninstall -# 也可直接用 systemctl -sudo systemctl status jiang13 -sudo journalctl -u jiang13 -f -``` - **Windows(Windows Service,需管理员 PowerShell):** ```powershell @@ -234,18 +231,7 @@ C:\jiang13\jiang13.exe --service install C:\jiang13\jiang13.exe --service start ``` -常用管理: - -```powershell -C:\jiang13\jiang13.exe --service status -C:\jiang13\jiang13.exe --service stop -C:\jiang13\jiang13.exe --service restart -C:\jiang13\jiang13.exe --service uninstall -Get-Service jiang13 -``` - -> 改 `app.ini` 后执行 `--service restart`(或 `systemctl restart jiang13` / `Restart-Service jiang13`)。 -> 运行日志写入数据目录下的 `jiang13.log`;Linux 上也可通过 `journalctl` 查看。 +> 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。 --- @@ -256,29 +242,50 @@ Get-Service jiang13 | **后端** | Go 1.26 · Gin · GORM · SQLite | | **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | | **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 | -| **认证** | bcrypt · JWT Cookie | +| **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider | --- ## 前端开发 -日常改前端**不需要**重新 `npm run build` 或 `go build`,Vite 开发服务器支持秒级热更新(HMR,热模块替换): +日常改前端不需要重新完整构建,Vite 支持秒级热更新(HMR,热模块替换): -```powershell -.\build.ps1 -Target dev # Windows -make dev # Linux / macOS +```bat +build.bat -Target dev +``` + +```bash +make dev ``` 浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。 **何时需要完整构建:** -- 修改 Go 代码、前端或 embed 静态资源 → `go build` / `make build`(或 `build.bat`) -- 发布单二进制前 → `npm run build` + `make build` -- 更新 README 界面截图 → 启动服务后执行 `node scripts/capture-screenshots.mjs` +- 修改 Go 代码或要发布单二进制 → `build.bat` / `make build` +- 更新 README 界面截图 → 见下方「更新截图」 > 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。 +### 更新截图 + +默认从演示站抓取到 `docs/screenshots/`(需本机已安装 Playwright): + +```bash +npm install -D playwright +npx playwright install chromium +node scripts/capture-screenshots.mjs +``` + +| 环境变量 | 说明 | 默认 | +|----------|------|------| +| `J13_URL` | 抓取目标 | `https://bbs.iioio.com` | +| `J13_POST_ID` | 详情页帖子 ID | `1` | +| `J13_RICH_POST_ID` | 富文本展示帖 ID | `8` | +| `J13_USER` / `J13_PASS` | 发帖页登录(可选) | `admin` / `admin123` | + +本地站点示例:`J13_URL=http://localhost:3000 node scripts/capture-screenshots.mjs` + --- ## 项目结构 @@ -289,16 +296,15 @@ jiang13-forum/ ├── config/ # app.ini 与命令行配置 ├── app.ini.example # 配置文件示例 ├── model/ # GORM 模型与数据库迁移 -├── service/ # 业务逻辑(认证、帖子、评论…) +├── service/ # 业务逻辑 ├── handler/ # HTTP 处理器(前台 + 后台) -├── middleware/ # JWT 鉴权 +├── middleware/ # JWT 鉴权等 ├── router/ # 路由注册 -├── embed_static/ # go:embed 内嵌的 SPA 与模板 +├── embed_static/ # go:embed 内嵌的 SPA ├── frontend/ # React 源码(Vite 构建) ├── docs/screenshots/ # README 界面截图 -├── docs/issue-templates.md # Issue 预填模板 ├── ROADMAP.md # 路线图与已知问题 -└── scripts/ # 开发辅助脚本 +└── scripts/ # 开发辅助脚本(含截图) ``` --- @@ -320,23 +326,23 @@ data/ ## 开发状态 -项目**积极开发中**,作为论坛产品功能尚未完善,欢迎参与共建。 +项目**积极开发中**,欢迎参与共建。完整列表见 **[ROADMAP.md](ROADMAP.md)**。 | 类型 | 示例 | |------|------| | ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 | -| ✅ 发帖体验 | TipTap 富文本、正文图片上传、修订历史、可配置编辑时限 | -| ✅ 管理后台 | React SPA:仪表盘、帖子置顶、用户禁言、论坛参数与敏感词配置 | +| ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 | +| ✅ 管理后台 | React SPA:仪表盘、置顶 / 精华、禁言、系统设置 | | 📋 计划中 | 通知动态优化、邮件提醒 | -完整列表见 **[路线图 ROADMAP.md](ROADMAP.md)**。发现问题请提交 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues),认领任务请参考 [CONTRIBUTING.md](CONTRIBUTING.md)。 - --- ## 参与贡献 欢迎提交 Issue 和 Pull Request!详见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +在线体验与反馈也可直接在演示站进行:[https://bbs.iioio.com/](https://bbs.iioio.com/) + --- ## 许可证 diff --git a/ROADMAP.md b/ROADMAP.md index 10dc2ac..d18884c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # 路线图 ROADMAP > 姜十三论坛仍在积极开发中,功能尚未完善。 -> 欢迎通过 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 反馈问题或认领任务。 +> 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。 **图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷 @@ -20,7 +20,7 @@ ## 🐛 已知缺陷(Bug) -_当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.com/freefire/jiang13-forum/issues/new/choose)。_ +_当前无已记录缺陷。发现新问题请在本仓库提交 Issue。_ --- @@ -65,10 +65,10 @@ _当前无公开认领任务。_ ## 如何参与 -1. 在 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md)) +1. 在 Issues 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md)) 2. Fork → 分支 → PR,详见 [CONTRIBUTING.md](CONTRIBUTING.md) 3. 有新想法先开 Issue 讨论,避免重复劳动 --- -_最后更新:2026-06-16_ +_最后更新:2026-08-05_ diff --git a/docs/issue-templates.md b/docs/issue-templates.md index 3d2efc8..31a13f4 100644 --- a/docs/issue-templates.md +++ b/docs/issue-templates.md @@ -1,6 +1,6 @@ # Issue 预填模板 -以下两条可直接复制到 [Gitea Issues](https://git.iioio.com/freefire/jiang13-forum/issues/new) 创建,或使用仓库自带的 Issue 模板。 +以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。 --- diff --git a/docs/screenshots/compose.png b/docs/screenshots/compose.png deleted file mode 100644 index 9e8bc27..0000000 Binary files a/docs/screenshots/compose.png and /dev/null differ diff --git a/docs/screenshots/home-dark.png b/docs/screenshots/home-dark.png index 0604ce9..dabeafc 100644 Binary files a/docs/screenshots/home-dark.png and b/docs/screenshots/home-dark.png differ diff --git a/docs/screenshots/home-light.png b/docs/screenshots/home-light.png index c4d17d6..af19976 100644 Binary files a/docs/screenshots/home-light.png and b/docs/screenshots/home-light.png differ diff --git a/docs/screenshots/mobile-home.png b/docs/screenshots/mobile-home.png index 43a7ce6..28f43b6 100644 Binary files a/docs/screenshots/mobile-home.png and b/docs/screenshots/mobile-home.png differ diff --git a/docs/screenshots/post-detail.png b/docs/screenshots/post-detail.png index 029075b..57ff50b 100644 Binary files a/docs/screenshots/post-detail.png and b/docs/screenshots/post-detail.png differ diff --git a/docs/screenshots/post-rich.png b/docs/screenshots/post-rich.png new file mode 100644 index 0000000..49318c3 Binary files /dev/null and b/docs/screenshots/post-rich.png differ diff --git a/frontend/src/components/BackToTop.tsx b/frontend/src/components/BackToTop.tsx index 1e38817..e03cd1a 100644 --- a/frontend/src/components/BackToTop.tsx +++ b/frontend/src/components/BackToTop.tsx @@ -10,10 +10,18 @@ const BIND_RETRY_MS = 50; /** * 定位当前真正滚动的容器。 - * 前台:.post-list-scroll / .page-wrap / .main-content--compose + * 前台:手机 Feed 整栏 / .post-list-scroll / .page-wrap / .main-content--compose * 后台:.admin-main */ function pickScrollEl(scope: ParentNode): HTMLElement | null { + // 手机首页:板块条 + 排序栏随列表滚动,滚动根是 main + if (scope instanceof HTMLElement && scope.classList.contains('main-content--feed-mobile-scroll')) { + return scope; + } + const mobileFeed = scope.querySelector?.('.main-content--feed-mobile-scroll') + ?? document.querySelector('.main-content--feed-mobile-scroll'); + if (mobileFeed instanceof HTMLElement) return mobileFeed; + const list = scope.querySelector('.post-list-scroll'); if (list) return list; const page = scope.querySelector('.page-wrap'); @@ -24,7 +32,8 @@ function pickScrollEl(scope: ParentNode): HTMLElement | null { } function findScrollScope(): ParentNode | null { - return document.querySelector('.main-content') + return document.querySelector('.main-content--feed-mobile-scroll') + ?? document.querySelector('.main-content') ?? document.querySelector('.admin-shell'); } diff --git a/frontend/src/components/PullToRefresh.tsx b/frontend/src/components/PullToRefresh.tsx index 9076233..62c04fc 100644 --- a/frontend/src/components/PullToRefresh.tsx +++ b/frontend/src/components/PullToRefresh.tsx @@ -13,6 +13,10 @@ const ARM_DELTA = 10; * SPA 使用内部滚动(body overflow:hidden),原生下拉刷新不可用,需挂到此容器。 */ function pickScrollEl(): HTMLElement | null { + // 手机 Feed 整栏滚动(板块 / 排序栏可滚走) + const mobileFeed = document.querySelector('.main-content--feed-mobile-scroll'); + if (mobileFeed) return mobileFeed; + const list = document.querySelector('.post-list-scroll'); if (list) return list; diff --git a/frontend/src/components/VirtualPostList.tsx b/frontend/src/components/VirtualPostList.tsx index 24721bc..3a0305e 100644 --- a/frontend/src/components/VirtualPostList.tsx +++ b/frontend/src/components/VirtualPostList.tsx @@ -1,4 +1,4 @@ -import { useRef, useEffect, useLayoutEffect } from 'react'; +import { useRef, useEffect, useLayoutEffect, useState, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Inbox, SearchX } from 'lucide-react'; @@ -8,6 +8,7 @@ import PostListSkeleton from './PostListSkeleton'; import FeedPagination from './FeedPagination'; import { InFlowSiteFooter } from './SiteFooter'; import { useAuth } from '../hooks/useAuth'; +import { useMediaQuery } from '../hooks/useTheme'; import { loginPath } from '../utils/authRedirect'; import type { PostItem } from '../api/types'; import type { FeedSort } from './FeedSortBar'; @@ -39,6 +40,11 @@ interface Props { noBoards?: boolean; } +/** 手机 Feed 整栏滚动时的容器 */ +function getMobileFeedScrollEl(): HTMLElement | null { + return document.querySelector('.main-content--feed-mobile-scroll'); +} + export default function VirtualPostList({ posts, sort = 'latest', @@ -61,6 +67,7 @@ export default function VirtualPostList({ }: Props) { const nav = useNavigate(); const { user } = useAuth(); + const isMobile = useMediaQuery('(max-width: 768px)'); const parentRef = useRef(null); const restoredRef = useRef(false); const onScrollTopChangeRef = useRef(onScrollTopChange); @@ -68,11 +75,50 @@ export default function VirtualPostList({ onScrollTopChangeRef.current = onScrollTopChange; onScrollRestoredRef.current = onScrollRestored; + const [scrollMargin, setScrollMargin] = useState(0); + + const getScrollElement = useCallback(() => { + if (isMobile) { + return getMobileFeedScrollEl() ?? parentRef.current; + } + return parentRef.current; + }, [isMobile]); + + /** 列表相对整栏滚动容器顶部的偏移,供虚拟列表对齐 */ + useLayoutEffect(() => { + if (!isMobile) { + setScrollMargin(0); + return; + } + const main = getMobileFeedScrollEl(); + const list = parentRef.current; + if (!main || !list) return; + + const update = () => { + // 相对 main 内容顶(含当前 scrollTop),避免 offsetParent 链不准 + const next = Math.max( + 0, + Math.round(list.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop), + ); + setScrollMargin(prev => (prev === next ? prev : next)); + }; + update(); + + const ro = new ResizeObserver(update); + ro.observe(main); + const boardBar = main.querySelector('.mobile-board-bar'); + const feedTop = main.querySelector('.feed-top'); + if (boardBar) ro.observe(boardBar); + if (feedTop) ro.observe(feedTop); + return () => ro.disconnect(); + }, [isMobile, posts.length, loading, keyword, boardId]); + const virtualizer = useVirtualizer({ count: posts.length, - getScrollElement: () => parentRef.current, + getScrollElement, estimateSize: () => 108, overscan: 8, + scrollMargin: isMobile ? scrollMargin : 0, measureElement: typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox') ? (el) => el.getBoundingClientRect().height @@ -88,35 +134,40 @@ export default function VirtualPostList({ useLayoutEffect(() => { if (resetScrollKey <= 0) return; - const el = parentRef.current; + const el = getScrollElement(); if (el) { el.scrollTop = 0; virtualizer.scrollToOffset(0); } restoredRef.current = true; onScrollTopChangeRef.current?.(0); - }, [resetScrollKey, virtualizer]); + }, [resetScrollKey, virtualizer, getScrollElement]); useLayoutEffect(() => { if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return; - virtualizer.scrollToOffset(restoreScrollTop); + const el = getScrollElement(); + if (el) { + el.scrollTop = restoreScrollTop; + } else { + virtualizer.scrollToOffset(restoreScrollTop); + } restoredRef.current = true; onScrollRestoredRef.current?.(); - }, [restoreScrollTop, posts.length, virtualizer]); + }, [restoreScrollTop, posts.length, virtualizer, getScrollElement]); useEffect(() => { restoredRef.current = false; }, [restoreScrollTop]); useEffect(() => { - const el = parentRef.current; + const el = getScrollElement(); if (!el) return; const onScroll = () => { onScrollTopChangeRef.current?.(el.scrollTop); }; - el.addEventListener('scroll', onScroll); + el.addEventListener('scroll', onScroll, { passive: true }); return () => el.removeEventListener('scroll', onScroll); - }, []); + }, [getScrollElement, isMobile]); const emptyActions = (
@@ -193,6 +244,8 @@ export default function VirtualPostList({ {virtualizer.getVirtualItems().map(vi => { const post = posts[vi.index]; if (!post) return null; + // scrollMargin 模式下 start 含偏移,需减回才能在列表内绝对定位 + const offsetY = isMobile ? vi.start - scrollMargin : vi.start; return (
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 5513ddf..10e721d 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -506,7 +506,12 @@ export default function MainLayout() { isCompose && 'content-workspace--compose', hideAside && !isCompose && 'content-workspace--aside-hidden', )}> -
+
{isMobile && !isCompose && isFeedHome && (
!url.pathname.endsWith('/login'), { timeout: 10000 }); - await page.waitForTimeout(600); + try { + await page.goto(`${base}/login`, { waitUntil: 'networkidle' }); + await page.fill('input[name="username"], #username, input[autocomplete="username"]', DEMO_USER.username); + await page.fill('input[name="password"], #password, input[type="password"]', DEMO_USER.password); + await page.click('button[type="submit"]'); + await page.waitForURL((url) => !url.pathname.endsWith('/login'), { timeout: 10000 }); + await page.waitForTimeout(600); + return true; + } catch (err) { + console.warn('login skipped:', err.message); + return false; + } } const browser = await chromium.launch(); @@ -48,8 +71,7 @@ try { await page.setViewportSize({ width: 1440, height: 900 }); await setTheme(page, 'light'); await page.goto(`${base}/`, { waitUntil: 'networkidle' }); - await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 10000 }); - await page.waitForTimeout(1000); + await waitFeed(page); await shot(page, 'home-light.png'); await page.close(); } @@ -60,54 +82,72 @@ try { await page.setViewportSize({ width: 1440, height: 900 }); await setTheme(page, 'dark'); await page.goto(`${base}/`, { waitUntil: 'networkidle' }); - await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 10000 }); - await page.waitForTimeout(1000); + await waitFeed(page); await shot(page, 'home-dark.png'); await page.close(); } - // 帖子详情(富文本 + 楼层回复) + // 帖子详情(介绍文 / 目录) { const page = await context.newPage(); await page.setViewportSize({ width: 1440, height: 900 }); await setTheme(page, 'light'); - await page.goto(`${base}/post/${DEMO_POST_ID}`, { waitUntil: 'networkidle' }); - await page.waitForSelector('.post-detail-page, .post-detail-title', { timeout: 10000 }); + await page.goto(postPath(DEMO_POST_ID), { waitUntil: 'networkidle' }); + await page.waitForSelector('.post-detail-page, .post-detail-title, h1', { timeout: 15000 }); await page.waitForTimeout(1200); await shot(page, 'post-detail.png'); await page.close(); } - // 发帖编辑器(需登录) + // 富文本帖(图片 + 代码高亮,展示 TipTap 渲染) { const page = await context.newPage(); await page.setViewportSize({ width: 1440, height: 900 }); await setTheme(page, 'light'); - await login(page); - await page.goto(`${base}/compose`, { waitUntil: 'networkidle' }); - await page.waitForSelector('.compose-page, .compose-canvas, .article-editor', { timeout: 10000 }); - await page.fill('.compose-title', '分享你的技术见解'); - const editor = page.locator('.article-editor .ProseMirror, .article-editor [contenteditable="true"]').first(); - if (await editor.count()) { - await editor.click(); - await editor.fill('支持 **粗体**、列表、代码块与图片上传的 TipTap 编辑器。'); + await page.goto(postPath(RICH_POST_ID), { waitUntil: 'networkidle' }); + await page.waitForSelector('.post-detail-page, .post-detail-title, h1', { timeout: 15000 }); + const img = page.locator('.article-img, .post-detail-page img, article img').first(); + if (await img.count()) { + await img.scrollIntoViewIfNeeded(); + await page.waitForTimeout(400); } - await page.waitForTimeout(800); - await shot(page, 'compose.png', { - clip: { x: 0, y: 56, width: 1440, height: 844 }, - }); + await shot(page, 'post-rich.png'); await page.close(); } - // 移动端 + // 发帖编辑器(需登录;演示站无账号时自动跳过) + { + const page = await context.newPage(); + await page.setViewportSize({ width: 1440, height: 900 }); + await setTheme(page, 'light'); + const ok = await login(page); + if (ok) { + await page.goto(`${base}/compose`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.compose-page, .compose-canvas, .article-editor', { timeout: 10000 }); + await page.fill('.compose-title', '分享你的技术见解'); + const editor = page.locator('.article-editor .ProseMirror, .article-editor [contenteditable="true"]').first(); + if (await editor.count()) { + await editor.click(); + await editor.fill('支持 **粗体**、列表、代码块与图片上传的 TipTap 编辑器。'); + } + await page.waitForTimeout(800); + await shot(page, 'compose.png', { + clip: { x: 0, y: 56, width: 1440, height: 844 }, + }); + } else { + console.warn('compose.png 未生成:请设置 J13_USER / J13_PASS 后重试'); + } + await page.close(); + } + + // 移动端首页 { const page = await context.newPage(); await page.setViewportSize({ width: 390, height: 844 }); await setTheme(page, 'light'); await page.goto(`${base}/`, { waitUntil: 'networkidle' }); - await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 10000 }); - await page.waitForTimeout(1000); - await shot(page, 'mobile-home.png', { fullPage: true }); + await waitFeed(page); + await shot(page, 'mobile-home.png'); await page.close(); } } finally {