项目截图、readme更新
@@ -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)。
|
||||||
|
|
||||||
## 行为准则
|
## 行为准则
|
||||||
|
|
||||||
|
|||||||
190
README.md
@@ -2,17 +2,20 @@
|
|||||||
|
|
||||||
# 姜十三论坛 Jiang13 Forum
|
# 姜十三论坛 Jiang13 Forum
|
||||||
|
|
||||||
**轻量 · 好看 · 单文件部署的现代化论坛**
|
**能聊 · 好看 · 好装**
|
||||||
|
|
||||||
面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite,开箱即用。
|
面向小圈子、团队与同好社群的轻量现代化论坛。
|
||||||
|
编译为单个 Go 二进制,前端 SPA(单页应用)内嵌,内置 SQLite,拷到服务器即可运行。
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
|
|
||||||
|
[](https://bbs.iioio.com/)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
[](frontend/package.json)
|
[](frontend/package.json)
|
||||||
[](#)
|
[](#)
|
||||||
|
|
||||||
|
[在线演示](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
|
||||||
@@ -153,7 +164,7 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
|||||||
./dist/jiang13
|
./dist/jiang13
|
||||||
```
|
```
|
||||||
|
|
||||||
也可先复制示例配置再改端口/数据目录:
|
也可先复制示例配置再改端口 / 数据目录:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp app.ini.example /opt/jiang13/app.ini
|
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` 注册账号
|
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 / Linux(systemd,需 root):**
|
**Ubuntu / Linux(systemd,需 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
|
|
||||||
```
|
|
||||||
|
|
||||||
**Windows(Windows Service,需管理员 PowerShell):**
|
**Windows(Windows 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/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|||||||
@@ -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_
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Issue 预填模板
|
# Issue 预填模板
|
||||||
|
|
||||||
以下两条可直接复制到 [Gitea Issues](https://git.iioio.com/freefire/jiang13-forum/issues/new) 创建,或使用仓库自带的 Issue 模板。
|
以下两条可直接复制到本仓库 Issues 创建,或使用仓库自带的 Issue 模板。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 509 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 167 KiB |
BIN
docs/screenshots/post-rich.png
Normal file
|
After Width: | Height: | Size: 269 KiB |
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
virtualizer.scrollToOffset(restoreScrollTop);
|
const el = getScrollElement();
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = restoreScrollTop;
|
||||||
|
} else {
|
||||||
|
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} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
await page.goto(`${base}/login`, { waitUntil: 'networkidle' });
|
try {
|
||||||
await page.fill('input[name="username"], #username, input[autocomplete="username"]', DEMO_USER.username);
|
await page.goto(`${base}/login`, { waitUntil: 'networkidle' });
|
||||||
await page.fill('input[name="password"], #password, input[type="password"]', DEMO_USER.password);
|
await page.fill('input[name="username"], #username, input[autocomplete="username"]', DEMO_USER.username);
|
||||||
await page.click('button[type="submit"]');
|
await page.fill('input[name="password"], #password, input[type="password"]', DEMO_USER.password);
|
||||||
await page.waitForURL((url) => !url.pathname.endsWith('/login'), { timeout: 10000 });
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForTimeout(600);
|
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();
|
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,54 +82,72 @@ 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.goto(`${base}/compose`, { waitUntil: 'networkidle' });
|
await page.waitForSelector('.post-detail-page, .post-detail-title, h1', { timeout: 15000 });
|
||||||
await page.waitForSelector('.compose-page, .compose-canvas, .article-editor', { timeout: 10000 });
|
const img = page.locator('.article-img, .post-detail-page img, article img').first();
|
||||||
await page.fill('.compose-title', '分享你的技术见解');
|
if (await img.count()) {
|
||||||
const editor = page.locator('.article-editor .ProseMirror, .article-editor [contenteditable="true"]').first();
|
await img.scrollIntoViewIfNeeded();
|
||||||
if (await editor.count()) {
|
await page.waitForTimeout(400);
|
||||||
await editor.click();
|
|
||||||
await editor.fill('支持 **粗体**、列表、代码块与图片上传的 TipTap 编辑器。');
|
|
||||||
}
|
}
|
||||||
await page.waitForTimeout(800);
|
await shot(page, 'post-rich.png');
|
||||||
await shot(page, 'compose.png', {
|
|
||||||
clip: { x: 0, y: 56, width: 1440, height: 844 },
|
|
||||||
});
|
|
||||||
await page.close();
|
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();
|
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 {
|
||||||
|
|||||||