项目截图、readme更新

This commit is contained in:
2026-08-05 17:11:05 +08:00
parent cc51c49272
commit e8b28a37e4
16 changed files with 316 additions and 154 deletions

View File

@@ -6,10 +6,10 @@
**要求:** Go 1.26+、Node.js 18+ **要求:** Go 1.26+、Node.js 18+
```powershell ```bat
# Windows一键启动后端 + 前端热更新 REM Windows一键启动后端 + 前端热更新(请用 build.bat
.\build.ps1 -Target dev build.bat -Target dev
# 浏览器访问 http://localhost:5173 REM 浏览器访问 http://localhost:5173
``` ```
```bash ```bash
@@ -27,21 +27,25 @@ make dev
发布单二进制前需先构建前端并 embed 发布单二进制前需先构建前端并 embed
```powershell ```bat
.\build.ps1 # Windows build.bat REM Windows
```
```bash
make build # Linux / macOS make build # Linux / macOS
``` ```
## 报告问题 ## 报告问题
[Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 中描述(也可使用仓库自带的 Issue 模板 本仓库 Issues 中描述(也可参考 [docs/issue-templates.md](docs/issue-templates.md)
1. 复现步骤 1. 复现步骤
2. 期望行为 vs 实际行为 2. 期望行为 vs 实际行为
3. 环境信息系统、浏览器、Go/Node 版本) 3. 环境信息系统、浏览器、Go/Node 版本)
4. 截图或日志(如有) 4. 截图或日志(如有)
已知问题与计划功能见 [ROADMAP.md](../ROADMAP.md)。 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/)
已知问题与计划功能见 [ROADMAP.md](ROADMAP.md)。
## 行为准则 ## 行为准则

188
README.md
View File

