Compare commits
14 Commits
99ae0ccdaa
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 962bb15298 | |||
| 55c256654b | |||
| e038d4f9e9 | |||
| f527c91c27 | |||
|
|
773639fd7c | ||
|
|
9767b54192 | ||
|
|
0eae3f8c47 | ||
|
|
57172eb053 | ||
|
|
e4d1dd139e | ||
|
|
b451703642 | ||
|
|
1d273066b0 | ||
|
|
d0555de28e | ||
|
|
9230f7272d | ||
|
|
b27289bbea |
36
.cursor/rules/build-scripts.mdc
Normal file
36
.cursor/rules/build-scripts.mdc
Normal file
@@ -0,0 +1,36 @@
|
||||
---
|
||||
description: 编译与构建脚本约定(Windows build.bat/ps1、Makefile、跨平台同步)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# 编译与构建脚本
|
||||
|
||||
Go 单二进制 + `go:embed` 前端;产物在 `dist/`。
|
||||
|
||||
## 运行编译(优先用封装命令,不要猜命令)
|
||||
|
||||
| 平台 | 命令 |
|
||||
|------|------|
|
||||
| Windows | `build.bat` 或 `build.bat -Target <target>` |
|
||||
| Linux / macOS | `make` 或 `make <target>` |
|
||||
|
||||
**不要**在 Windows 上直接运行 `make`(系统自带多为 Embarcadero MAKE,不兼容本项目 Makefile)。
|
||||
|
||||
Windows 上**不要**让用户直接 `.\build.ps1`(默认 ExecutionPolicy 会拦截);应通过 `build.bat`(内部 `-ExecutionPolicy Bypass`)调用。
|
||||
|
||||
常用 target:`build`(默认)、`dev`、`run`、`frontend`、`clean`、`build-all`、`build-windows`、`build-linux`、`tidy`、`help`。
|
||||
|
||||
## 修改构建脚本时的约定
|
||||
|
||||
1. **双轨同步**:`build.ps1` 与 `Makefile` 目标与行为保持一致;改其一须同步另一份。
|
||||
2. **`build.bat` 仅用 ASCII 注释**:`.bat` 会被 cmd 按 GBK 解析,UTF-8 中文注释会导致整行乱码、`powershell` 无法执行。
|
||||
3. **`build.ps1` 可用 UTF-8**:由 PowerShell 执行,中文注释无妨。
|
||||
4. **构建顺序**:先 `frontend` 内 `npm run build`,再 `go build -trimpath -ldflags "-s -w -X main.version=..." -o dist/jiang13 ./cmd/jiang13`。
|
||||
5. **入口包**:`./cmd/jiang13`;Windows 产物带 `.exe`。
|
||||
|
||||
## 新增 target 检查清单
|
||||
|
||||
- [ ] `build.ps1` 的 `ValidateSet` 与 `switch` 分支
|
||||
- [ ] `Makefile` 对应 `.PHONY` 与 recipe
|
||||
- [ ] `build.bat -Target help` 说明(help 输出在 ps1 内)
|
||||
- [ ] README「快速开始」如需对外暴露再更新
|
||||
17
.gitattributes
vendored
Normal file
17
.gitattributes
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# 跨平台行尾:仓库内统一 LF,避免 Windows autocrlf 造成「假 modified」
|
||||
* text=auto eol=lf
|
||||
|
||||
# Windows 批处理在 cmd 下需要 CRLF
|
||||
*.bat text eol=crlf
|
||||
*.cmd text eol=crlf
|
||||
|
||||
# 二进制
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.webp binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.exe binary
|
||||
56
.gitea/ISSUE_TEMPLATE/bug_report.yaml
Normal file
56
.gitea/ISSUE_TEMPLATE/bug_report.yaml
Normal file
@@ -0,0 +1,56 @@
|
||||
name: 缺陷报告
|
||||
about: 报告一个可复现的问题
|
||||
title: "[Bug] "
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢反馈!请尽量提供复现步骤,方便我们定位问题。
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 简要说明发生了什么
|
||||
placeholder: 评论回复换行后,页面上显示为一行…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
placeholder: |
|
||||
1. 打开帖子详情页
|
||||
2. 在评论框输入多行文字
|
||||
3. 点击发送
|
||||
4. 观察到换行丢失
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: 实际行为
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: 版本 / 提交
|
||||
placeholder: main @ abc1234 或 v1.0.0
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境
|
||||
placeholder: Windows 11 / Chrome 125 / Go 1.26
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 截图或日志
|
||||
description: 可粘贴图片或相关日志
|
||||
44
.gitea/ISSUE_TEMPLATE/feature_request.yaml
Normal file
44
.gitea/ISSUE_TEMPLATE/feature_request.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
name: 功能建议
|
||||
about: 提出新功能或改进现有体验
|
||||
title: "[Feature] "
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
欢迎提出想法!请先搜索已有 Issue,避免重复。
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: 要解决什么问题?
|
||||
description: 从用户场景出发描述痛点
|
||||
placeholder: 管理员在 React 前台无法置顶帖子,必须切到旧版后台…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: 期望的方案
|
||||
description: 你心目中的实现方式(可简略)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 备选方案
|
||||
description: 如有其他思路可一并说明
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: 涉及模块
|
||||
options:
|
||||
- 前台 UI/UX
|
||||
- 管理后台
|
||||
- 评论系统
|
||||
- 帖子 / 板块
|
||||
- 认证 / 权限
|
||||
- 部署 / 构建
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,8 +2,10 @@
|
||||
/data/
|
||||
|
||||
# 前端依赖与构建缓存
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/embed_static/static/spa/
|
||||
|
||||
# Go 编译产物
|
||||
/dist/
|
||||
|
||||
48
CONTRIBUTING.md
Normal file
48
CONTRIBUTING.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 参与贡献
|
||||
|
||||
感谢你对姜十三论坛的关注!这是一个开源项目,欢迎提交 Issue 和 Pull Request。
|
||||
|
||||
## 开发环境
|
||||
|
||||
**要求:** Go 1.26+、Node.js 18+
|
||||
|
||||
```powershell
|
||||
# Windows:一键启动后端 + 前端热更新
|
||||
.\build.ps1 -Target dev
|
||||
# 浏览器访问 http://localhost:5173
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux / macOS
|
||||
make dev
|
||||
```
|
||||
|
||||
## 提交规范
|
||||
|
||||
- 一个 PR 只做一件事,保持 diff 小而清晰
|
||||
- 前端改动请确认浅色 / 暗色主题下都正常
|
||||
- 涉及 UI 变更时,建议在 PR 中附上截图
|
||||
|
||||
## 完整构建
|
||||
|
||||
发布单二进制前需先构建前端并 embed:
|
||||
|
||||
```powershell
|
||||
.\build.ps1 # Windows
|
||||
make build # Linux / macOS
|
||||
```
|
||||
|
||||
## 报告问题
|
||||
|
||||
在 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 中描述(也可使用仓库自带的 Issue 模板):
|
||||
|
||||
1. 复现步骤
|
||||
2. 期望行为 vs 实际行为
|
||||
3. 环境信息(系统、浏览器、Go/Node 版本)
|
||||
4. 截图或日志(如有)
|
||||
|
||||
已知问题与计划功能见 [ROADMAP.md](../ROADMAP.md)。
|
||||
|
||||
## 行为准则
|
||||
|
||||
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。
|
||||
316
README.md
316
README.md
@@ -1,68 +1,135 @@
|
||||
<div align="center">
|
||||
|
||||
# 姜十三论坛 Jiang13 Forum
|
||||
|
||||
轻量自用论坛系统,面向小圈子内部交流。与 Gitea 相同,编译为**单个独立 Go 二进制**,前端静态资源 embed 内嵌,内置 SQLite,开箱即用。
|
||||
**轻量 · 好看 · 单文件部署的现代化论坛**
|
||||
|
||||
## 项目目录结构
|
||||
面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite,开箱即用。
|
||||
|
||||
```
|
||||
js13-forum/
|
||||
├── cmd/jiang13/main.go # 程序入口
|
||||
├── config/config.go # 命令行参数与配置
|
||||
├── model/
|
||||
│ ├── models.go # GORM 模型定义
|
||||
│ └── db.go # 数据库初始化与自动迁移
|
||||
├── service/ # 业务逻辑层
|
||||
│ ├── auth.go # 注册/登录/JWT
|
||||
│ ├── user.go # 用户资料/头像
|
||||
│ ├── board.go # 板块 CRUD
|
||||
│ ├── post.go # 帖子/点赞/收藏
|
||||
│ ├── comment.go # 楼层评论
|
||||
│ ├── backup.go # SQLite 备份
|
||||
│ ├── ratelimit.go # 访问限流
|
||||
│ └── common.go # 敏感词过滤等
|
||||
├── handler/
|
||||
│ ├── handlers.go # 前台页面与 API
|
||||
│ └── admin.go # 后台管理
|
||||
├── middleware/auth.go # JWT 鉴权与限流中间件
|
||||
├── router/router.go # 路由注册
|
||||
├── embed_static/
|
||||
│ ├── embed.go # go:embed 声明
|
||||
│ ├── static/css/style.css # 内嵌 CSS
|
||||
│ ├── static/js/app.js # 内嵌 JS
|
||||
│ └── templates/ # 内嵌 HTML 模板
|
||||
├── go.mod
|
||||
├── Makefile
|
||||
└── README.md
|
||||
```
|
||||
<br>
|
||||
|
||||
[](LICENSE)
|
||||
[](go.mod)
|
||||
[](frontend/package.json)
|
||||
[](#)
|
||||
|
||||
[快速开始](#-快速开始) ·
|
||||
[界面预览](#-界面预览) ·
|
||||
[功能亮点](#-功能亮点) ·
|
||||
[路线图](ROADMAP.md) ·
|
||||
[参与贡献](CONTRIBUTING.md)
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/screenshots/home-light.png" alt="姜十三论坛首页 - 浅色主题三栏布局" width="92%">
|
||||
|
||||
<sub>浅色主题 · 三栏布局 · Feed 排序 · 虚拟滚动帖列表</sub>
|
||||
|
||||
<br>
|
||||
|
||||
> **开发状态:** 项目积极开发中。管理后台已统一为 React SPA(`/admin`),欢迎参与共建。
|
||||
> 查看 [路线图 ROADMAP.md](ROADMAP.md) · [Issues 反馈](https://git.iioio.com/freefire/jiang13-forum/issues)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## 界面预览
|
||||
|
||||
> 论坛用户第一眼看到的是界面。姜十三论坛采用清新绿色主题、高密度信息布局,兼顾桌面与移动端体验。
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
||||
<br><b>浅色主题</b><br>
|
||||
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
||||
<br><b>暗色主题</b><br>
|
||||
<sub>一键切换 · 护眼阅读 · 全局色彩自适应</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/post-detail.png" alt="帖子详情页" width="100%">
|
||||
<br><b>帖子详情</b><br>
|
||||
<sub>TipTap 富文本渲染 · 标签展示 · 点赞收藏互动</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/mobile-home.png" alt="移动端首页" width="280">
|
||||
<br><b>移动端适配</b><br>
|
||||
<sub>板块快捷筛选 · Feed 排序 · 触控友好列表</sub>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/screenshots/compose.png" alt="发帖页" width="360">
|
||||
<br><b>发帖</b> — 板块胶囊选择 · TipTap 工具栏 · 本地上传图片
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 功能亮点
|
||||
|
||||
### 界面与交互
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
||||
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
|
||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
|
||||
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
|
||||
| **响应式** | 平板 / 手机自动收起侧栏,搜索、发帖、登录触手可及 |
|
||||
| **高密度排版** | V2EX / NGA 风格信息密度,一屏浏览更多内容 |
|
||||
|
||||
### 社区功能
|
||||
|
||||
- 用户注册 / 登录(bcrypt + JWT Cookie)
|
||||
- 普通用户 / 管理员两级权限,**首个注册用户自动成为管理员**
|
||||
- 板块管理、发帖、TipTap 富文本编辑、正文图片本地上传、标签、置顶
|
||||
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
|
||||
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
|
||||
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
|
||||
- 点赞、收藏、热门帖、最新动态
|
||||
- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
|
||||
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
|
||||
|
||||
### 部署体验
|
||||
|
||||
- **单二进制部署** — 与 Gitea 同款 `go:embed` 打包,无需 Nginx 反代静态资源
|
||||
- **零依赖数据库** — SQLite 内建,数据目录 `--data` 一处管理
|
||||
- **跨平台** — Windows / Linux / macOS 一键编译
|
||||
|
||||
---
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 编译
|
||||
|
||||
**Windows(推荐,无需 GNU Make):**
|
||||
**Windows(推荐):**
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
.\build.ps1
|
||||
|
||||
# 或双击 / cmd
|
||||
build.bat
|
||||
# 或双击 build.bat
|
||||
```
|
||||
|
||||
**Linux / macOS(需 GNU Make):**
|
||||
**Linux / macOS:**
|
||||
|
||||
```bash
|
||||
make build
|
||||
```
|
||||
|
||||
**手动分步(全平台通用):**
|
||||
**手动分步(全平台):**
|
||||
|
||||
```bash
|
||||
cd frontend && npm install && npm run build
|
||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13
|
||||
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||
```
|
||||
|
||||
> Windows 自带的 `make` 通常是 Embarcadero MAKE,**不能**识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make(`choco install make`)后再用 `make build`。
|
||||
> Windows 自带的 `make` 通常是 Embarcadero MAKE,不能识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make 后再用 `make build`。
|
||||
|
||||
跨平台编译:
|
||||
|
||||
@@ -84,104 +151,109 @@ cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13.exe ./cmd/jiang13
|
||||
|
||||
### 3. 首次使用
|
||||
|
||||
1. 浏览器打开 http://localhost:3000/register 注册账号
|
||||
2. **第一个注册的用户自动成为管理员**,无需额外命令
|
||||
3. 登录后可访问 http://localhost:3000/admin/dashboard 进入后台
|
||||
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||
2. **第一个注册的用户自动成为管理员**
|
||||
3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台
|
||||
|
||||
## 启动参数
|
||||
### 启动参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `--port` | 3000 | HTTP 监听端口 |
|
||||
| `--data` | ./data | 数据目录(SQLite、上传、日志) |
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则自动生成并持久化到 data/.jwt_secret) |
|
||||
| `--port` | `3000` | HTTP 监听端口 |
|
||||
| `--data` | `./data` | 数据目录(SQLite、上传、日志) |
|
||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||
|
||||
## 数据目录说明
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| **后端** | Go 1.26 · Gin · GORM · SQLite |
|
||||
| **前端** | React 18 · TipTap · Radix UI · Tailwind CSS · TanStack Virtual |
|
||||
| **构建** | Vite → `go:embed` 内嵌 SPA,单二进制发布 |
|
||||
| **认证** | bcrypt · JWT Cookie |
|
||||
|
||||
---
|
||||
|
||||
## 前端开发
|
||||
|
||||
日常改前端**不需要**重新 `npm run build` 或 `go build`,Vite 开发服务器支持秒级热更新(HMR,热模块替换):
|
||||
|
||||
```powershell
|
||||
.\build.ps1 -Target dev # Windows
|
||||
make dev # Linux / macOS
|
||||
```
|
||||
|
||||
浏览器访问 `http://localhost:5173`,API 自动代理到 `http://localhost:3000`。
|
||||
|
||||
**何时需要完整构建:**
|
||||
|
||||
- 修改 Go 代码、HTML 模板、embed 静态资源 → `go build` / `make build`
|
||||
- 发布单二进制前 → `npm run build` + `make build`
|
||||
- 更新 README 界面截图 → 启动服务后执行 `node scripts/capture-screenshots.mjs`
|
||||
|
||||
> 直接访问 `:3000` 看到的是上次 build 嵌入的前端;开发时请用 `:5173`。
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
jiang13-forum/
|
||||
├── cmd/jiang13/ # 程序入口
|
||||
├── config/ # 命令行参数与配置
|
||||
├── model/ # GORM 模型与数据库迁移
|
||||
├── service/ # 业务逻辑(认证、帖子、评论…)
|
||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||
├── middleware/ # JWT 鉴权、在线状态
|
||||
├── router/ # 路由注册
|
||||
├── embed_static/ # go:embed 内嵌的 SPA 与模板
|
||||
├── frontend/ # React 源码(Vite 构建)
|
||||
├── docs/screenshots/ # README 界面截图
|
||||
├── docs/issue-templates.md # Issue 预填模板
|
||||
├── ROADMAP.md # 路线图与已知问题
|
||||
└── scripts/ # 开发辅助脚本
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据目录
|
||||
|
||||
```
|
||||
data/
|
||||
├── jiang13.db # SQLite 主数据库
|
||||
├── jiang13.log # 运行日志
|
||||
├── filter_words.txt # 敏感词配置(可编辑)
|
||||
├── filter_words.txt # 敏感词配置
|
||||
├── .jwt_secret # JWT 密钥(自动生成)
|
||||
├── uploads/avatars/ # 用户头像(运行时写入,非 embed)
|
||||
└── jiang13_backup_*.db # 后台导出的备份文件
|
||||
├── uploads/avatars/ # 用户头像
|
||||
├── uploads/posts/ # 帖子正文图片
|
||||
└── jiang13_backup_*.db # 后台导出的备份
|
||||
```
|
||||
|
||||
## 修改前端并重新打包(Gitea 同款流程)
|
||||
---
|
||||
|
||||
1. 编辑 `embed_static/templates/` 下的 HTML 模板
|
||||
2. 编辑 `embed_static/static/css/` 或 `static/js/` 下的静态资源
|
||||
3. 重新编译:`make build`
|
||||
4. 替换旧二进制,重启服务
|
||||
## 开发状态
|
||||
|
||||
前端资源通过 `//go:embed` 编译进二进制,**运行时不需要单独的前端文件夹**。只有用户上传的头像保存在 `--data` 目录。
|
||||
项目**积极开发中**,作为论坛产品功能尚未完善,欢迎参与共建。
|
||||
|
||||
```go
|
||||
// embed_static/embed.go
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS
|
||||
```
|
||||
|
||||
## 功能概览
|
||||
|
||||
- 用户注册/登录(bcrypt + JWT Cookie)
|
||||
- 普通用户 / 管理员两级权限
|
||||
- 板块管理、发帖、富文本 HTML、标签、置顶
|
||||
- 楼层式评论,支持回复指定楼层
|
||||
- 点赞、收藏
|
||||
- 管理员:删帖、删评论、禁言、SQLite 备份
|
||||
- 内置敏感词过滤、发帖/评论限流
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Go 1.26 + Gin + GORM + SQLite
|
||||
- **前端**:React 18 + Arco Design + TanStack Virtual(虚拟滚动)
|
||||
- **打包**:Vite 构建 → `go:embed` 内嵌 SPA,与 Gitea 同款单二进制
|
||||
|
||||
## 前端开发(热更新 HMR)
|
||||
|
||||
日常改前端**不需要**重新 `npm run build` 或 `go build`,用 Vite 开发服务器即可秒级热更新:
|
||||
|
||||
```bash
|
||||
# 一键启动:Go 后端 + Vite 热更新(推荐)
|
||||
.\build.ps1 -Target dev # Windows
|
||||
make dev # Linux / macOS(需 GNU Make)
|
||||
|
||||
# 浏览器访问 http://localhost:5173
|
||||
# API 自动代理到 http://localhost:3000
|
||||
```
|
||||
|
||||
也可开两个终端分别启动:
|
||||
|
||||
```bash
|
||||
# 终端 1:仅后端
|
||||
.\build.ps1 -Target run # 或 make run
|
||||
|
||||
# 终端 2:仅前端热更新
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
**何时才需要完整构建:**
|
||||
|
||||
- 改 Go 代码、模板、embed 静态资源 → `go build` / `make build`
|
||||
- 发布单二进制前 → `npm run build` + `make build`(前端产物写入 `embed_static/static/spa/` 后 embed 进二进制)
|
||||
|
||||
> 若直接访问 `:3000`,看到的是**上次 build 嵌入**的前端,不会有热更新。开发时请用 `:5173`。
|
||||
|
||||
### 前端特性(高密度 V2EX/NGA 风格)
|
||||
|
||||
| 特性 | 实现 |
|
||||
| 类型 | 示例 |
|
||||
|------|------|
|
||||
| 三栏布局 | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
||||
| 虚拟滚动 | `@tanstack/react-virtual` 帖列表 & 楼层回复 |
|
||||
| 已读/未读 | 未读高亮 + 角标 + 批量标记已读 |
|
||||
| 主题 | 浅色/暗黑一键切换,平板/手机自动收起侧栏 |
|
||||
| 交互 | 滚动加载更多、快捷回帖、楼层锚点、引用回复、@高亮 |
|
||||
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、Feed 排序、楼层评论 |
|
||||
| ✅ 发帖体验 | 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)。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT — 自用随意,欢迎修改。
|
||||
[MIT](LICENSE) — 自由使用、修改与分发。
|
||||
|
||||
71
ROADMAP.md
Normal file
71
ROADMAP.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 路线图 ROADMAP
|
||||
|
||||
> 姜十三论坛仍在积极开发中,功能尚未完善。
|
||||
> 欢迎通过 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 反馈问题或认领任务。
|
||||
|
||||
**图例:** ✅ 已完成 · 🚧 进行中 · 📋 计划中 · 🐛 已知缺陷
|
||||
|
||||
---
|
||||
|
||||
## 开发状态概览
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
||||
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
||||
| 评论系统 | ✅ | 换行显示已修复 |
|
||||
|
||||
---
|
||||
|
||||
## 🐛 已知缺陷(Bug)
|
||||
|
||||
_当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.com/freefire/jiang13-forum/issues/new/choose)。_
|
||||
|
||||
---
|
||||
|
||||
## 📋 计划中(Planned)
|
||||
|
||||
| 优先级 | 功能 | 说明 |
|
||||
|--------|------|------|
|
||||
| 中 | 通知动态优化 | 右栏最新动态的展示与交互 |
|
||||
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
|
||||
|
||||
---
|
||||
|
||||
## 🚧 进行中(In Progress)
|
||||
|
||||
_当前无公开认领任务。_
|
||||
|
||||
---
|
||||
|
||||
## ✅ 已完成(Done)
|
||||
|
||||
- [x] React 管理后台(仪表盘、板块、帖子、评论、用户、设置)
|
||||
- [x] 帖子置顶(帖子详情 + 管理后台)
|
||||
- [x] 评论回复换行正确显示
|
||||
- [x] 三栏布局 + 虚拟滚动帖列表
|
||||
- [x] 浅色 / 暗色主题切换
|
||||
- [x] 移动端响应式适配
|
||||
- [x] 用户注册登录、JWT 鉴权
|
||||
- [x] 板块管理、发帖、TipTap 富文本编辑
|
||||
- [x] 帖子正文图片本地上传
|
||||
- [x] 帖子修订历史与 diff 对比
|
||||
- [x] Feed 排序(最新发帖 / 最新回复 / 热门讨论)
|
||||
- [x] 可配置编辑时限与论坛参数(限流、字数上限等)
|
||||
- [x] 楼层式评论、引用回复、@ 高亮
|
||||
- [x] 点赞、收藏、热门帖
|
||||
- [x] 敏感词过滤、发帖限流
|
||||
- [x] SQLite 备份、单二进制部署
|
||||
|
||||
---
|
||||
|
||||
## 如何参与
|
||||
|
||||
1. 在 [Issues](https://git.iioio.com/freefire/jiang13-forum/issues) 挑选任务(预填内容见 [docs/issue-templates.md](docs/issue-templates.md))
|
||||
2. Fork → 分支 → PR,详见 [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
3. 有新想法先开 Issue 讨论,避免重复劳动
|
||||
|
||||
---
|
||||
|
||||
_最后更新:2026-06-16_
|
||||
@@ -1,4 +1,4 @@
|
||||
@echo off
|
||||
REM 姜十三论坛 Windows 快捷构建(双击或命令行均可)
|
||||
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||
exit /b %ERRORLEVEL%
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jiang13/forum/config"
|
||||
"github.com/jiang13/forum/model"
|
||||
"github.com/jiang13/forum/router"
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -34,6 +34,10 @@ func Parse() (*Config, error) {
|
||||
if err := os.MkdirAll(uploadDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建上传目录失败: %w", err)
|
||||
}
|
||||
postImgDir := filepath.Join(*dataDir, "uploads", "posts")
|
||||
if err := os.MkdirAll(postImgDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("创建帖子图片目录失败: %w", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
Port: *port,
|
||||
@@ -61,11 +65,21 @@ func (c *Config) DBPath() string {
|
||||
return filepath.Join(c.DataDir, "jiang13.db")
|
||||
}
|
||||
|
||||
// UploadDir 返回头像上传目录
|
||||
func (c *Config) UploadDir() string {
|
||||
// AvatarUploadDir 返回头像上传目录
|
||||
func (c *Config) AvatarUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||
}
|
||||
|
||||
// PostImageUploadDir 返回帖子正文图片上传目录
|
||||
func (c *Config) PostImageUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||
}
|
||||
|
||||
// UploadDir 返回头像上传目录(兼容旧调用)
|
||||
func (c *Config) UploadDir() string {
|
||||
return c.AvatarUploadDir()
|
||||
}
|
||||
|
||||
// FilterWordsPath 返回敏感词配置文件路径
|
||||
func (c *Config) FilterWordsPath() string {
|
||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||
|
||||
99
docs/issue-templates.md
Normal file
99
docs/issue-templates.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Issue 预填模板
|
||||
|
||||
以下两条可直接复制到 [Gitea Issues](https://git.iioio.com/freefire/jiang13-forum/issues/new) 创建,或使用仓库自带的 Issue 模板。
|
||||
|
||||
---
|
||||
|
||||
## Issue #1 · 评论回复换行不显示
|
||||
|
||||
**标题:** `[Bug] 评论回复换行不显示`
|
||||
|
||||
**标签:** `bug` `ui/ux`
|
||||
|
||||
**正文:**
|
||||
|
||||
### 问题描述
|
||||
|
||||
在帖子详情页的评论框中输入多行文字(按 Enter 换行),提交后评论展示区域不保留换行,所有文字合并为一行。
|
||||
|
||||
### 复现步骤
|
||||
|
||||
1. 打开任意帖子详情页(如 `/post/2`)
|
||||
2. 在底部评论框输入:
|
||||
```
|
||||
第一行
|
||||
第二行
|
||||
第三行
|
||||
```
|
||||
3. 点击发送
|
||||
4. 查看刚发布的评论
|
||||
|
||||
### 期望行为
|
||||
|
||||
评论正文按输入时的换行分段显示,行与行之间有明显间隔。
|
||||
|
||||
### 实际行为
|
||||
|
||||
多行内容被渲染成单行连续文字。
|
||||
|
||||
### 相关代码
|
||||
|
||||
- `frontend/src/components/CommentContent.tsx`
|
||||
- `frontend/src/utils/content.ts`(`highlightMentions` 中的 `\n` → `<br>` 转换)
|
||||
- `frontend/src/styles/global.css`(`.floor-body` 的 `white-space: pre-wrap`)
|
||||
|
||||
### 可能原因
|
||||
|
||||
- 仅处理了 `\n`,未处理 Windows 的 `\r\n`
|
||||
- `dangerouslySetInnerHTML` 与 `pre-wrap` 样式叠加导致表现异常
|
||||
- 服务端 `strings.TrimSpace` 或其他处理误删换行(待排查)
|
||||
|
||||
### 环境
|
||||
|
||||
- 前台:React SPA(`:3000` 嵌入版或 `:5173` 开发版)
|
||||
- 浏览器:Chrome / Edge 最新版
|
||||
|
||||
---
|
||||
|
||||
## Issue #2 · React 前台支持帖子置顶
|
||||
|
||||
**标题:** `[Feature] React 前台增加帖子置顶操作`
|
||||
|
||||
**标签:** `enhancement` `ui/ux` `good first issue`
|
||||
|
||||
**正文:**
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
管理员无法在 React SPA 前台对帖子执行置顶/取消置顶,必须跳转到旧版 HTML 管理后台(`/admin/posts`),体验割裂。
|
||||
|
||||
### 现状
|
||||
|
||||
|
||||
| 能力 | 状态 |
|
||||
| ----------------------------------- | ---- |
|
||||
| 数据模型 `pinned` 字段 | ✅ 已有 |
|
||||
| 列表按置顶排序 | ✅ 已有 |
|
||||
| API `POST /admin/api/posts/:id/pin` | ✅ 已有 |
|
||||
| 旧版后台置顶按钮 | ✅ 已有 |
|
||||
| React 列表/详情显示置顶徽章 | ✅ 已有 |
|
||||
| **React 前台置顶操作入口** | ❌ 缺失 |
|
||||
|
||||
|
||||
### 期望方案
|
||||
|
||||
在 React SPA 中为管理员提供置顶操作,例如:
|
||||
|
||||
1. **帖子详情页**:标题旁增加「置顶 / 取消置顶」按钮(仅 `role === 'admin'` 可见)
|
||||
2. **帖列表项**:管理员 hover 时显示置顶快捷操作(可选)
|
||||
3. 调用已有 API,成功后刷新列表/详情,无需跳转旧后台
|
||||
|
||||
### 相关代码
|
||||
|
||||
- 后端:`service/post.go` → `SetPinned`,`handler/admin.go` → `AdminAPIPinPost`
|
||||
- 前端:`frontend/src/pages/PostDetailPage.tsx`、`frontend/src/components/PostListItem.tsx`
|
||||
- 参考旧版:`embed_static/templates/admin/posts.html` 中的 `togglePin`
|
||||
|
||||
### 备注
|
||||
|
||||
适合作为 `good first issue`,改动范围小、API 已就绪。
|
||||
BIN
docs/screenshots/compose.png
Normal file
BIN
docs/screenshots/compose.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
BIN
docs/screenshots/home-dark.png
Normal file
BIN
docs/screenshots/home-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
BIN
docs/screenshots/home-light.png
Normal file
BIN
docs/screenshots/home-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 509 KiB |
BIN
docs/screenshots/mobile-home.png
Normal file
BIN
docs/screenshots/mobile-home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 281 KiB |
BIN
docs/screenshots/post-detail.png
Normal file
BIN
docs/screenshots/post-detail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{e as j,r as n,j as s}from"./react-vendor-CgzNJrV9.js";import{u as f,a as g,B as p}from"./index-474mTIgd.js";import{S as N}from"./spinner-DH1syOsQ.js";import{n as v}from"./notify-Zesh5czd.js";import{f as y}from"./content-CKr4Ur51.js";import{A as k}from"./ui-vendor-CdH1UOKW.js";function E(){const t=j(),{user:a,loading:r}=f(),[i,h]=n.useState([]),[u,x]=n.useState(!0);return n.useEffect(()=>{if(!r){if(!a){t("/login");return}g.favorites().then(e=>h(Array.isArray(e.favorites)?e.favorites:[])).catch(e=>v.error(e.message)).finally(()=>x(!1))}},[a,r,t]),r||u?s.jsx("div",{className:"flex justify-center py-16",children:s.jsx(N,{size:"lg"})}):a?s.jsx("div",{className:"page-wrap",children:s.jsxs("div",{className:"page-inner-wide",children:[s.jsxs(p,{variant:"ghost",className:"mb-3",onClick:()=>t("/"),children:[s.jsx(k,{}),"返回"]}),s.jsx("h1",{className:"page-title",children:"我的收藏"}),s.jsxs("p",{className:"page-desc",children:["共 ",i.length," 篇收藏帖子"]}),i.length===0?s.jsxs("div",{className:"empty-state",children:[s.jsx("p",{children:"还没有收藏任何帖子"}),s.jsx(p,{onClick:()=>t("/"),children:"去逛逛"})]}):s.jsx("div",{className:"content-surface",children:i.map(e=>{var o,l,c,d,m;return s.jsx("div",{className:"post-row",onClick:()=>t(`/post/${e.post_id}`),children:s.jsxs("div",{className:"post-body",children:[s.jsx("div",{className:"post-title",children:((o=e.post)==null?void 0:o.title)||"帖子已删除"}),s.jsxs("div",{className:"post-meta",children:[((c=(l=e.post)==null?void 0:l.board)==null?void 0:c.name)&&s.jsx("span",{children:e.post.board.name}),((m=(d=e.post)==null?void 0:d.user)==null?void 0:m.nickname)&&s.jsx("span",{children:e.post.user.nickname}),s.jsxs("span",{children:["收藏于 ",y(e.created_at)]})]})]})},e.id)})})]})}):null}export{E as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{e as f,r as g,j as s,L as b}from"./react-vendor-CgzNJrV9.js";import{u as w,a as F,F as y,b as t,c as n,d as l,e as c,I as i,f as m,o as N,s as d}from"./form-BzHbu9bJ.js";import{u as v,B as L,a as S}from"./index-474mTIgd.js";import{n as u}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=N({username:d().min(1,"请输入用户名"),password:d().min(1,"请输入密码")});function z(){const x=f(),{refresh:p}=v(),[h,a]=g.useState(!1),r=w({resolver:F(C),defaultValues:{username:"",password:""}}),j=async e=>{a(!0);try{await S.login(e.username,e.password),await p(),u.success("登录成功"),x("/",{replace:!0})}catch(o){u.error(o instanceof Error?o.message:"登录失败")}finally{a(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"登录姜十三论坛"}),s.jsx("p",{className:"subtitle",children:"拾三一隅,自在交流"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(j),className:"space-y-4",children:[s.jsx(t,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"用户名"}),s.jsx(c,{children:s.jsx(i,{placeholder:"用户名",autoComplete:"username",...e})}),s.jsx(m,{})]})}),s.jsx(t,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(l,{children:"密码"}),s.jsx(c,{children:s.jsx(i,{type:"password",placeholder:"密码",autoComplete:"current-password",...e})}),s.jsx(m,{})]})}),s.jsx(L,{type:"submit",className:"w-full",loading:h,children:"登录"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["没有账号?",s.jsx(b,{to:"/register",children:"注册"})]})]})})}export{z as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{e as f,r as g,j as s,L as w}from"./react-vendor-CgzNJrV9.js";import{u as b,a as F,F as y,b as a,c as n,d as o,e as t,I as c,f as l,o as k,s as i}from"./form-BzHbu9bJ.js";import{u as N,B as S,a as v}from"./index-474mTIgd.js";import{n as x}from"./notify-Zesh5czd.js";import"./ui-vendor-CdH1UOKW.js";const C=k({username:i().min(1,"请输入用户名"),nickname:i().optional(),password:i().min(6,"密码至少 6 位")});function R(){const u=f(),{refresh:j}=N(),[p,m]=g.useState(!1),r=b({resolver:F(C),defaultValues:{username:"",nickname:"",password:""}}),h=async e=>{m(!0);try{await v.register(e.username,e.password,e.nickname||e.username),await j(),x.success("注册成功"),u("/",{replace:!0})}catch(d){x.error(d instanceof Error?d.message:"注册失败")}finally{m(!1)}};return s.jsx("div",{className:"auth-page",children:s.jsxs("div",{className:"auth-box",children:[s.jsx("div",{className:"logo-mark",children:"姜"}),s.jsx("h1",{children:"注册账号"}),s.jsx("p",{className:"subtitle",children:"首个注册用户自动成为管理员"}),s.jsx(y,{...r,children:s.jsxs("form",{onSubmit:r.handleSubmit(h),className:"space-y-4",children:[s.jsx(a,{control:r.control,name:"username",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"用户名"}),s.jsx(t,{children:s.jsx(c,{placeholder:"3-32 位字母数字下划线",autoComplete:"username",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"nickname",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"昵称"}),s.jsx(t,{children:s.jsx(c,{placeholder:"显示名称(可选)",autoComplete:"nickname",...e})}),s.jsx(l,{})]})}),s.jsx(a,{control:r.control,name:"password",render:({field:e})=>s.jsxs(n,{children:[s.jsx(o,{children:"密码"}),s.jsx(t,{children:s.jsx(c,{type:"password",placeholder:"至少 6 位",autoComplete:"new-password",...e})}),s.jsx(l,{})]})}),s.jsx(S,{type:"submit",className:"w-full",loading:p,children:"注册"})]})}),s.jsxs("p",{style:{textAlign:"center",marginTop:16,fontSize:13,color:"var(--color-text-3)"},children:["已有账号?",s.jsx(w,{to:"/login",children:"登录"})]})]})})}export{R as default};
|
||||
@@ -1 +0,0 @@
|
||||
import{j as n}from"./react-vendor-CgzNJrV9.js";import{c as a,d as o}from"./index-474mTIgd.js";const s=o("inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow",secondary:"border-transparent bg-secondary text-secondary-foreground",destructive:"border-transparent bg-destructive text-destructive-foreground shadow",outline:"text-foreground",green:"border-transparent bg-[var(--j13-green-bg)] text-[var(--j13-green)]",orange:"border-transparent bg-orange-100 text-orange-700 dark:bg-orange-950 dark:text-orange-300"}},defaultVariants:{variant:"default"}});function g({className:r,variant:e,...t}){return n.jsx("div",{className:a(s({variant:e}),r),...t})}export{g as B};
|
||||
@@ -1 +0,0 @@
|
||||
function a(n,t){return n.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/@([\w\u4e00-\u9fa5_-]+)/g,'<span class="mention">@$1</span>')}function o(n){const t=new Date(n),e=(new Date().getTime()-t.getTime())/1e3;return e<60?"刚刚":e<3600?`${Math.floor(e/60)}分钟前`:e<86400?`${Math.floor(e/3600)}小时前`:`${t.getMonth()+1}-${t.getDate()} ${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}`}export{o as f,a as h};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{t as s}from"./index-474mTIgd.js";const n={success:r=>s.success(r),error:r=>s.error(r),warning:r=>s.warning(r)};export{n};
|
||||
@@ -1,23 +0,0 @@
|
||||
import{p as m}from"./markdown-vendor-DxR1h-Bq.js";const d={ADD_TAGS:["members-only"],ADD_ATTR:["data-locked","data-length"]},c=`
|
||||
<div class="post-members-only__badge">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
</div>`,a='<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';function p(s){const r=s>0?Math.min(6,Math.max(3,Math.ceil(s/42))):4,n=Array.from({length:r},(l,i)=>{const o=i%3;return`<div class="post-members-only__preview-line${o===1?" post-members-only__preview-line--medium":o===2?" post-members-only__preview-line--short":""}"></div>`}).join(""),e=s>0?`约 ${s} 字的`:"一段";return`
|
||||
<div class="post-members-only__locked-wrap">
|
||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">${a}</span>
|
||||
<span>登录可见</span>
|
||||
</div>
|
||||
<div class="post-members-only__preview" aria-hidden="true">
|
||||
${n}
|
||||
</div>
|
||||
<div class="post-members-only__gate">
|
||||
<div class="post-members-only__gate-icon" aria-hidden="true">${a}</div>
|
||||
<p class="post-members-only__gate-title">此处有${e}专属内容</p>
|
||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
|
||||
</div>
|
||||
</div>`}function b(s,r){if(!s.trim())return"";const n=new DOMParser().parseFromString(m.sanitize(s,d),"text/html");return n.querySelectorAll("members-only").forEach(e=>{var o;if(e.getAttribute("data-locked")==="true"||!r){const t=parseInt(e.getAttribute("data-length")||"0",10)||0;e.setAttribute("data-locked","true"),e.className="post-members-only post-members-only--locked",e.innerHTML=p(t);return}const i=((o=e.querySelector(".post-members-only__body"))==null?void 0:o.innerHTML)??Array.from(e.childNodes).filter(t=>!(t instanceof Element&&t.classList.contains("post-members-only__badge"))).map(t=>t instanceof Element?t.outerHTML:t.textContent??"").join("");e.className="post-members-only post-members-only--visible",e.innerHTML=`${c}<div class="post-members-only__body">${i}</div>`}),n.body.innerHTML}export{d as P,b as r};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{j as e}from"./react-vendor-CgzNJrV9.js";import{c as s}from"./index-474mTIgd.js";import{p as m}from"./ui-vendor-CdH1UOKW.js";const t={sm:"h-4 w-4",md:"h-5 w-5",lg:"h-8 w-8"};function p({className:r,size:a="md"}){return e.jsx(m,{className:s("animate-spin text-[var(--j13-green)]",t[a],r),"aria-label":"加载中"})}export{p as S};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>姜十三论坛 Jiang13 Forum</title>
|
||||
<style>
|
||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
||||
html { scrollbar-gutter: stable; }
|
||||
html, body, #root { height: 100%; margin: 0; }
|
||||
body { overflow: hidden; font-size: 14px; line-height: 1.5; }
|
||||
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-header { height: 56px; flex-shrink: 0; }
|
||||
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
|
||||
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.sidebar { width: 210px; flex-shrink: 0; }
|
||||
.main-content { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.aside-panel { width: 280px; flex-shrink: 0; }
|
||||
@media (max-width: 1100px) { .aside-panel { display: none; } }
|
||||
@media (max-width: 768px) { .sidebar { display: none; } }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var theme = localStorage.getItem('j13-theme') || 'light';
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-474mTIgd.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/react-vendor-CgzNJrV9.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-CdH1UOKW.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JNN-o0YU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
714
frontend/package-lock.json
generated
714
frontend/package-lock.json
generated
@@ -16,28 +16,39 @@
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
"@tiptap/extension-underline": "^3.26.1",
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
"@tiptap/starter-kit": "^3.26.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
@@ -824,6 +835,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mixmark-io/domino": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
|
||||
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
@@ -1921,6 +1938,461 @@
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.26.1.tgz",
|
||||
"integrity": "sha512-TX9PyPqBoix0qDLjtok/bddtdSy54QhzLVha405C07V+WySOpH3s/pWYkywehZQY0SQtcrcY4MNSCeQjCbA28A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.26.1.tgz",
|
||||
"integrity": "sha512-WaKjKmUaadgvZDDBk9JOn/oidlOFr6booqJIWHGL5S0aUUTKHS19oGfKQq/l9Z1y1niaRePk0Y4fy/jxCnfKPA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.26.1.tgz",
|
||||
"integrity": "sha512-VIlF2sAiV6K009pcIDotfY8mvsPaq90dxeG9Q0ZIqfMD958TUCqjHw4MGYZf0/FgP12xksBfmcR7W312xgUf9Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.26.1.tgz",
|
||||
"integrity": "sha512-Y3R9wFKP/U9M04JG+0PM/yW3OV+MSbUp6YBKQWZmUu8x6y7TbcNvDsaJ6QEFZt5aRMS6qH1ksYPTOz47JdjcfA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.26.1.tgz",
|
||||
"integrity": "sha512-JB6bEJJHxXNAXEXTIAN3/j70p1ARHdeMfhzshGZswWKUWtDibTCrspIp7p1VNeiuVtJ/HB6PpFkGi7yWtQ3RTg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.26.1.tgz",
|
||||
"integrity": "sha512-t9/VR5k3rGPyhcGau9YvVgaAQ+nP9R9WzS996bQQ7GIrMOTSXb0FWwoQFBiYl83V6VA16Tlj/oScC7SFlA8lvA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.26.1.tgz",
|
||||
"integrity": "sha512-NY7SYqcrqDVYTSWyaNGdSfCims6pOHoRQ2Rh4DEFb/rb8gLVkqbLZhcHzQCVfinlPqgV3xWF6cYMORwmnlBkXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.26.1.tgz",
|
||||
"integrity": "sha512-6W2vZjvi0Mv+4xEtwMDGhWwo7FotWR6eKfmntmduvehWevFpMxOKcTtyotjLigfZv738y50YWmvbaPuAPJG3BA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.26.1.tgz",
|
||||
"integrity": "sha512-eVq3BvFIa3YD+pBIlj1i72vYEixlegGVKHnSYiVF2ovkQOSAH9sca7pkq6WgV1sMTCyWCU8e+WznTqtydvHUWA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.26.1.tgz",
|
||||
"integrity": "sha512-xn0g4m/q2bjG+hULPwp6Aqb/6wpzUtc65jOhgJsG/S3Ey3kLJGUvZBuhozwNFu8FcugxM1fMUpNhkJkodCCGFw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.26.1.tgz",
|
||||
"integrity": "sha512-BWW1yMQQA4TbEU0LLK+4cd9ebLTuZG5KjHwFMBRD/bGiRW9V1gTWFsCqThBbczcANoQiZK9pn5/4Ad/rGM3HUg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.26.1.tgz",
|
||||
"integrity": "sha512-gzNb1e/fK6HN+ko1axsrasjK7F1q0Bnm0G4ZY/0eq7pV7s1wZuwoCiGbvUx/9LCFKRV6+94FTqlb0A3NbYN36g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.26.1.tgz",
|
||||
"integrity": "sha512-eRlv9XxzUL8FobKAiF1WjP35CT2QpbcxxeyYFF7BmGEONvKI7r5g7JGwyGli4Cvclh70h8w6JuoXSmGUVEU65A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.26.1.tgz",
|
||||
"integrity": "sha512-l9lPZYeSmY90y/2GkQcKaICFD5Atr8sx2SzJGkQzpNC9tRxZXyAHnfJE3OjBkspuGzjWIN0DimxBj4ibz58sKw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-image": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.26.1.tgz",
|
||||
"integrity": "sha512-IjoT+kRK4a1sTImvUz257yfk5l9kMxXxfxCfix5AUKdiWyn8SGUjJZapLICcZVY05UDqXmwsBvBK9lHkKX5ERg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.26.1.tgz",
|
||||
"integrity": "sha512-cLKYvOLToWEkJkAPspgIZ/PYDzAxacLm1VWcAq1tO1QDQCDe2Kw+y/zsGlyYEq/aKsAgpp4JNopBwAXRXxt2/A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.26.1.tgz",
|
||||
"integrity": "sha512-aLLGLgikuhLFHRbjfUC6D4gRg+NUty4uhW7YkyVl8AxxPME47dPbCOX4H6uLCjEZcn3WnfNuCTr6HCTl0KEmGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.26.1.tgz",
|
||||
"integrity": "sha512-06nOjnyXpzMO8Ys5k3IbYsDsKib1mv2OtaxBYX1/1uvRyOKwUX5tqDLb/qigic0LIANNL73lkNC8Z8XPeG4Tkg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.26.1.tgz",
|
||||
"integrity": "sha512-5gLXJUiP763NA6i4HgrtcwUDXPP8820hsaBQyF1Y1VsXNi02uW9FVLe3RZK8jF0NZUNh9CqD0gogYJCbKOUU8A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.26.1.tgz",
|
||||
"integrity": "sha512-EReSayePO6SIxtRbxx+7KfBQreWHvoZmMb3O/RemfT8W6J0hCG5N/Rh8Z12+YZOnCDRXJ4RzFpAikYka3E54jQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.26.1.tgz",
|
||||
"integrity": "sha512-LeFPeFwb7ylkQVuuaHj+niu7WhWHpjDOi1GKZJE/ohOa2lgt7P221HMqhUzPiDlXOExN72oWTNmXUlT0ymCTkw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.26.1.tgz",
|
||||
"integrity": "sha512-OkBeYUNM3eTzjm3z6IcC3NHryOX8g3eGNI86P/B+tFoFQSRuzLsKZU50ARCfIiLLg812NjcqujeJ1eX3BKDZrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.26.1.tgz",
|
||||
"integrity": "sha512-oJCEVmaaUY1Jn5v8KbRMdgYLFH9aptLkir+M0ZMnl+8TTmvMdLK2H02X9ofZQwAb12qreQgb890hB3PFen7TDg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.26.1.tgz",
|
||||
"integrity": "sha512-7hmQ2mBsA+75GRrJIKYxb+10H23mblEQSGGsv9Ptl7JLaGmj+8sv2HGQGSUT9QBiBVprxaYTqyWFXQC9akfLWg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.26.1.tgz",
|
||||
"integrity": "sha512-Gocui5WvcCCJJIX17gdOVCSdYi5H4fDwaR0qkMAUZPq5kJCdrfl+vNpt8BTt53Bk+/QumiUW21fhQ184w7RoeQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.26.1.tgz",
|
||||
"integrity": "sha512-HUHtQ+DRWDM0opW7Nk3YQwrLzw876hMU7cr1X/ZTG+8Bp+AKHihlwU+bqrPgG5St0mqASyUEhHQ/vK5PlnUYOQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.26.1.tgz",
|
||||
"integrity": "sha512-PmRaoe6bebTgz/ZQrjmzwZMST1d9js9ZTiKnUXeXl3Fm+V5U/c3TbbKDfqmL63qPQdjtShDMHi9tYuv+c77OFQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.26.1.tgz",
|
||||
"integrity": "sha512-48cJQRbvr9Ux0+IgM1BR5vOLU5hkC+n+uerdQy2JjrIRKpYE/huU8fQFm6PoRppoKYfilklzb29elsQ+n2TA+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-inputrules": "^1.4.0",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.7",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.0",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.41.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.26.1.tgz",
|
||||
"integrity": "sha512-Gl7AhTJM7pjQ2WFwdIwD736oQeqUcw3GVaXYmCKtwTSO3F9PszLgeKEp6DvM+CmctTNYhu/apRfzkH3vU0h0uA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.26.1",
|
||||
"@tiptap/extension-floating-menu": "^3.26.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.26.1",
|
||||
"@tiptap/pm": "3.26.1",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.26.1",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.26.1.tgz",
|
||||
"integrity": "sha512-A0zsvwGU9exLND34F8e8KqUXFSfs835tNN+VC+ZT3yNeaO/WXnlh/Cgal1F6pHHbcxy7RV2CRwJU5S3cWLPxrA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-blockquote": "^3.26.1",
|
||||
"@tiptap/extension-bold": "^3.26.1",
|
||||
"@tiptap/extension-bullet-list": "^3.26.1",
|
||||
"@tiptap/extension-code": "^3.26.1",
|
||||
"@tiptap/extension-code-block": "^3.26.1",
|
||||
"@tiptap/extension-document": "^3.26.1",
|
||||
"@tiptap/extension-dropcursor": "^3.26.1",
|
||||
"@tiptap/extension-gapcursor": "^3.26.1",
|
||||
"@tiptap/extension-hard-break": "^3.26.1",
|
||||
"@tiptap/extension-heading": "^3.26.1",
|
||||
"@tiptap/extension-horizontal-rule": "^3.26.1",
|
||||
"@tiptap/extension-italic": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-list": "^3.26.1",
|
||||
"@tiptap/extension-list-item": "^3.26.1",
|
||||
"@tiptap/extension-list-keymap": "^3.26.1",
|
||||
"@tiptap/extension-ordered-list": "^3.26.1",
|
||||
"@tiptap/extension-paragraph": "^3.26.1",
|
||||
"@tiptap/extension-strike": "^3.26.1",
|
||||
"@tiptap/extension-text": "^3.26.1",
|
||||
"@tiptap/extension-underline": "^3.26.1",
|
||||
"@tiptap/extensions": "^3.26.1",
|
||||
"@tiptap/pm": "^3.26.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1987,14 +2459,12 @@
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
|
||||
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
@@ -2005,7 +2475,6 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.0.0"
|
||||
@@ -2018,6 +2487,19 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/turndown": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz",
|
||||
"integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -2299,7 +2781,6 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
@@ -2338,6 +2819,15 @@
|
||||
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "9.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz",
|
||||
"integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -2416,6 +2906,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -2660,6 +3159,12 @@
|
||||
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -2779,6 +3284,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-wheel": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz",
|
||||
"integrity": "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -2797,6 +3308,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2995,6 +3512,145 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.9.tgz",
|
||||
"integrity": "sha512-pRTklkDDMMRopyoAcrr9wV/8g/RYgrLHBuJAb5hlEuYZRdm5yqmPjWId83fpBwPpSFqEdja0H7Dfd7z1X/npcA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.9",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.9.tgz",
|
||||
"integrity": "sha512-clTunTX+eaLbr87L1V1QPheRlEQJyTlL3gXe9x3jQIk3rL0RVWxviDGz8tFaydwIVm+hKhYCyr+R/zBtWr9s6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.8",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -3020,6 +3676,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -3040,6 +3697,19 @@
|
||||
"react": "^18.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-easy-crop": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-6.0.2.tgz",
|
||||
"integrity": "sha512-nY/YiNEuRjc851+/PsOR6Q7XoshmnXMl+oEOsxp3Ah0PrhECi5388jjRnHwsTFx3W0o2zPwvq85oljzUqZNpEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"normalize-wheel": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.4.0",
|
||||
"react-dom": ">=16.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.79.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz",
|
||||
@@ -3264,6 +3934,12 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-parallel": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||
@@ -3505,6 +4181,19 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/turndown": {
|
||||
"version": "7.2.4",
|
||||
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.4.tgz",
|
||||
"integrity": "sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mixmark-io/domino": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18",
|
||||
"npm": ">=9"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -3599,6 +4288,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
@@ -3665,6 +4363,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -17,28 +17,39 @@
|
||||
"@radix-ui/react-slot": "^1.2.5",
|
||||
"@radix-ui/react-switch": "^1.3.0",
|
||||
"@tanstack/react-virtual": "^3.11.2",
|
||||
"@tiptap/core": "^3.26.1",
|
||||
"@tiptap/extension-image": "^3.26.1",
|
||||
"@tiptap/extension-link": "^3.26.1",
|
||||
"@tiptap/extension-placeholder": "^3.26.1",
|
||||
"@tiptap/extension-underline": "^3.26.1",
|
||||
"@tiptap/pm": "^3.26.1",
|
||||
"@tiptap/react": "^3.26.1",
|
||||
"@tiptap/starter-kit": "^3.26.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-easy-crop": "^6.0.2",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
RouterProvider,
|
||||
Route,
|
||||
Navigate,
|
||||
} from 'react-router-dom';
|
||||
import './styles/global.css';
|
||||
import { AuthProvider } from './hooks/useAuth';
|
||||
import { ThemeProvider } from './hooks/useTheme';
|
||||
import MainLayout from './layouts/MainLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import PageLoader from './components/PageLoader';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
@@ -16,27 +23,45 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
||||
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Suspense fallback={<PageLoader />}><AdminDashboardPage /></Suspense>} />
|
||||
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
|
||||
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
||||
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
|
||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||
</Route>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/post/:id" element={<PostDetailPage />} />
|
||||
<Route path="/post/:id/edit" element={<ComposePage />} />
|
||||
<Route path="/compose" element={<ComposePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
</Route>
|
||||
</>,
|
||||
),
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/post/:id" element={<PostDetailPage />} />
|
||||
<Route path="/post/:id/edit" element={<ComposePage />} />
|
||||
<Route path="/compose" element={<ComposePage />} />
|
||||
<Route path="/boards" element={<BoardsManagePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats } from './types';
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -24,13 +24,17 @@ async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
|
||||
export const api = {
|
||||
me: () => request<{ user: User | null }>('/api/me'),
|
||||
stats: () => request<ForumStats>('/api/stats'),
|
||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
||||
posts: (params: Record<string, string | number>) => {
|
||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||
},
|
||||
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
||||
post: (id: number) => request<{ post: PostItem; comment_count: number; liked: boolean; favorited: boolean }>(`/api/posts/${id}`),
|
||||
post: (id: number, opts?: { skipView?: boolean }) => {
|
||||
const q = opts?.skipView ? '?skip_view=1' : '';
|
||||
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
||||
},
|
||||
comments: (id: number, myIds?: number[]) => {
|
||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||
@@ -39,11 +43,59 @@ export const api = {
|
||||
online: () => request<OnlineStats>('/api/online'),
|
||||
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
|
||||
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
|
||||
createBoard: (body: { name: string; description: string; sort_order: number }) =>
|
||||
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updateBoard: (id: number, body: { name: string; description: string; sort_order: number }) =>
|
||||
updateBoard: (id: number, body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||
request<{ board: Board }>(`/api/admin/boards/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deleteBoard: (id: number) => request(`/api/admin/boards/${id}`, { method: 'DELETE' }),
|
||||
// 管理后台 API
|
||||
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
|
||||
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
|
||||
adminPosts: (params: { page?: number; keyword?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.keyword) q.set('keyword', params.keyword);
|
||||
const qs = q.toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/posts${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
adminPinPost: (id: number, pinned: boolean) =>
|
||||
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
||||
method: 'POST', body: JSON.stringify({ pinned }),
|
||||
}),
|
||||
adminLockPost: (id: number, locked: boolean) =>
|
||||
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
|
||||
method: 'POST', body: JSON.stringify({ locked }),
|
||||
}),
|
||||
adminUpdateForumSettings: (body: ForumLimits) =>
|
||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateFilterWords: (content: string) =>
|
||||
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
||||
method: 'PUT', body: JSON.stringify({ content }),
|
||||
}),
|
||||
postRevisions: (id: number) =>
|
||||
request<{ revisions: PostRevision[] }>(`/api/posts/${id}/revisions`),
|
||||
postRevision: (id: number, revId: number) =>
|
||||
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
|
||||
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
adminComments: (page = 1) =>
|
||||
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/comments?page=${page}`,
|
||||
),
|
||||
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
|
||||
adminUsers: (page = 1) =>
|
||||
request<{ users: User[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/users?page=${page}`,
|
||||
),
|
||||
adminBanUser: (id: number, banned: boolean) =>
|
||||
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
|
||||
method: 'POST', body: JSON.stringify({ banned }),
|
||||
}),
|
||||
adminBackup: () =>
|
||||
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
|
||||
updateNickname: (nickname: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('nickname', nickname);
|
||||
@@ -60,6 +112,11 @@ export const api = {
|
||||
fd.append('avatar', file);
|
||||
return request<{ avatar: string }>('/api/profile/avatar', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
uploadPostImage: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
return request<{ url: string }>('/api/uploads/image', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
createPost: (data: { board_id: string; title: string; content: string; tags?: string }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('board_id', data.board_id);
|
||||
|
||||
@@ -4,12 +4,16 @@ export interface User {
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
role: 'user' | 'admin';
|
||||
banned?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
color_index?: number;
|
||||
sort_order: number;
|
||||
post_count?: number;
|
||||
}
|
||||
@@ -28,14 +32,39 @@ export interface PostItem {
|
||||
content?: string;
|
||||
tags: string;
|
||||
pinned: boolean;
|
||||
edit_locked?: boolean;
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
comment_count: number;
|
||||
last_reply_at?: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
board?: Board;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export interface PostRevision {
|
||||
id: number;
|
||||
post_id: number;
|
||||
editor_id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
created_at: string;
|
||||
editor?: User;
|
||||
}
|
||||
|
||||
export interface PostDetailResponse {
|
||||
post: PostItem;
|
||||
comment_count: number;
|
||||
liked: boolean;
|
||||
favorited: boolean;
|
||||
can_edit?: boolean;
|
||||
edit_block_reason?: string;
|
||||
is_edited?: boolean;
|
||||
post_edit_window_hours?: number;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
post_id: number;
|
||||
@@ -50,9 +79,71 @@ export interface Comment {
|
||||
content_hidden?: boolean;
|
||||
created_at: string;
|
||||
user?: User;
|
||||
post?: PostItem;
|
||||
reply_target?: Comment;
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
users: number;
|
||||
posts: number;
|
||||
boards: number;
|
||||
comments: number;
|
||||
online: number;
|
||||
recent_posts: PostItem[];
|
||||
}
|
||||
|
||||
export interface ForumLimits {
|
||||
post_edit_window_hours: number;
|
||||
rate_limit_post: number;
|
||||
rate_limit_comment: number;
|
||||
rate_limit_register: number;
|
||||
rate_limit_login: number;
|
||||
rate_limit_window_sec: number;
|
||||
post_title_max: number;
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
page_size_max: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
}
|
||||
|
||||
export interface ForumLimitsPublic {
|
||||
post_title_max: number;
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
filter_path: string;
|
||||
data_dir: string;
|
||||
db_path: string;
|
||||
port: number;
|
||||
limits: ForumLimits;
|
||||
filter_words: string;
|
||||
filter_word_count: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
items: T;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number;
|
||||
title: string;
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
import {
|
||||
useState, useRef, useEffect, useCallback, useImperativeHandle, forwardRef, useMemo, type ReactNode,
|
||||
useRef, useEffect, useImperativeHandle, forwardRef, useCallback, useState, useMemo, type ReactNode,
|
||||
} from 'react';
|
||||
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
|
||||
import { TextSelection } from '@tiptap/pm/state';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import DOMPurify from 'dompurify';
|
||||
import {
|
||||
Bold, Italic, Strikethrough, Link, Code, Quote,
|
||||
List, ListOrdered, Image, Eye, Pencil, Minus, LockKeyhole,
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { markdownToHtml, countWords } from '../utils/markdown';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
prefixMarkdownLines,
|
||||
cycleMarkdownHeading,
|
||||
insertMarkdownMembersOnly,
|
||||
insertMarkdownLink,
|
||||
} from '../utils/markdownFormat';
|
||||
import { countWords } from '../utils/text';
|
||||
import { api } from '../api/client';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { MembersOnly } from './editor/MembersOnlyExtension';
|
||||
import { TabIndent } from './editor/TabIndentExtension';
|
||||
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
|
||||
import { Tooltip } from './ui/Tooltip';
|
||||
|
||||
export interface ArticleEditorHandle {
|
||||
getHTML: () => string;
|
||||
getMarkdown: () => string;
|
||||
isEmpty: () => boolean;
|
||||
focus: () => void;
|
||||
}
|
||||
@@ -21,259 +45,476 @@ interface Props {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
type ViewMode = 'edit' | 'preview' | 'split';
|
||||
type EditorMode = 'rich' | 'markdown';
|
||||
type LinkTarget = 'rich' | 'markdown';
|
||||
|
||||
interface ToolBtn {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
/** 去掉行首已有的 Markdown 块级前缀 */
|
||||
function stripLinePrefix(line: string): string {
|
||||
return line
|
||||
.replace(/^\r/, '')
|
||||
.replace(/^#{1,6}\s*/, '')
|
||||
.replace(/^>\s*/, '')
|
||||
.replace(/^[-*+]\s*/, '')
|
||||
.replace(/^\d+\.\s*/, '');
|
||||
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
|
||||
|
||||
/** 净化编辑器 HTML,保留 members-only 自定义标签 */
|
||||
function sanitizeHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 判断编辑器内容是否为空 */
|
||||
function isEditorEmpty(editor: Editor): boolean {
|
||||
return editor.state.doc.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 标题循环:正文 → H2 → H3 → … → H6 → 正文 */
|
||||
function cycleHeading(editor: Editor) {
|
||||
for (let level = 2; level <= 6; level += 1) {
|
||||
if (editor.isActive('heading', { level })) {
|
||||
if (level === 6) {
|
||||
editor.chain().focus().setParagraph().run();
|
||||
} else {
|
||||
editor.chain().focus().toggleHeading({ level: (level + 1) as 2 | 3 | 4 | 5 | 6 }).run();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
}
|
||||
|
||||
/** 触发图片文件选择并上传 */
|
||||
async function uploadPostImageFile(): Promise<string | null> {
|
||||
return new Promise(resolve => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await api.uploadPostImage(file);
|
||||
resolve(url);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
/** 渲染工具栏按钮列表 */
|
||||
function renderToolButtons(tools: ToolBtn[]) {
|
||||
return tools.map((t, i) => (
|
||||
<span key={i} className={t.className === 'article-tool-btn--members' ? 'article-editor-tools-members' : undefined}>
|
||||
{t.className === 'article-tool-btn--members' ? (
|
||||
<span className="article-editor-tools-sep" aria-hidden="true" />
|
||||
) : null}
|
||||
<Tooltip content={t.title} hint={t.hint} align={t.align} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={`article-tool-btn${t.active ? ' active' : ''}${t.className ? ` ${t.className}` : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEditor(
|
||||
{ value, onChange, placeholder = '在此撰写正文…' },
|
||||
ref,
|
||||
) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const selectionRef = useRef({ start: 0, end: 0 });
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('split');
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const isInternalUpdate = useRef(false);
|
||||
const lastValueRef = useRef(value);
|
||||
const [, setEditorTick] = useState(0);
|
||||
const [mode, setMode] = useState<EditorMode>('rich');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [markdownSource, setMarkdownSource] = useState('');
|
||||
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
|
||||
const [linkDialogUrl, setLinkDialogUrl] = useState('');
|
||||
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
|
||||
const markdownRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => markdownToHtml(value),
|
||||
getMarkdown: () => value,
|
||||
isEmpty: () => value.trim().length === 0,
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
}));
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: { levels: [2, 3, 4, 5, 6] },
|
||||
}),
|
||||
Underline,
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
defaultProtocol: 'https',
|
||||
}),
|
||||
Image.configure({ inline: false, allowBase64: false }),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'paragraph' && node.parent?.type.name === 'membersOnly') {
|
||||
return MEMBERS_ONLY_PLACEHOLDER;
|
||||
}
|
||||
return placeholder;
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
MembersOnly,
|
||||
TabIndent,
|
||||
],
|
||||
content: sanitizeHtml(value) || '',
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const html = sanitizeHtml(ed.getHTML());
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
|
||||
const saveSelection = useCallback(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
selectionRef.current = { start: ta.selectionStart, end: ta.selectionEnd };
|
||||
}, []);
|
||||
// 清空后折叠选区,避免空文档残留全选高亮
|
||||
if (isEditorEmpty(ed) && !ed.state.selection.empty) {
|
||||
ed.commands.setTextSelection(1);
|
||||
}
|
||||
},
|
||||
onSelectionUpdate: () => {
|
||||
setEditorTick(t => t + 1);
|
||||
},
|
||||
onTransaction: () => {
|
||||
setEditorTick(t => t + 1);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'article-prosemirror post-detail-content',
|
||||
spellcheck: 'false',
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
|
||||
if (!view.state.doc.textContent.trim()) {
|
||||
event.preventDefault();
|
||||
view.dispatch(view.state.tr.setSelection(
|
||||
TextSelection.create(view.state.doc, 1),
|
||||
));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const getSelection = useCallback(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (ta && document.activeElement === ta) {
|
||||
return { start: ta.selectionStart, end: ta.selectionEnd };
|
||||
}
|
||||
return selectionRef.current;
|
||||
}, []);
|
||||
|
||||
const restoreSelection = useCallback((start: number, end = start) => {
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(start, end);
|
||||
selectionRef.current = { start, end };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 实时预览,短 debounce 保证流畅
|
||||
// 外部 value 变更时同步到编辑器(如加载已有帖子)
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setPreviewHtml(markdownToHtml(value)), 60);
|
||||
return () => clearTimeout(t);
|
||||
}, [value]);
|
||||
|
||||
// 编辑区随内容向下延伸,最小高度撑满视口剩余空间
|
||||
const adjustTextareaHeight = useCallback(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta || viewMode === 'preview') return;
|
||||
ta.style.height = '0px';
|
||||
const contentHeight = ta.scrollHeight;
|
||||
const top = ta.getBoundingClientRect().top;
|
||||
const minHeight = Math.max(280, window.innerHeight - top - 56);
|
||||
ta.style.height = `${Math.max(minHeight, contentHeight)}px`;
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [value, viewMode, adjustTextareaHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => adjustTextareaHeight();
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, [adjustTextareaHeight]);
|
||||
|
||||
const insertAtCursor = useCallback((before: string, after = '', placeholderText = '') => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const { start, end } = getSelection();
|
||||
const selected = value.slice(start, end) || placeholderText;
|
||||
const next = value.slice(0, start) + before + selected + after + value.slice(end);
|
||||
onChange(next);
|
||||
restoreSelection(start + before.length + selected.length);
|
||||
}, [value, onChange, getSelection, restoreSelection]);
|
||||
|
||||
const wrapLine = useCallback((prefix: string) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const { start } = getSelection();
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEnd = value.indexOf('\n', start);
|
||||
const end = lineEnd === -1 ? value.length : lineEnd;
|
||||
const line = value.slice(lineStart, end);
|
||||
const stripped = stripLinePrefix(line);
|
||||
const next = value.slice(0, lineStart) + prefix + stripped + value.slice(end);
|
||||
onChange(next);
|
||||
restoreSelection(lineStart + prefix.length + stripped.length);
|
||||
}, [value, onChange, getSelection, restoreSelection]);
|
||||
|
||||
/** 标题:无 → H2 → H3 → … → H6 → 取消 */
|
||||
const toggleHeading = useCallback(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const { start } = getSelection();
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const lineEnd = value.indexOf('\n', start);
|
||||
const end = lineEnd === -1 ? value.length : lineEnd;
|
||||
const line = value.slice(lineStart, end);
|
||||
const normalized = line.replace(/^\r/, '');
|
||||
const match = normalized.match(/^(#{1,6})\s+(.*)$/);
|
||||
|
||||
let nextLine: string;
|
||||
if (match) {
|
||||
const level = match[1].length;
|
||||
const text = match[2];
|
||||
nextLine = level >= 6 ? text : `${'#'.repeat(level + 1)} ${text}`;
|
||||
} else {
|
||||
nextLine = `## ${stripLinePrefix(normalized)}`;
|
||||
}
|
||||
|
||||
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
|
||||
onChange(next);
|
||||
const cursor = lineStart + nextLine.length;
|
||||
restoreSelection(cursor);
|
||||
}, [value, onChange, getSelection, restoreSelection]);
|
||||
|
||||
/** 包裹为仅登录用户可见区块 */
|
||||
const wrapMembersOnly = useCallback(() => {
|
||||
const { start, end } = getSelection();
|
||||
if (start !== end) {
|
||||
const selected = value.slice(start, end);
|
||||
const block = `\n:::members\n${selected}\n:::\n`;
|
||||
const next = value.slice(0, start) + block + value.slice(end);
|
||||
onChange(next);
|
||||
restoreSelection(start + ':::members\n'.length + 1);
|
||||
if (!editor || mode !== 'rich') return;
|
||||
if (isInternalUpdate.current) {
|
||||
isInternalUpdate.current = false;
|
||||
return;
|
||||
}
|
||||
insertAtCursor('\n:::members\n', '\n:::\n', '在此输入仅登录用户可见的内容…');
|
||||
}, [value, onChange, getSelection, restoreSelection, insertAtCursor]);
|
||||
const next = sanitizeHtml(value);
|
||||
if (next === lastValueRef.current) return;
|
||||
lastValueRef.current = next;
|
||||
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||
}, [value, editor, mode]);
|
||||
|
||||
const tools: ToolBtn[] = [
|
||||
{ icon: <strong>H</strong>, title: '标题(H2,再次点击升级)', action: toggleHeading },
|
||||
{ icon: <Bold size={15} />, title: '加粗', action: () => insertAtCursor('**', '**', '加粗') },
|
||||
{ icon: <Italic size={15} />, title: '斜体', action: () => insertAtCursor('*', '*', '斜体') },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', action: () => insertAtCursor('~~', '~~', '删除') },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: () => insertAtCursor('\n\n---\n\n') },
|
||||
{ icon: <Quote size={15} />, title: '引用', action: () => wrapLine('> ') },
|
||||
{ icon: <List size={15} />, title: '无序列表', action: () => wrapLine('- ') },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: () => wrapLine('1. ') },
|
||||
{ icon: <Code size={15} />, title: '代码块', action: () => insertAtCursor('\n```\n', '\n```\n', 'code') },
|
||||
{ icon: <Link size={15} />, title: '链接', action: () => insertAtCursor('[', '](url)', '链接文字') },
|
||||
{ icon: <Image size={15} />, title: '图片', action: () => insertAtCursor('', '描述') },
|
||||
{ icon: <LockKeyhole size={15} />, title: '登录可见(选中文字后点击可包裹)', action: wrapMembersOnly },
|
||||
];
|
||||
// 全屏时锁定页面滚动,Esc 退出
|
||||
useEffect(() => {
|
||||
if (!fullscreen) return undefined;
|
||||
|
||||
const displayPreviewHtml = useMemo(() => {
|
||||
if (!value.trim()) {
|
||||
return `<p class="article-preview-placeholder">${placeholder}</p>`;
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setFullscreen(false);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = prevOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getHTML: () => {
|
||||
if (mode === 'markdown') {
|
||||
return sanitizeHtml(markdownToHtml(markdownSource));
|
||||
}
|
||||
return editor ? sanitizeHtml(editor.getHTML()) : value;
|
||||
},
|
||||
isEmpty: () => {
|
||||
if (mode === 'markdown') {
|
||||
return markdownSource.trim().length === 0;
|
||||
}
|
||||
return editor ? isEditorEmpty(editor) : !value.trim();
|
||||
},
|
||||
focus: () => {
|
||||
if (mode === 'markdown') {
|
||||
markdownRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
editor?.commands.focus();
|
||||
},
|
||||
}), [editor, value, mode, markdownSource]);
|
||||
|
||||
const handleMarkdownChange = useCallback((next: string) => {
|
||||
setMarkdownSource(next);
|
||||
const html = sanitizeHtml(markdownToHtml(next));
|
||||
isInternalUpdate.current = true;
|
||||
lastValueRef.current = html;
|
||||
onChange(html);
|
||||
}, [onChange]);
|
||||
|
||||
const openLinkDialog = useCallback((target: LinkTarget) => {
|
||||
if (target === 'rich') {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
setLinkDialogUrl(prev ?? '');
|
||||
} else {
|
||||
setLinkDialogUrl('');
|
||||
}
|
||||
return renderPostContentHtml(previewHtml, true);
|
||||
}, [value, previewHtml, placeholder]);
|
||||
setLinkTarget(target);
|
||||
setLinkDialogOpen(true);
|
||||
}, [editor]);
|
||||
|
||||
const words = countWords(value);
|
||||
const showEdit = viewMode === 'edit' || viewMode === 'split';
|
||||
const showPreview = viewMode === 'preview' || viewMode === 'split';
|
||||
const applyLink = useCallback((url: string) => {
|
||||
if (linkTarget === 'markdown') {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea || !url) return;
|
||||
insertMarkdownLink(textarea, markdownSource, url, handleMarkdownChange);
|
||||
return;
|
||||
}
|
||||
if (!editor) return;
|
||||
if (!url) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||||
}, [editor, linkTarget, markdownSource, handleMarkdownChange]);
|
||||
|
||||
const removeLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
}, [editor]);
|
||||
|
||||
const setImage = useCallback(async () => {
|
||||
if (!editor) return;
|
||||
const url = await uploadPostImageFile();
|
||||
if (url) {
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const wrapMembersOnly = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
if (editor.isActive('membersOnly')) {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (!empty && from !== to) {
|
||||
editor.chain().focus().wrapMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().insertMembersOnly().run();
|
||||
}, [editor]);
|
||||
|
||||
const switchToMarkdown = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const html = sanitizeHtml(editor.getHTML());
|
||||
lastValueRef.current = html;
|
||||
setMarkdownSource(htmlToMarkdown(html));
|
||||
setMode('markdown');
|
||||
}, [editor]);
|
||||
|
||||
const switchToRich = useCallback(() => {
|
||||
const html = sanitizeHtml(markdownToHtml(markdownSource));
|
||||
lastValueRef.current = html;
|
||||
isInternalUpdate.current = true;
|
||||
onChange(html);
|
||||
if (editor) {
|
||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||
}
|
||||
setMode('rich');
|
||||
}, [editor, markdownSource, onChange]);
|
||||
|
||||
const withMarkdown = useCallback((fn: (
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: (v: string) => void,
|
||||
) => void) => () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
fn(textarea, markdownSource, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const insertMarkdownImage = useCallback(async () => {
|
||||
const textarea = markdownRef.current;
|
||||
if (!textarea) return;
|
||||
const url = await uploadPostImageFile();
|
||||
if (!url) return;
|
||||
insertAtCursor(textarea, markdownSource, `\n\n\n\n`, handleMarkdownChange);
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const markdownPreviewHtml = useMemo(
|
||||
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
|
||||
[markdownSource],
|
||||
);
|
||||
|
||||
const buildRichTools = useCallback((): ToolBtn[] => {
|
||||
if (!editor) return [];
|
||||
return [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', active: editor.isActive('heading'), action: () => cycleHeading(editor) },
|
||||
{ icon: <Bold size={15} />, title: '加粗', active: editor.isActive('bold'), action: () => editor.chain().focus().toggleBold().run() },
|
||||
{ icon: <Italic size={15} />, title: '斜体', active: editor.isActive('italic'), action: () => editor.chain().focus().toggleItalic().run() },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', active: editor.isActive('underline'), action: () => editor.chain().focus().toggleUnderline().run() },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', active: editor.isActive('strike'), action: () => editor.chain().focus().toggleStrike().run() },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: () => editor.chain().focus().setHorizontalRule().run() },
|
||||
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
|
||||
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
|
||||
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: setImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '独立输入区;Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
},
|
||||
];
|
||||
}, [editor, openLinkDialog, setImage, wrapMembersOnly]);
|
||||
|
||||
const buildMarkdownTools = useCallback((): ToolBtn[] => [
|
||||
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
|
||||
{ icon: <Bold size={15} />, title: '加粗', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '**', '**', '加粗文字', ch)) },
|
||||
{ icon: <Italic size={15} />, title: '斜体', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '*', '*', '斜体文字', ch)) },
|
||||
{ icon: <UnderlineIcon size={15} />, title: '下划线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '<u>', '</u>', '下划线文字', ch)) },
|
||||
{ icon: <Strikethrough size={15} />, title: '删除线', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '~~', '~~', '删除线文字', ch)) },
|
||||
{ icon: <Minus size={15} />, title: '分割线', action: withMarkdown((ta, v, ch) => insertAtCursor(ta, v, '\n\n---\n\n', ch)) },
|
||||
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
|
||||
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
|
||||
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
|
||||
{ icon: <Code size={15} />, title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
|
||||
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
|
||||
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '插入 <members-only> 区块',
|
||||
className: 'article-tool-btn--members',
|
||||
action: withMarkdown(insertMarkdownMembersOnly),
|
||||
},
|
||||
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
|
||||
|
||||
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
|
||||
const words = mode === 'markdown'
|
||||
? countWords(markdownSource)
|
||||
: (editor ? countWords(editor.getText()) : 0);
|
||||
|
||||
return (
|
||||
<div className="article-editor">
|
||||
<div className={`article-editor article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||||
<div className="article-editor-bar">
|
||||
<div className="article-editor-tools">
|
||||
{tools.map((t, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className={`article-tool-btn${i === tools.length - 1 ? ' article-tool-btn--members' : ''}`}
|
||||
title={t.title}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={t.action}
|
||||
>
|
||||
{t.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="article-editor-modes">
|
||||
<button
|
||||
type="button"
|
||||
className={`article-mode-btn${viewMode === 'edit' ? ' active' : ''}`}
|
||||
onClick={() => setViewMode('edit')}
|
||||
title="仅编辑"
|
||||
>
|
||||
<Pencil size={14} /> 编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`article-mode-btn${viewMode === 'split' ? ' active' : ''}`}
|
||||
onClick={() => setViewMode('split')}
|
||||
title="分栏预览"
|
||||
>
|
||||
分栏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`article-mode-btn${viewMode === 'preview' ? ' active' : ''}`}
|
||||
onClick={() => setViewMode('preview')}
|
||||
title="仅预览"
|
||||
>
|
||||
<Eye size={14} /> 预览
|
||||
</button>
|
||||
{renderToolButtons(tools)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`article-editor-panes article-editor-panes--${viewMode}`}>
|
||||
{showEdit && (
|
||||
<div className="article-pane article-pane--edit">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="article-textarea"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
onSelect={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
onClick={saveSelection}
|
||||
onFocus={saveSelection}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="article-editor-body">
|
||||
{mode === 'rich' ? (
|
||||
<div className="article-editor-pane article-editor-pane--rich">
|
||||
<div className="article-editor-scroll">
|
||||
<EditorContent editor={editor} className="article-editor-content" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showPreview && (
|
||||
<div className="article-pane article-pane--preview">
|
||||
{viewMode === 'split' && <div className="article-pane-label">实时预览</div>}
|
||||
<div
|
||||
className={`article-preview post-detail-content${!value.trim() ? ' article-preview--empty' : ''}`}
|
||||
dangerouslySetInnerHTML={{ __html: displayPreviewHtml }}
|
||||
/>
|
||||
) : (
|
||||
<div className="article-editor-markdown">
|
||||
<div className="article-editor-pane article-editor-pane--source">
|
||||
<div className="article-editor-scroll">
|
||||
<textarea
|
||||
ref={markdownRef}
|
||||
className="article-editor-markdown-input"
|
||||
value={markdownSource}
|
||||
onChange={e => handleMarkdownChange(e.target.value)}
|
||||
onKeyDown={e => handleMarkdownTabKey(e, handleMarkdownChange)}
|
||||
placeholder="在此编写 Markdown 源码…"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="article-editor-markdown-preview">
|
||||
<div className="article-editor-markdown-preview-label">预览</div>
|
||||
<div
|
||||
className="article-editor-markdown-preview-body post-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status">
|
||||
<span>{words} 字</span>
|
||||
<span>Markdown</span>
|
||||
<div className="article-editor-status-meta">
|
||||
<span>{words} 字</span>
|
||||
<span className="article-editor-status-sep">·</span>
|
||||
<span>
|
||||
{mode === 'rich' ? '富文本' : 'Markdown 源码'}
|
||||
{' · Tab 缩进 / Shift+Tab 回退'}
|
||||
{mode === 'rich' ? ' · 登录可见内 Ctrl+Enter 退出' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="article-editor-status-actions">
|
||||
<Tooltip
|
||||
content={mode === 'rich' ? 'Markdown 源码' : '富文本编辑'}
|
||||
hint={mode === 'rich' ? '切换为 Markdown 源码编写' : '返回所见即所得编辑'}
|
||||
align="end"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`article-editor-view-btn${mode === 'markdown' ? ' active' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={mode === 'rich' ? switchToMarkdown : switchToRich}
|
||||
>
|
||||
{mode === 'rich' ? <FileCode size={15} /> : <PenLine size={15} />}
|
||||
<span>{mode === 'rich' ? '源码' : '富文本'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
content={fullscreen ? '退出全屏' : '全屏编辑'}
|
||||
hint={fullscreen ? 'Esc 也可退出' : '沉浸式编写长文'}
|
||||
align="end"
|
||||
side="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="article-editor-view-btn"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => setFullscreen(v => !v)}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
|
||||
<span>{fullscreen ? '退出全屏' : '全屏'}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArticleLinkDialog
|
||||
open={linkDialogOpen}
|
||||
onOpenChange={setLinkDialogOpen}
|
||||
initialUrl={linkDialogUrl}
|
||||
onConfirm={applyLink}
|
||||
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
116
frontend/src/components/AvatarCropDialog.tsx
Normal file
116
frontend/src/components/AvatarCropDialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import Cropper, { type Area } from 'react-easy-crop';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { getCroppedAvatarFile } from '../utils/avatarCrop';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
imageSrc: string | null;
|
||||
fileName?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (file: File) => void;
|
||||
}
|
||||
|
||||
export default function AvatarCropDialog({
|
||||
open,
|
||||
imageSrc,
|
||||
fileName,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: Props) {
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [croppedAreaPixels, setCroppedAreaPixels] = useState<Area | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setCroppedAreaPixels(null);
|
||||
}
|
||||
}, [open, imageSrc]);
|
||||
|
||||
const onCropComplete = useCallback((_: Area, pixels: Area) => {
|
||||
setCroppedAreaPixels(pixels);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!imageSrc || !croppedAreaPixels) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
const file = await getCroppedAvatarFile(imageSrc, croppedAreaPixels, fileName);
|
||||
onConfirm(file);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
notify.error('裁剪失败,请重试');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="avatar-crop-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>裁剪头像</DialogTitle>
|
||||
<DialogDescription>
|
||||
拖动图片调整位置,滚轮或滑块缩放。GIF 裁剪后将变为静态 JPG。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="avatar-crop-stage">
|
||||
{imageSrc ? (
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
showGrid={false}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
) : (
|
||||
<div className="avatar-crop-loading">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="avatar-crop-zoom">
|
||||
<span className="avatar-crop-zoom-label">缩放</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={e => setZoom(Number(e.target.value))}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={confirming} onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button loading={confirming} disabled={!imageSrc} onClick={handleConfirm}>
|
||||
确认裁剪
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
93
frontend/src/components/BoardAppearancePicker.tsx
Normal file
93
frontend/src/components/BoardAppearancePicker.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BOARD_ICON_OPTIONS,
|
||||
BOARD_PALETTE_SIZE,
|
||||
getBoardIcon,
|
||||
getBoardThemeIndex,
|
||||
} from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
interface IconPickerProps {
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
board: Pick<Board, 'id' | 'color_index'>;
|
||||
}
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: number;
|
||||
onChange: (index: number) => void;
|
||||
boardId: number;
|
||||
}
|
||||
|
||||
/** 板块图标选择器 */
|
||||
export function BoardIconPicker({ value, onChange, board }: IconPickerProps) {
|
||||
const previewBoard = { id: board.id, icon: value || undefined, color_index: board.color_index };
|
||||
const PreviewIcon = getBoardIcon(previewBoard);
|
||||
const themeIdx = getBoardThemeIndex(previewBoard);
|
||||
|
||||
return (
|
||||
<div className="board-appearance-picker">
|
||||
<div className="board-appearance-picker__preview">
|
||||
<span className={cn('board-appearance-picker__preview-icon', `sidebar-board-icon--${themeIdx}`)}>
|
||||
<PreviewIcon aria-hidden />
|
||||
</span>
|
||||
<span className="board-appearance-picker__preview-hint">预览</span>
|
||||
</div>
|
||||
<div className="board-icon-grid" role="listbox" aria-label="选择板块图标">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={!value}
|
||||
className={cn('board-icon-option', !value && 'board-icon-option--active')}
|
||||
title="自动(按板块 ID)"
|
||||
onClick={() => onChange('')}
|
||||
>
|
||||
<span className="board-icon-option__auto">A</span>
|
||||
</button>
|
||||
{BOARD_ICON_OPTIONS.map(({ key, label, Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === key}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn('board-icon-option', value === key && 'board-icon-option--active')}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 板块色标选择器(-1 为自动) */
|
||||
export function BoardColorPicker({ value, onChange, boardId }: ColorPickerProps) {
|
||||
return (
|
||||
<div className="board-color-grid" role="listbox" aria-label="选择板块色标">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value < 0}
|
||||
className={cn('board-color-option board-color-option--auto', value < 0 && 'board-color-option--active')}
|
||||
title="自动(按板块 ID)"
|
||||
onClick={() => onChange(-1)}
|
||||
>
|
||||
A
|
||||
</button>
|
||||
{Array.from({ length: BOARD_PALETTE_SIZE }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={value === i}
|
||||
className={cn('board-color-option', `board-color-option--${i}`, value === i && 'board-color-option--active')}
|
||||
title={`色标 ${i + 1}`}
|
||||
onClick={() => onChange(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/BoardBadge.tsx
Normal file
18
frontend/src/components/BoardBadge.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
board: Pick<Board, 'id' | 'name' | 'color_index'>;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 按板块配置或 id 映射不同色标的 Badge */
|
||||
export default function BoardBadge({ board, className }: Props) {
|
||||
const idx = getBoardThemeIndex(board);
|
||||
return (
|
||||
<span className={cn('board-badge', `board-badge--${idx}`, className)}>
|
||||
{board.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LayoutGrid, Folder, Plus } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
interface Props {
|
||||
boards: Board[];
|
||||
loading?: boolean;
|
||||
selectedId?: number;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function BoardGrid({ boards, loading = false, selectedId = 0, onSelect }: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="board-grid board-grid--skeleton" aria-hidden>
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="board-tab board-tab--skeleton" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (boards.length === 0) {
|
||||
return (
|
||||
<div className="board-grid-empty">
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{user?.role === 'admin' ? '还没有板块,请先创建' : '管理员尚未创建板块'}
|
||||
</p>
|
||||
{user?.role === 'admin' && (
|
||||
<div style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
<Button onClick={() => nav('/boards')}>
|
||||
<Plus />
|
||||
创建板块
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="board-grid">
|
||||
<button
|
||||
type="button"
|
||||
className={`board-tab ${selectedId === 0 ? 'active' : ''}`}
|
||||
onClick={() => onSelect(0)}
|
||||
>
|
||||
<span className="board-tab-icon"><LayoutGrid size={16} /></span>
|
||||
<span className="board-tab-name">全部帖子</span>
|
||||
</button>
|
||||
{boards.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
className={`board-tab ${selectedId === b.id ? 'active' : ''}`}
|
||||
title={b.description ? `${b.name} — ${b.description}` : b.name}
|
||||
onClick={() => onSelect(b.id)}
|
||||
>
|
||||
<span className="board-tab-icon"><Folder size={16} /></span>
|
||||
<span className="board-tab-name">{b.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
frontend/src/components/BoardIconDisplay.tsx
Normal file
14
frontend/src/components/BoardIconDisplay.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { getBoardIcon } from '../utils/boardTheme';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
/** 渲染板块 Lucide 图标 */
|
||||
export default function BoardIconDisplay({
|
||||
board,
|
||||
className,
|
||||
}: {
|
||||
board: Pick<Board, 'id' | 'icon' | 'color_index'>;
|
||||
className?: string;
|
||||
}) {
|
||||
const Icon = getBoardIcon(board);
|
||||
return <Icon className={className} aria-hidden />;
|
||||
}
|
||||
@@ -8,12 +8,11 @@ interface Props {
|
||||
/** 渲染评论正文(支持正文内 @ 高亮) */
|
||||
export default function CommentContent({ content, onMentionClick }: Props) {
|
||||
return (
|
||||
<div className="floor-body">
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="floor-body"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: highlightMentions(content, onMentionClick),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus, Settings } from 'lucide-react';
|
||||
import { Users, FileText, LayoutGrid } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Board, ForumStats } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
interface Props {
|
||||
boardId: number;
|
||||
@@ -14,55 +12,47 @@ interface Props {
|
||||
|
||||
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const board = boards.find(b => b.id === boardId);
|
||||
|
||||
const title = keyword
|
||||
? `搜索:${keyword}`
|
||||
: (boardId && board ? board.name : '全部帖子');
|
||||
|
||||
const hint = keyword
|
||||
? `找到 ${postTotal} 篇相关帖子`
|
||||
: boardId && board
|
||||
? (board.description || '欢迎在本板块交流讨论')
|
||||
: '姜十三论坛 · 拾三一隅,自在交流';
|
||||
const boardHint = boardId && board ? (board.description || '') : '';
|
||||
|
||||
return (
|
||||
<div className="feed-banner">
|
||||
<div className="feed-banner-row">
|
||||
<div className="feed-banner-title">
|
||||
<h2>{title}</h2>
|
||||
<p>{hint}</p>
|
||||
</div>
|
||||
{!keyword && (
|
||||
<div className="feed-actions flex flex-wrap items-center gap-2">
|
||||
{authLoading ? (
|
||||
<span className="feed-action-slot" aria-hidden />
|
||||
) : user ? (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => nav(boardId ? `/compose?board=${boardId}` : '/compose')}
|
||||
>
|
||||
<Plus />
|
||||
{boardId ? '在此发帖' : '发布帖子'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => nav('/login')}>登录参与</Button>
|
||||
)}
|
||||
{!authLoading && user?.role === 'admin' && (
|
||||
<Button size="sm" variant="outline" onClick={() => nav('/boards')}>
|
||||
<Settings />
|
||||
管理板块
|
||||
</Button>
|
||||
)}
|
||||
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
|
||||
<div className="feed-head__title">
|
||||
<h2 title={boardHint || undefined}>{title}</h2>
|
||||
{!keyword && stats && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<Users aria-hidden />
|
||||
会员 <strong>{stats.users}</strong>
|
||||
</span>
|
||||
<span className="feed-stat-chip">
|
||||
<FileText aria-hidden />
|
||||
帖子 <strong>{stats.posts}</strong>
|
||||
</span>
|
||||
<span className="feed-stat-chip">
|
||||
<LayoutGrid aria-hidden />
|
||||
板块 <strong>{stats.boards}</strong>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="feed-stats">
|
||||
<span>会员 <strong>{stats?.users ?? '—'}</strong></span>
|
||||
<span>帖子 <strong>{stats?.posts ?? '—'}</strong></span>
|
||||
<span>板块 <strong>{stats?.boards ?? '—'}</strong></span>
|
||||
</div>
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="feed-head__clear"
|
||||
onClick={() => nav('/')}
|
||||
>
|
||||
清除搜索
|
||||
</button>
|
||||
)}
|
||||
{keyword && (
|
||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
64
frontend/src/components/FeedSortBar.tsx
Normal file
64
frontend/src/components/FeedSortBar.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Clock, MessageCircle, Flame } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type FeedSort = 'latest' | 'reply' | 'hot';
|
||||
|
||||
const SORT_OPTIONS: {
|
||||
key: FeedSort;
|
||||
label: string;
|
||||
hint: string;
|
||||
icon: typeof Clock;
|
||||
}[] = [
|
||||
{ key: 'latest', label: '最新发帖', hint: '按发布时间', icon: Clock },
|
||||
{ key: 'reply', label: '最新回复', hint: '最近有人回复', icon: MessageCircle },
|
||||
{ key: 'hot', label: '热门讨论', hint: '按互动热度', icon: Flame },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
value: FeedSort;
|
||||
onChange: (sort: FeedSort) => void;
|
||||
postTotal?: number;
|
||||
}
|
||||
|
||||
export function parseFeedSort(raw: string | null): FeedSort {
|
||||
if (raw === 'reply' || raw === 'hot') return raw;
|
||||
return 'latest';
|
||||
}
|
||||
|
||||
export function buildHomeUrl(boardId: number, sort: FeedSort = 'latest') {
|
||||
const p = new URLSearchParams();
|
||||
if (boardId) p.set('board', String(boardId));
|
||||
if (sort !== 'latest') p.set('sort', sort);
|
||||
const qs = p.toString();
|
||||
return qs ? `/?${qs}` : '/';
|
||||
}
|
||||
|
||||
export function feedSortLabel(sort: FeedSort): string {
|
||||
return SORT_OPTIONS.find(o => o.key === sort)?.label ?? '帖子列表';
|
||||
}
|
||||
|
||||
export default function FeedSortBar({ value, onChange, postTotal }: Props) {
|
||||
return (
|
||||
<div className="feed-toolbar">
|
||||
<div className="feed-sort-bar" role="tablist" aria-label="帖子排序">
|
||||
{SORT_OPTIONS.map(({ key, label, hint, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={value === key}
|
||||
title={`${label} · ${hint}`}
|
||||
className={cn('feed-sort-tab', value === key && 'active')}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
<Icon aria-hidden />
|
||||
<span className="feed-sort-tab__label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{postTotal != null && (
|
||||
<span className="feed-toolbar__count">共 {postTotal} 条</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
frontend/src/components/PinnedIcon.tsx
Normal file
20
frontend/src/components/PinnedIcon.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Pin } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 置顶图钉标识 */
|
||||
export default function PinnedIcon({ className, size = 16 }: Props) {
|
||||
return (
|
||||
<Pin
|
||||
className={cn('post-pinned-icon', className)}
|
||||
size={size}
|
||||
fill="currentColor"
|
||||
aria-label="置顶"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,25 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
import { formatTime } from '../utils/content';
|
||||
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
sort?: FeedSort;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function PostListItem({ post, onClick }: Props) {
|
||||
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
|
||||
const initial = post.user?.nickname?.[0] || '?';
|
||||
const timeLabel = sort === 'reply'
|
||||
? (post.last_reply_at
|
||||
? `${formatTime(post.last_reply_at)} 回复`
|
||||
: '暂无回复')
|
||||
: formatTime(post.created_at);
|
||||
const commentCount = post.comment_count ?? 0;
|
||||
const likeCount = post.like_count ?? 0;
|
||||
|
||||
return (
|
||||
<div className="post-row" onClick={onClick}>
|
||||
@@ -17,18 +28,24 @@ export default function PostListItem({ post, onClick }: Props) {
|
||||
</div>
|
||||
<div className="post-body">
|
||||
<div className="post-title">
|
||||
{post.pinned && <Badge variant="orange" className="mr-1.5">置顶</Badge>}
|
||||
{post.pinned && <PinnedIcon className="mr-1.5" />}
|
||||
{post.title}
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
{post.board && <Badge variant="green">{post.board.name}</Badge>}
|
||||
{post.board && <BoardBadge board={post.board} />}
|
||||
<span>{post.user?.nickname || '匿名'}</span>
|
||||
<span>{formatTime(post.created_at)}</span>
|
||||
<span>{timeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<span>💬 {post.comment_count ?? 0}</span>
|
||||
<span>👍 {post.like_count ?? 0}</span>
|
||||
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
|
||||
<MessageCircle aria-hidden />
|
||||
{commentCount}
|
||||
</span>
|
||||
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
|
||||
<ThumbsUp aria-hidden />
|
||||
{likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
30
frontend/src/components/PostListSkeleton.tsx
Normal file
30
frontend/src/components/PostListSkeleton.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** 帖子列表加载骨架屏 */
|
||||
export default function PostListSkeleton({ count = 8 }: Props) {
|
||||
return (
|
||||
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="post-row post-row--skeleton">
|
||||
<Skeleton className="skeleton--avatar" />
|
||||
<div className="post-body">
|
||||
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
|
||||
<div className="skeleton-meta-row">
|
||||
<Skeleton className="skeleton--badge" />
|
||||
<Skeleton className="skeleton--meta" />
|
||||
<Skeleton className="skeleton--meta skeleton--meta-short" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
334
frontend/src/components/PostRevisionPanel.tsx
Normal file
334
frontend/src/components/PostRevisionPanel.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
History, X, Maximize2, Minimize2, GitCompare, FileText, ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostRevision } from '../api/types';
|
||||
import PostContent from './PostContent';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import {
|
||||
type PostSnapshot,
|
||||
htmlToDiffText,
|
||||
summarizeChange,
|
||||
diffTextLines,
|
||||
diffTextWords,
|
||||
countLineChanges,
|
||||
} from '../utils/revisionDiff';
|
||||
|
||||
interface Props {
|
||||
postId: number;
|
||||
currentPost: PostSnapshot;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
type ViewMode = 'diff' | 'before' | 'after';
|
||||
|
||||
interface RevisionEntry {
|
||||
rev: PostRevision;
|
||||
after: PostSnapshot;
|
||||
summary: ReturnType<typeof summarizeChange>;
|
||||
index: number;
|
||||
}
|
||||
|
||||
function DiffWords({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffTextWords(before, after);
|
||||
if (before === after) {
|
||||
return <span className="revision-diff-unchanged">{before || '(空)'}</span>;
|
||||
}
|
||||
return (
|
||||
<span className="revision-diff-inline">
|
||||
{parts.map((part, i) => {
|
||||
if (part.added) return <ins key={i}>{part.value}</ins>;
|
||||
if (part.removed) return <del key={i}>{part.value}</del>;
|
||||
return <span key={i}>{part.value}</span>;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffLines({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffTextLines(before, after);
|
||||
const { added, removed } = countLineChanges(parts);
|
||||
|
||||
if (before === after) {
|
||||
return <p className="revision-diff-unchanged">正文无变化</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="revision-diff-lines">
|
||||
<div className="revision-diff-stats">
|
||||
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del">删除 {removed} 行</span>}
|
||||
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add">新增 {added} 行</span>}
|
||||
</div>
|
||||
<pre className="revision-diff-pre">
|
||||
{parts.map((part, i) => {
|
||||
const lines = part.value.split('\n');
|
||||
return lines.map((line, j) => {
|
||||
if (j === lines.length - 1 && line === '') return null;
|
||||
const cls = part.added
|
||||
? 'revision-diff-line revision-diff-line--add'
|
||||
: part.removed
|
||||
? 'revision-diff-line revision-diff-line--del'
|
||||
: 'revision-diff-line revision-diff-line--same';
|
||||
const prefix = part.added ? '+' : part.removed ? '−' : ' ';
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={cls}>
|
||||
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
|
||||
<span className="revision-diff-text">{line || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeBadges({ summary }: { summary: ReturnType<typeof summarizeChange> }) {
|
||||
if (!summary.hasChanges) return <span className="revision-badge revision-badge--none">无变更</span>;
|
||||
return (
|
||||
<>
|
||||
{summary.titleChanged && <span className="revision-badge">标题</span>}
|
||||
{summary.contentChanged && <span className="revision-badge">正文</span>}
|
||||
{summary.tagsChanged && <span className="revision-badge">标签</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PostRevisionPanel({ postId, currentPost, open, onClose, isLoggedIn }: Props) {
|
||||
const [revisions, setRevisions] = useState<PostRevision[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('diff');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSelectedId(null);
|
||||
setViewMode('diff');
|
||||
setFullscreen(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api.postRevisions(postId)
|
||||
.then(d => {
|
||||
const list = d.revisions ?? [];
|
||||
setRevisions(list);
|
||||
if (list.length > 0) setSelectedId(list[0].id);
|
||||
})
|
||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载历史失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, postId]);
|
||||
|
||||
// 阻止背景滚动
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = prev; };
|
||||
}, [open]);
|
||||
|
||||
const entries: RevisionEntry[] = useMemo(() => {
|
||||
return revisions.map((rev, index) => {
|
||||
const after: PostSnapshot = index === 0
|
||||
? currentPost
|
||||
: {
|
||||
title: revisions[index - 1].title,
|
||||
content: revisions[index - 1].content,
|
||||
tags: revisions[index - 1].tags,
|
||||
};
|
||||
const before: PostSnapshot = {
|
||||
title: rev.title,
|
||||
content: rev.content,
|
||||
tags: rev.tags,
|
||||
};
|
||||
return {
|
||||
rev,
|
||||
after,
|
||||
summary: summarizeChange(before, after),
|
||||
index: revisions.length - index,
|
||||
};
|
||||
});
|
||||
}, [revisions, currentPost]);
|
||||
|
||||
const selected = entries.find(e => e.rev.id === selectedId) ?? null;
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const beforeSnap: PostSnapshot | null = selected
|
||||
? { title: selected.rev.title, content: selected.rev.content, tags: selected.rev.tags }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`post-revision-overlay${fullscreen ? ' post-revision-overlay--fullscreen' : ''}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`post-revision-panel${fullscreen ? ' post-revision-panel--fullscreen' : ''}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="编辑历史"
|
||||
>
|
||||
<header className="post-revision-head">
|
||||
<div className="post-revision-head-left">
|
||||
<History size={18} />
|
||||
<h3>编辑历史</h3>
|
||||
{selected && (
|
||||
<span className="post-revision-head-sub">
|
||||
第 {selected.index} 次编辑
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="post-revision-head-actions">
|
||||
<div className="post-revision-view-tabs" role="tablist">
|
||||
{([
|
||||
['diff', GitCompare, '变更对比'],
|
||||
['before', FileText, '编辑前'],
|
||||
['after', ArrowRight, '编辑后'],
|
||||
] as const).map(([mode, Icon, label]) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={viewMode === mode}
|
||||
className={`post-revision-tab${viewMode === mode ? ' active' : ''}`}
|
||||
onClick={() => setViewMode(mode)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
<span className="post-revision-tab-label">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="post-revision-icon-btn"
|
||||
onClick={() => setFullscreen(f => !f)}
|
||||
title={fullscreen ? '退出全屏' : '全屏显示'}
|
||||
aria-label={fullscreen ? '退出全屏' : '全屏显示'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
|
||||
</button>
|
||||
<button type="button" className="post-revision-icon-btn" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading ? (
|
||||
<div className="post-revision-loading"><Spinner size="lg" /></div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="post-revision-empty">暂无编辑记录</p>
|
||||
) : (
|
||||
<div className="post-revision-body">
|
||||
<aside className="post-revision-sidebar">
|
||||
<div className="post-revision-sidebar-label">时间线</div>
|
||||
<ul className="post-revision-list">
|
||||
{entries.map(entry => (
|
||||
<li key={entry.rev.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`post-revision-item${selectedId === entry.rev.id ? ' active' : ''}`}
|
||||
onClick={() => setSelectedId(entry.rev.id)}
|
||||
>
|
||||
<div className="post-revision-item-head">
|
||||
<span className="post-revision-item-num">#{entry.index}</span>
|
||||
<ChangeBadges summary={entry.summary} />
|
||||
</div>
|
||||
<span className="post-revision-item-title">{entry.rev.title}</span>
|
||||
<span className="post-revision-item-meta">
|
||||
{entry.rev.editor?.nickname ?? '未知'} · {formatDateTime(entry.rev.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="post-revision-current">
|
||||
<span className="post-revision-current-label">当前版本</span>
|
||||
<span className="post-revision-current-title">{currentPost.title}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="post-revision-main">
|
||||
{selected && beforeSnap ? (
|
||||
<>
|
||||
<div className="post-revision-main-head">
|
||||
<div>
|
||||
<span className="post-revision-main-editor">
|
||||
{selected.rev.editor?.nickname ?? '未知'}
|
||||
</span>
|
||||
<span className="post-revision-main-time">
|
||||
{formatDateTime(selected.rev.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<ChangeBadges summary={selected.summary} />
|
||||
</div>
|
||||
|
||||
<div className="post-revision-scroll">
|
||||
{viewMode === 'diff' && (
|
||||
<div className="revision-diff-view">
|
||||
<section className="revision-diff-section">
|
||||
<h4>标题</h4>
|
||||
<div className="revision-diff-block">
|
||||
<DiffWords before={beforeSnap.title} after={selected.after.title} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{(beforeSnap.tags || selected.after.tags) && (
|
||||
<section className="revision-diff-section">
|
||||
<h4>标签</h4>
|
||||
<div className="revision-diff-block">
|
||||
<DiffWords
|
||||
before={beforeSnap.tags || '(无)'}
|
||||
after={selected.after.tags || '(无)'}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="revision-diff-section revision-diff-section--content">
|
||||
<h4>正文</h4>
|
||||
<DiffLines
|
||||
before={htmlToDiffText(beforeSnap.content)}
|
||||
after={htmlToDiffText(selected.after.content)}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'before' && (
|
||||
<div className="revision-full-view">
|
||||
<h4>{beforeSnap.title}</h4>
|
||||
{beforeSnap.tags && (
|
||||
<p className="revision-full-tags">标签:{beforeSnap.tags}</p>
|
||||
)}
|
||||
<PostContent html={beforeSnap.content} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === 'after' && (
|
||||
<div className="revision-full-view">
|
||||
<h4>{selected.after.title}</h4>
|
||||
{selected.after.tags && (
|
||||
<p className="revision-full-tags">标签:{selected.after.tags}</p>
|
||||
)}
|
||||
<PostContent html={selected.after.content} isLoggedIn={isLoggedIn} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="post-revision-empty">请从左侧选择一次编辑记录</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Flame, Megaphone, Users } from 'lucide-react';
|
||||
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
||||
|
||||
interface Props {
|
||||
@@ -7,58 +8,73 @@ interface Props {
|
||||
onPostClick: (id: number) => void;
|
||||
}
|
||||
|
||||
function hotRankClass(index: number): string {
|
||||
if (index === 0) return 'widget-rank widget-rank--1';
|
||||
if (index === 1) return 'widget-rank widget-rank--2';
|
||||
if (index === 2) return 'widget-rank widget-rank--3';
|
||||
return 'widget-rank';
|
||||
}
|
||||
|
||||
export default function RightPanel({ hot, notifications, online, onPostClick }: Props) {
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
||||
const members = online?.users ?? [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="aside-panel-inner">
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">🔥 热门帖子</div>
|
||||
<div className="widget-card-head">
|
||||
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
热门帖子
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{hotList.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}>暂无数据</div>
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<span style={{ color: i < 3 ? '#e74c3c' : 'var(--color-text-3)', fontWeight: 600, minWidth: 18 }}>{i + 1}</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">📢 最新动态</div>
|
||||
<div className="widget-card-head">
|
||||
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新动态
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{noticeList.length === 0 ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)', padding: '8px 0' }}>暂无动态</div>
|
||||
<div className="widget-empty">暂无动态</div>
|
||||
) : noticeList.map(item => (
|
||||
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.title}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--color-text-4)', flexShrink: 0 }}>{item.created_at}</span>
|
||||
<div key={item.id} className="widget-item widget-item--notice" onClick={() => onPostClick(item.id)}>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">👀 当前浏览 {online?.count ?? '—'} 人</div>
|
||||
<div className="widget-card-head">
|
||||
<Users className="widget-card-icon widget-card-icon--online" aria-hidden />
|
||||
当前浏览 <span className="widget-head-count">{online?.count ?? '—'}</span> 人
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-3)', marginBottom: 8 }}>
|
||||
<div className="widget-online-meta">
|
||||
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
<div className="widget-online-list">
|
||||
{members.map(u => (
|
||||
<span key={u.id} title={u.nickname} style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--j13-green)',
|
||||
color: '#fff', fontSize: 12, display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{u.nickname?.[0] || '?'}
|
||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
||||
{u.avatar
|
||||
? <img src={u.avatar} alt="" />
|
||||
: (u.nickname?.[0] || '?')}
|
||||
</span>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<span style={{ fontSize: 13, color: 'var(--color-text-3)' }}>暂无会员在线</span>
|
||||
<span className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -72,6 +88,6 @@ export default function RightPanel({ hot, notifications, online, onPostClick }:
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
Home, Settings, Star, LayoutDashboard,
|
||||
Home, Star, LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import BoardIconDisplay from './BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
@@ -17,7 +20,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/boards')) return 'boards';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
}
|
||||
|
||||
@@ -30,6 +33,8 @@ interface Props {
|
||||
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
@@ -51,24 +56,40 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); nav('/'); })}
|
||||
{navItem('all', '全部帖子', <Home />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star />, () => nav('/favorites'))}
|
||||
</nav>
|
||||
|
||||
{boards.length > 0 && (
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>板块</div>
|
||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||
<nav className="sidebar-nav">
|
||||
{boards.map(b => (
|
||||
<button
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn('sidebar-nav-item', menuKey != null && menuKey === String(b.id) && 'active')}
|
||||
onClick={() => { onSelectBoard(b.id); nav(`/?board=${b.id}`); }}
|
||||
>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{boards.map(b => {
|
||||
const isActive = menuKey != null && menuKey === String(b.id);
|
||||
const themeIdx = getBoardThemeIndex(b);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={b.id}
|
||||
className={cn(
|
||||
'sidebar-nav-item',
|
||||
'sidebar-nav-item--board',
|
||||
isActive && 'active',
|
||||
isActive && `sidebar-nav-item--board-${themeIdx}`,
|
||||
)}
|
||||
onClick={() => { onSelectBoard(b.id); navigateFeed(nav, buildHomeUrl(b.id, sort)); }}
|
||||
>
|
||||
<BoardIconDisplay
|
||||
board={b}
|
||||
className={cn('sidebar-board-icon', `sidebar-board-icon--${themeIdx}`)}
|
||||
/>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
{(b.post_count ?? 0) > 0 && (
|
||||
<span className="sidebar-nav-item__meta">{b.post_count}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
@@ -77,8 +98,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
<>
|
||||
<div className="sidebar-section" style={{ marginTop: 8 }}>管理</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('boards', '管理板块', <Settings />, () => nav('/boards'))}
|
||||
{navItem('admin', '系统后台', <LayoutDashboard />, openAdminDashboard)}
|
||||
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
|
||||
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal file
45
frontend/src/components/UnsavedChangesDialog.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onStay: () => void;
|
||||
onLeave: () => void;
|
||||
/** 编辑已有帖子时为 true */
|
||||
isEdit?: boolean;
|
||||
}
|
||||
|
||||
export default function UnsavedChangesDialog({ open, onStay, onLeave, isEdit = false }: Props) {
|
||||
return (
|
||||
<AlertDialog open={open}>
|
||||
<AlertDialogContent onEscapeKeyDown={onStay}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>放弃未保存的修改?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isEdit
|
||||
? '你对这篇文章的修改尚未保存,离开后将无法恢复。'
|
||||
: '你正在撰写的内容尚未发布,离开后将无法恢复。'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={onStay}>继续编辑</AlertDialogCancel>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={onLeave}
|
||||
>
|
||||
放弃并离开
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,45 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
|
||||
interface Props {
|
||||
posts: PostItem[];
|
||||
sort?: FeedSort;
|
||||
loading: boolean;
|
||||
hasMore: boolean;
|
||||
/** 是否允许滚动触底自动加载(达到上限后为 false) */
|
||||
canAutoLoad: boolean;
|
||||
postTotal: number;
|
||||
onLoadMore: () => void;
|
||||
onSelect: (id: number) => void;
|
||||
/** 返回列表时恢复的滚动位置 */
|
||||
restoreScrollTop?: number | null;
|
||||
/** 递增时强制回到列表顶部(主动刷新导航) */
|
||||
resetScrollKey?: number;
|
||||
onScrollTopChange?: (top: number) => void;
|
||||
onScrollRestored?: () => void;
|
||||
}
|
||||
|
||||
export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, onSelect }: Props) {
|
||||
export default function VirtualPostList({
|
||||
posts,
|
||||
sort = 'latest',
|
||||
loading,
|
||||
hasMore,
|
||||
canAutoLoad,
|
||||
postTotal,
|
||||
onLoadMore,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
resetScrollKey = 0,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
}: Props) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: posts.length,
|
||||
@@ -22,46 +48,86 @@ export default function VirtualPostList({ posts, loading, hasMore, onLoadMore, o
|
||||
overscan: 8,
|
||||
});
|
||||
|
||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
||||
const isInitialLoad = loading && posts.length === 0;
|
||||
const isLoadingMore = loading && posts.length > 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (resetScrollKey <= 0) return;
|
||||
const el = parentRef.current;
|
||||
if (el) {
|
||||
el.scrollTop = 0;
|
||||
virtualizer.scrollToOffset(0);
|
||||
}
|
||||
restoredRef.current = true;
|
||||
onScrollTopChange?.(0);
|
||||
}, [resetScrollKey, virtualizer, onScrollTopChange]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||
virtualizer.scrollToOffset(restoreScrollTop);
|
||||
restoredRef.current = true;
|
||||
onScrollRestored?.();
|
||||
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
|
||||
|
||||
useEffect(() => {
|
||||
restoredRef.current = false;
|
||||
}, [restoreScrollTop]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && hasMore && !loading) {
|
||||
onScrollTopChange?.(el.scrollTop);
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [hasMore, loading, onLoadMore]);
|
||||
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} onClick={() => onSelect(post.id)} />
|
||||
{isInitialLoad ? (
|
||||
<PostListSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isLoadingMore && <PostListSkeleton count={2} />}
|
||||
{showHistoryPrompt && (
|
||||
<div className="feed-list-footer feed-list-footer--history">
|
||||
<p className="feed-list-footer__hint">
|
||||
已显示 {posts.length} / {postTotal} 条
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
|
||||
加载更多历史
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
{!loading && !hasMore && posts.length > 0 && (
|
||||
<div style={{ textAlign: 'center', padding: 6, fontSize: 12, color: 'var(--color-text-3)' }}>— 已加载全部 —</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
<div className="feed-list-footer feed-list-footer--end">— 已加载全部 —</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
76
frontend/src/components/editor/ArticleLinkDialog.tsx
Normal file
76
frontend/src/components/editor/ArticleLinkDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialUrl?: string;
|
||||
onConfirm: (url: string) => void;
|
||||
onRemove?: () => void;
|
||||
}
|
||||
|
||||
/** 文章编辑器链接输入弹窗 */
|
||||
export function ArticleLinkDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialUrl = '',
|
||||
onConfirm,
|
||||
onRemove,
|
||||
}: Props) {
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setUrl(initialUrl || 'https://');
|
||||
}, [open, initialUrl]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(url.trim());
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="article-link-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>插入链接</DialogTitle>
|
||||
<DialogDescription>输入完整 URL,留空并确认可移除已有链接。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
type="url"
|
||||
value={url}
|
||||
placeholder="https://"
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleConfirm();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter className="article-link-dialog__footer">
|
||||
{onRemove && initialUrl ? (
|
||||
<Button type="button" variant="outline" onClick={() => { onRemove(); onOpenChange(false); }}>
|
||||
移除链接
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={handleConfirm}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/editor/MembersOnlyExtension.tsx
Normal file
171
frontend/src/components/editor/MembersOnlyExtension.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} from '@tiptap/react';
|
||||
import { LockKeyhole, LogOut } from 'lucide-react';
|
||||
|
||||
/** 查找光标所在的登录可见节点深度 */
|
||||
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
|
||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
||||
const handleExit = () => {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
};
|
||||
|
||||
const handleUnwrap = () => {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="members-only"
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
|
||||
>
|
||||
<div className="post-members-only__badge" contentEditable={false}>
|
||||
<span className="post-members-only__badge-icon" aria-hidden="true">
|
||||
<LockKeyhole size={12} />
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__exit-btn"
|
||||
title="Ctrl+Enter 退出到公开区域"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleExit}
|
||||
>
|
||||
<LogOut size={11} />
|
||||
退出
|
||||
</button>
|
||||
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退出</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
title="取消登录可见包裹,保留正文"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleUnwrap}
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
membersOnly: {
|
||||
insertMembersOnly: () => ReturnType;
|
||||
wrapMembersOnly: () => ReturnType;
|
||||
exitMembersOnly: () => ReturnType;
|
||||
unwrapMembersOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** TipTap 自定义节点:登录可见内容区块 */
|
||||
export const MembersOnly = Node.create({
|
||||
name: 'membersOnly',
|
||||
group: 'block',
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'members-only' }];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['members-only', mergeAttributes(HTMLAttributes), 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MembersOnlyView);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// 在区块末尾空行按 Enter 时退出到公开区域
|
||||
Enter: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
if (!empty) return false;
|
||||
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const parent = $from.parent;
|
||||
const atBlockEnd = $from.parentOffset === parent.content.size;
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||
'Mod-Enter': ({ editor }) => {
|
||||
if (!editor.isActive('membersOnly')) return false;
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertMembersOnly: () => ({ chain }) => chain()
|
||||
.insertContent({
|
||||
type: this.name,
|
||||
content: [{ type: 'paragraph' }],
|
||||
})
|
||||
.run(),
|
||||
|
||||
wrapMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { from, to, empty } = state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const slice = state.doc.slice(from, to);
|
||||
if (!slice.content.size) return false;
|
||||
|
||||
const node = state.schema.nodes.membersOnly.create(null, slice.content);
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(from, to, node);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
exitMembersOnly: () => ({ state, chain }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
const end = pos + node.nodeSize;
|
||||
|
||||
return chain()
|
||||
.insertContentAt(end, { type: 'paragraph' })
|
||||
.setTextSelection(end + 1)
|
||||
.run();
|
||||
},
|
||||
|
||||
unwrapMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
189
frontend/src/components/editor/TabIndentExtension.tsx
Normal file
189
frontend/src/components/editor/TabIndentExtension.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import type { Editor } from '@tiptap/react';
|
||||
|
||||
export const TAB_SPACES = ' ';
|
||||
|
||||
/** 收集选区覆盖的文本块 */
|
||||
function collectSelectedTextblocks(editor: Editor): { pos: number }[] {
|
||||
const { from, to } = editor.state.selection;
|
||||
const blocks: { pos: number }[] = [];
|
||||
editor.state.doc.nodesBetween(from, to, (node, pos) => {
|
||||
if (node.isTextblock) {
|
||||
blocks.push({ pos });
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** 代码块多行选区:每行首插入缩进 */
|
||||
function indentCodeBlockSelection(editor: Editor): boolean {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const text = editor.state.doc.textBetween(from, to, '\n', '\n');
|
||||
const lineStarts = [from];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '\n') lineStarts.push(from + i + 1);
|
||||
}
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
lineStarts.forEach(pos => {
|
||||
tr = tr.insertText(TAB_SPACES, pos + offset);
|
||||
offset += TAB_SPACES.length;
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 代码块多行选区:每行首移除最多 4 个空格 */
|
||||
function outdentCodeBlockSelection(editor: Editor): boolean {
|
||||
const { from, to, empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
const doc = editor.state.doc;
|
||||
const text = doc.textBetween(from, to, '\n', '\n');
|
||||
const lineStarts = [from];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
if (text[i] === '\n') lineStarts.push(from + i + 1);
|
||||
}
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
let changed = false;
|
||||
lineStarts.forEach(pos => {
|
||||
const adjPos = pos + offset;
|
||||
const lineEnd = doc.textBetween(adjPos, to + offset).split('\n')[0] ?? '';
|
||||
const match = lineEnd.match(/^ {1,4}/);
|
||||
if (!match) return;
|
||||
tr = tr.delete(adjPos, adjPos + match[0].length);
|
||||
offset -= match[0].length;
|
||||
changed = true;
|
||||
});
|
||||
|
||||
if (!changed) return false;
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 多行选区:在每个文本块行首插入缩进 */
|
||||
function indentSelection(editor: Editor): boolean {
|
||||
const { empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return indentCodeBlockSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().sinkListItem('listItem').run();
|
||||
}
|
||||
|
||||
const blocks = collectSelectedTextblocks(editor);
|
||||
if (blocks.length === 0) return false;
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
blocks.forEach(({ pos }) => {
|
||||
const insertPos = pos + 1 + offset;
|
||||
tr = tr.insertText(TAB_SPACES, insertPos);
|
||||
offset += TAB_SPACES.length;
|
||||
});
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 多行选区:移除每个文本块行首最多 4 个空格 */
|
||||
function outdentSelection(editor: Editor): boolean {
|
||||
const { empty } = editor.state.selection;
|
||||
if (empty) return false;
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return outdentCodeBlockSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().liftListItem('listItem').run();
|
||||
}
|
||||
|
||||
const blocks = collectSelectedTextblocks(editor);
|
||||
if (blocks.length === 0) return false;
|
||||
|
||||
let tr = editor.state.tr;
|
||||
let offset = 0;
|
||||
blocks.forEach(({ pos }) => {
|
||||
const node = editor.state.doc.nodeAt(pos);
|
||||
if (!node?.isTextblock) return;
|
||||
const text = node.textContent;
|
||||
const match = text.match(/^ {1,4}/);
|
||||
if (!match) return;
|
||||
const removeLen = match[0].length;
|
||||
const from = pos + 1 + offset;
|
||||
tr = tr.delete(from, from + removeLen);
|
||||
offset -= removeLen;
|
||||
});
|
||||
|
||||
if (tr.steps.length === 0) return false;
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 在代码块或段落中移除光标前的 4 个空格缩进 */
|
||||
function outdentTextblock(editor: Editor): boolean {
|
||||
const { state } = editor;
|
||||
const { $from, empty } = state.selection;
|
||||
if (!empty) return false;
|
||||
if (!$from.parent.isTextblock) return false;
|
||||
|
||||
const lineStart = $from.start();
|
||||
const beforeCursor = state.doc.textBetween(lineStart, $from.pos);
|
||||
|
||||
if (beforeCursor.endsWith(TAB_SPACES)) {
|
||||
return editor.chain().focus().deleteRange({ from: $from.pos - TAB_SPACES.length, to: $from.pos }).run();
|
||||
}
|
||||
|
||||
const leading = beforeCursor.match(/^ {1,4}/);
|
||||
if (leading) {
|
||||
return editor.chain().focus().deleteRange({ from: lineStart, to: lineStart + leading[0].length }).run();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Tab 缩进:列表缩进/反缩进,多行选区整体缩进,其余插入 4 个空格 */
|
||||
export const TabIndent = Extension.create({
|
||||
name: 'tabIndent',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
if (!editor.state.selection.empty) {
|
||||
return indentSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return editor.chain().focus().insertContent(TAB_SPACES).run();
|
||||
}
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().sinkListItem('listItem').run();
|
||||
}
|
||||
return editor.chain().focus().insertContent(TAB_SPACES).run();
|
||||
},
|
||||
'Shift-Tab': ({ editor }) => {
|
||||
if (!editor.state.selection.empty) {
|
||||
return outdentSelection(editor);
|
||||
}
|
||||
|
||||
if (editor.isActive('codeBlock')) {
|
||||
return outdentTextblock(editor);
|
||||
}
|
||||
if (editor.isActive('listItem')) {
|
||||
return editor.chain().focus().liftListItem('listItem').run();
|
||||
}
|
||||
return outdentTextblock(editor);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
49
frontend/src/components/ui/Tooltip.tsx
Normal file
49
frontend/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { cloneElement, isValidElement, type ReactElement } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type TooltipSide = 'top' | 'bottom';
|
||||
type TooltipAlign = 'start' | 'center' | 'end';
|
||||
|
||||
interface TooltipProps {
|
||||
content: string;
|
||||
hint?: string;
|
||||
side?: TooltipSide;
|
||||
align?: TooltipAlign;
|
||||
children: ReactElement;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 即时显示的网页式气泡提示,替代原生 title */
|
||||
export function Tooltip({
|
||||
content,
|
||||
hint,
|
||||
side = 'bottom',
|
||||
align = 'center',
|
||||
children,
|
||||
className,
|
||||
}: TooltipProps) {
|
||||
if (!isValidElement(children)) return children;
|
||||
|
||||
const ariaLabel = hint ? `${content},${hint}` : content;
|
||||
const child = cloneElement(children, {
|
||||
'aria-label': ariaLabel,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
return (
|
||||
<span className={cn('ui-tooltip', className)}>
|
||||
{child}
|
||||
<span
|
||||
role="tooltip"
|
||||
className={cn(
|
||||
'ui-tooltip-bubble',
|
||||
`ui-tooltip-bubble--${side}`,
|
||||
`ui-tooltip-bubble--${align}`,
|
||||
hint ? 'ui-tooltip-bubble--rich' : 'ui-tooltip-bubble--compact',
|
||||
)}
|
||||
>
|
||||
<span className="ui-tooltip-bubble__title">{content}</span>
|
||||
{hint ? <span className="ui-tooltip-bubble__hint">{hint}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
7
frontend/src/components/ui/skeleton.tsx
Normal file
7
frontend/src/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('skeleton', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
49
frontend/src/hooks/useForumLimits.ts
Normal file
49
frontend/src/hooks/useForumLimits.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { ForumLimitsPublic } from '../api/types';
|
||||
|
||||
const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
post_title_max: 128,
|
||||
post_tags_max: 256,
|
||||
post_content_max: 50000,
|
||||
comment_max: 5000,
|
||||
search_keyword_min: 1,
|
||||
search_keyword_max: 50,
|
||||
page_size_default: 30,
|
||||
feed_max_pages: 10,
|
||||
feed_max_items: 300,
|
||||
password_min_len: 6,
|
||||
avatar_max_mb: 2,
|
||||
};
|
||||
|
||||
let cached: ForumLimitsPublic | null = null;
|
||||
let inflight: Promise<ForumLimitsPublic> | null = null;
|
||||
|
||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (inflight) return inflight;
|
||||
inflight = api.forumLimits()
|
||||
.then(limits => {
|
||||
cached = limits;
|
||||
return limits;
|
||||
})
|
||||
.catch(() => DEFAULT_LIMITS)
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
/** 获取前台可见的论坛限制配置 */
|
||||
export function useForumLimits() {
|
||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLimits().then(setLimits).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { limits, loading };
|
||||
}
|
||||
|
||||
export function invalidateForumLimitsCache() {
|
||||
cached = null;
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
|
||||
if (!root) return;
|
||||
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
// Ctrl/⌘ + 滚轮用于浏览器缩放,不拦截
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
|
||||
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||
if (!target) return;
|
||||
|
||||
|
||||
79
frontend/src/hooks/useUnsavedChangesGuard.ts
Normal file
79
frontend/src/hooks/useUnsavedChangesGuard.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useBlocker } from 'react-router-dom';
|
||||
|
||||
interface Options {
|
||||
/** 是否存在未保存的修改 */
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拦截页面内导航与关闭标签页,在存在未保存修改时提示用户确认。
|
||||
*/
|
||||
export function useUnsavedChangesGuard({ isDirty }: Options) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const pendingLeaveRef = useRef<(() => void) | null>(null);
|
||||
/** 同步标记,避免 setState 未及时生效导致 useBlocker 二次拦截 */
|
||||
const allowNavigationRef = useRef(false);
|
||||
|
||||
const shouldBlock = isDirty && !allowNavigationRef.current;
|
||||
const blocker = useBlocker(() => isDirty && !allowNavigationRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state === 'blocked') {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
}, [blocker.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldBlock) return;
|
||||
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', onBeforeUnload);
|
||||
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
||||
}, [shouldBlock]);
|
||||
|
||||
const stayOnPage = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
pendingLeaveRef.current = null;
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.reset?.();
|
||||
}
|
||||
}, [blocker]);
|
||||
|
||||
const discardAndLeave = useCallback(() => {
|
||||
setDialogOpen(false);
|
||||
allowNavigationRef.current = true;
|
||||
if (blocker.state === 'blocked') {
|
||||
blocker.proceed?.();
|
||||
return;
|
||||
}
|
||||
const action = pendingLeaveRef.current;
|
||||
pendingLeaveRef.current = null;
|
||||
action?.();
|
||||
}, [blocker]);
|
||||
|
||||
/** 主动发起离开(如点击返回按钮) */
|
||||
const requestLeave = useCallback((action: () => void) => {
|
||||
if (!shouldBlock) {
|
||||
action();
|
||||
return;
|
||||
}
|
||||
pendingLeaveRef.current = action;
|
||||
setDialogOpen(true);
|
||||
}, [shouldBlock]);
|
||||
|
||||
/** 保存成功后调用,允许后续导航不再拦截 */
|
||||
const markSaved = useCallback(() => {
|
||||
allowNavigationRef.current = true;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
dialogOpen,
|
||||
stayOnPage,
|
||||
discardAndLeave,
|
||||
requestLeave,
|
||||
markSaved,
|
||||
};
|
||||
}
|
||||
97
frontend/src/layouts/AdminLayout.tsx
Normal file
97
frontend/src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
|
||||
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
|
||||
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
|
||||
{ to: '/admin/users', label: '用户管理', icon: Users },
|
||||
{ to: '/admin/settings', label: '系统设置', icon: Settings },
|
||||
];
|
||||
|
||||
/** React 管理后台布局,与前台 SPA 风格统一 */
|
||||
export default function AdminLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
nav('/login');
|
||||
return;
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
notify.warning('需要管理员权限');
|
||||
nav('/');
|
||||
}
|
||||
}, [user, loading, nav]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-24"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!user || user.role !== 'admin') return null;
|
||||
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-brand">
|
||||
<div className="admin-topbar-mark">姜</div>
|
||||
<div>
|
||||
<div className="admin-topbar-title">姜十三论坛</div>
|
||||
<div className="admin-topbar-sub">管理后台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<button type="button" className="admin-link-btn" onClick={() => nav('/')}>
|
||||
<ArrowLeft size={16} />
|
||||
返回论坛
|
||||
</button>
|
||||
<span className="admin-topbar-user">{user.nickname}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-body">
|
||||
<aside className="admin-sidebar">
|
||||
{NAV.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) => cn('admin-nav-item', isActive && 'active')}
|
||||
>
|
||||
<Icon size={16} />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 管理页通用权限守卫 */
|
||||
export function useAdminGuard() {
|
||||
const { user, loading } = useAuth();
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) nav('/login');
|
||||
else if (user.role !== 'admin') {
|
||||
notify.warning('需要管理员权限');
|
||||
nav('/');
|
||||
}
|
||||
}, [user, loading, nav]);
|
||||
|
||||
return { user, loading, ready: !loading && !!user && user.role === 'admin' };
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||
import { api } from '../api/client';
|
||||
@@ -17,6 +16,12 @@ import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
@@ -35,6 +40,8 @@ export default function MainLayout() {
|
||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
const feedSort = parseFeedSort(params.get('sort'));
|
||||
const { limits: forumLimits } = useForumLimits();
|
||||
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
@@ -84,8 +91,21 @@ export default function MainLayout() {
|
||||
}, [refreshBoards, refreshOnline]);
|
||||
|
||||
const doSearch = () => {
|
||||
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`);
|
||||
else nav('/');
|
||||
const kw = keyword.trim();
|
||||
if (!kw) {
|
||||
nav('/');
|
||||
return;
|
||||
}
|
||||
const len = [...kw].length;
|
||||
if (forumLimits.search_keyword_min > 0 && len < forumLimits.search_keyword_min) {
|
||||
notify.warning(`搜索关键词至少 ${forumLimits.search_keyword_min} 个字`);
|
||||
return;
|
||||
}
|
||||
if (forumLimits.search_keyword_max > 0 && len > forumLimits.search_keyword_max) {
|
||||
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
|
||||
return;
|
||||
}
|
||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||
};
|
||||
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
@@ -96,7 +116,7 @@ export default function MainLayout() {
|
||||
<div className="app-frame">
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
<button type="button" className="header-brand" onClick={() => nav('/')}>
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<span className="header-logo-mark">姜</span>
|
||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||
</button>
|
||||
@@ -110,6 +130,7 @@ export default function MainLayout() {
|
||||
placeholder="搜索帖子..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
onKeyDown={e => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
{keyword && (
|
||||
@@ -156,14 +177,17 @@ export default function MainLayout() {
|
||||
: <span className="header-user-initial">{userInitial}</span>}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-40"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => nav('/profile')}>个人中心</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => nav('/boards')}>管理板块</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openAdminDashboard}>系统后台</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/admin/dashboard')}>管理后台</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
@@ -197,15 +221,23 @@ export default function MainLayout() {
|
||||
<div className="mobile-board-bar">
|
||||
<span
|
||||
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(0); nav('/'); }}
|
||||
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
|
||||
>全部</span>
|
||||
{boards.map(b => (
|
||||
<span
|
||||
key={b.id}
|
||||
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
|
||||
>{b.name}</span>
|
||||
))}
|
||||
{boards.map(b => {
|
||||
const themeIdx = getBoardThemeIndex(b);
|
||||
const isActive = mobileActiveBoard === b.id;
|
||||
return (
|
||||
<span
|
||||
key={b.id}
|
||||
className={cn(
|
||||
'board-chip',
|
||||
isActive && 'active',
|
||||
isActive && `board-chip--${themeIdx}`,
|
||||
)}
|
||||
onClick={() => { setBoardId(b.id); navigateFeed(nav, buildHomeUrl(b.id, feedSort)); }}
|
||||
>{b.name}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft, Plus } from 'lucide-react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -24,21 +23,26 @@ import {
|
||||
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useAdminGuard } from '../layouts/AdminLayout';
|
||||
import type { Board } from '../api/types';
|
||||
import { BoardColorPicker, BoardIconPicker } from '../components/BoardAppearancePicker';
|
||||
import BoardIconDisplay from '../components/BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
|
||||
const boardSchema = z.object({
|
||||
name: z.string().min(1, '请输入名称').max(64),
|
||||
description: z.string().max(500).optional(),
|
||||
sort_order: z.coerce.number().min(0),
|
||||
icon: z.string().max(64).optional(),
|
||||
color_index: z.coerce.number().min(-1).max(7),
|
||||
});
|
||||
|
||||
type BoardFormValues = z.infer<typeof boardSchema>;
|
||||
|
||||
export default function BoardsManagePage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { ready } = useAdminGuard();
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -47,9 +51,12 @@ export default function BoardsManagePage() {
|
||||
|
||||
const form = useForm<BoardFormValues>({
|
||||
resolver: zodResolver(boardSchema),
|
||||
defaultValues: { name: '', description: '', sort_order: 1 },
|
||||
defaultValues: { name: '', description: '', sort_order: 1, icon: '', color_index: -1 },
|
||||
});
|
||||
|
||||
const watchColorIndex = form.watch('color_index');
|
||||
const editingPreviewId = editing?.id ?? boards.length + 1;
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api.boards()
|
||||
@@ -59,15 +66,12 @@ export default function BoardsManagePage() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
|
||||
load();
|
||||
}, [user, authLoading, nav]);
|
||||
if (ready) load();
|
||||
}, [ready]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.reset({ name: '', description: '', sort_order: boards.length + 1 });
|
||||
form.reset({ name: '', description: '', sort_order: boards.length + 1, icon: '', color_index: -1 });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -77,6 +81,8 @@ export default function BoardsManagePage() {
|
||||
name: board.name,
|
||||
description: board.description ?? '',
|
||||
sort_order: board.sort_order,
|
||||
icon: board.icon ?? '',
|
||||
color_index: board.color_index ?? -1,
|
||||
});
|
||||
setModalOpen(true);
|
||||
};
|
||||
@@ -84,11 +90,18 @@ export default function BoardsManagePage() {
|
||||
const handleSubmit = async (values: BoardFormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const body = {
|
||||
name: values.name,
|
||||
description: values.description ?? '',
|
||||
sort_order: values.sort_order,
|
||||
icon: values.icon ?? '',
|
||||
color_index: values.color_index ?? -1,
|
||||
};
|
||||
if (editing) {
|
||||
await api.updateBoard(editing.id, values);
|
||||
await api.updateBoard(editing.id, body);
|
||||
notify.success('板块已更新');
|
||||
} else {
|
||||
await api.createBoard(values);
|
||||
await api.createBoard(body);
|
||||
notify.success('板块已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
@@ -112,31 +125,26 @@ export default function BoardsManagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading) {
|
||||
if (!ready) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
|
||||
if (!user || user.role !== 'admin') return null;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h1 className="page-title">板块管理</h1>
|
||||
<p className="page-desc">创建、编辑或删除论坛板块,用户发帖前需先有板块</p>
|
||||
<h1>板块管理</h1>
|
||||
<p>创建、编辑或删除论坛板块;可为每个板块自定义图标与色标</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus />
|
||||
新建板块
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
@@ -145,6 +153,7 @@ export default function BoardsManagePage() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px]">ID</TableHead>
|
||||
<TableHead className="w-[52px]">图标</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>简介</TableHead>
|
||||
<TableHead className="w-[70px]">排序</TableHead>
|
||||
@@ -153,9 +162,16 @@ export default function BoardsManagePage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{boards.map(board => (
|
||||
{boards.map(board => {
|
||||
const themeIdx = getBoardThemeIndex(board);
|
||||
return (
|
||||
<TableRow key={board.id}>
|
||||
<TableCell>{board.id}</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn('board-table-icon', `sidebar-board-icon--${themeIdx}`)}>
|
||||
<BoardIconDisplay board={board} />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><strong>{board.name}</strong></TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
|
||||
<TableCell>{board.sort_order}</TableCell>
|
||||
@@ -187,7 +203,8 @@ export default function BoardsManagePage() {
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{boards.length === 0 && (
|
||||
@@ -197,11 +214,10 @@ export default function BoardsManagePage() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
||||
<DialogContent>
|
||||
<DialogContent className="board-manage-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -233,6 +249,40 @@ export default function BoardsManagePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>板块图标</FormLabel>
|
||||
<FormControl>
|
||||
<BoardIconPicker
|
||||
value={field.value ?? ''}
|
||||
onChange={field.onChange}
|
||||
board={{ id: editingPreviewId, color_index: watchColorIndex }}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="color_index"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>色标颜色</FormLabel>
|
||||
<FormControl>
|
||||
<BoardColorPicker
|
||||
value={field.value ?? -1}
|
||||
onChange={field.onChange}
|
||||
boardId={editingPreviewId}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sort_order"
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Send, Tag } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import type { Board } from '../api/types';
|
||||
import { isHtmlEmpty } from '../utils/postContent';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useUnsavedChangesGuard } from '../hooks/useUnsavedChangesGuard';
|
||||
import ArticleEditor from '../components/ArticleEditor';
|
||||
import UnsavedChangesDialog from '../components/UnsavedChangesDialog';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
|
||||
|
||||
interface ComposeBaseline {
|
||||
title: string;
|
||||
tags: string;
|
||||
content: string;
|
||||
boardId: string;
|
||||
}
|
||||
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -17,6 +27,7 @@ export default function ComposePage() {
|
||||
const [params] = useSearchParams();
|
||||
const defaultBoard = params.get('board') || '';
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>([]);
|
||||
const [boardId, setBoardId] = useState(defaultBoard);
|
||||
@@ -25,6 +36,7 @@ export default function ComposePage() {
|
||||
const [content, setContent] = useState('');
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
@@ -32,21 +44,33 @@ export default function ComposePage() {
|
||||
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
Promise.all([api.boards(), api.post(editId!)])
|
||||
Promise.all([api.boards(), api.post(editId!, { skipView: true })])
|
||||
.then(([boardsData, postData]) => {
|
||||
const list = boardsData.boards ?? [];
|
||||
setBoards(list);
|
||||
const post = postData.post;
|
||||
const canEdit = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!canEdit) {
|
||||
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!isOwnerOrAdmin) {
|
||||
notify.error('无权编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
return;
|
||||
}
|
||||
setBoardId(String(post.board_id));
|
||||
if (!postData.can_edit) {
|
||||
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
return;
|
||||
}
|
||||
const loadedBoardId = String(post.board_id);
|
||||
setBoardId(loadedBoardId);
|
||||
setTitle(post.title);
|
||||
setTags(post.tags ?? '');
|
||||
setContent(htmlToMarkdown(post.content ?? ''));
|
||||
setContent(post.content ?? '');
|
||||
setBaseline({
|
||||
title: post.title,
|
||||
tags: post.tags ?? '',
|
||||
content: post.content ?? '',
|
||||
boardId: loadedBoardId,
|
||||
});
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
||||
@@ -59,12 +83,37 @@ export default function ComposePage() {
|
||||
api.boards().then(d => {
|
||||
const list = d.boards ?? [];
|
||||
setBoards(list);
|
||||
const initialBoardId = defaultBoard || (list.length > 0 ? String(list[0].id) : '');
|
||||
if (!defaultBoard && list.length > 0) {
|
||||
setBoardId(String(list[0].id));
|
||||
setBoardId(initialBoardId);
|
||||
}
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: initialBoardId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (!baseline) return false;
|
||||
return (
|
||||
title !== baseline.title
|
||||
|| tags !== baseline.tags
|
||||
|| content !== baseline.content
|
||||
|| (!isEdit && boardId !== baseline.boardId)
|
||||
);
|
||||
}, [baseline, title, tags, content, boardId, isEdit]);
|
||||
|
||||
const {
|
||||
dialogOpen,
|
||||
stayOnPage,
|
||||
discardAndLeave,
|
||||
requestLeave,
|
||||
markSaved,
|
||||
} = useUnsavedChangesGuard({ isDirty });
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="compose-page compose-page--empty">
|
||||
@@ -91,7 +140,7 @@ export default function ComposePage() {
|
||||
<h2>暂无可发帖板块</h2>
|
||||
<p>需要管理员先创建板块后才能发布内容</p>
|
||||
{user.role === 'admin' ? (
|
||||
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}>
|
||||
<button type="button" className="compose-primary-btn" onClick={() => nav('/admin/boards')}>
|
||||
去创建板块
|
||||
</button>
|
||||
) : (
|
||||
@@ -108,22 +157,24 @@ export default function ComposePage() {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
|
||||
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
|
||||
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
|
||||
|
||||
setPublishing(true);
|
||||
try {
|
||||
const payload = {
|
||||
title: trimmedTitle,
|
||||
content: markdownToHtml(content),
|
||||
content: content.trim(),
|
||||
tags: tags.trim(),
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
notify.success('帖子已更新');
|
||||
markSaved();
|
||||
nav(`/post/${editId}`);
|
||||
} else {
|
||||
const res = await api.createPost({ board_id: boardId, ...payload });
|
||||
notify.success('发帖成功');
|
||||
markSaved();
|
||||
nav(`/post/${res.post_id}`);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
@@ -139,7 +190,11 @@ export default function ComposePage() {
|
||||
<div className="compose-page">
|
||||
<div className="compose-canvas">
|
||||
<header className="compose-header">
|
||||
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}>
|
||||
<button
|
||||
type="button"
|
||||
className="compose-back"
|
||||
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
@@ -182,7 +237,7 @@ export default function ComposePage() {
|
||||
placeholder="添加标签,逗号分隔"
|
||||
value={tags}
|
||||
onChange={e => setTags(e.target.value)}
|
||||
maxLength={128}
|
||||
maxLength={limits.post_tags_max > 0 ? limits.post_tags_max : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +249,7 @@ export default function ComposePage() {
|
||||
placeholder="输入文章标题…"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
maxLength={256}
|
||||
maxLength={limits.post_title_max > 0 ? limits.post_title_max : undefined}
|
||||
/>
|
||||
{currentBoard && (
|
||||
<div className="compose-subtitle">
|
||||
@@ -204,10 +259,16 @@ export default function ComposePage() {
|
||||
<ArticleEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
|
||||
placeholder="开始写作。所见即所得,选中文字后使用工具栏设置格式。"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UnsavedChangesDialog
|
||||
open={dialogOpen}
|
||||
onStay={stayOnPage}
|
||||
onLeave={discardAndLeave}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,78 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import BoardGrid from '../components/BoardGrid';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import {
|
||||
getFeedCache,
|
||||
setFeedCache,
|
||||
clearAllFeedCache,
|
||||
navigateFeed,
|
||||
FEED_RESET_EVENT,
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default;
|
||||
const feedMaxPages = limits.feed_max_pages;
|
||||
const feedMaxItems = limits.feed_max_items;
|
||||
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const initialCache = getFeedCache(boardId, keyword, sort);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
||||
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
||||
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
const pageWrapRef = useRef<HTMLDivElement>(null);
|
||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
|
||||
const canAutoLoad = useMemo(
|
||||
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
|
||||
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
|
||||
);
|
||||
|
||||
const resetFeedView = useCallback(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
pageWrapRef.current?.scrollTo(0);
|
||||
}, []);
|
||||
|
||||
const beginFeedRefresh = useCallback(() => {
|
||||
skipCacheSaveRef.current = true;
|
||||
clearAllFeedCache();
|
||||
resetFeedView();
|
||||
}, [resetFeedView]);
|
||||
|
||||
const load = useCallback(async (p: number, reset = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
|
||||
const data = await api.posts({
|
||||
page: p,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
// 切换筛选时保留旧列表,避免中间区域瞬间空白
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
@@ -37,51 +83,134 @@ export default function HomePage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword]);
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
|
||||
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
|
||||
const revalidate = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.posts({
|
||||
page: 1,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const fresh = Array.isArray(data.posts) ? data.posts : [];
|
||||
const freshIds = new Set(fresh.map(p => p.id));
|
||||
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
} catch {
|
||||
// 静默失败,保留缓存数据
|
||||
}
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
load(page + 1);
|
||||
}, [loading, hasMore, page, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
return;
|
||||
}
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
if (cached) {
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
setPage(cached.page);
|
||||
setHasMore(cached.hasMore);
|
||||
setRestoreScrollTop(cached.scrollTop);
|
||||
scrollTopRef.current = cached.scrollTop;
|
||||
setLoading(false);
|
||||
revalidate();
|
||||
return;
|
||||
}
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, load]);
|
||||
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => load(1, true);
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
hasMore,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) {
|
||||
skipCacheSaveRef.current = false;
|
||||
}
|
||||
}, [loading, posts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFeedReset = () => {
|
||||
beginFeedRefresh();
|
||||
};
|
||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
}, [beginFeedRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [load]);
|
||||
}, [beginFeedRefresh, load]);
|
||||
|
||||
const showBoardGrid = !keyword;
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
{showBoardGrid && (
|
||||
<BoardGrid
|
||||
boards={ctx?.boards ?? []}
|
||||
loading={!ctx?.layoutReady}
|
||||
selectedId={boardId}
|
||||
onSelect={(id) => {
|
||||
ctx?.setBoardId(id);
|
||||
nav(id ? `/?board=${id}` : '/');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="post-list-bar">
|
||||
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
|
||||
<span>共 {postTotal} 条</span>
|
||||
</div>
|
||||
<VirtualPostList
|
||||
<div className="page-wrap" ref={pageWrapRef}>
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
</div>
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={() => !loading && hasMore && load(page + 1)}
|
||||
canAutoLoad={canAutoLoad}
|
||||
postTotal={postTotal}
|
||||
onLoadMore={loadNextPage}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil } from 'lucide-react';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock } from 'lucide-react';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
@@ -10,9 +12,11 @@ import type { PostItem, Comment } from '../api/types';
|
||||
import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
|
||||
export default function PostDetailPage() {
|
||||
@@ -30,6 +34,10 @@ export default function PostDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||
const [submitCount, setSubmitCount] = useState(0);
|
||||
const [canEdit, setCanEdit] = useState(false);
|
||||
const [isEdited, setIsEdited] = useState(false);
|
||||
const [editBlockReason, setEditBlockReason] = useState('');
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -55,6 +63,9 @@ export default function PostDetailPage() {
|
||||
setPost(detail.post);
|
||||
setLiked(detail.liked);
|
||||
setFavorited(detail.favorited);
|
||||
setCanEdit(detail.can_edit ?? false);
|
||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||
setComments(commList);
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
@@ -161,7 +172,39 @@ export default function PostDetailPage() {
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
||||
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
|
||||
const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const showEdited = isEdited && post.updated_at;
|
||||
|
||||
const handlePin = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminPinPost(postId, !post.pinned);
|
||||
setPost(p => p ? { ...p, pinned: r.pinned } : p);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminLockPost(postId, !post.edit_locked);
|
||||
setPost(p => p ? { ...p, edit_locked: r.edit_locked } : p);
|
||||
if (user?.role === 'admin') {
|
||||
setCanEdit(true);
|
||||
} else if (user?.id === post.user_id && r.edit_locked) {
|
||||
setCanEdit(false);
|
||||
setEditBlockReason('帖子已被管理员锁定,无法编辑');
|
||||
}
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrap post-detail-page" ref={pageRef}>
|
||||
@@ -172,13 +215,13 @@ export default function PostDetailPage() {
|
||||
返回
|
||||
</Button>
|
||||
{post.board && (
|
||||
<Badge variant="green" className="post-detail-board-tag">{post.board.name}</Badge>
|
||||
<BoardBadge board={post.board} className="post-detail-board-tag" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="post-detail-head">
|
||||
<h1 className="post-detail-title">
|
||||
{post.pinned && <Badge variant="orange" className="mr-2 align-middle">置顶</Badge>}
|
||||
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="post-detail-author-row">
|
||||
@@ -188,7 +231,16 @@ export default function PostDetailPage() {
|
||||
<div className="post-detail-author-info">
|
||||
<span className="post-detail-author-name">{post.user?.nickname}</span>
|
||||
<span className="post-detail-meta-line">
|
||||
{formatTime(post.created_at)} · {post.view_count} 次浏览
|
||||
发布于 {formatDateTime(post.created_at)}
|
||||
{showEdited && (
|
||||
<> · 编辑于 {formatDateTime(post.updated_at!)}</>
|
||||
)}
|
||||
{' · '}{post.view_count} 次浏览
|
||||
{post.edit_locked && (
|
||||
<span className="post-detail-locked-tag" title="管理员已锁定编辑">
|
||||
<Lock size={12} /> 已锁定
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -217,9 +269,40 @@ export default function PostDetailPage() {
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrAdmin && isEdited && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
|
||||
<History />
|
||||
编辑历史
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrAdmin && !canEdit && editBlockReason && (
|
||||
<span className="post-detail-edit-hint" title={editBlockReason}>
|
||||
{editBlockReason}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||
<Pin />
|
||||
{post.pinned ? '取消置顶' : '置顶'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleLock}>
|
||||
<Lock />
|
||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PostRevisionPanel
|
||||
postId={postId}
|
||||
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
|
||||
open={showRevisions}
|
||||
onClose={() => setShowRevisions(false)}
|
||||
isLoggedIn={!!user}
|
||||
/>
|
||||
|
||||
<div className="comment-section" ref={commentSectionRef}>
|
||||
<div className="comment-section-bar">
|
||||
<span className="comment-section-title">评论区</span>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
|
||||
import { ArrowLeft, Camera, LayoutDashboard, Settings, Upload, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -12,15 +12,17 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { api } from '../api/client';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import AvatarCropDialog from '../components/AvatarCropDialog';
|
||||
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
|
||||
const nickSchema = z.object({
|
||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||
});
|
||||
|
||||
const pwdSchema = z.object({
|
||||
const pwdSchema = (minLen: number) => z.object({
|
||||
old_password: z.string().min(1, '请输入当前密码'),
|
||||
new_password: z.string().min(6, '新密码至少 6 位'),
|
||||
new_password: z.string().min(minLen, `新密码至少 ${minLen} 位`),
|
||||
confirm_password: z.string().min(1, '请确认新密码'),
|
||||
}).refine(d => d.new_password === d.confirm_password, {
|
||||
message: '两次输入的新密码不一致',
|
||||
@@ -28,7 +30,7 @@ const pwdSchema = z.object({
|
||||
});
|
||||
|
||||
type NickValues = z.infer<typeof nickSchema>;
|
||||
type PwdValues = z.infer<typeof pwdSchema>;
|
||||
type PwdValues = z.infer<ReturnType<typeof pwdSchema>>;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const nav = useNavigate();
|
||||
@@ -36,7 +38,16 @@ export default function ProfilePage() {
|
||||
const [nickLoading, setNickLoading] = useState(false);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
const [avatarLoading, setAvatarLoading] = useState(false);
|
||||
const [pendingAvatar, setPendingAvatar] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [cropOpen, setCropOpen] = useState(false);
|
||||
const [cropImageSrc, setCropImageSrc] = useState<string | null>(null);
|
||||
const [cropFileName, setCropFileName] = useState('');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const { limits } = useForumLimits();
|
||||
|
||||
const nickForm = useForm<NickValues>({
|
||||
resolver: zodResolver(nickSchema),
|
||||
@@ -44,7 +55,7 @@ export default function ProfilePage() {
|
||||
});
|
||||
|
||||
const pwdForm = useForm<PwdValues>({
|
||||
resolver: zodResolver(pwdSchema),
|
||||
resolver: zodResolver(pwdSchema(limits.password_min_len)),
|
||||
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
|
||||
});
|
||||
|
||||
@@ -54,6 +65,30 @@ export default function ProfilePage() {
|
||||
}
|
||||
}, [authLoading, user, nav]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
};
|
||||
}, [avatarPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
};
|
||||
}, [cropImageSrc]);
|
||||
|
||||
const closeCropDialog = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
setCropOpen(false);
|
||||
setCropImageSrc(prev => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
setCropFileName('');
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (authLoading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
@@ -88,26 +123,88 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
notify.error('头像不能超过 2MB');
|
||||
const clearPendingAvatar = () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
setPendingAvatar(null);
|
||||
setAvatarPreview(null);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
};
|
||||
|
||||
const openCropForFile = (file: File) => {
|
||||
const err = validateAvatarFile(file, limits.avatar_max_mb);
|
||||
if (err) {
|
||||
notify.error(err);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
return;
|
||||
}
|
||||
if (cropImageSrc) URL.revokeObjectURL(cropImageSrc);
|
||||
setCropFileName(file.name);
|
||||
setCropImageSrc(URL.createObjectURL(file));
|
||||
setCropOpen(true);
|
||||
};
|
||||
|
||||
const onAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) openCropForFile(file);
|
||||
};
|
||||
|
||||
const onCropConfirm = (file: File) => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
setPendingAvatar(file);
|
||||
setAvatarPreview(URL.createObjectURL(file));
|
||||
};
|
||||
|
||||
const onSaveAvatar = async () => {
|
||||
if (!pendingAvatar) return;
|
||||
setAvatarLoading(true);
|
||||
try {
|
||||
await api.uploadAvatar(file);
|
||||
await api.uploadAvatar(pendingAvatar);
|
||||
await refresh();
|
||||
clearPendingAvatar();
|
||||
notify.success('头像已更新');
|
||||
} catch (err: unknown) {
|
||||
notify.error(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setAvatarLoading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const onDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current += 1;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current <= 0) {
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current = 0;
|
||||
setDragOver(false);
|
||||
if (avatarLoading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) openCropForFile(file);
|
||||
};
|
||||
|
||||
const displayAvatar = avatarPreview ?? user.avatar;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
|
||||
@@ -115,18 +212,82 @@ export default function ProfilePage() {
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title mb-4">个人中心</h1>
|
||||
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar-lg">
|
||||
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
|
||||
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
|
||||
{user.role === 'admin' && <Badge variant="green" className="mt-1.5">管理员</Badge>}
|
||||
<div
|
||||
className={`profile-header-card${dragOver ? ' profile-header-card--dragover' : ''}`}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="profile-drop-overlay">
|
||||
<Upload size={28} strokeWidth={1.5} />
|
||||
<span>松开以上传头像</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={AVATAR_ACCEPT}
|
||||
className="sr-only"
|
||||
onChange={onAvatarSelect}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-avatar-btn"
|
||||
title="点击或拖拽图片到此处更换头像"
|
||||
disabled={avatarLoading}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<div className={`profile-avatar-lg${pendingAvatar ? ' profile-avatar-lg--pending' : ''}`}>
|
||||
{displayAvatar
|
||||
? <img src={displayAvatar} alt="" />
|
||||
: user.nickname[0]}
|
||||
<span className="profile-avatar-overlay">
|
||||
{avatarLoading
|
||||
? <Spinner size="sm" className="text-white" />
|
||||
: <Camera size={22} strokeWidth={1.75} />}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="profile-header-info">
|
||||
<div className="profile-header-main">
|
||||
<h1 className="profile-display-name">{user.nickname}</h1>
|
||||
<div className="profile-username">@{user.username}</div>
|
||||
<p className="profile-avatar-tip">点击头像选择图片,或拖拽到此处</p>
|
||||
{user.role === 'admin' && <Badge variant="green" className="mt-1.5">管理员</Badge>}
|
||||
</div>
|
||||
{pendingAvatar && (
|
||||
<div className="profile-avatar-actions">
|
||||
<span className="profile-avatar-hint">已裁剪,待保存</span>
|
||||
<Button size="sm" loading={avatarLoading} onClick={onSaveAvatar}>
|
||||
保存头像
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={avatarLoading}
|
||||
onClick={clearPendingAvatar}
|
||||
aria-label="取消"
|
||||
>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AvatarCropDialog
|
||||
open={cropOpen}
|
||||
imageSrc={cropImageSrc}
|
||||
fileName={cropFileName}
|
||||
onOpenChange={closeCropDialog}
|
||||
onConfirm={onCropConfirm}
|
||||
/>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<div className="section-card admin-entry-card">
|
||||
<div className="section-card-title">管理员入口</div>
|
||||
@@ -134,11 +295,11 @@ export default function ProfilePage() {
|
||||
管理板块、用户、帖子及系统设置
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => nav('/boards')}>
|
||||
<Button variant="outline" onClick={() => nav('/admin/boards')}>
|
||||
<Settings />
|
||||
管理板块
|
||||
</Button>
|
||||
<Button onClick={openAdminDashboard}>
|
||||
<Button onClick={() => nav('/admin/dashboard')}>
|
||||
<LayoutDashboard />
|
||||
进入系统后台
|
||||
</Button>
|
||||
@@ -149,7 +310,7 @@ export default function ProfilePage() {
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">基本资料</div>
|
||||
<Form {...nickForm}>
|
||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
|
||||
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="profile-form">
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
@@ -169,32 +330,20 @@ export default function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||
<div className="profile-form-footer">
|
||||
<span className="profile-form-hint">
|
||||
支持 JPG、PNG、GIF、WebP,头像不超过 {limits.avatar_max_mb}MB
|
||||
</span>
|
||||
<Button type="submit" loading={nickLoading}>保存</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">头像</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
|
||||
支持 JPG、PNG、GIF、WebP,不超过 2MB
|
||||
</p>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
style={{ display: 'none' }}
|
||||
onChange={onAvatarChange}
|
||||
/>
|
||||
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
|
||||
选择图片上传
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="section-card">
|
||||
<div className="section-card-title">修改密码</div>
|
||||
<Form {...pwdForm}>
|
||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
|
||||
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="profile-form">
|
||||
<FormField
|
||||
control={pwdForm.control}
|
||||
name="old_password"
|
||||
@@ -234,9 +383,11 @@ export default function ProfilePage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||
修改密码
|
||||
</Button>
|
||||
<div className="profile-form-footer profile-form-footer--end">
|
||||
<Button type="submit" variant="destructive" loading={pwdLoading}>
|
||||
修改密码
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -8,22 +8,24 @@ import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
const schema = z.object({
|
||||
const schema = (minLen: number) => z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
nickname: z.string().optional(),
|
||||
password: z.string().min(6, '密码至少 6 位'),
|
||||
password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const nav = useNavigate();
|
||||
const { refresh } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
resolver: zodResolver(schema(limits.password_min_len)),
|
||||
defaultValues: { username: '', nickname: '', password: '' },
|
||||
});
|
||||
|
||||
@@ -82,7 +84,7 @@ export default function RegisterPage() {
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="至少 6 位" autoComplete="new-password" {...field} />
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
125
frontend/src/pages/admin/AdminCommentsPage.tsx
Normal file
125
frontend/src/pages/admin/AdminCommentsPage.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { Comment } from '../../api/types';
|
||||
|
||||
export default function AdminCommentsPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const load = (p = page) => {
|
||||
setLoading(true);
|
||||
api.adminComments(p)
|
||||
.then(d => {
|
||||
setComments(d.comments ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1);
|
||||
}, [ready]);
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await api.adminDeleteComment(id);
|
||||
notify.success('评论已删除');
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>评论管理</h1>
|
||||
<p>查看与删除楼层评论</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>楼层</th>
|
||||
<th>帖子</th>
|
||||
<th>作者</th>
|
||||
<th>内容</th>
|
||||
<th>私密</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comments.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td>#{c.floor}</td>
|
||||
<td>
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${c.post_id}`)}>
|
||||
{c.post?.title ?? `#${c.post_id}`}
|
||||
</button>
|
||||
</td>
|
||||
<td>{c.user?.nickname || c.guest_nick || '游客'}</td>
|
||||
<td className="max-w-[200px] truncate">{c.content}</td>
|
||||
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
|
||||
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>此操作不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{comments.length === 0 && <div className="admin-empty">暂无评论</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
88
frontend/src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { AdminDashboard } from '../../api/types';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [data, setData] = useState<AdminDashboard | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
api.adminDashboard()
|
||||
.then(setData)
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready]);
|
||||
|
||||
if (!ready || loading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const stats = [
|
||||
{ label: '注册用户', value: data.users, cls: 'admin-stat-users' },
|
||||
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
|
||||
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
|
||||
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
|
||||
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>仪表盘</h1>
|
||||
<p>论坛运行概览与最新帖子</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-stat-grid">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className={`admin-stat-card ${s.cls}`}>
|
||||
<div className="admin-stat-value">{s.value}</div>
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<span>最新帖子</span>
|
||||
<button type="button" className="admin-text-link" onClick={() => nav('/admin/posts')}>
|
||||
查看全部 →
|
||||
</button>
|
||||
</div>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>置顶</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recent_posts.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td>
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
|
||||
{p.title}
|
||||
</button>
|
||||
</td>
|
||||
<td>{p.user?.nickname ?? '—'}</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{data.recent_posts.length === 0 && <div className="admin-empty">暂无帖子</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
frontend/src/pages/admin/AdminPostsPage.tsx
Normal file
206
frontend/src/pages/admin/AdminPostsPage.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Lock, LockOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
||||
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { PostItem } from '../../api/types';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import { isTimeDiffSignificant } from '../../utils/content';
|
||||
|
||||
function formatAdminTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = (p = page, kw = search) => {
|
||||
setLoading(true);
|
||||
api.adminPosts({ page: p, keyword: kw })
|
||||
.then(d => {
|
||||
setPosts(d.posts ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1, search);
|
||||
}, [ready, search]);
|
||||
|
||||
const togglePin = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminPinPost(post.id, !post.pinned);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLock = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminLockPost(post.id, !post.edit_locked);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await api.adminDeletePost(id);
|
||||
notify.success('帖子已删除');
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>帖子管理</h1>
|
||||
<p>置顶、锁定编辑、删除帖子;支持按标题、标签或正文关键词搜索</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="admin-search-bar"
|
||||
onSubmit={e => { e.preventDefault(); setSearch(keyword.trim()); }}
|
||||
>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="搜索标题、标签或正文…"
|
||||
/>
|
||||
<Button type="submit"><Search size={16} />搜索</Button>
|
||||
{search && (
|
||||
<Button type="button" variant="outline" onClick={() => { setKeyword(''); setSearch(''); }}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>板块</th>
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>评论</th>
|
||||
<th>置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>点赞</th>
|
||||
<th>浏览</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map(p => {
|
||||
const edited = p.updated_at && isTimeDiffSignificant(p.created_at, p.updated_at);
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td className="max-w-[200px] truncate">
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/post/${p.id}`)}>
|
||||
{p.title}
|
||||
</button>
|
||||
{edited && <Badge variant="secondary" className="ml-1">已编辑</Badge>}
|
||||
</td>
|
||||
<td>{p.board?.name ?? '—'}</td>
|
||||
<td>{p.user?.nickname ?? '—'}</td>
|
||||
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
|
||||
<td>{p.comment_count ?? 0}</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.like_count}</td>
|
||||
<td>{p.view_count}</td>
|
||||
<td className="text-sm whitespace-nowrap">
|
||||
<span>{formatAdminTime(p.created_at)}</span>
|
||||
{edited && p.updated_at && (
|
||||
<span className="block text-muted-foreground text-xs">
|
||||
改于 {formatAdminTime(p.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
||||
{p.pinned ? '取消置顶' : '置顶'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => toggleLock(p)}>
|
||||
{p.edit_locked ? <><LockOpen size={14} /> 解锁</> : <><Lock size={14} /> 锁定</>}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(p.id)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{posts.length === 0 && <div className="admin-empty">没有找到帖子</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
326
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal file
326
frontend/src/pages/admin/AdminSettingsPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database, Shield, Server, SlidersHorizontal } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import type { AdminSettings, ForumLimits } from '../../api/types';
|
||||
|
||||
type TabId = 'limits' | 'filter' | 'system';
|
||||
|
||||
type SettingRow = {
|
||||
key: keyof ForumLimits;
|
||||
label: string;
|
||||
unit?: string;
|
||||
hint?: string;
|
||||
min?: number;
|
||||
};
|
||||
|
||||
type SettingSection = {
|
||||
id: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
rows: SettingRow[];
|
||||
};
|
||||
|
||||
const SETTING_SECTIONS: SettingSection[] = [
|
||||
{
|
||||
id: 'rule',
|
||||
title: '编辑规则',
|
||||
summary: '控制普通用户修改自己帖子的时限',
|
||||
rows: [
|
||||
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rate',
|
||||
title: '操作限流',
|
||||
summary: '同一用户或 IP 在窗口期内的最大请求次数',
|
||||
rows: [
|
||||
{ key: 'rate_limit_window_sec', label: '限流窗口', unit: '秒', min: 10 },
|
||||
{ key: 'rate_limit_post', label: '发帖', unit: '次', min: 1 },
|
||||
{ key: 'rate_limit_comment', label: '评论', unit: '次', min: 1 },
|
||||
{ key: 'rate_limit_register', label: '注册', unit: '次', min: 1 },
|
||||
{ key: 'rate_limit_login', label: '登录', unit: '次', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'content',
|
||||
title: '内容长度',
|
||||
summary: '发帖与评论的字数上限,服务端强制校验',
|
||||
rows: [
|
||||
{ key: 'post_title_max', label: '帖子标题', unit: '字', min: 1 },
|
||||
{ key: 'post_tags_max', label: '帖子标签', unit: '字', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'post_content_max', label: '帖子正文', unit: '字', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'comment_max', label: '评论内容', unit: '字', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: '搜索与列表',
|
||||
summary: '关键词长度与帖子列表分页',
|
||||
rows: [
|
||||
{ key: 'search_keyword_min', label: '关键词最短', unit: '字', min: 0 },
|
||||
{ key: 'search_keyword_max', label: '关键词最长', unit: '字', min: 1 },
|
||||
{ key: 'page_size_default', label: '默认每页', unit: '条', min: 1 },
|
||||
{ key: 'page_size_max', label: '最大每页', unit: '条', min: 1 },
|
||||
{ key: 'feed_max_pages', label: '首页自动加载页数', unit: '页', hint: '超出后需手动加载', min: 1 },
|
||||
{ key: 'feed_max_items', label: '首页自动加载条数', unit: '条', hint: '与页数取先到上限', min: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'user',
|
||||
title: '用户账号',
|
||||
summary: '注册、改密与头像上传限制',
|
||||
rows: [
|
||||
{ key: 'password_min_len', label: '密码最短', unit: '位', min: 4 },
|
||||
{ key: 'avatar_max_mb', label: '头像上限', unit: 'MB', min: 1 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
|
||||
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
|
||||
{ id: 'filter', label: '敏感词', icon: Shield },
|
||||
{ id: 'system', label: '系统维护', icon: Server },
|
||||
];
|
||||
|
||||
function SettingTable({
|
||||
sections,
|
||||
limits,
|
||||
onChange,
|
||||
}: {
|
||||
sections: SettingSection[];
|
||||
limits: ForumLimits;
|
||||
onChange: (key: keyof ForumLimits, value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-settings-sections">
|
||||
{sections.map(section => (
|
||||
<section key={section.id} className="admin-settings-section" id={`settings-${section.id}`}>
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>{section.title}</h3>
|
||||
<p>{section.summary}</p>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label={section.title}>
|
||||
{section.rows.map(row => (
|
||||
<div key={row.key} className="admin-settings-row">
|
||||
<label htmlFor={`limit-${row.key}`} className="admin-settings-row-label">
|
||||
{row.label}
|
||||
</label>
|
||||
<div className="admin-settings-row-input">
|
||||
<Input
|
||||
id={`limit-${row.key}`}
|
||||
type="number"
|
||||
min={row.min ?? 0}
|
||||
value={limits[row.key]}
|
||||
onChange={e => onChange(row.key, e.target.value)}
|
||||
className="admin-settings-input"
|
||||
/>
|
||||
{row.unit && <span className="admin-settings-unit">{row.unit}</span>}
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">{row.hint ?? ''}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [limits, setLimits] = useState<ForumLimits | null>(null);
|
||||
const [filterWords, setFilterWords] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<TabId>('limits');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backing, setBacking] = useState(false);
|
||||
const [savingForum, setSavingForum] = useState(false);
|
||||
const [savingFilter, setSavingFilter] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
api.adminSettings()
|
||||
.then(s => {
|
||||
setSettings(s);
|
||||
setLimits(s.limits);
|
||||
setFilterWords(s.filter_words);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready]);
|
||||
|
||||
const handleLimitChange = (key: keyof ForumLimits, value: string) => {
|
||||
const n = parseInt(value, 10);
|
||||
if (Number.isNaN(n)) return;
|
||||
setLimits(prev => prev ? { ...prev, [key]: n } : prev);
|
||||
};
|
||||
|
||||
const handleSaveForumSettings = async () => {
|
||||
if (!limits) return;
|
||||
setSavingForum(true);
|
||||
try {
|
||||
const r = await api.adminUpdateForumSettings(limits);
|
||||
notify.success(r.message);
|
||||
invalidateForumLimitsCache();
|
||||
setLimits(r.limits);
|
||||
setSettings(s => s ? { ...s, limits: r.limits } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingForum(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFilterWords = async () => {
|
||||
setSavingFilter(true);
|
||||
try {
|
||||
const r = await api.adminUpdateFilterWords(filterWords);
|
||||
notify.success(r.message);
|
||||
setSettings(s => s ? { ...s, filter_words: filterWords, filter_word_count: r.word_count } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingFilter(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
setBacking(true);
|
||||
try {
|
||||
const r = await api.adminBackup();
|
||||
notify.success(r.message);
|
||||
window.location.href = r.download;
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '备份失败');
|
||||
} finally {
|
||||
setBacking(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready || loading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!settings || !limits) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-settings-page">
|
||||
<header className="admin-page-head">
|
||||
<h1>系统设置</h1>
|
||||
<p>管理论坛运行规则、敏感词过滤与数据维护</p>
|
||||
</header>
|
||||
|
||||
<nav className="admin-settings-tabs" aria-label="设置分类">
|
||||
{TABS.map(({ id, label, icon: Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`admin-settings-tab${activeTab === id ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(id)}
|
||||
aria-selected={activeTab === id}
|
||||
>
|
||||
<Icon size={15} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeTab === 'limits' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>论坛限制</span>
|
||||
<span className="admin-settings-card-badge">共 {SETTING_SECTIONS.length} 组</span>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<SettingTable sections={SETTING_SECTIONS} limits={limits} onChange={handleLimitChange} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>修改后请点击保存,新规则立即对全部用户生效</p>
|
||||
<Button onClick={handleSaveForumSettings} loading={savingForum}>
|
||||
保存论坛限制
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'filter' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>敏感词过滤</span>
|
||||
<span className="admin-settings-card-badge">{settings.filter_word_count} 个词条</span>
|
||||
</div>
|
||||
<div className="admin-card-body admin-settings-filter-body">
|
||||
<p className="admin-settings-filter-tip">
|
||||
每行一个词,<code>#</code> 开头为注释。保存后写入 <code>filter_words.txt</code> 并立即生效。
|
||||
</p>
|
||||
<Textarea
|
||||
rows={16}
|
||||
value={filterWords}
|
||||
onChange={e => setFilterWords(e.target.value)}
|
||||
className="admin-settings-filter-textarea"
|
||||
spellCheck={false}
|
||||
placeholder={'# 示例\n违禁词\n广告刷单'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>敏感词会在发帖、评论、昵称等文本中自动替换为 *</p>
|
||||
<Button onClick={handleSaveFilterWords} loading={savingFilter}>
|
||||
保存敏感词
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">运行信息</div>
|
||||
<div className="admin-card-body">
|
||||
<dl className="admin-settings-info">
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>数据目录</dt>
|
||||
<dd><code>{settings.data_dir}</code></dd>
|
||||
</div>
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>数据库</dt>
|
||||
<dd><code>{settings.db_path}</code></dd>
|
||||
</div>
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>敏感词文件</dt>
|
||||
<dd><code>{settings.filter_path}</code></dd>
|
||||
</div>
|
||||
<div className="admin-settings-info-row">
|
||||
<dt>监听端口</dt>
|
||||
<dd>{settings.port}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">数据备份</div>
|
||||
<div className="admin-card-body admin-settings-backup-body">
|
||||
<p>导出当前 SQLite 数据库副本,便于迁移或灾难恢复。</p>
|
||||
<p className="admin-settings-backup-name">
|
||||
文件名:<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
|
||||
</p>
|
||||
<Button onClick={handleBackup} loading={backing}>
|
||||
<Database size={16} />
|
||||
立即备份并下载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
110
frontend/src/pages/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { User } from '../../api/types';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
const load = (p = page) => {
|
||||
setLoading(true);
|
||||
api.adminUsers(p)
|
||||
.then(d => {
|
||||
setUsers(d.users ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1);
|
||||
}, [ready]);
|
||||
|
||||
const toggleBan = async (user: User) => {
|
||||
if (user.role === 'admin') {
|
||||
notify.warning('不能禁言管理员');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.adminBanUser(user.id, !user.banned);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>用户管理</h1>
|
||||
<p>查看注册用户,禁言或解除禁言</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.id}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.nickname}</td>
|
||||
<td>
|
||||
{u.role === 'admin'
|
||||
? <Badge variant="orange">管理员</Badge>
|
||||
: <Badge variant="secondary">用户</Badge>}
|
||||
</td>
|
||||
<td>{u.banned ? <Badge variant="destructive">已禁言</Badge> : '正常'}</td>
|
||||
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
|
||||
<td>
|
||||
{u.role !== 'admin' && (
|
||||
<Button size="sm" variant="outline" onClick={() => toggleBan(u)}>
|
||||
{u.banned ? '解除禁言' : '禁言'}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
/** 服务端管理后台地址(非 SPA 路由) */
|
||||
/** 管理后台入口(React SPA 内路由) */
|
||||
export const ADMIN_DASHBOARD_URL = '/admin/dashboard';
|
||||
|
||||
/** 跳转到系统管理后台 */
|
||||
export function openAdminDashboard() {
|
||||
window.location.href = ADMIN_DASHBOARD_URL;
|
||||
}
|
||||
|
||||
66
frontend/src/utils/avatarCrop.ts
Normal file
66
frontend/src/utils/avatarCrop.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Area } from 'react-easy-crop';
|
||||
|
||||
export const AVATAR_ACCEPT = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
export const AVATAR_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
|
||||
/** 头像输出尺寸 */
|
||||
export const AVATAR_OUTPUT_SIZE = 512;
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('图片加载失败'));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验头像文件,返回错误信息或 null */
|
||||
export function validateAvatarFile(file: File, maxMb: number): string | null {
|
||||
if (!AVATAR_MIME_TYPES.includes(file.type)) {
|
||||
return '仅支持 JPG、PNG、GIF、WebP 格式';
|
||||
}
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
return `头像不能超过 ${maxMb}MB`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 将裁剪区域渲染为 JPEG 文件 */
|
||||
export async function getCroppedAvatarFile(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
originalName = 'avatar.jpg',
|
||||
): Promise<File> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
const size = AVATAR_OUTPUT_SIZE;
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('裁剪失败');
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
pixelCrop.x,
|
||||
pixelCrop.y,
|
||||
pixelCrop.width,
|
||||
pixelCrop.height,
|
||||
0,
|
||||
0,
|
||||
size,
|
||||
size,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
|
||||
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||
}
|
||||
71
frontend/src/utils/boardTheme.ts
Normal file
71
frontend/src/utils/boardTheme.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Code2, Coffee, HelpCircle, MessageSquare, Lightbulb,
|
||||
BookOpen, Gamepad2, Palette, Music, Camera, Heart, Zap,
|
||||
Globe, Users, Briefcase, GraduationCap, ShoppingBag, MapPin,
|
||||
Megaphone, Flame, Star, Folder, Wrench, Cpu, type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Board } from '../api/types';
|
||||
|
||||
/** 板块主题色槽位数(与 global.css 中 board-badge--N 对应) */
|
||||
export const BOARD_PALETTE_SIZE = 8;
|
||||
|
||||
/** 可选板块图标(key 须与后端 AllowedBoardIcons 一致) */
|
||||
export const BOARD_ICON_OPTIONS: { key: string; label: string; Icon: LucideIcon }[] = [
|
||||
{ key: 'code-2', label: '代码', Icon: Code2 },
|
||||
{ key: 'coffee', label: '咖啡', Icon: Coffee },
|
||||
{ key: 'help-circle', label: '问答', Icon: HelpCircle },
|
||||
{ key: 'message-square', label: '讨论', Icon: MessageSquare },
|
||||
{ key: 'lightbulb', label: '灵感', Icon: Lightbulb },
|
||||
{ key: 'book-open', label: '阅读', Icon: BookOpen },
|
||||
{ key: 'gamepad-2', label: '游戏', Icon: Gamepad2 },
|
||||
{ key: 'palette', label: '设计', Icon: Palette },
|
||||
{ key: 'music', label: '音乐', Icon: Music },
|
||||
{ key: 'camera', label: '摄影', Icon: Camera },
|
||||
{ key: 'heart', label: '生活', Icon: Heart },
|
||||
{ key: 'zap', label: '快讯', Icon: Zap },
|
||||
{ key: 'globe', label: '综合', Icon: Globe },
|
||||
{ key: 'users', label: '社区', Icon: Users },
|
||||
{ key: 'briefcase', label: '职场', Icon: Briefcase },
|
||||
{ key: 'graduation-cap', label: '学习', Icon: GraduationCap },
|
||||
{ key: 'shopping-bag', label: '交易', Icon: ShoppingBag },
|
||||
{ key: 'map-pin', label: '本地', Icon: MapPin },
|
||||
{ key: 'megaphone', label: '公告', Icon: Megaphone },
|
||||
{ key: 'flame', label: '热门', Icon: Flame },
|
||||
{ key: 'star', label: '精华', Icon: Star },
|
||||
{ key: 'folder', label: '资源', Icon: Folder },
|
||||
{ key: 'wrench', label: '工具', Icon: Wrench },
|
||||
{ key: 'cpu', label: '硬件', Icon: Cpu },
|
||||
];
|
||||
|
||||
const ICON_MAP = Object.fromEntries(
|
||||
BOARD_ICON_OPTIONS.map(o => [o.key, o.Icon]),
|
||||
) as Record<string, LucideIcon>;
|
||||
|
||||
const DEFAULT_ICONS: LucideIcon[] = [
|
||||
Code2, Coffee, HelpCircle, MessageSquare,
|
||||
Lightbulb, BookOpen, Gamepad2, Palette,
|
||||
];
|
||||
|
||||
export type BoardVisual = Pick<Board, 'id' | 'icon' | 'color_index'>;
|
||||
|
||||
/** 按板块 id / color_index 取稳定色槽索引 */
|
||||
export function getBoardThemeIndex(board: BoardVisual | number): number {
|
||||
if (typeof board === 'object' && board.color_index != null && board.color_index >= 0) {
|
||||
return board.color_index % BOARD_PALETTE_SIZE;
|
||||
}
|
||||
const id = typeof board === 'number' ? board : board.id;
|
||||
return ((id % BOARD_PALETTE_SIZE) + BOARD_PALETTE_SIZE) % BOARD_PALETTE_SIZE;
|
||||
}
|
||||
|
||||
/** 解析板块图标:优先使用后台配置,否则按 id 回退 */
|
||||
export function getBoardIcon(board: BoardVisual | number): LucideIcon {
|
||||
if (typeof board === 'object' && board.icon && ICON_MAP[board.icon]) {
|
||||
return ICON_MAP[board.icon];
|
||||
}
|
||||
return DEFAULT_ICONS[getBoardThemeIndex(board)];
|
||||
}
|
||||
|
||||
export function getBoardIconOption(key: string | undefined) {
|
||||
if (!key) return undefined;
|
||||
return BOARD_ICON_OPTIONS.find(o => o.key === key);
|
||||
}
|
||||
@@ -1,20 +1,62 @@
|
||||
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
|
||||
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
|
||||
/** 转义 HTML 并保留换行 */
|
||||
function escapeWithBreaks(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// innerHTML 解析会吞掉换行,需转为 <br>
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
/** @用户名 高亮(仅用于评论正文中用户主动输入的 @) */
|
||||
export function highlightMentions(text: string, _onClick?: (name: string) => void): string {
|
||||
return escapeWithBreaks(text)
|
||||
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
|
||||
}
|
||||
|
||||
export function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||
if (diff < 60) return '刚刚';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
|
||||
return `${d.getMonth() + 1}-${d.getDate()} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (
|
||||
d.getFullYear() === yesterday.getFullYear()
|
||||
&& d.getMonth() === yesterday.getMonth()
|
||||
&& d.getDate() === yesterday.getDate()
|
||||
) {
|
||||
return `昨天 ${clock}`;
|
||||
}
|
||||
|
||||
if (d.getFullYear() === now.getFullYear()) {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${clock}`;
|
||||
}
|
||||
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${clock}`;
|
||||
}
|
||||
|
||||
/** 完整日期时间(用于帖子发布/修改时间展示) */
|
||||
export function formatDateTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** 判断两个 ISO 时间是否相差超过 1 分钟 */
|
||||
export function isTimeDiffSignificant(a: string, b: string) {
|
||||
const da = new Date(a).getTime();
|
||||
const db = new Date(b).getTime();
|
||||
if (Number.isNaN(da) || Number.isNaN(db)) return false;
|
||||
return Math.abs(da - db) > 60_000;
|
||||
}
|
||||
|
||||
123
frontend/src/utils/feedCache.ts
Normal file
123
frontend/src/utils/feedCache.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from '../components/FeedSortBar';
|
||||
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
|
||||
posts: PostItem[];
|
||||
|
||||
postTotal: number;
|
||||
|
||||
page: number;
|
||||
|
||||
hasMore: boolean;
|
||||
|
||||
scrollTop: number;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const PREFIX = 'j13-feed-cache:';
|
||||
|
||||
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
return `${PREFIX}${boardId}:${keyword}:${sort}`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
|
||||
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
||||
|
||||
try {
|
||||
|
||||
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
return raw ? (JSON.parse(raw) as FeedCache) : null;
|
||||
|
||||
} catch {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
|
||||
|
||||
} catch {
|
||||
|
||||
// sessionStorage 不可用时忽略
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除指定筛选条件下的列表缓存 */
|
||||
|
||||
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||
|
||||
const key = sessionStorage.key(i);
|
||||
|
||||
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
export const FEED_RESET_EVENT = 'feed-reset';
|
||||
|
||||
/** 清除缓存并导航到帖子列表(重复点击同一入口时也会刷新) */
|
||||
export function navigateFeed(nav: NavigateFunction, url: string) {
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event(FEED_RESET_EVENT));
|
||||
nav(url, { state: { refreshFeed: true } satisfies FeedNavState });
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
|
||||
const MEMBERS_BLOCK_RE = /:::members[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*:::/g;
|
||||
|
||||
type MdPart = { type: 'text' | 'members'; content: string };
|
||||
|
||||
/** 拆分普通 Markdown 与 :::members 区块 */
|
||||
function splitMembersBlocks(md: string): MdPart[] {
|
||||
const parts: MdPart[] = [];
|
||||
let lastIndex = 0;
|
||||
const re = new RegExp(MEMBERS_BLOCK_RE.source, 'g');
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = re.exec(md)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ type: 'text', content: md.slice(lastIndex, match.index) });
|
||||
}
|
||||
parts.push({ type: 'members', content: match[1] });
|
||||
lastIndex = re.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < md.length) {
|
||||
parts.push({ type: 'text', content: md.slice(lastIndex) });
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
parts.push({ type: 'text', content: md });
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Markdown 转 HTML,用于预览与发布 */
|
||||
export function markdownToHtml(md: string): string {
|
||||
const parts = splitMembersBlocks(md);
|
||||
const html = parts.map(part => {
|
||||
if (part.type === 'members') {
|
||||
const inner = marked.parse(part.content.trim()) as string;
|
||||
return `<members-only>${inner}</members-only>`;
|
||||
}
|
||||
return marked.parse(part.content) as string;
|
||||
}).join('');
|
||||
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** HTML 转 Markdown,用于编辑已有帖子时回填编辑器 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
const walk = (node: Node): string => {
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return '';
|
||||
|
||||
const el = node as HTMLElement;
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const inner = () => Array.from(el.childNodes).map(walk).join('');
|
||||
|
||||
switch (tag) {
|
||||
case 'members-only': {
|
||||
if (el.getAttribute('data-locked') === 'true') return '';
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const content = body ? Array.from(body.childNodes).map(walk).join('') : inner();
|
||||
return `:::members\n${content.trim()}\n:::\n\n`;
|
||||
}
|
||||
case 'br': return '\n';
|
||||
case 'p': return inner().trimEnd() + '\n\n';
|
||||
case 'h1': return `# ${inner().trim()}\n\n`;
|
||||
case 'h2': return `## ${inner().trim()}\n\n`;
|
||||
case 'h3': return `### ${inner().trim()}\n\n`;
|
||||
case 'h4': return `#### ${inner().trim()}\n\n`;
|
||||
case 'h5': return `##### ${inner().trim()}\n\n`;
|
||||
case 'h6': return `###### ${inner().trim()}\n\n`;
|
||||
case 'strong':
|
||||
case 'b': return `**${inner()}**`;
|
||||
case 'em':
|
||||
case 'i': return `*${inner()}*`;
|
||||
case 'code': return `\`${inner()}\``;
|
||||
case 'pre': return `\`\`\`\n${el.textContent ?? ''}\n\`\`\`\n\n`;
|
||||
case 'a': return `[${inner()}](${el.getAttribute('href') ?? ''})`;
|
||||
case 'img': return ` ?? ''})`;
|
||||
case 'ul':
|
||||
return Array.from(el.children)
|
||||
.map(li => `- ${walk(li).trim()}`)
|
||||
.join('\n') + '\n\n';
|
||||
case 'ol':
|
||||
return Array.from(el.children)
|
||||
.map((li, i) => `${i + 1}. ${walk(li).trim()}`)
|
||||
.join('\n') + '\n\n';
|
||||
case 'li': return inner();
|
||||
case 'blockquote': {
|
||||
const text = inner().trim().replace(/\n/g, '\n> ');
|
||||
return `> ${text}\n\n`;
|
||||
}
|
||||
case 'hr': return '---\n\n';
|
||||
case 'div':
|
||||
if (el.classList.contains('post-members-only__badge')
|
||||
|| el.classList.contains('post-members-only__gate')
|
||||
|| el.classList.contains('post-members-only__gate-icon')) {
|
||||
return '';
|
||||
}
|
||||
if (el.classList.contains('post-members-only__body')) {
|
||||
return inner();
|
||||
}
|
||||
return inner();
|
||||
default: return inner();
|
||||
}
|
||||
};
|
||||
|
||||
return walk(doc.body).replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/** 统计正文字数(不含空白) */
|
||||
export function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
if (!t) return 0;
|
||||
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
|
||||
return cjk + en;
|
||||
}
|
||||
|
||||
/** 编辑器插入用的会员专属区块模板 */
|
||||
export const MEMBERS_ONLY_TEMPLATE = ':::members\n在此输入仅登录用户可见的内容…\n:::';
|
||||
229
frontend/src/utils/markdownContent.ts
Normal file
229
frontend/src/utils/markdownContent.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
||||
|
||||
const TURNDOWN_OPTIONS = {
|
||||
headingStyle: 'atx' as const,
|
||||
codeBlockStyle: 'fenced' as const,
|
||||
emDelimiter: '*',
|
||||
bulletListMarker: '-',
|
||||
};
|
||||
|
||||
/** 仅去除区块首尾空行,保留各行行首缩进 */
|
||||
function trimBlockBoundaryLines(content: string): string {
|
||||
return content.replace(/^\n+/, '').replace(/\n+$/, '');
|
||||
}
|
||||
|
||||
/** 不换行空格还原为普通空格,便于 Markdown 源码编辑 */
|
||||
function nbspToSpaces(text: string): string {
|
||||
return text.replace(/\u00A0/g, ' ');
|
||||
}
|
||||
|
||||
/** 将含 <br> 的段落拆成多个 <p>,避免聚合转换时丢失首行缩进 */
|
||||
function splitParagraphBreaks(html: string): string {
|
||||
if (!/<br\s*\/?>/i.test(html)) return html;
|
||||
|
||||
const doc = new DOMParser().parseFromString(`<div data-wrap="1">${html}</div>`, 'text/html');
|
||||
const container = doc.querySelector('[data-wrap]');
|
||||
if (!container) return html;
|
||||
|
||||
[...container.querySelectorAll('p')].forEach(p => {
|
||||
const inner = p.innerHTML;
|
||||
if (!/<br\s*\/?>/i.test(inner)) return;
|
||||
|
||||
const parts = inner.split(/<br\s*\/?>/i);
|
||||
const fragment = doc.createDocumentFragment();
|
||||
parts.forEach((part, index) => {
|
||||
if (part === '' && index === parts.length - 1) return;
|
||||
const newP = doc.createElement('p');
|
||||
newP.innerHTML = part;
|
||||
fragment.appendChild(newP);
|
||||
});
|
||||
p.replaceWith(fragment);
|
||||
});
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
service.addRule('image', {
|
||||
filter: 'img',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLImageElement;
|
||||
const alt = el.getAttribute('alt') ?? '';
|
||||
const src = el.getAttribute('src') ?? '';
|
||||
return src ? `` : '';
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('underline', {
|
||||
filter: ['u'],
|
||||
replacement: (content) => `<u>${content}</u>`,
|
||||
});
|
||||
|
||||
service.addRule('strikethrough', {
|
||||
filter: ['del', 's', 'strike'],
|
||||
replacement: (content) => `~~${content}~~`,
|
||||
});
|
||||
|
||||
service.addRule('horizontalRule', {
|
||||
filter: 'hr',
|
||||
replacement: () => '\n\n---\n\n',
|
||||
});
|
||||
|
||||
service.addRule('anchor', {
|
||||
filter: (node) => {
|
||||
if (node.nodeName !== 'A') return false;
|
||||
const href = (node as HTMLAnchorElement).getAttribute('href');
|
||||
return Boolean(href);
|
||||
},
|
||||
replacement: (content, node) => {
|
||||
const el = node as HTMLAnchorElement;
|
||||
const href = el.getAttribute('href') ?? '';
|
||||
const title = el.getAttribute('title');
|
||||
return title ? `[${content}](${href} "${title}")` : `[${content}](${href})`;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
|
||||
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(contentTurndown);
|
||||
|
||||
const turndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(turndown);
|
||||
|
||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
||||
turndown.addRule('membersOnly', {
|
||||
filter: 'members-only',
|
||||
replacement: (_content, node) => {
|
||||
const el = node as HTMLElement;
|
||||
const parts: string[] = [];
|
||||
|
||||
el.childNodes.forEach(child => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? '').trim();
|
||||
if (text) parts.push(nbspToSpaces(text));
|
||||
return;
|
||||
}
|
||||
if (child instanceof HTMLElement) {
|
||||
parts.push(nbspToSpaces(contentTurndown.turndown(child.outerHTML).trim()));
|
||||
}
|
||||
});
|
||||
|
||||
const body = trimBlockBoundaryLines(parts.join('\n\n'));
|
||||
return `\n\n<members-only>\n\n${body}\n\n</members-only>\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
/** 禁用缩进代码块,Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
code() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 净化并保留 members-only 标签 */
|
||||
function sanitizeContentHtml(html: string): string {
|
||||
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
|
||||
}
|
||||
|
||||
/** 将行首空格转为不换行空格,避免 HTML 折叠缩进;跳过围栏代码块 */
|
||||
function preserveLeadingIndent(markdown: string): string {
|
||||
return markdown.split(/(```[\s\S]*?```)/g).map((part, index) => {
|
||||
if (index % 2 === 1) return part;
|
||||
return part.replace(/^( +)(?=\S)/gm, (_match, spaces: string) => '\u00A0'.repeat(spaces.length));
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** 将普通 Markdown 片段转为 HTML */
|
||||
function parseMarkdownFragment(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
return marked.parse(preserveLeadingIndent(markdown), { async: false }) as string;
|
||||
}
|
||||
|
||||
/** 转换前清理编辑态装饰结构,避免污染 Markdown */
|
||||
function prepareHtmlForMarkdown(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(sanitizeContentHtml(html), 'text/html');
|
||||
|
||||
doc.querySelectorAll('.post-members-only__badge, .post-members-only__exit-btn, .post-members-only__unwrap-btn').forEach(el => {
|
||||
el.remove();
|
||||
});
|
||||
|
||||
doc.querySelectorAll('members-only').forEach(el => {
|
||||
const body = el.querySelector('.post-members-only__body');
|
||||
const raw = body ? body.innerHTML : el.innerHTML;
|
||||
el.innerHTML = splitParagraphBreaks(raw);
|
||||
});
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
/** 规范化 members-only 标签边界,避免闭合标签与正文粘连 */
|
||||
function normalizeMembersOnlyMarkdown(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/<\/members-only>(?=[^\s\n])/g, '</members-only>\n\n')
|
||||
.replace(/<members-only>\s*<\/members-only>/g, '<members-only>\n\n</members-only>');
|
||||
}
|
||||
|
||||
/** 列表标记后统一为单个空格(Turndown 默认会输出两个及以上空格) */
|
||||
function normalizeListMarkerSpacing(markdown: string): string {
|
||||
return markdown
|
||||
.replace(/^(\s*[-+*])\s+(?=\S)/gm, '$1 ')
|
||||
.replace(/^(\s*\d+\.)\s+(?=\S)/gm, '$1 ');
|
||||
}
|
||||
|
||||
/** 编辑器 HTML 转为 Markdown 源码 */
|
||||
export function htmlToMarkdown(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
const prepared = prepareHtmlForMarkdown(html);
|
||||
const raw = turndown.turndown(prepared).replace(/\n{3,}/g, '\n\n').trim();
|
||||
return normalizeListMarkerSpacing(raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 源码转为编辑器 HTML。
|
||||
* 先提取 members-only 块再分别解析,避免闭合标签后同行文字被吞入区块。
|
||||
*/
|
||||
export function markdownToHtml(markdown: string): string {
|
||||
if (!markdown.trim()) return '';
|
||||
|
||||
const normalized = normalizeMembersOnlyMarkdown(markdown);
|
||||
const re = new RegExp(MEMBERS_ONLY_BLOCK_RE.source, 'gi');
|
||||
let result = '';
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null = re.exec(normalized);
|
||||
|
||||
while (match) {
|
||||
const before = normalized.slice(lastIndex, match.index);
|
||||
if (before.trim()) {
|
||||
result += parseMarkdownFragment(before);
|
||||
}
|
||||
|
||||
const innerMd = trimBlockBoundaryLines(match[1]);
|
||||
const innerHtml = innerMd.trim()
|
||||
? splitParagraphBreaks(parseMarkdownFragment(innerMd))
|
||||
: '';
|
||||
result += `<members-only>${innerHtml}</members-only>`;
|
||||
lastIndex = re.lastIndex;
|
||||
match = re.exec(normalized);
|
||||
}
|
||||
|
||||
const tail = normalized.slice(lastIndex);
|
||||
if (tail.trim()) {
|
||||
result += parseMarkdownFragment(tail);
|
||||
}
|
||||
|
||||
return sanitizeContentHtml(result);
|
||||
}
|
||||
109
frontend/src/utils/markdownFormat.ts
Normal file
109
frontend/src/utils/markdownFormat.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { applyTextareaChange } from './markdownIndent';
|
||||
|
||||
type ChangeHandler = (value: string) => void;
|
||||
|
||||
/** 在选区两侧包裹 Markdown 标记 */
|
||||
export function wrapMarkdownSelection(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
placeholder: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd);
|
||||
const text = selected || placeholder;
|
||||
const insert = prefix + text + suffix;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
const newStart = selectionStart + prefix.length;
|
||||
const newEnd = newStart + text.length;
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
}
|
||||
|
||||
/** 为当前行或选区行添加前缀(如引用) */
|
||||
export function prefixMarkdownLines(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
prefix: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', selectionEnd);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n').map(line => `${prefix}${line}`);
|
||||
const next = value.slice(0, lineStart) + lines.join('\n') + value.slice(lineEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + prefix.length,
|
||||
selectionEnd + prefix.length * lines.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 切换当前行标题级别(源码模式) */
|
||||
export function cycleMarkdownHeading(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart } = textarea;
|
||||
const lineStart = value.lastIndexOf('\n', selectionStart - 1) + 1;
|
||||
const lineEnd = value.indexOf('\n', selectionStart);
|
||||
const end = lineEnd === -1 ? value.length : lineEnd;
|
||||
const line = value.slice(lineStart, end);
|
||||
const match = line.match(/^(#{2,6})\s+(.*)$/);
|
||||
let nextLine: string;
|
||||
if (!match) {
|
||||
nextLine = `## ${line}`;
|
||||
} else {
|
||||
const level = match[1].length;
|
||||
const body = match[2];
|
||||
nextLine = level >= 6 ? body : `${'#'.repeat(level + 1)} ${body}`;
|
||||
}
|
||||
const next = value.slice(0, lineStart) + nextLine + value.slice(end);
|
||||
const offset = nextLine.length - line.length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + Math.max(0, offset),
|
||||
selectionStart + Math.max(0, offset),
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
|
||||
/** 插入登录可见区块模板 */
|
||||
export function insertMarkdownMembersOnly(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const snippet = '\n\n<members-only>\n\n\n</members-only>\n\n';
|
||||
const next = value.slice(0, selectionStart) + snippet + value.slice(selectionEnd);
|
||||
const cursor = selectionStart + '\n\n<members-only>\n\n'.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** 在光标处插入链接 Markdown */
|
||||
export function insertMarkdownLink(
|
||||
textarea: HTMLTextAreaElement,
|
||||
value: string,
|
||||
url: string,
|
||||
onChange: ChangeHandler,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const selected = value.slice(selectionStart, selectionEnd) || '链接文字';
|
||||
const insert = `[${selected}](${url})`;
|
||||
const next = value.slice(0, selectionStart) + insert + value.slice(selectionEnd);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + insert.length,
|
||||
selectionStart + insert.length,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
149
frontend/src/utils/markdownIndent.ts
Normal file
149
frontend/src/utils/markdownIndent.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
export const TAB_SPACES = ' ';
|
||||
|
||||
interface LineRange {
|
||||
lineStart: number;
|
||||
lineEnd: number;
|
||||
}
|
||||
|
||||
/** 获取选区覆盖的整行文本范围 */
|
||||
function getLineRange(value: string, start: number, end: number): LineRange {
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const nextNewline = value.indexOf('\n', end);
|
||||
const lineEnd = nextNewline === -1 ? value.length : nextNewline;
|
||||
return { lineStart, lineEnd };
|
||||
}
|
||||
|
||||
/** 多行整体增加一级缩进 */
|
||||
function indentBlock(value: string, lineStart: number, lineEnd: number): string {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const indented = block.split('\n').map(line => TAB_SPACES + line).join('\n');
|
||||
return value.slice(0, lineStart) + indented + value.slice(lineEnd);
|
||||
}
|
||||
|
||||
/** 多行整体减少一级缩进,返回新文本及选区偏移 */
|
||||
function outdentBlock(
|
||||
value: string,
|
||||
lineStart: number,
|
||||
lineEnd: number,
|
||||
selectionStart: number,
|
||||
selectionEnd: number,
|
||||
): { next: string; newStart: number; newEnd: number } {
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const lines = block.split('\n');
|
||||
let cursor = lineStart;
|
||||
let newStart = selectionStart;
|
||||
let newEnd = selectionEnd;
|
||||
|
||||
const outdented = lines.map(line => {
|
||||
const match = line.match(/^ {1,4}/);
|
||||
const removed = match ? match[0].length : 0;
|
||||
if (removed > 0) {
|
||||
if (selectionStart > cursor) {
|
||||
newStart -= Math.min(removed, selectionStart - cursor);
|
||||
}
|
||||
if (selectionEnd > cursor) {
|
||||
newEnd -= Math.min(removed, selectionEnd - cursor);
|
||||
}
|
||||
}
|
||||
cursor += line.length + 1;
|
||||
return line.replace(/^ {1,4}/, '');
|
||||
}).join('\n');
|
||||
|
||||
return {
|
||||
next: value.slice(0, lineStart) + outdented + value.slice(lineEnd),
|
||||
newStart: Math.max(lineStart, newStart),
|
||||
newEnd: Math.max(lineStart, newEnd),
|
||||
};
|
||||
}
|
||||
|
||||
/** 在光标处插入文本并恢复选区 */
|
||||
export function applyTextareaChange(
|
||||
textarea: HTMLTextAreaElement,
|
||||
nextValue: string,
|
||||
cursorStart: number,
|
||||
cursorEnd = cursorStart,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
onChange(nextValue);
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = cursorStart;
|
||||
textarea.selectionEnd = cursorEnd;
|
||||
textarea.focus();
|
||||
});
|
||||
}
|
||||
|
||||
/** 在 textarea 光标处插入文本 */
|
||||
export function insertAtCursor(
|
||||
textarea: HTMLTextAreaElement,
|
||||
currentValue: string,
|
||||
insertText: string,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
const { selectionStart, selectionEnd } = textarea;
|
||||
const next = currentValue.slice(0, selectionStart) + insertText + currentValue.slice(selectionEnd);
|
||||
const cursor = selectionStart + insertText.length;
|
||||
applyTextareaChange(textarea, next, cursor, cursor, onChange);
|
||||
}
|
||||
|
||||
/** Markdown 源码 Tab / Shift+Tab 缩进 */
|
||||
export function handleMarkdownTabKey(
|
||||
e: React.KeyboardEvent<HTMLTextAreaElement>,
|
||||
onChange: (value: string) => void,
|
||||
) {
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const { lineStart, lineEnd } = getLineRange(value, selectionStart, selectionEnd);
|
||||
const hasSelection = selectionStart !== selectionEnd;
|
||||
const block = value.slice(lineStart, lineEnd);
|
||||
const multiLine = hasSelection && block.split('\n').length > 1;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (multiLine) {
|
||||
const { next, newStart, newEnd } = outdentBlock(value, lineStart, lineEnd, selectionStart, selectionEnd);
|
||||
applyTextareaChange(textarea, next, newStart, newEnd, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const lineText = value.slice(lineStart, selectionStart);
|
||||
const trailingMatch = lineText.match(/ {1,4}$/);
|
||||
if (trailingMatch) {
|
||||
const removeLen = trailingMatch[0].length;
|
||||
const next = value.slice(0, selectionStart - removeLen) + value.slice(selectionStart);
|
||||
applyTextareaChange(textarea, next, selectionStart - removeLen, selectionEnd - removeLen, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const leadingMatch = value.slice(lineStart, selectionStart).match(/^ {1,4}/);
|
||||
if (leadingMatch) {
|
||||
const removeLen = leadingMatch[0].length;
|
||||
const next = value.slice(0, lineStart) + value.slice(lineStart + removeLen);
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart - removeLen,
|
||||
selectionEnd - removeLen,
|
||||
onChange,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (multiLine) {
|
||||
const next = indentBlock(value, lineStart, lineEnd);
|
||||
const lineCount = value.slice(lineStart, lineEnd).split('\n').length;
|
||||
applyTextareaChange(
|
||||
textarea,
|
||||
next,
|
||||
selectionStart + TAB_SPACES.length,
|
||||
selectionEnd + TAB_SPACES.length * lineCount,
|
||||
onChange,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const next = value.slice(0, selectionStart) + TAB_SPACES + value.slice(selectionEnd);
|
||||
applyTextareaChange(textarea, next, selectionStart + TAB_SPACES.length, selectionStart + TAB_SPACES.length, onChange);
|
||||
}
|
||||
@@ -51,6 +51,16 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 判断 HTML 正文是否为空(忽略空段落等) */
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
if (!html.trim()) return true;
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
return (doc.body.textContent ?? '').trim().length === 0;
|
||||
}
|
||||
|
||||
/** 根据登录状态渲染帖子正文 HTML */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
77
frontend/src/utils/revisionDiff.ts
Normal file
77
frontend/src/utils/revisionDiff.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { diffLines, diffWords } from 'diff';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
|
||||
export interface PostSnapshot {
|
||||
title: string;
|
||||
content: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
export interface RevisionChangeSummary {
|
||||
titleChanged: boolean;
|
||||
tagsChanged: boolean;
|
||||
contentChanged: boolean;
|
||||
hasChanges: boolean;
|
||||
}
|
||||
|
||||
export interface DiffPart {
|
||||
value: string;
|
||||
added?: boolean;
|
||||
removed?: boolean;
|
||||
}
|
||||
|
||||
/** 将 HTML 正文转为适合 diff 的纯文本(保留段落换行) */
|
||||
export function htmlToDiffText(html: string): string {
|
||||
if (!html.trim()) return '';
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
'text/html',
|
||||
);
|
||||
doc.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
||||
const blocks = doc.querySelectorAll('p, div, li, h1, h2, h3, h4, h5, h6, blockquote, pre, members-only');
|
||||
blocks.forEach(el => {
|
||||
el.prepend(doc.createTextNode('\n'));
|
||||
el.append(doc.createTextNode('\n'));
|
||||
});
|
||||
return (doc.body.textContent ?? '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function summarizeChange(before: PostSnapshot, after: PostSnapshot): RevisionChangeSummary {
|
||||
const titleChanged = before.title.trim() !== after.title.trim();
|
||||
const tagsChanged = normalizeTags(before.tags) !== normalizeTags(after.tags);
|
||||
const contentChanged = htmlToDiffText(before.content) !== htmlToDiffText(after.content);
|
||||
return {
|
||||
titleChanged,
|
||||
tagsChanged,
|
||||
contentChanged,
|
||||
hasChanges: titleChanged || tagsChanged || contentChanged,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTags(tags: string) {
|
||||
return tags.split(/[,,]/).map(t => t.trim()).filter(Boolean).join(',');
|
||||
}
|
||||
|
||||
export function diffTextLines(before: string, after: string): DiffPart[] {
|
||||
return diffLines(before || '', after || '');
|
||||
}
|
||||
|
||||
export function diffTextWords(before: string, after: string): DiffPart[] {
|
||||
return diffWords(before || '', after || '');
|
||||
}
|
||||
|
||||
/** 统计 diff 片段中的增删行数 */
|
||||
export function countLineChanges(parts: DiffPart[]) {
|
||||
let added = 0;
|
||||
let removed = 0;
|
||||
for (const p of parts) {
|
||||
const lines = p.value.split('\n').filter(l => l.length > 0);
|
||||
if (p.added) added += lines.length;
|
||||
else if (p.removed) removed += lines.length;
|
||||
}
|
||||
return { added, removed };
|
||||
}
|
||||
8
frontend/src/utils/text.ts
Normal file
8
frontend/src/utils/text.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** 统计正文字数(CJK 按字、英文按词) */
|
||||
export function countWords(text: string): number {
|
||||
const t = text.trim();
|
||||
if (!t) return 0;
|
||||
const cjk = t.match(/[\u4e00-\u9fff]/g)?.length ?? 0;
|
||||
const en = t.match(/[a-zA-Z0-9]+/g)?.length ?? 0;
|
||||
return cjk + en;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ export default defineConfig({
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (!id.includes('node_modules')) return;
|
||||
if (id.includes('marked') || id.includes('dompurify')) return 'markdown-vendor';
|
||||
if (id.includes('dompurify')) return 'purify-vendor';
|
||||
if (id.includes('@tanstack/react-virtual')) return 'virtual-vendor';
|
||||
if (id.includes('@radix-ui') || id.includes('lucide-react')) return 'ui-vendor';
|
||||
if (
|
||||
|
||||
2
go.mod
2
go.mod
@@ -1,4 +1,4 @@
|
||||
module github.com/jiang13/forum
|
||||
module git.iioio.com/freefire/jiang13-forum
|
||||
|
||||
go 1.26
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jiang13/forum/model"
|
||||
"github.com/jiang13/forum/service"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// --- 后台管理页面 ---
|
||||
@@ -114,7 +114,8 @@ func calcTotalPages(total int64, size int) int {
|
||||
|
||||
func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
|
||||
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), sortOrder)
|
||||
colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -125,7 +126,8 @@ func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
|
||||
func (h *Handlers) AdminAPIUpdateBoard(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), sortOrder); err != nil {
|
||||
colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
351
handler/api.go
351
handler/api.go
@@ -1,12 +1,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jiang13/forum/model"
|
||||
"github.com/jiang13/forum/service"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APIMe 当前登录用户
|
||||
@@ -50,18 +54,25 @@ func (h *Handlers) APIStats(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// APIForumLimits 前台可见的论坛限制配置
|
||||
func (h *Handlers) APIForumLimits(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, h.Settings.PublicLimits())
|
||||
}
|
||||
|
||||
// APIAdminCreateBoard 管理员创建板块(JSON)
|
||||
func (h *Handlers) APIAdminCreateBoard(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
ColorIndex int `json:"color_index"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
board, err := h.Board.Create(req.Name, req.Description, req.SortOrder)
|
||||
board, err := h.Board.Create(req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -75,13 +86,15 @@ func (h *Handlers) APIAdminUpdateBoard(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
ColorIndex int `json:"color_index"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.Update(uint(id), req.Name, req.Description, req.SortOrder); err != nil {
|
||||
if err := h.Board.Update(uint(id), req.Name, req.Description, req.Icon, req.ColorIndex, req.SortOrder); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -99,10 +112,257 @@ func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "板块已删除"})
|
||||
}
|
||||
|
||||
// APIAdminDashboard 管理后台概览
|
||||
func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
||||
var userCount, postCount, boardCount, commentCount int64
|
||||
model.DB.Model(&model.User{}).Count(&userCount)
|
||||
model.DB.Model(&model.Post{}).Count(&postCount)
|
||||
model.DB.Model(&model.Board{}).Count(&boardCount)
|
||||
model.DB.Model(&model.Comment{}).Count(&commentCount)
|
||||
recentPosts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
|
||||
if recentPosts == nil {
|
||||
recentPosts = []model.Post{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||
"comments": commentCount, "online": h.Online.Count(),
|
||||
"recent_posts": recentPosts,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminPosts 管理员帖子列表
|
||||
func (h *Handlers) APIAdminPosts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
posts, total, err := h.Post.ListItems(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if posts == nil {
|
||||
posts = []service.PostListItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"posts": posts, "total": total, "page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminLockPost 锁定/解锁帖子编辑
|
||||
func (h *Handlers) APIAdminLockPost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Post.SetEditLocked(uint(id), req.Locked); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已解锁编辑"
|
||||
if req.Locked {
|
||||
msg = "已锁定编辑"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "edit_locked": req.Locked})
|
||||
}
|
||||
|
||||
// APIAdminPinPost 置顶/取消置顶(JSON)
|
||||
func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Post.SetPinned(uint(id), req.Pinned); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已取消置顶"
|
||||
if req.Pinned {
|
||||
msg = "已置顶"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
|
||||
}
|
||||
|
||||
// APIAdminDeletePost 管理员删除帖子
|
||||
func (h *Handlers) APIAdminDeletePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.Delete(0, uint(id), true); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
|
||||
}
|
||||
|
||||
// APIAdminComments 管理员评论列表
|
||||
func (h *Handlers) APIAdminComments(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
comments, total, err := h.Comment.ListRecent(page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if comments == nil {
|
||||
comments = []model.Comment{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments, "total": total, "page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminDeleteComment 管理员删除评论
|
||||
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Comment.AdminDelete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||
}
|
||||
|
||||
// APIAdminUsers 管理员用户列表
|
||||
func (h *Handlers) APIAdminUsers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
users, total, err := h.User.ListUsers(page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if users == nil {
|
||||
users = []model.User{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": users, "total": total, "page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminBanUser 禁言/解除禁言(JSON)
|
||||
func (h *Handlers) APIAdminBanUser(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.User.BanUser(uint(id), req.Banned); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已解除禁言"
|
||||
if req.Banned {
|
||||
msg = "已禁言"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "banned": req.Banned})
|
||||
}
|
||||
|
||||
// APIAdminBackup 导出 SQLite 备份
|
||||
func (h *Handlers) APIAdminBackup(c *gin.Context) {
|
||||
path, err := h.Backup.ExportSQLite()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "备份成功",
|
||||
"filename": filename,
|
||||
"download": "/api/admin/backup/download/" + filename,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminDownloadBackup 下载备份文件
|
||||
func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的备份文件名"})
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.Cfg.DataDir, name)
|
||||
c.FileAttachment(path, name)
|
||||
}
|
||||
|
||||
// APIAdminSettings 系统设置信息
|
||||
func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
||||
limits := h.Settings.Limits()
|
||||
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"filter_path": h.Cfg.FilterWordsPath(),
|
||||
"data_dir": h.Cfg.DataDir,
|
||||
"db_path": h.Cfg.DBPath(),
|
||||
"port": h.Cfg.Port,
|
||||
"limits": limits,
|
||||
"filter_words": filterContent,
|
||||
"filter_word_count": service.CountFilterWords(filterContent),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUpdateForumSettings 更新论坛设置
|
||||
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
|
||||
var req service.ForumLimits
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Settings.UpdateLimits(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "设置已保存",
|
||||
"limits": h.Settings.Limits(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminFilterWords 读取敏感词配置
|
||||
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
|
||||
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取敏感词配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"content": content,
|
||||
"word_count": service.CountFilterWords(content),
|
||||
"path": h.Cfg.FilterWordsPath(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUpdateFilterWords 更新敏感词配置
|
||||
func (h *Handlers) APIAdminUpdateFilterWords(c *gin.Context) {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := service.WriteFilterWordsFile(h.Cfg.FilterWordsPath(), req.Content, h.Filter); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存敏感词配置失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "敏感词已保存并生效",
|
||||
"word_count": service.CountFilterWords(req.Content),
|
||||
})
|
||||
}
|
||||
|
||||
// APIPosts 帖子列表(分页)
|
||||
func (h *Handlers) APIPosts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", strconv.Itoa(h.Settings.PageSizeDefault())))
|
||||
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
@@ -111,9 +371,14 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
||||
Page: page,
|
||||
Size: size,
|
||||
Keyword: keyword,
|
||||
Sort: c.DefaultQuery("sort", "latest"),
|
||||
}
|
||||
items, total, err := h.Post.ListItems(q)
|
||||
if err != nil {
|
||||
if isClientLimitError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -132,28 +397,42 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
||||
// APIPostDetail 帖子详情
|
||||
func (h *Handlers) APIPostDetail(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.GetByID(uint(id))
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
if c.Query("skip_view") != "1" {
|
||||
h.Post.RecordView(uint(id))
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
post.Content = service.RedactMembersOnlyHTML(post.Content)
|
||||
}
|
||||
comments, _ := h.Comment.ListByPost(uint(id), uid, h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
|
||||
isAdmin := h.isAdmin(c)
|
||||
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
|
||||
editReason := ""
|
||||
if !canEdit && uid > 0 {
|
||||
editReason = h.Post.UserEditBlockReason(post, uid, isAdmin)
|
||||
}
|
||||
isEdited := post.UpdatedAt.Sub(post.CreatedAt) > time.Minute
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"post": post,
|
||||
"comment_count": len(comments),
|
||||
"liked": h.Post.IsLiked(uid, uint(id)),
|
||||
"favorited": h.Post.IsFavorited(uid, uint(id)),
|
||||
"post": post,
|
||||
"comment_count": len(comments),
|
||||
"liked": h.Post.IsLiked(uid, uint(id)),
|
||||
"favorited": h.Post.IsFavorited(uid, uint(id)),
|
||||
"can_edit": canEdit,
|
||||
"edit_block_reason": editReason,
|
||||
"is_edited": isEdited,
|
||||
"post_edit_window_hours": h.Settings.PostEditWindowHours(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIPostComments 楼层列表
|
||||
func (h *Handlers) APIPostComments(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.GetByID(uint(id))
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
@@ -232,6 +511,56 @@ func (h *Handlers) APIFavorites(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"favorites": favs, "total": total, "page": page})
|
||||
}
|
||||
|
||||
// APIPostRevisions 帖子编辑历史列表(作者或管理员)
|
||||
func (h *Handlers) APIPostRevisions(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
isAdmin := h.isAdmin(c)
|
||||
if !isAdmin && post.UserID != uid {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
|
||||
return
|
||||
}
|
||||
revs, err := h.Post.ListRevisions(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"revisions": revs})
|
||||
}
|
||||
|
||||
// APIPostRevisionDetail 查看某个历史版本
|
||||
func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
revID, _ := strconv.ParseUint(c.Param("revId"), 10, 64)
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
isAdmin := h.isAdmin(c)
|
||||
if !isAdmin && post.UserID != uid {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权查看编辑历史"})
|
||||
return
|
||||
}
|
||||
rev, err := h.Post.GetRevision(uint(id), uint(revID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIPing(c *gin.Context) {
|
||||
h.APIPresence(c)
|
||||
}
|
||||
|
||||
func isClientLimitError(err error) bool {
|
||||
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
|
||||
errors.Is(err, service.ErrSearchKeywordTooLong)
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jiang13/forum/config"
|
||||
"github.com/jiang13/forum/middleware"
|
||||
"github.com/jiang13/forum/model"
|
||||
"github.com/jiang13/forum/service"
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// Handlers 聚合所有 HTTP 处理器
|
||||
@@ -24,6 +25,7 @@ type Handlers struct {
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
Online *service.OnlineService
|
||||
Settings *service.ForumSettingsService
|
||||
}
|
||||
|
||||
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
|
||||
@@ -135,7 +137,7 @@ func (h *Handlers) PostNewPage(c *gin.Context) {
|
||||
|
||||
func (h *Handlers) PostEditPage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.GetByID(uint(id))
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil || (!h.isAdmin(c) && post.UserID != h.currentUserID(c)) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
@@ -237,6 +239,11 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"})
|
||||
return
|
||||
}
|
||||
maxBytes := int64(h.Settings.AvatarMaxMB()) * 1024 * 1024
|
||||
if file.Size > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"})
|
||||
return
|
||||
}
|
||||
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Cfg.UploadDir())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -245,6 +252,26 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "头像已更新", "avatar": url})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUploadPostImage(c *gin.Context) {
|
||||
file, err := c.FormFile("image")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
url, err := service.SaveUploadedImage(
|
||||
file,
|
||||
h.Cfg.PostImageUploadDir(),
|
||||
"/uploads/posts",
|
||||
fmt.Sprintf("%d", uid),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "图片已上传", "url": url})
|
||||
}
|
||||
|
||||
func (h *Handlers) APICreatePost(c *gin.Context) {
|
||||
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||
title := c.PostForm("title")
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jiang13/forum/model"
|
||||
"github.com/jiang13/forum/service"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -142,11 +142,11 @@ func respondBanned(c *gin.Context) {
|
||||
// RateLimitMiddleware 限流中间件
|
||||
func RateLimitMiddleware(limiter *service.RateLimiter, action string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.ClientIP() + ":" + action
|
||||
key := c.ClientIP()
|
||||
if uid, ok := c.Get(CtxUserID); ok {
|
||||
key = fmt.Sprintf("%s:%d", action, uid.(uint))
|
||||
key = fmt.Sprintf("%d", uid.(uint))
|
||||
}
|
||||
if !limiter.Allow(key) {
|
||||
if !limiter.Allow(action, key) {
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
|
||||
c.Abort()
|
||||
return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user