@@ -2,17 +2,20 @@
# 姜十三论坛 Jiang13 Forum # 姜十三论坛 Jiang13 Forum
**轻量 · 好看 · 单文件部署的现代化论坛** **能聊 · 好看 · 好装**
面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite开箱即用。 面向小圈子、团队与同好社群的轻量现代化论坛。
编译为单个 Go 二进制,前端 SPA单页应用内嵌内置 SQLite拷到服务器即可运行。
<br> <br>
[![在线演示](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) [![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) [![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) [![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)](#) [![SQLite](https://img.shields.io/badge/SQLite-内置-003B57?style=flat-square&logo=sqlite&logoColor=white)](#)
[在线演示](https://bbs.iioio.com/) ·
[快速开始](#-快速开始) · [快速开始](#-快速开始) ·
[界面预览](#-界面预览) · [界面预览](#-界面预览) ·
[功能亮点](#-功能亮点) · [功能亮点](#-功能亮点) ·
@@ -23,27 +26,40 @@
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%"> <img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
<sub>浅色主题 · 三栏布局 · Feed 排序 · 虚拟滚动帖列表</sub> <sub>浅色主题 · 三栏布局 · Feed 排序 · 板块导航 · 标签云</sub>
<br> <br>
> **开发状态:** 项目积极开发中。管理后台已统一为 React SPA`/admin`),欢迎参与共建。 > **演示站点:** [https://bbs.iioio.com/](https://bbs.iioio.com/)
> 查看 [路线图 ROADMAP.md](ROADMAP.md) · [Issues 反馈](https://git.iioio.com/freefire/jiang13-forum/issues) > 项目积极开发中。管理后台已统一为 React SPA`/admin`),欢迎提 Issue / PR 共建。
</div> </div>
--- ---
## 它是什么
姜十三论坛不做大而全的社区平台,只做好一件事:给「几人到几百人」的内部交流,一个干净、顺手、数据在自己手里的地方。
| 场景 | 说明 |
|------|------|
| 团队 / 工作室 | 需求讨论、进度同步、知识沉淀 |
| 兴趣小圈子 | 同好交流、作品分享、活动组织 |
| 项目配套社区 | 可与 Gitea 等通过 OIDC开放身份连接做 SSO单点登录 |
| 个人站长 | 单机可跑,无需云数据库与一堆微服务 |
---
## 界面预览 ## 界面预览
> 论坛用户第一眼看到的是界面。姜十三论坛采用清新绿色主题、高密度信息布局,兼顾桌面与移动端体验 截图来自演示站 [bbs.iioio.com](https://bbs.iioio.com/)
<table> <table>
<tr> <tr>
<td width="50%" align="center"> <td width="50%" align="center">
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%"> <img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
<br><b>浅色主题</b><br> <br><b>浅色主题</b><br>
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/评论</sub> <sub>左栏板块 · Feed 排序 · 右栏热门 / 标签 / 评论</sub>
</td> </td>
<td width="50%" align="center"> <td width="50%" align="center">
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%"> <img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
@@ -55,19 +71,19 @@
<td width="50%" align="center"> <td width="50%" align="center">
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%"> <img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
<br><b>帖子详情</b><br> <br><b>帖子详情</b><br>
<sub>TipTap 富文本渲染 · 标签展示 · 点赞收藏互动</sub> <sub>文章目录 · 标签 · 作者卡片 · 修订信息</sub>
</td> </td>
<td width="50%" align="center"> <td width="50%" align="center">
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280"> <img src="docs/screenshots/post-rich.png" alt="富文本与代码高亮" width="100%">
<br><b>移动端适配</b><br> <br><b>富文本渲染</b><br>
<sub>板块快捷筛选 · Feed 排序 · 触控友好列表</sub> <sub>TipTap 排版 · 图片 · 代码高亮 · 目录导航</sub>
</td> </td>
</tr> </tr>
</table> </table>
<p align="center"> <p align="center">
<img src="docs/screenshots/compose.png" alt="发帖页" width="360"> <img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
<br><b>发帖</b> — 板块胶囊选择 · TipTap 工具栏 · 本地上传图片 <br><b>移动端</b> — 板块快捷筛选 · Feed 排序 · 触控友好列表
</p> </p>
--- ---
@@ -78,32 +94,28 @@
| 特性 | 说明 | | 特性 | 说明 |
|------|------| |------|------|
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/最新评论 | | **三栏布局** | 左栏板块导航 + 中间虚拟滚动帖列表 + 右栏热门 / 标签 / 最新评论 |
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 | | **虚拟滚动** | `@tanstack/react-virtual` 驱动长列表,浏览依然流畅 |
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 | | **帖子排序** | 最新发帖 / 最新回复 / 热门讨论 |
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme`本地记忆 | | **主题切换** | 浅色 / 暗色,跟随系统偏好并本地记忆 |
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 | | **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 |
### 社区功能 ### 社区功能
- 用户注册 / 登录bcrypt + JWT Cookie - 用户注册 / 登录bcrypt + JWT Cookie**首个注册用户自动成为管理员**
- 普通用户 / 管理员两级权限,**首个注册用户自动成为管理员** - 板块、发帖、TipTap 富文本、正文图片上传、标签、置顶 / 精华
- 板块管理、发帖、TipTap 富文本编辑、正文图片本地上传、标签、置顶 - 帖子修订历史与 diff差异对比可配置普通用户编辑时限
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看 - 楼层式评论:回复指定楼层、@ 高亮、引用回复;支持回复可见等内容门控
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口 - 点赞、收藏、热门帖、最新评论、站内私信、公开用户主页
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复 - 管理后台:仪表盘、删帖 / 删评、禁言、举报、敏感词、限流、SQLite 一键备份
- 点赞、收藏、热门帖、最新评论 - 可选邮件验证码、OIDC Provider、Gitea 仓库同步开源码桶、S3 兼容对象存储
- 管理员后台删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
### 部署体验 ### 部署体验
- **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源 - **单二进制** — `go:embed` 打包前端,无需再单独部署静态资源
- **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理 - **零依赖数据库** — SQLite 内建,数据目录由 `app.ini` 统一管理
- **配置文件** — 工作目录下 `app.ini`(类似 Gitea启动可省略一长串参数
- **跨平台** — Windows / Linux / macOS 一键编译 - **跨平台** — Windows / Linux / macOS 一键编译
- **系统服务** — 内置注册:Linux systemd / Windows Service,一条命令安装与启停 - **系统服务** — 内置 Linux systemd / Windows Service 注册
--- ---
@@ -113,11 +125,12 @@
**Windows推荐** **Windows推荐**
```powershell ```bat
.\build.ps1 build.bat
# 或双击 build.bat
``` ```
> 请通过 `build.bat` 调用(内部已处理 ExecutionPolicy。不要直接 `.\build.ps1`,也不要在 Windows 上使用系统自带的 Embarcadero `make`。
**Linux / macOS** **Linux / macOS**
```bash ```bash
@@ -131,19 +144,17 @@ cd frontend && npm install && npm run build
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13 cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
``` ```
> Windows 自带的 `make` 通常是 Embarcadero MAKE不能识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make 后再用 `make build`。
跨平台编译: 跨平台编译:
```powershell ```bat
.\build.ps1 -Target build-windows build.bat -Target build-windows
.\build.ps1 -Target build-linux build.bat -Target build-linux
.\build.ps1 -Target build-all build.bat -Target build-all
``` ```
### 2. 启动 ### 2. 启动
把二进制放到目标目录后直接运行即可(首次会在同目录生成 `app.ini` 把二进制放到目标目录后直接运行(首次会在同目录生成 `app.ini`
```bash ```bash
# Windows # Windows
@@ -165,11 +176,11 @@ cp app.ini.example /opt/jiang13/app.ini
1. 浏览器打开 `http://localhost:3000/register` 注册账号 1. 浏览器打开 `http://localhost:3000/register` 注册账号
2. **第一个注册的用户自动成为管理员** 2. **第一个注册的用户自动成为管理员**
3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台 3. 登录后访问 `http://localhost:3000/admin` 进入后台
### 配置文件(`app.ini` ### 配置文件(`app.ini`
默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录`go run` 开发时回退为当前目录)。 默认读取**工作目录**下的 `app.ini`(工作目录默认可执行文件所在目录)。
```ini ```ini
[server] [server]
@@ -182,7 +193,7 @@ DATA = data
JWT_SECRET = JWT_SECRET =
``` ```
完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。 完整示例见 [`app.ini.example`](app.ini.example)。OIDC、邮件、Gitea 同步、对象存储等请在管理后台「系统设置」配置(保存即生效)。
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。 **优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
@@ -192,39 +203,25 @@ JWT_SECRET =
|------|--------|------| |------|--------|------|
| `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) | | `--work-path` | 可执行文件目录 | 工作目录(`app.ini` 与相对 `DATA` 的基准) |
| `--config` | `{work-path}/app.ini` | 配置文件路径 | | `--config` | `{work-path}/app.ini` | 配置文件路径 |
| `--port` | (读配置 / `3000` | HTTP 监听端口,覆盖配置文件 | | `--port` | (读配置 / `3000` | HTTP 监听端口 |
| `--data` | (读配置 / `data` | 数据目录,覆盖配置文件 | | `--data` | (读配置 / `data` | 数据目录 |
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` | | `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret` |
| `--service` | (空) | 系统服务控制:`install` / `uninstall` / `start` / `stop` / `restart` / `status` | | `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
### 4. 注册为系统服务(可选) ### 4. 注册为系统服务(可选)
将二进制与 `app.ini` 放到同一目录后注册即可。服务会绑定 `--work-path``--config`之后改端口或数据目录只需编辑 `app.ini` 并重启服务,**不必重新安装** 将二进制与 `app.ini` 放到同一目录后注册即可。之后改端口或数据目录只需编辑 `app.ini` 并重启服务,不必重新安装。
**Ubuntu / Linuxsystemd需 root** **Ubuntu / Linuxsystemd需 root**
```bash ```bash
sudo mkdir -p /opt/jiang13 sudo mkdir -p /opt/jiang13
sudo cp jiang13 /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 install
sudo /opt/jiang13/jiang13 --service start sudo /opt/jiang13/jiang13 --service start
sudo systemctl enable jiang13 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
```
**WindowsWindows Service需管理员 PowerShell** **WindowsWindows Service需管理员 PowerShell**
```powershell ```powershell
@@ -234,18 +231,7 @@ C:\jiang13\jiang13.exe --service install
C:\jiang13\jiang13.exe --service start C:\jiang13\jiang13.exe --service start
``` ```
常用管理: > 改 `app.ini` 后执行 `--service restart`。运行日志写入数据目录下的 `jiang13.log`。
```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` 查看。
--- ---
@@ -256,29 +242,50 @@ Get-Service jiang13
| **后端** | Go 1.26 · Gin · GORM · SQLite | | **后端** | Go 1.26 · Gin · GORM · SQLite |
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual | | **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
| **构建** | Vite → `go:embed` 内嵌 SPA单二进制发布 | | **构建** | Vite → `go:embed` 内嵌 SPA单二进制发布 |
| **认证** | bcrypt · JWT Cookie | | **认证** | bcrypt · JWT Cookie · 可选 OIDC Provider |
--- ---
## 前端开发 ## 前端开发
日常改前端**不需要**重新 `npm run build``go build`Vite 开发服务器支持秒级热更新HMR热模块替换 日常改前端不需要重新完整构建Vite 支持秒级热更新HMR热模块替换
```powershell ```bat
.\build.ps1 -Target dev # Windows build.bat -Target dev
make dev # Linux / macOS ```
```bash
make dev
``` ```
浏览器访问 `http://localhost:5173`API 自动代理到 `http://localhost:3000` 浏览器访问 `http://localhost:5173`API 自动代理到 `http://localhost:3000`
**何时需要完整构建:** **何时需要完整构建:**
- 修改 Go 代码、前端或 embed 静态资源`go build` / `make build`(或 `build.bat` - 修改 Go 代码或要发布单二进制`build.bat` / `make build`
- 发布单二进制前 → `npm run build` + `make build` - 更新 README 界面截图 → 见下方「更新截图」
- 更新 README 界面截图 → 启动服务后执行 `node scripts/capture-screenshots.mjs`
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。 > 直接访问 `: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 与命令行配置 ├── config/ # app.ini 与命令行配置
├── app.ini.example # 配置文件示例 ├── app.ini.example # 配置文件示例
├── model/ # GORM 模型与数据库迁移 ├── model/ # GORM 模型与数据库迁移
├── service/ # 业务逻辑(认证、帖子、评论…) ├── service/ # 业务逻辑
├── handler/ # HTTP 处理器(前台 + 后台) ├── handler/ # HTTP 处理器(前台 + 后台)
├── middleware/ # JWT 鉴权 ├── middleware/ # JWT 鉴权
├── router/ # 路由注册 ├── router/ # 路由注册
├── embed_static/ # go:embed 内嵌的 SPA 与模板 ├── embed_static/ # go:embed 内嵌的 SPA
├── frontend/ # React 源码Vite 构建) ├── frontend/ # React 源码Vite 构建)
├── docs/screenshots/ # README 界面截图 ├── docs/screenshots/ # README 界面截图
├── docs/issue-templates.md # Issue 预填模板
├── ROADMAP.md # 路线图与已知问题 ├── ROADMAP.md # 路线图与已知问题
└── scripts/ # 开发辅助脚本 └── scripts/ # 开发辅助脚本(含截图)
``` ```
--- ---
@@ -320,23 +326,23 @@ data/
## 开发状态 ## 开发状态
项目**积极开发中**作为论坛产品功能尚未完善,欢迎参与共建。 项目**积极开发中**,欢迎参与共建。完整列表见 **[ROADMAP.md](ROADMAP.md)**
| 类型 | 示例 | | 类型 | 示例 |
|------|------| |------|------|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 | | ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
| ✅ 发帖体验 | TipTap 富文本、正文图片上传、修订历史、可配置编辑时限 | | ✅ 发帖体验 | TipTap 富文本、图片上传、修订历史、回复可见等门控 |
| ✅ 管理后台 | React SPA仪表盘、帖子置顶、用户禁言、论坛参数与敏感词配置 | | ✅ 管理后台 | 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)。 欢迎提交 Issue 和 Pull Request详见 [CONTRIBUTING.md](CONTRIBUTING.md)。
在线体验与反馈也可直接在演示站进行:[https://bbs.iioio.com/](https://bbs.iioio.com/)
--- ---
## 许可证 ## 许可证

View File

@@ -1,7 +1,7 @@
# 路线图 ROADMAP # 路线图 ROADMAP
> 姜十三论坛仍在积极开发中,功能尚未完善。 > 姜十三论坛仍在积极开发中,功能尚未完善。
> 欢迎通过 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 反馈问题或认领任务。 > 演示站:[https://bbs.iioio.com/](https://bbs.iioio.com/) · 欢迎通过本仓库 Issues 反馈问题或认领任务。
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷 **图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
@@ -20,7 +20,7 @@
## 🐛 已知缺陷Bug ## 🐛 已知缺陷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) 2. Fork → 分支 → PR详见 [CONTRIBUTING.md](CONTRIBUTING.md)
3. 有新想法先开 Issue 讨论,避免重复劳动 3. 有新想法先开 Issue 讨论,避免重复劳动
--- ---
_最后更新2026-06-16_ _最后更新2026-08-05_

View File

@@ -1,6 +1,6 @@
# Issue 预填模板 # Issue 预填模板
以下两条可直接复制到 [Gitea Issues](https://git.iioio.com/freefire/jiang13-forum/issues/new) 创建,或使用仓库自带的 Issue 模板。 以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。
--- ---

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 509 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

View File

@@ -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 * 后台:.admin-main
*/ */
function pickScrollEl(scope: ParentNode): HTMLElement | null { 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<HTMLElement>('.post-list-scroll'); const list = scope.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list; if (list) return list;
const page = scope.querySelector<HTMLElement>('.page-wrap'); const page = scope.querySelector<HTMLElement>('.page-wrap');
@@ -24,7 +32,8 @@ function pickScrollEl(scope: ParentNode): HTMLElement | null {
} }
function findScrollScope(): ParentNode | 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'); ?? document.querySelector('.admin-shell');
} }

View File

@@ -13,6 +13,10 @@ const ARM_DELTA = 10;
* SPA 使用内部滚动body overflow:hidden原生下拉刷新不可用需挂到此容器。 * SPA 使用内部滚动body overflow:hidden原生下拉刷新不可用需挂到此容器。
*/ */
function pickScrollEl(): HTMLElement | null { function pickScrollEl(): HTMLElement | null {
// 手机 Feed 整栏滚动(板块 / 排序栏可滚走)
const mobileFeed = document.querySelector<HTMLElement>('.main-content--feed-mobile-scroll');
if (mobileFeed) return mobileFeed;
const list = document.querySelector<HTMLElement>('.post-list-scroll'); const list = document.querySelector<HTMLElement>('.post-list-scroll');
if (list) return list; if (list) return list;

View File

@@ -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 { useNavigate } from 'react-router-dom';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Inbox, SearchX } from 'lucide-react'; import { Inbox, SearchX } from 'lucide-react';
@@ -8,6 +8,7 @@ import PostListSkeleton from './PostListSkeleton';
import FeedPagination from './FeedPagination'; import FeedPagination from './FeedPagination';
import { InFlowSiteFooter } from './SiteFooter'; import { InFlowSiteFooter } from './SiteFooter';
import { useAuth } from '../hooks/useAuth'; import { useAuth } from '../hooks/useAuth';
import { useMediaQuery } from '../hooks/useTheme';
import { loginPath } from '../utils/authRedirect'; import { loginPath } from '../utils/authRedirect';
import type { PostItem } from '../api/types'; import type { PostItem } from '../api/types';
import type { FeedSort } from './FeedSortBar'; import type { FeedSort } from './FeedSortBar';
@@ -39,6 +40,11 @@ interface Props {
noBoards?: boolean; noBoards?: boolean;
} }
/** 手机 Feed 整栏滚动时的容器 */
function getMobileFeedScrollEl(): HTMLElement | null {
return document.querySelector('.main-content--feed-mobile-scroll');
}
export default function VirtualPostList({ export default function VirtualPostList({
posts, posts,
sort = 'latest', sort = 'latest',
@@ -61,6 +67,7 @@ export default function VirtualPostList({
}: Props) { }: Props) {
const nav = useNavigate(); const nav = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
const isMobile = useMediaQuery('(max-width: 768px)');
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false); const restoredRef = useRef(false);
const onScrollTopChangeRef = useRef(onScrollTopChange); const onScrollTopChangeRef = useRef(onScrollTopChange);
@@ -68,11 +75,50 @@ export default function VirtualPostList({
onScrollTopChangeRef.current = onScrollTopChange; onScrollTopChangeRef.current = onScrollTopChange;
onScrollRestoredRef.current = onScrollRestored; 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({ const virtualizer = useVirtualizer({
count: posts.length, count: posts.length,
getScrollElement: () => parentRef.current, getScrollElement,
estimateSize: () => 108, estimateSize: () => 108,
overscan: 8, overscan: 8,
scrollMargin: isMobile ? scrollMargin : 0,
measureElement: measureElement:
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox') typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
? (el) => el.getBoundingClientRect().height ? (el) => el.getBoundingClientRect().height
@@ -88,35 +134,40 @@ export default function VirtualPostList({
useLayoutEffect(() => { useLayoutEffect(() => {
if (resetScrollKey <= 0) return; if (resetScrollKey <= 0) return;
const el = parentRef.current; const el = getScrollElement();
if (el) { if (el) {
el.scrollTop = 0; el.scrollTop = 0;
virtualizer.scrollToOffset(0); virtualizer.scrollToOffset(0);
} }
restoredRef.current = true; restoredRef.current = true;
onScrollTopChangeRef.current?.(0); onScrollTopChangeRef.current?.(0);
}, [resetScrollKey, virtualizer]); }, [resetScrollKey, virtualizer, getScrollElement]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return; if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
const el = getScrollElement();
if (el) {
el.scrollTop = restoreScrollTop;
} else {
virtualizer.scrollToOffset(restoreScrollTop); virtualizer.scrollToOffset(restoreScrollTop);
}
restoredRef.current = true; restoredRef.current = true;
onScrollRestoredRef.current?.(); onScrollRestoredRef.current?.();
}, [restoreScrollTop, posts.length, virtualizer]); }, [restoreScrollTop, posts.length, virtualizer, getScrollElement]);
useEffect(() => { useEffect(() => {
restoredRef.current = false; restoredRef.current = false;
}, [restoreScrollTop]); }, [restoreScrollTop]);
useEffect(() => { useEffect(() => {
const el = parentRef.current; const el = getScrollElement();
if (!el) return; if (!el) return;
const onScroll = () => { const onScroll = () => {
onScrollTopChangeRef.current?.(el.scrollTop); onScrollTopChangeRef.current?.(el.scrollTop);
}; };
el.addEventListener('scroll', onScroll); el.addEventListener('scroll', onScroll, { passive: true });
return () => el.removeEventListener('scroll', onScroll); return () => el.removeEventListener('scroll', onScroll);
}, []); }, [getScrollElement, isMobile]);
const emptyActions = ( const emptyActions = (
<div className="empty-feed-actions"> <div className="empty-feed-actions">
@@ -193,6 +244,8 @@ export default function VirtualPostList({
{virtualizer.getVirtualItems().map(vi => { {virtualizer.getVirtualItems().map(vi => {
const post = posts[vi.index]; const post = posts[vi.index];
if (!post) return null; if (!post) return null;
// scrollMargin 模式下 start 含偏移,需减回才能在列表内绝对定位
const offsetY = isMobile ? vi.start - scrollMargin : vi.start;
return ( return (
<div <div
key={vi.key} key={vi.key}
@@ -203,7 +256,7 @@ export default function VirtualPostList({
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
transform: `translateY(${vi.start}px)`, transform: `translateY(${offsetY}px)`,
}} }}
> >
<PostListItem post={post} sort={sort} onSelect={onSelect} /> <PostListItem post={post} sort={sort} onSelect={onSelect} />

View File

@@ -506,7 +506,12 @@ export default function MainLayout() {
isCompose && 'content-workspace--compose', isCompose && 'content-workspace--compose',
hideAside && !isCompose && 'content-workspace--aside-hidden', hideAside && !isCompose && 'content-workspace--aside-hidden',
)}> )}>
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}> <main className={cn(
'main-content',
isCompose && 'main-content--compose',
// 手机 Feed整栏滚动板块条 / 排序栏可滚出视口,多露出帖子列表
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
)}>
{isMobile && !isCompose && isFeedHome && ( {isMobile && !isCompose && isFeedHome && (
<div <div
ref={boardBarRef} ref={boardBarRef}

View File

@@ -2131,6 +2131,47 @@ img.site-brand-logo-img {
border-radius: 0; border-radius: 0;
box-shadow: none; box-shadow: none;
} }
/*
* 手机 Feed主栏整页滚动板块条 / 最新发帖·回复排序栏随列表滚走,
* 避免它们占住视口上半,给帖子列表更多空间。
*/
.main-content--feed-mobile-scroll {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior-y: contain;
}
.main-content--feed-mobile-scroll .page-wrap--feed {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
height: auto;
}
.main-content--feed-mobile-scroll .feed-panel {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
height: auto;
}
.main-content--feed-mobile-scroll .feed-top {
flex-shrink: 0;
}
.main-content--feed-mobile-scroll .post-list-scroll {
overflow: visible;
flex: 0 0 auto;
min-height: auto;
height: auto;
}
.main-content--feed-mobile-scroll .mobile-board-bar {
flex-shrink: 0;
position: static;
}
} }
.post-list-bar { .post-list-bar {

View File

@@ -5,12 +5,24 @@ import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const outDir = path.join(__dirname, '..', 'docs', 'screenshots'); const outDir = path.join(__dirname, '..', 'docs', 'screenshots');
const base = process.env.J13_URL || 'http://localhost:8080'; /** 默认抓取在线演示站;本地可用 J13_URL=http://localhost:3000 */
const base = (process.env.J13_URL || 'https://bbs.iioio.com').replace(/\/$/, '');
/** 用于发帖页截图的演示账号 */ /** 用于发帖页截图的演示账号(本地开发时填写) */
const DEMO_USER = { username: 'admin', password: 'admin123' }; const DEMO_USER = {
/** 富文本示例帖,用于详情页截图 */ username: process.env.J13_USER || 'admin',
const DEMO_POST_ID = Number(process.env.J13_POST_ID || 36); password: process.env.J13_PASS || 'admin123',
};
/** 帖子详情 / 富文本展示用帖 ID */
const DEMO_POST_ID = Number(process.env.J13_POST_ID || 1);
const RICH_POST_ID = Number(process.env.J13_RICH_POST_ID || 8);
/** 演示站开启永久链接时带 .html */
const permalinkExt = process.env.J13_PERMALINK_EXT || 'html';
function postPath(id) {
const ext = permalinkExt ? `.${permalinkExt.replace(/^\./, '')}` : '';
return `${base}/post/${id}${ext}`;
}
async function shot(page, name, opts = {}) { async function shot(page, name, opts = {}) {
const file = path.join(outDir, name); const file = path.join(outDir, name);
@@ -26,14 +38,25 @@ async function setTheme(page, theme) {
}, theme); }, theme);
} }
/** 通过登录页写入 Cookie供发帖页等需登录场景使用 */ async function waitFeed(page) {
await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 15000 });
await page.waitForTimeout(1000);
}
/** 通过登录页写入 Cookie失败时返回 false演示站无账号时跳过发帖页 */
async function login(page) { async function login(page) {
try {
await page.goto(`${base}/login`, { waitUntil: 'networkidle' }); 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="username"], #username, input[autocomplete="username"]', DEMO_USER.username);
await page.fill('input[name="password"], #password, input[type="password"]', DEMO_USER.password); await page.fill('input[name="password"], #password, input[type="password"]', DEMO_USER.password);
await page.click('button[type="submit"]'); await page.click('button[type="submit"]');
await page.waitForURL((url) => !url.pathname.endsWith('/login'), { timeout: 10000 }); await page.waitForURL((url) => !url.pathname.endsWith('/login'), { timeout: 10000 });
await page.waitForTimeout(600); await page.waitForTimeout(600);
return true;
} catch (err) {
console.warn('login skipped:', err.message);
return false;
}
} }
const browser = await chromium.launch(); const browser = await chromium.launch();
@@ -48,8 +71,7 @@ try {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await setTheme(page, 'light'); await setTheme(page, 'light');
await page.goto(`${base}/`, { waitUntil: 'networkidle' }); await page.goto(`${base}/`, { waitUntil: 'networkidle' });
await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 10000 }); await waitFeed(page);
await page.waitForTimeout(1000);
await shot(page, 'home-light.png'); await shot(page, 'home-light.png');
await page.close(); await page.close();
} }
@@ -60,30 +82,46 @@ try {
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await setTheme(page, 'dark'); await setTheme(page, 'dark');
await page.goto(`${base}/`, { waitUntil: 'networkidle' }); await page.goto(`${base}/`, { waitUntil: 'networkidle' });
await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 10000 }); await waitFeed(page);
await page.waitForTimeout(1000);
await shot(page, 'home-dark.png'); await shot(page, 'home-dark.png');
await page.close(); await page.close();
} }
// 帖子详情(富文本 + 楼层回复 // 帖子详情(介绍文 / 目录
{ {
const page = await context.newPage(); const page = await context.newPage();
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await setTheme(page, 'light'); await setTheme(page, 'light');
await page.goto(`${base}/post/${DEMO_POST_ID}`, { waitUntil: 'networkidle' }); await page.goto(postPath(DEMO_POST_ID), { waitUntil: 'networkidle' });
await page.waitForSelector('.post-detail-page, .post-detail-title', { timeout: 10000 }); await page.waitForSelector('.post-detail-page, .post-detail-title, h1', { timeout: 15000 });
await page.waitForTimeout(1200); await page.waitForTimeout(1200);
await shot(page, 'post-detail.png'); await shot(page, 'post-detail.png');
await page.close(); await page.close();
} }
// 发帖编辑器(需登录 // 富文本帖(图片 + 代码高亮,展示 TipTap 渲染
{ {
const page = await context.newPage(); const page = await context.newPage();
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await setTheme(page, 'light'); await setTheme(page, 'light');
await login(page); 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 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.goto(`${base}/compose`, { waitUntil: 'networkidle' });
await page.waitForSelector('.compose-page, .compose-canvas, .article-editor', { timeout: 10000 }); await page.waitForSelector('.compose-page, .compose-canvas, .article-editor', { timeout: 10000 });
await page.fill('.compose-title', '分享你的技术见解'); await page.fill('.compose-title', '分享你的技术见解');
@@ -96,18 +134,20 @@ try {
await shot(page, 'compose.png', { await shot(page, 'compose.png', {
clip: { x: 0, y: 56, width: 1440, height: 844 }, clip: { x: 0, y: 56, width: 1440, height: 844 },
}); });
} else {
console.warn('compose.png 未生成:请设置 J13_USER / J13_PASS 后重试');
}
await page.close(); await page.close();
} }
// 移动端 // 移动端首页
{ {
const page = await context.newPage(); const page = await context.newPage();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await setTheme(page, 'light'); await setTheme(page, 'light');
await page.goto(`${base}/`, { waitUntil: 'networkidle' }); await page.goto(`${base}/`, { waitUntil: 'networkidle' });
await page.waitForSelector('.feed-sort-bar, .virtual-post-list, .post-list', { timeout: 10000 }); await waitFeed(page);
await page.waitForTimeout(1000); await shot(page, 'mobile-home.png');
await shot(page, 'mobile-home.png', { fullPage: true });
await page.close(); await page.close();
} }
} finally { } finally {