Compare commits
16 Commits
be1241d8c9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 962bb15298 | |||
| 55c256654b | |||
| e038d4f9e9 | |||
| f527c91c27 | |||
|
|
773639fd7c | ||
|
|
9767b54192 | ||
|
|
0eae3f8c47 | ||
|
|
57172eb053 | ||
|
|
e4d1dd139e | ||
|
|
b451703642 | ||
|
|
1d273066b0 | ||
|
|
d0555de28e | ||
|
|
9230f7272d | ||
|
|
b27289bbea | ||
|
|
99ae0ccdaa | ||
|
|
e1c1708715 |
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
|
||||||
43
.gitignore
vendored
43
.gitignore
vendored
@@ -1,27 +1,22 @@
|
|||||||
# ---> Go
|
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
/data/
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
|
||||||
#
|
# 前端依赖与构建缓存
|
||||||
# Binaries for programs and plugins
|
/node_modules/
|
||||||
|
/frontend/node_modules/
|
||||||
|
/frontend/dist/
|
||||||
|
/embed_static/static/spa/
|
||||||
|
|
||||||
|
# Go 编译产物
|
||||||
|
/dist/
|
||||||
*.exe
|
*.exe
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# 临时文件
|
||||||
*.test
|
tmp-cookie.txt
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
|
||||||
go.work.sum
|
|
||||||
|
|
||||||
# env file
|
|
||||||
.env
|
|
||||||
|
|
||||||
|
# 编辑器 / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
Thumbs.db
|
||||||
|
.DS_Store
|
||||||
|
|||||||
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)。
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
请保持友善、尊重他人。骚扰、歧视或恶意行为不被容忍。
|
||||||
27
LICENSE
27
LICENSE
@@ -2,17 +2,20 @@ MIT License
|
|||||||
|
|
||||||
Copyright (c) 2026 freefire
|
Copyright (c) 2026 freefire
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
in the Software without restriction, including without limitation the rights
|
||||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
following conditions:
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
The above copyright notice and this permission notice shall be included in all
|
||||||
portions of the Software.
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|||||||
76
Makefile
Normal file
76
Makefile
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 姜十三论坛 Jiang13 Forum - Makefile
|
||||||
|
# Go 1.26 单二进制编译,与 Gitea 打包方式一致
|
||||||
|
|
||||||
|
APP_NAME := jiang13
|
||||||
|
MAIN_PKG := ./cmd/jiang13
|
||||||
|
BUILD_DIR := dist
|
||||||
|
VERSION := 1.0.0
|
||||||
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
|
|
||||||
|
GO := go
|
||||||
|
GOFLAGS := -trimpath
|
||||||
|
|
||||||
|
.PHONY: all build build-windows build-linux build-darwin clean run dev tidy help frontend frontend-build
|
||||||
|
|
||||||
|
all: build
|
||||||
|
|
||||||
|
frontend-build:
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
|
## 编译当前平台二进制
|
||||||
|
build: frontend-build
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||||
|
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
||||||
|
|
||||||
|
## Windows amd64
|
||||||
|
build-windows:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||||
|
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
||||||
|
|
||||||
|
## Linux amd64
|
||||||
|
build-linux:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||||
|
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
||||||
|
|
||||||
|
## macOS arm64 (Apple Silicon)
|
||||||
|
build-darwin:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||||
|
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
||||||
|
|
||||||
|
## 跨平台全量编译
|
||||||
|
build-all: build-windows build-linux build-darwin build
|
||||||
|
@echo "✓ 全平台编译完成"
|
||||||
|
|
||||||
|
## 整理依赖
|
||||||
|
tidy:
|
||||||
|
$(GO) mod tidy
|
||||||
|
|
||||||
|
## 本地运行(仅后端,使用已 embed 的前端)
|
||||||
|
run:
|
||||||
|
$(GO) run $(MAIN_PKG) --port 3000 --data ./data
|
||||||
|
|
||||||
|
## 前端热更新开发(后端 :3000 + Vite :5173,Ctrl+C 同时退出)
|
||||||
|
dev:
|
||||||
|
@echo "前端热更新: http://localhost:5173"
|
||||||
|
@echo "后端 API : http://localhost:3000"
|
||||||
|
@trap 'kill 0' INT; \
|
||||||
|
$(GO) run $(MAIN_PKG) --port 3000 --data ./data & \
|
||||||
|
cd frontend && (test -d node_modules || npm install) && npm run dev
|
||||||
|
|
||||||
|
## 清理编译产物
|
||||||
|
clean:
|
||||||
|
rm -rf $(BUILD_DIR)
|
||||||
|
|
||||||
|
help:
|
||||||
|
@echo "姜十三论坛编译命令:"
|
||||||
|
@echo " make build - 编译当前平台"
|
||||||
|
@echo " make build-windows - 编译 Windows"
|
||||||
|
@echo " make build-linux - 编译 Linux"
|
||||||
|
@echo " make build-darwin - 编译 macOS"
|
||||||
|
@echo " make build-all - 编译全部平台"
|
||||||
|
@echo " make run - 仅启动后端(:3000)"
|
||||||
|
@echo " make dev - 前端热更新开发(:5173 + :3000)"
|
||||||
260
README.md
260
README.md
@@ -1,3 +1,259 @@
|
|||||||
# jiang13-forum
|
<div align="center">
|
||||||
|
|
||||||
轻量自用论坛:Go 单二进制 + React SPA 内嵌 + SQLite,面向小圈子内部交流。
|
# 姜十三论坛 Jiang13 Forum
|
||||||
|
|
||||||
|
**轻量 · 好看 · 单文件部署的现代化论坛**
|
||||||
|
|
||||||
|
面向小圈子内部交流,编译为单个 Go 二进制,前端 SPA 内嵌,内置 SQLite,开箱即用。
|
||||||
|
|
||||||
|
<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(推荐):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1
|
||||||
|
# 或双击 build.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
**Linux / macOS:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
**手动分步(全平台):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && npm install && npm run build
|
||||||
|
cd .. && go build -trimpath -ldflags "-s -w" -o dist/jiang13 ./cmd/jiang13
|
||||||
|
```
|
||||||
|
|
||||||
|
> Windows 自带的 `make` 通常是 Embarcadero MAKE,不能识别本项目 Makefile。请用 `.\build.ps1` 或安装 GNU Make 后再用 `make build`。
|
||||||
|
|
||||||
|
跨平台编译:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build.ps1 -Target build-windows
|
||||||
|
.\build.ps1 -Target build-linux
|
||||||
|
.\build.ps1 -Target build-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
.\dist\jiang13.exe --port 3000 --data ./data
|
||||||
|
|
||||||
|
# Linux / macOS
|
||||||
|
./dist/jiang13 --port 3000 --data ./data
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 首次使用
|
||||||
|
|
||||||
|
1. 浏览器打开 `http://localhost:3000/register` 注册账号
|
||||||
|
2. **第一个注册的用户自动成为管理员**
|
||||||
|
3. 登录后访问 `http://localhost:3000/admin/dashboard` 进入后台
|
||||||
|
|
||||||
|
### 启动参数
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `--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 # 敏感词配置
|
||||||
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── uploads/avatars/ # 用户头像
|
||||||
|
├── uploads/posts/ # 帖子正文图片
|
||||||
|
└── jiang13_backup_*.db # 后台导出的备份
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开发状态
|
||||||
|
|
||||||
|
项目**积极开发中**,作为论坛产品功能尚未完善,欢迎参与共建。
|
||||||
|
|
||||||
|
| 类型 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| ✅ 已可用 | 三栏布局、暗色主题、虚拟滚动、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](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_
|
||||||
4
build.bat
Normal file
4
build.bat
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
@echo off
|
||||||
|
REM Jiang13 Forum Windows build wrapper (double-click or run from cmd)
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" %*
|
||||||
|
exit /b %ERRORLEVEL%
|
||||||
120
build.ps1
Normal file
120
build.ps1
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
# Jiang13 Forum - Windows build script (replaces GNU Make)
|
||||||
|
# Usage: .\build.ps1
|
||||||
|
# .\build.ps1 -Target build-windows
|
||||||
|
|
||||||
|
param(
|
||||||
|
[ValidateSet('build', 'build-windows', 'build-linux', 'build-darwin', 'build-all', 'frontend', 'tidy', 'run', 'dev', 'clean', 'help')]
|
||||||
|
[string]$Target = 'build'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$AppName = 'jiang13'
|
||||||
|
$MainPkg = './cmd/jiang13'
|
||||||
|
$BuildDir = 'dist'
|
||||||
|
$Version = '1.0.0'
|
||||||
|
$Ldlags = "-s -w -X main.version=$Version"
|
||||||
|
|
||||||
|
function Ensure-Dir($path) {
|
||||||
|
if (-not (Test-Path $path)) {
|
||||||
|
New-Item -ItemType Directory -Path $path | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Frontend {
|
||||||
|
Write-Host '[frontend] npm run build...' -ForegroundColor Cyan
|
||||||
|
Push-Location frontend
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path node_modules)) {
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
npm run build
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'frontend build failed' }
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
||||||
|
Ensure-Dir $BuildDir
|
||||||
|
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
|
||||||
|
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
||||||
|
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
||||||
|
$OutFile = "$OutFile.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
$outPath = Join-Path $BuildDir $OutFile
|
||||||
|
Write-Host "[go] build -> $outPath" -ForegroundColor Cyan
|
||||||
|
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
||||||
|
Write-Host "[ok] $outPath" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Target) {
|
||||||
|
'help' {
|
||||||
|
Write-Host '.\build.ps1 build current platform'
|
||||||
|
Write-Host '.\build.ps1 -Target frontend frontend only'
|
||||||
|
Write-Host '.\build.ps1 -Target build-windows'
|
||||||
|
Write-Host '.\build.ps1 -Target build-linux'
|
||||||
|
Write-Host '.\build.ps1 -Target build-all'
|
||||||
|
Write-Host '.\build.ps1 -Target run backend only (port 3000)'
|
||||||
|
Write-Host '.\build.ps1 -Target dev backend + Vite HMR (recommended for frontend dev)'
|
||||||
|
Write-Host '.\build.ps1 -Target tidy'
|
||||||
|
Write-Host '.\build.ps1 -Target clean'
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host 'Note: Windows "make" is often Embarcadero MAKE, not GNU Make.'
|
||||||
|
}
|
||||||
|
'frontend' { Build-Frontend }
|
||||||
|
'tidy' { go mod tidy }
|
||||||
|
'clean' {
|
||||||
|
if (Test-Path $BuildDir) { Remove-Item -Recurse -Force $BuildDir }
|
||||||
|
Write-Host '[ok] cleaned dist' -ForegroundColor Green
|
||||||
|
}
|
||||||
|
'run' {
|
||||||
|
go run $MainPkg --port 3000 --data ./data
|
||||||
|
}
|
||||||
|
'dev' {
|
||||||
|
$root = (Get-Location).Path
|
||||||
|
Write-Host ''
|
||||||
|
Write-Host '[dev] 前端热更新: http://localhost:5173' -ForegroundColor Green
|
||||||
|
Write-Host '[dev] 后端 API : http://localhost:3000' -ForegroundColor Green
|
||||||
|
Write-Host '[dev] 后台管理 : http://localhost:3000/admin/dashboard' -ForegroundColor Green
|
||||||
|
Write-Host '[dev] 正在新窗口启动 Go 后端...' -ForegroundColor Cyan
|
||||||
|
Start-Process powershell -ArgumentList @(
|
||||||
|
'-NoExit', '-Command',
|
||||||
|
"Set-Location '$root'; Write-Host '[backend] Go API on :3000' -ForegroundColor Cyan; go run $MainPkg --port 3000 --data ./data"
|
||||||
|
) | Out-Null
|
||||||
|
Start-Sleep -Seconds 2
|
||||||
|
Push-Location frontend
|
||||||
|
try {
|
||||||
|
if (-not (Test-Path node_modules)) { npm install }
|
||||||
|
npm run dev
|
||||||
|
} finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'build' {
|
||||||
|
Build-Frontend
|
||||||
|
Build-Go -OutFile $AppName
|
||||||
|
}
|
||||||
|
'build-windows' {
|
||||||
|
Build-Frontend
|
||||||
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
|
}
|
||||||
|
'build-linux' {
|
||||||
|
Build-Frontend
|
||||||
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
|
}
|
||||||
|
'build-darwin' {
|
||||||
|
Build-Frontend
|
||||||
|
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||||
|
}
|
||||||
|
'build-all' {
|
||||||
|
Build-Frontend
|
||||||
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
|
Build-Go -OutFile "$AppName-darwin-arm64" -GoOS 'darwin' -GoArch 'arm64'
|
||||||
|
Write-Host '[ok] all platforms done' -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
76
cmd/jiang13/main.go
Normal file
76
cmd/jiang13/main.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Parse()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("配置解析失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 日志同时输出到控制台和文件
|
||||||
|
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("打开日志文件失败: %v", err)
|
||||||
|
}
|
||||||
|
defer logFile.Close()
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||||
|
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||||
|
|
||||||
|
log.Println("========================================")
|
||||||
|
log.Println(" 姜十三论坛 Jiang13 Forum 启动中...")
|
||||||
|
log.Println("========================================")
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||||
|
log.Fatalf("数据库初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置路由
|
||||||
|
engine, err := router.Setup(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("路由初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: engine,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优雅关机
|
||||||
|
go func() {
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-sigCh
|
||||||
|
log.Println("收到关机信号,正在优雅关闭...")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
|
log.Printf("HTTP 服务关闭异常: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("姜十三论坛已启动: http://localhost%s", addr)
|
||||||
|
log.Printf("后台管理地址: http://localhost%s/admin/dashboard", addr)
|
||||||
|
log.Printf("数据目录: %s", cfg.DataDir)
|
||||||
|
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
log.Fatalf("HTTP 服务异常: %v", err)
|
||||||
|
}
|
||||||
|
log.Println("姜十三论坛已安全退出")
|
||||||
|
}
|
||||||
95
config/config.go
Normal file
95
config/config.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 应用全局配置,通过命令行参数注入
|
||||||
|
type Config struct {
|
||||||
|
// 监听端口
|
||||||
|
Port int
|
||||||
|
// 数据目录:SQLite 数据库、上传头像、日志文件均存放于此
|
||||||
|
DataDir string
|
||||||
|
// JWT 签名密钥
|
||||||
|
JWTSecret string
|
||||||
|
// 日志文件路径(相对 DataDir)
|
||||||
|
LogFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse 解析命令行参数并初始化目录
|
||||||
|
func Parse() (*Config, error) {
|
||||||
|
port := flag.Int("port", 3000, "HTTP 监听端口")
|
||||||
|
dataDir := flag.String("data", "./data", "数据存储目录(数据库、上传、日志)")
|
||||||
|
jwtSecret := flag.String("jwt-secret", "", "JWT 签名密钥(留空则自动生成并持久化)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 确保数据目录存在
|
||||||
|
if err := os.MkdirAll(*dataDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("创建数据目录失败: %w", err)
|
||||||
|
}
|
||||||
|
uploadDir := filepath.Join(*dataDir, "uploads", "avatars")
|
||||||
|
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,
|
||||||
|
DataDir: *dataDir,
|
||||||
|
JWTSecret: *jwtSecret,
|
||||||
|
LogFile: filepath.Join(*dataDir, "jiang13.log"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 JWT 密钥持久化
|
||||||
|
secretFile := filepath.Join(*dataDir, ".jwt_secret")
|
||||||
|
if cfg.JWTSecret == "" {
|
||||||
|
if data, err := os.ReadFile(secretFile); err == nil && len(data) > 0 {
|
||||||
|
cfg.JWTSecret = string(data)
|
||||||
|
} else {
|
||||||
|
cfg.JWTSecret = generateRandomSecret(32)
|
||||||
|
_ = os.WriteFile(secretFile, []byte(cfg.JWTSecret), 0600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBPath 返回 SQLite 数据库文件路径
|
||||||
|
func (c *Config) DBPath() string {
|
||||||
|
return filepath.Join(c.DataDir, "jiang13.db")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateRandomSecret(n int) string {
|
||||||
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = chars[i%len(chars)]
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
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 |
73
embed_static/embed.go
Normal file
73
embed_static/embed.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package embed_static
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static/*
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
//go:embed templates/*
|
||||||
|
var templatesFS embed.FS
|
||||||
|
|
||||||
|
// SetupEmbed 配置内嵌资源:SPA 前端 + 后台 HTML 模板
|
||||||
|
func SetupEmbed(r *gin.Engine) error {
|
||||||
|
tmpl, err := LoadTemplates()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.SetHTMLTemplate(tmpl)
|
||||||
|
|
||||||
|
// React SPA 构建产物(Vite)
|
||||||
|
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||||
|
r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台管理遗留静态资源
|
||||||
|
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
|
||||||
|
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeSPA 返回 React SPA 入口
|
||||||
|
func ServeSPA(c *gin.Context) {
|
||||||
|
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSPARoute 判断是否应由 SPA 处理
|
||||||
|
func IsSPARoute(path string) bool {
|
||||||
|
if strings.HasPrefix(path, "/api") ||
|
||||||
|
strings.HasPrefix(path, "/admin") ||
|
||||||
|
strings.HasPrefix(path, "/uploads") ||
|
||||||
|
strings.HasPrefix(path, "/legacy") ||
|
||||||
|
strings.HasPrefix(path, "/assets") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadTemplates() (*template.Template, error) {
|
||||||
|
sub, err := fs.Sub(templatesFS, "templates")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpl := template.New("").Funcs(template.FuncMap{
|
||||||
|
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||||
|
"add": func(a, b int) int { return a + b },
|
||||||
|
"sub": func(a, b int) int { return a - b },
|
||||||
|
})
|
||||||
|
return tmpl.ParseFS(sub, "*.html", "admin/*.html")
|
||||||
|
}
|
||||||
80
embed_static/static/css/style.css
Normal file
80
embed_static/static/css/style.css
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/* 姜十三论坛全局样式 */
|
||||||
|
:root {
|
||||||
|
--primary: #2c5530;
|
||||||
|
--primary-light: #3d7a44;
|
||||||
|
--accent: #c9a227;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: auto;
|
||||||
|
background: #212529;
|
||||||
|
color: #adb5bd;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content {
|
||||||
|
line-height: 1.8;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-content img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-floor {
|
||||||
|
border-left: 3px solid var(--primary);
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-sm {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-md {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pin {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar .nav-link.active {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar .nav-link {
|
||||||
|
color: #333;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-slogan {
|
||||||
|
font-style: italic;
|
||||||
|
color: #6c757d;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.navbar-brand span.en { display: none; }
|
||||||
|
}
|
||||||
28
embed_static/static/js/app.js
Normal file
28
embed_static/static/js/app.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
// 姜十三论坛前端通用脚本
|
||||||
|
function apiPost(url, data, isForm) {
|
||||||
|
const opts = { method: 'POST', credentials: 'same-origin' };
|
||||||
|
if (isForm) {
|
||||||
|
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
||||||
|
if (!(data instanceof HTMLFormElement)) {
|
||||||
|
const fd = new FormData();
|
||||||
|
for (const k in data) fd.append(k, data[k]);
|
||||||
|
opts.body = fd;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
opts.headers = { 'Content-Type': 'application/json' };
|
||||||
|
opts.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
return fetch(url, opts).then(r => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type) {
|
||||||
|
const el = document.getElementById('toast');
|
||||||
|
if (!el) { alert(msg); return; }
|
||||||
|
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
||||||
|
el.querySelector('.toast-body').textContent = msg;
|
||||||
|
new bootstrap.Toast(el).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAction(msg, callback) {
|
||||||
|
if (confirm(msg)) callback();
|
||||||
|
}
|
||||||
499
embed_static/static/legacy/css/style.css
Normal file
499
embed_static/static/legacy/css/style.css
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
/* 姜十三论坛 - 前台 + 管理后台样式 */
|
||||||
|
:root {
|
||||||
|
--primary: #1a7f4b;
|
||||||
|
--primary-dark: #156b3f;
|
||||||
|
--primary-light: #e8f5ee;
|
||||||
|
--accent: #c9a227;
|
||||||
|
--admin-sidebar-w: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f4f6f8;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 管理后台 ===== */
|
||||||
|
.admin-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar {
|
||||||
|
height: 56px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 24px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255,255,255,.2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-title {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-sub {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: .75;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-topbar-user {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .9;
|
||||||
|
padding-right: 4px;
|
||||||
|
border-right: 1px solid rgba(255,255,255,.25);
|
||||||
|
margin-right: 4px;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar {
|
||||||
|
width: var(--admin-sidebar-w);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #fff;
|
||||||
|
border-right: 1px solid #e5e6eb;
|
||||||
|
padding: 20px 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-section {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #86909c;
|
||||||
|
padding: 12px 12px 6px;
|
||||||
|
letter-spacing: .06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar-section:first-child {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar .nav-link {
|
||||||
|
display: block;
|
||||||
|
color: #4e5969;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .15s, color .15s;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar .nav-link:hover {
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-sidebar .nav-link.active {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 600;
|
||||||
|
border-left-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 28px 32px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-width: 1280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-head h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-page-head p {
|
||||||
|
margin: 0;
|
||||||
|
color: #86909c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-search-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-search-bar .form-control {
|
||||||
|
width: 240px;
|
||||||
|
border-color: #e5e6eb;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
transition: box-shadow .2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-users .admin-stat-icon { background: #e8f3ff; color: #3491fa; }
|
||||||
|
.admin-stat-posts .admin-stat-icon { background: #e8f5ee; color: var(--primary); }
|
||||||
|
.admin-stat-boards .admin-stat-icon { background: #fff7e8; color: #ff7d00; }
|
||||||
|
.admin-stat-comments .admin-stat-icon { background: #f5e8ff; color: #722ed1; }
|
||||||
|
.admin-stat-online .admin-stat-icon { background: #e8ffea; color: #00b42a; }
|
||||||
|
|
||||||
|
.admin-stat-users .value { color: #3491fa; }
|
||||||
|
.admin-stat-posts .value { color: var(--primary); }
|
||||||
|
.admin-stat-boards .value { color: #ff7d00; }
|
||||||
|
.admin-stat-comments .value { color: #722ed1; }
|
||||||
|
.admin-stat-online .value { color: #00b42a; }
|
||||||
|
|
||||||
|
.admin-stat-card .value {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-stat-card .label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #86909c;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-head {
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-link {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-link:hover {
|
||||||
|
opacity: .75;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
--bs-table-hover-bg: #f7f8fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table th {
|
||||||
|
background: #fafbfc;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #4e5969;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #e5e6eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover td {
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table .btn-link {
|
||||||
|
color: var(--primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table .btn-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-truncate-cell {
|
||||||
|
max-width: 360px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pin {
|
||||||
|
background: #ff7d00;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-admin {
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-banned {
|
||||||
|
background: #ffece8;
|
||||||
|
color: #f53f3f;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 20px;
|
||||||
|
color: #86909c;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-pagination-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #86909c;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body .btn-success {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body .btn-success:hover {
|
||||||
|
background: var(--primary-dark);
|
||||||
|
border-color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body .btn-outline-success {
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body .btn-outline-success:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body .form-control:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(26,127,75,.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-body .form-label {
|
||||||
|
color: #4e5969;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(160deg, #f2f3f5 0%, #e8f5ee 60%, #d4edda 100%);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-login-mark {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
box-shadow: 0 4px 12px rgba(26,127,75,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-slogan {
|
||||||
|
color: #86909c;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f2f3f5;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row:last-child { border-bottom: none; }
|
||||||
|
.info-row .info-label { width: 100px; color: #86909c; flex-shrink: 0; }
|
||||||
|
.info-row .info-value { flex: 1; word-break: break-all; color: #1d2129; }
|
||||||
|
|
||||||
|
#adminToast.toast {
|
||||||
|
background: #1d2129;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.admin-stat-grid {
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-shell { flex-direction: column; }
|
||||||
|
.admin-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid #e5e6eb;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.admin-sidebar-section { display: none; }
|
||||||
|
.admin-sidebar .nav-link {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.admin-sidebar .nav-link.active {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.admin-main { padding: 16px; max-width: none; }
|
||||||
|
.admin-stat-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.admin-stat-card { padding: 14px 16px; }
|
||||||
|
.admin-stat-card .value { font-size: 22px; }
|
||||||
|
.admin-topbar-user { display: none; }
|
||||||
|
.admin-search-bar .form-control { width: 160px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.admin-stat-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 旧版前台兼容 ===== */
|
||||||
|
.navbar-brand { font-weight: 700; letter-spacing: 0.05em; }
|
||||||
|
.post-content { line-height: 1.8; word-break: break-word; }
|
||||||
|
.post-content img { max-width: 100%; height: auto; }
|
||||||
|
.avatar-sm { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; }
|
||||||
79
embed_static/static/legacy/js/app.js
Normal file
79
embed_static/static/legacy/js/app.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// 姜十三论坛 - 管理后台通用脚本
|
||||||
|
|
||||||
|
async function adminFetch(url, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
||||||
|
var data;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('服务器响应异常,请重新登录后再试');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || '请求失败');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, type) {
|
||||||
|
var el = document.getElementById('adminToast');
|
||||||
|
if (!el) {
|
||||||
|
alert(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
||||||
|
el.querySelector('.toast-body').textContent = msg;
|
||||||
|
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminConfirm(msg, fn) {
|
||||||
|
if (confirm(msg)) fn();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBtnLoading(btn, loading) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.disabled = loading;
|
||||||
|
if (loading) {
|
||||||
|
btn.dataset.originalHtml = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
} else if (btn.dataset.originalHtml) {
|
||||||
|
btn.innerHTML = btn.dataset.originalHtml;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminLogout() {
|
||||||
|
adminFetch('/admin/api/logout', { method: 'POST' })
|
||||||
|
.then(function () { location.href = '/admin/login'; })
|
||||||
|
.catch(function (e) { showToast(e.message, 'danger'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function postForm(url, method, fields) {
|
||||||
|
var fd = new FormData();
|
||||||
|
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
|
||||||
|
return adminFetch(url, { method: method, body: fd });
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadSoon() {
|
||||||
|
setTimeout(function () { location.reload(); }, 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容旧模板
|
||||||
|
function apiPost(url, data, isForm) {
|
||||||
|
var opts = { method: 'POST', credentials: 'same-origin' };
|
||||||
|
if (isForm) {
|
||||||
|
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
||||||
|
if (!(data instanceof HTMLFormElement)) {
|
||||||
|
var fd = new FormData();
|
||||||
|
for (var k in data) fd.append(k, data[k]);
|
||||||
|
opts.body = fd;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
opts.headers = { 'Content-Type': 'application/json' };
|
||||||
|
opts.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
return fetch(url, opts).then(function (r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmAction(msg, callback) {
|
||||||
|
adminConfirm(msg, callback);
|
||||||
|
}
|
||||||
95
embed_static/templates/admin/boards.html
Normal file
95
embed_static/templates/admin/boards.html
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{{define "admin/boards.html"}}{{template "admin/layout" .}}{{end}}
|
||||||
|
{{define "admin_content_boards"}}
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1>板块管理</h1>
|
||||||
|
<p>创建和维护论坛板块,有帖子的板块无法删除</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card-head">新建板块</div>
|
||||||
|
<div class="admin-card-body">
|
||||||
|
<form id="createBoardForm" class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small">板块名称</label>
|
||||||
|
<input name="name" class="form-control" placeholder="如:技术交流" required maxlength="64">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">简介</label>
|
||||||
|
<input name="description" class="form-control" placeholder="板块说明(可选)" maxlength="500">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label small">排序</label>
|
||||||
|
<input name="sort_order" type="number" class="form-control" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<button type="submit" class="btn btn-success w-100" id="createBoardBtn">创建板块</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card-head">板块列表 <span class="text-muted fw-normal">共 {{len .Boards}} 个</span></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table admin-table mb-0">
|
||||||
|
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>排序</th><th>帖子数</th><th width="160">操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Boards}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td><input class="form-control form-control-sm" id="name-{{.ID}}" value="{{.Name}}"></td>
|
||||||
|
<td><input class="form-control form-control-sm" id="desc-{{.ID}}" value="{{.Description}}"></td>
|
||||||
|
<td><input class="form-control form-control-sm" id="sort-{{.ID}}" type="number" value="{{.SortOrder}}" style="width:72px"></td>
|
||||||
|
<td><span class="badge bg-secondary">{{.PostCount}}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-primary" onclick="updateBoard({{.ID}}, this)">保存</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteBoard({{.ID}}, {{.PostCount}})">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="6" class="admin-empty">还没有板块,请先创建</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts_boards"}}
|
||||||
|
<script>
|
||||||
|
document.getElementById('createBoardForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('createBoardBtn');
|
||||||
|
setBtnLoading(btn, true);
|
||||||
|
adminFetch('/admin/api/boards', { method: 'POST', body: new FormData(this) })
|
||||||
|
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); })
|
||||||
|
.finally(function() { setBtnLoading(btn, false); });
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateBoard(id, btn) {
|
||||||
|
setBtnLoading(btn, true);
|
||||||
|
postForm('/admin/api/boards/' + id, 'PUT', {
|
||||||
|
name: document.getElementById('name-' + id).value,
|
||||||
|
description: document.getElementById('desc-' + id).value,
|
||||||
|
sort_order: document.getElementById('sort-' + id).value
|
||||||
|
}).then(function(d) { showToast(d.message); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); })
|
||||||
|
.finally(function() { setBtnLoading(btn, false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBoard(id, postCount) {
|
||||||
|
if (postCount > 0) {
|
||||||
|
showToast('该板块下还有 ' + postCount + ' 篇帖子,无法删除', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adminConfirm('确定删除该板块?此操作不可恢复。', function() {
|
||||||
|
adminFetch('/admin/api/boards/' + id, { method: 'DELETE' })
|
||||||
|
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
58
embed_static/templates/admin/comments.html
Normal file
58
embed_static/templates/admin/comments.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{{define "admin/comments.html"}}{{template "admin/layout" .}}{{end}}
|
||||||
|
{{define "admin_content_comments"}}
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1>评论管理</h1>
|
||||||
|
<p>查看和删除评论,共 {{.Total}} 条</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table admin-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>楼层</th><th>帖子</th><th>作者</th><th>回复</th><th>内容</th><th>时间</th><th width="100">操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Comments}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>#{{.Floor}}</td>
|
||||||
|
<td class="text-truncate-cell">
|
||||||
|
{{if .Post}}
|
||||||
|
<a href="/post/{{.PostID}}" target="_blank">{{.Post.Title}}</a>
|
||||||
|
{{else}}帖子 #{{.PostID}}{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{if .UserID}}{{if .User}}{{.User.Nickname}}{{else}}-{{end}}{{else}}{{.GuestNick}}{{end}}</td>
|
||||||
|
<td>
|
||||||
|
{{if .ReplyTarget}}
|
||||||
|
@{{if .ReplyTarget.UserID}}{{if .ReplyTarget.User}}{{.ReplyTarget.User.Nickname}}{{else}}-{{end}}{{else}}{{.ReplyTarget.GuestNick}}{{end}}
|
||||||
|
{{else}}-{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="text-truncate-cell">{{.Content}}</td>
|
||||||
|
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteComment({{.ID}})">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="8" class="admin-empty">暂无评论</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{template "admin_pagination" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts_comments"}}
|
||||||
|
<script>
|
||||||
|
function deleteComment(id) {
|
||||||
|
adminConfirm('确定删除该评论?', function() {
|
||||||
|
adminFetch('/admin/api/comments/' + id, { method: 'DELETE' })
|
||||||
|
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
76
embed_static/templates/admin/dashboard.html
Normal file
76
embed_static/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{{define "admin/dashboard.html"}}{{template "admin/layout" .}}{{end}}
|
||||||
|
{{define "admin_content_dashboard"}}
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1>仪表盘</h1>
|
||||||
|
<p>欢迎回来{{if .CurrentUser}},{{.CurrentUser.Nickname}}{{end}} · 论坛运行概况</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-stat-grid">
|
||||||
|
<div class="admin-stat-card admin-stat-users">
|
||||||
|
<div class="admin-stat-icon">用</div>
|
||||||
|
<div class="admin-stat-info">
|
||||||
|
<div class="value">{{.UserCount}}</div>
|
||||||
|
<div class="label">注册用户</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card admin-stat-posts">
|
||||||
|
<div class="admin-stat-icon">帖</div>
|
||||||
|
<div class="admin-stat-info">
|
||||||
|
<div class="value">{{.PostCount}}</div>
|
||||||
|
<div class="label">帖子总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card admin-stat-boards">
|
||||||
|
<div class="admin-stat-icon">板</div>
|
||||||
|
<div class="admin-stat-info">
|
||||||
|
<div class="value">{{.BoardCount}}</div>
|
||||||
|
<div class="label">板块数量</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card admin-stat-comments">
|
||||||
|
<div class="admin-stat-icon">评</div>
|
||||||
|
<div class="admin-stat-info">
|
||||||
|
<div class="value">{{.CommentCount}}</div>
|
||||||
|
<div class="label">评论总数</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-stat-card admin-stat-online">
|
||||||
|
<div class="admin-stat-icon">线</div>
|
||||||
|
<div class="admin-stat-info">
|
||||||
|
<div class="value">{{.OnlineCount}}</div>
|
||||||
|
<div class="label">当前浏览</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card-head">
|
||||||
|
<span>最近帖子</span>
|
||||||
|
<a href="/admin/posts" class="admin-card-link">查看全部 →</a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table admin-table mb-0">
|
||||||
|
<thead><tr><th>标题</th><th>板块</th><th>作者</th><th>时间</th><th width="80"></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .RecentPosts}}
|
||||||
|
<tr>
|
||||||
|
<td class="text-truncate-cell">
|
||||||
|
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Title}}
|
||||||
|
</td>
|
||||||
|
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
|
||||||
|
<td class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</td>
|
||||||
|
<td><a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link px-0">查看</a></td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="5" class="admin-empty">暂无帖子</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts_dashboard"}}{{end}}
|
||||||
88
embed_static/templates/admin/layout.html
Normal file
88
embed_static/templates/admin/layout.html
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
{{define "admin/layout"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} - 姜十三论坛管理后台</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/legacy/css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="admin-body">
|
||||||
|
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index:9999">
|
||||||
|
<div id="adminToast" class="toast" role="alert">
|
||||||
|
<div class="d-flex">
|
||||||
|
<div class="toast-body"></div>
|
||||||
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="admin-topbar">
|
||||||
|
<div class="admin-topbar-brand">
|
||||||
|
<div class="admin-topbar-mark">姜</div>
|
||||||
|
<div>
|
||||||
|
<span class="admin-topbar-title">姜十三论坛</span>
|
||||||
|
<span class="admin-topbar-sub">管理后台</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-topbar-actions">
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<span class="admin-topbar-user">{{.CurrentUser.Nickname}}</span>
|
||||||
|
{{end}}
|
||||||
|
<a href="/" class="btn btn-outline-light btn-sm">返回前台</a>
|
||||||
|
<button class="btn btn-light btn-sm" onclick="adminLogout()">退出</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="admin-shell">
|
||||||
|
<aside class="admin-sidebar">
|
||||||
|
<div class="admin-sidebar-section">概览</div>
|
||||||
|
<a href="/admin/dashboard" class="nav-link {{if eq .ActiveNav "dashboard"}}active{{end}}">仪表盘</a>
|
||||||
|
|
||||||
|
<div class="admin-sidebar-section">内容</div>
|
||||||
|
<a href="/admin/boards" class="nav-link {{if eq .ActiveNav "boards"}}active{{end}}">板块管理</a>
|
||||||
|
<a href="/admin/posts" class="nav-link {{if eq .ActiveNav "posts"}}active{{end}}">帖子管理</a>
|
||||||
|
<a href="/admin/comments" class="nav-link {{if eq .ActiveNav "comments"}}active{{end}}">评论管理</a>
|
||||||
|
|
||||||
|
<div class="admin-sidebar-section">系统</div>
|
||||||
|
<a href="/admin/users" class="nav-link {{if eq .ActiveNav "users"}}active{{end}}">用户管理</a>
|
||||||
|
<a href="/admin/settings" class="nav-link {{if eq .ActiveNav "settings"}}active{{end}}">系统设置</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="admin-main">
|
||||||
|
{{if eq .ActiveNav "dashboard"}}{{template "admin_content_dashboard" .}}
|
||||||
|
{{else if eq .ActiveNav "boards"}}{{template "admin_content_boards" .}}
|
||||||
|
{{else if eq .ActiveNav "posts"}}{{template "admin_content_posts" .}}
|
||||||
|
{{else if eq .ActiveNav "comments"}}{{template "admin_content_comments" .}}
|
||||||
|
{{else if eq .ActiveNav "users"}}{{template "admin_content_users" .}}
|
||||||
|
{{else if eq .ActiveNav "settings"}}{{template "admin_content_settings" .}}
|
||||||
|
{{end}}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/legacy/js/app.js"></script>
|
||||||
|
{{if eq .ActiveNav "boards"}}{{template "admin_scripts_boards" .}}
|
||||||
|
{{else if eq .ActiveNav "posts"}}{{template "admin_scripts_posts" .}}
|
||||||
|
{{else if eq .ActiveNav "comments"}}{{template "admin_scripts_comments" .}}
|
||||||
|
{{else if eq .ActiveNav "users"}}{{template "admin_scripts_users" .}}
|
||||||
|
{{else if eq .ActiveNav "settings"}}{{template "admin_scripts_settings" .}}
|
||||||
|
{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_pagination"}}
|
||||||
|
{{if gt .TotalPages 1}}
|
||||||
|
<nav class="admin-pagination">
|
||||||
|
{{if gt .Page 1}}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="?page={{sub .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span class="admin-pagination-info">第 {{.Page}} / {{.TotalPages}} 页</span>
|
||||||
|
{{if lt .Page .TotalPages}}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="?page={{add .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
49
embed_static/templates/admin/login.html
Normal file
49
embed_static/templates/admin/login.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "admin/login.html"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>后台登录 - 姜十三论坛</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/legacy/css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="admin-login-page">
|
||||||
|
<div class="admin-login-card">
|
||||||
|
<div class="admin-login-mark">姜</div>
|
||||||
|
<h4 class="text-center mb-1">管理后台登录</h4>
|
||||||
|
<p class="login-slogan text-center mb-4">姜十三论坛 · 仅管理员可访问</p>
|
||||||
|
{{if eq .QueryBanned "1"}}
|
||||||
|
<div class="alert alert-warning small py-2">账号已被禁言,无法登录后台</div>
|
||||||
|
{{end}}
|
||||||
|
<form id="adminLoginForm">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">管理员账号</label>
|
||||||
|
<input type="text" name="username" class="form-control" placeholder="用户名" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small">密码</label>
|
||||||
|
<input type="password" name="password" class="form-control" placeholder="密码" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success w-100" id="loginBtn">登录</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center mt-3 mb-0 small">
|
||||||
|
<a href="/" class="text-muted">← 返回论坛前台</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/legacy/js/app.js"></script>
|
||||||
|
<script>
|
||||||
|
document.getElementById('adminLoginForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var btn = document.getElementById('loginBtn');
|
||||||
|
setBtnLoading(btn, true);
|
||||||
|
adminFetch('/admin/api/login', { method: 'POST', body: new FormData(this) })
|
||||||
|
.then(function() { location.href = '/admin/dashboard'; })
|
||||||
|
.catch(function(err) { alert(err.message); })
|
||||||
|
.finally(function() { setBtnLoading(btn, false); });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
67
embed_static/templates/admin/posts.html
Normal file
67
embed_static/templates/admin/posts.html
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{{define "admin/posts.html"}}{{template "admin/layout" .}}{{end}}
|
||||||
|
{{define "admin_content_posts"}}
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1>帖子管理</h1>
|
||||||
|
<p>置顶、删除帖子,共 {{.Total}} 篇</p>
|
||||||
|
</div>
|
||||||
|
<form class="admin-search-bar" method="get">
|
||||||
|
<input name="keyword" class="form-control form-control-sm" placeholder="搜索标题/内容..." value="{{.Keyword}}">
|
||||||
|
<button class="btn btn-sm btn-success">搜索</button>
|
||||||
|
{{if .Keyword}}<a href="/admin/posts" class="btn btn-sm btn-outline-secondary">清除</a>{{end}}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table admin-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th><th>标题</th><th>板块</th><th>作者</th>
|
||||||
|
<th>置顶</th><th>点赞</th><th>浏览</th><th>时间</th><th width="180">操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Posts}}
|
||||||
|
<tr id="post-row-{{.ID}}">
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td class="text-truncate-cell">{{.Title}}</td>
|
||||||
|
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
|
||||||
|
<td>{{if .Pinned}}<span class="badge badge-pin">是</span>{{else}}<span class="text-muted">否</span>{{end}}</td>
|
||||||
|
<td>{{.LikeCount}}</td>
|
||||||
|
<td>{{.ViewCount}}</td>
|
||||||
|
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link">查看</a>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" onclick="togglePin({{.ID}}, {{.Pinned}})">{{if .Pinned}}取消置顶{{else}}置顶{{end}}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePost({{.ID}})">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="9" class="admin-empty">没有找到帖子</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{template "admin_pagination" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts_posts"}}
|
||||||
|
<script>
|
||||||
|
function togglePin(id, pinned) {
|
||||||
|
postForm('/admin/api/posts/' + id + '/pin', 'POST', { pinned: pinned ? 'false' : 'true' })
|
||||||
|
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePost(id) {
|
||||||
|
adminConfirm('确定删除该帖子?相关评论也将一并删除。', function() {
|
||||||
|
adminFetch('/admin/api/posts/' + id, { method: 'DELETE' })
|
||||||
|
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
56
embed_static/templates/admin/settings.html
Normal file
56
embed_static/templates/admin/settings.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{{define "admin/settings.html"}}{{template "admin/layout" .}}{{end}}
|
||||||
|
{{define "admin_content_settings"}}
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1>系统设置</h1>
|
||||||
|
<p>数据目录、敏感词配置与数据库备份</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card-head">运行信息</div>
|
||||||
|
<div class="admin-card-body">
|
||||||
|
<div class="info-row"><div class="info-label">数据目录</div><div class="info-value"><code>{{.DataDir}}</code></div></div>
|
||||||
|
<div class="info-row"><div class="info-label">数据库文件</div><div class="info-value"><code>{{.DBPath}}</code></div></div>
|
||||||
|
<div class="info-row"><div class="info-label">监听端口</div><div class="info-value">{{.Port}}</div></div>
|
||||||
|
<div class="info-row"><div class="info-label">敏感词配置</div><div class="info-value"><code>{{.FilterPath}}</code></div></div>
|
||||||
|
<p class="text-muted small mt-3 mb-0">敏感词文件每行一个词,<code>#</code> 开头为注释,修改后需重启服务生效。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="admin-card-head">数据库备份</div>
|
||||||
|
<div class="admin-card-body">
|
||||||
|
<p class="text-muted small">将 SQLite 数据库复制到数据目录,生成带时间戳的备份文件。</p>
|
||||||
|
<button class="btn btn-success" id="backupBtn" onclick="doBackup()">一键导出备份</button>
|
||||||
|
<div id="backupResult" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts_settings"}}
|
||||||
|
<script>
|
||||||
|
function doBackup() {
|
||||||
|
var btn = document.getElementById('backupBtn');
|
||||||
|
setBtnLoading(btn, true);
|
||||||
|
adminFetch('/admin/api/backup', { method: 'POST' })
|
||||||
|
.then(function(d) {
|
||||||
|
showToast(d.message);
|
||||||
|
var html = '<div class="alert alert-success small mb-0">';
|
||||||
|
html += '<div>备份文件:<code>' + d.filename + '</code></div>';
|
||||||
|
if (d.download) {
|
||||||
|
html += '<a href="' + d.download + '" class="btn btn-sm btn-outline-success mt-2">下载备份文件</a>';
|
||||||
|
}
|
||||||
|
html += '<div class="text-muted mt-2">存储路径:' + d.path + '</div></div>';
|
||||||
|
document.getElementById('backupResult').innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); })
|
||||||
|
.finally(function() { setBtnLoading(btn, false); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
62
embed_static/templates/admin/users.html
Normal file
62
embed_static/templates/admin/users.html
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{{define "admin/users.html"}}{{template "admin/layout" .}}{{end}}
|
||||||
|
{{define "admin_content_users"}}
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1>用户管理</h1>
|
||||||
|
<p>禁言违规用户,共 {{.Total}} 位注册用户</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-card">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table admin-table mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>注册时间</th><th width="120">操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Users}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.Username}}</td>
|
||||||
|
<td>{{.Nickname}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Role "admin"}}
|
||||||
|
<span class="badge badge-admin">管理员</span>
|
||||||
|
{{else}}普通用户{{end}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
|
||||||
|
</td>
|
||||||
|
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
|
||||||
|
<td>
|
||||||
|
{{if eq .Role "admin"}}
|
||||||
|
<span class="text-muted small">—</span>
|
||||||
|
{{else if .Banned}}
|
||||||
|
<button class="btn btn-sm btn-success" onclick="banUser({{.ID}}, false)">解除禁言</button>
|
||||||
|
{{else}}
|
||||||
|
<button class="btn btn-sm btn-outline-warning" onclick="banUser({{.ID}}, true)">禁言</button>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{else}}
|
||||||
|
<tr><td colspan="7" class="admin-empty">暂无用户</td></tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{template "admin_pagination" .}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "admin_scripts_users"}}
|
||||||
|
<script>
|
||||||
|
function banUser(id, banned) {
|
||||||
|
var msg = banned ? '确定禁言该用户?禁言后无法登录和发帖。' : '确定解除该用户的禁言?';
|
||||||
|
adminConfirm(msg, function() {
|
||||||
|
postForm('/admin/api/users/' + id + '/ban', 'POST', { banned: banned ? 'true' : 'false' })
|
||||||
|
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||||
|
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
16
embed_static/templates/board.html
Normal file
16
embed_static/templates/board.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{{define "board.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item active">{{.Board.Name}}</li></ol></nav>
|
||||||
|
<h3>{{.Board.Name}}</h3>
|
||||||
|
<p class="text-muted">{{.Board.Description}}</p>
|
||||||
|
{{if .CurrentUser}}<a href="/post/new?board={{.Board.ID}}" class="btn btn-success btn-sm mb-3">在此板块发帖</a>{{end}}
|
||||||
|
{{range .Posts}}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
|
||||||
|
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
|
||||||
|
<div class="small text-muted mt-1">{{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}<p class="text-muted">该板块暂无帖子</p>{{end}}
|
||||||
|
{{end}}
|
||||||
7
embed_static/templates/error.html
Normal file
7
embed_static/templates/error.html
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "error.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<h3>{{.Message}}</h3>
|
||||||
|
<a href="/" class="btn btn-primary mt-3">返回首页</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
10
embed_static/templates/favorites.html
Normal file
10
embed_static/templates/favorites.html
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{{define "favorites.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h3>我的收藏</h3>
|
||||||
|
{{range .Favorites}}
|
||||||
|
<div class="card mb-2"><div class="card-body py-2">
|
||||||
|
<a href="/post/{{.Post.ID}}" class="fw-semibold text-decoration-none">{{.Post.Title}}</a>
|
||||||
|
<div class="small text-muted">{{.Post.Board.Name}} · 收藏于 {{.CreatedAt.Format "2006-01-02"}}</div>
|
||||||
|
</div></div>
|
||||||
|
{{else}}<p class="text-muted">暂无收藏</p>{{end}}
|
||||||
|
{{end}}
|
||||||
34
embed_static/templates/index.html
Normal file
34
embed_static/templates/index.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{{define "index.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<h4 class="mb-3">最新帖子</h4>
|
||||||
|
{{range .Posts}}
|
||||||
|
<div class="card mb-2">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
|
||||||
|
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
{{.Board.Name}} · {{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}} · 👁 {{.ViewCount}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted">暂无帖子,快来发帖吧!</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<h5>板块列表</h5>
|
||||||
|
<div class="list-group">
|
||||||
|
{{range .Boards}}
|
||||||
|
<a href="/board/{{.ID}}" class="list-group-item list-group-item-action">
|
||||||
|
<strong>{{.Name}}</strong>
|
||||||
|
<small class="d-block text-muted">{{.Description}}</small>
|
||||||
|
</a>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-muted small">管理员尚未创建板块</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
56
embed_static/templates/layout.html
Normal file
56
embed_static/templates/layout.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{{define "layout"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Title}} - 姜十三论坛</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/assets/css/style.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark" style="background:var(--primary)">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">姜十三论坛 <span class="en fs-6 opacity-75">Jiang13 Forum</span></a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="nav">
|
||||||
|
<ul class="navbar-nav me-auto">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/post/new">发帖</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/favorites">我的收藏</a></li>
|
||||||
|
{{end}}
|
||||||
|
{{if .IsAdmin}}<li class="nav-item"><a class="nav-link" href="/admin/dashboard">管理后台</a></li>{{end}}
|
||||||
|
</ul>
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/profile">{{.CurrentUser.Nickname}}</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#" onclick="logout()">退出</a></li>
|
||||||
|
{{else}}
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/login">登录</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="/register">注册</a></li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="container py-4 flex-grow-1">
|
||||||
|
{{template "content" .}}
|
||||||
|
</main>
|
||||||
|
<footer class="site-footer text-center">
|
||||||
|
<div class="container">© 2026 姜十三论坛 Jiang13 Forum · 拾三一隅,自在交流</div>
|
||||||
|
</footer>
|
||||||
|
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||||
|
<div id="toast" class="toast" role="alert"><div class="toast-body"></div></div>
|
||||||
|
</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="/assets/js/app.js"></script>
|
||||||
|
<script>
|
||||||
|
function logout(){ fetch('/api/logout',{method:'POST',credentials:'same-origin'}).then(()=>location.href='/'); }
|
||||||
|
</script>
|
||||||
|
{{block "scripts" .}}{{end}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
36
embed_static/templates/login.html
Normal file
36
embed_static/templates/login.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{{define "login.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h3 class="text-center mb-1">登录</h3>
|
||||||
|
<p class="text-center login-slogan mb-4">拾三一隅,自在交流</p>
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">用户名</label>
|
||||||
|
<input type="text" name="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">密码</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success w-100">登录</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center mt-3 mb-0">还没有账号?<a href="/register">立即注册</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
apiPost('/api/login', this, true).then(d=>{
|
||||||
|
if(d.error){ showToast(d.error,'danger'); return; }
|
||||||
|
location.href='/';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
72
embed_static/templates/post.html
Normal file
72
embed_static/templates/post.html
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{{define "post.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item"><a href="/board/{{.Post.BoardID}}">{{.Post.Board.Name}}</a></li><li class="breadcrumb-item active">帖子</li></ol></nav>
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>{{if .Post.Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Post.Title}}</h3>
|
||||||
|
<div class="small text-muted mb-3">
|
||||||
|
<a href="/user/{{.Post.UserID}}">{{.Post.User.Nickname}}</a> · {{.Post.CreatedAt.Format "2006-01-02 15:04"}} · 👁 {{.Post.ViewCount}}
|
||||||
|
{{if .Post.Tags}}· 标签:{{.Post.Tags}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="post-content">{{safeHTML .Post.Content}}</div>
|
||||||
|
<div class="mt-3 d-flex gap-2 flex-wrap">
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<button class="btn btn-outline-primary btn-sm" id="likeBtn" onclick="toggleLike()">{{if .Liked}}已点赞{{else}}点赞{{end}}</button>
|
||||||
|
<button class="btn btn-outline-warning btn-sm" id="favBtn" onclick="toggleFav()">{{if .Favorited}}已收藏{{else}}收藏{{end}}</button>
|
||||||
|
{{if or (eq .CurrentUserID .Post.UserID) .IsAdmin}}
|
||||||
|
<a href="/post/{{.Post.ID}}/edit" class="btn btn-outline-secondary btn-sm">编辑</a>
|
||||||
|
<button class="btn btn-outline-danger btn-sm" onclick="deletePost()">删除</button>
|
||||||
|
{{end}}
|
||||||
|
{{if .IsAdmin}}
|
||||||
|
<button class="btn btn-outline-dark btn-sm" onclick="pinPost()">{{if .Post.Pinned}}取消置顶{{else}}置顶{{end}}</button>
|
||||||
|
{{end}}
|
||||||
|
{{else}}<a href="/login" class="btn btn-outline-primary btn-sm">登录后互动</a>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h5>评论 ({{len .Comments}})</h5>
|
||||||
|
{{range .Comments}}
|
||||||
|
<div class="card mb-2 comment-floor">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<strong>#{{.Floor}} {{.User.Nickname}}</strong>
|
||||||
|
<small class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</small>
|
||||||
|
</div>
|
||||||
|
{{if .ReplyUser}}<div class="small text-muted">回复 #{{.ReplyUser.Floor}} {{.ReplyUser.User.Nickname}}</div>{{end}}
|
||||||
|
<div class="mt-1">{{.Content}}</div>
|
||||||
|
{{if $.CurrentUser}}
|
||||||
|
<button class="btn btn-link btn-sm p-0" onclick="replyTo({{.ID}},{{.Floor}},'{{.User.Nickname}}')">回复</button>
|
||||||
|
{{if or (eq $.CurrentUserID .UserID) $.IsAdmin}}
|
||||||
|
<button class="btn btn-link btn-sm p-0 text-danger" onclick="deleteComment({{.ID}})">删除</button>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{if .CurrentUser}}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="commentForm">
|
||||||
|
<div id="replyHint" class="small text-muted mb-2" style="display:none"></div>
|
||||||
|
<input type="hidden" name="reply_to" id="replyTo">
|
||||||
|
<textarea name="content" class="form-control mb-2" rows="3" placeholder="写下你的评论..." required></textarea>
|
||||||
|
<button type="submit" class="btn btn-success btn-sm">发表评论</button>
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelReply()" id="cancelReplyBtn" style="display:none">取消回复</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
const postId={{.Post.ID}};
|
||||||
|
function toggleLike(){ fetch('/api/posts/'+postId+'/like',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('likeBtn').textContent=d.liked?'已点赞':'点赞'; }); }
|
||||||
|
function toggleFav(){ fetch('/api/posts/'+postId+'/favorite',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('favBtn').textContent=d.favorited?'已收藏':'收藏'; }); }
|
||||||
|
function deletePost(){ confirmAction('确定删除此帖?',()=>{ fetch('/api/posts/'+postId,{method:'DELETE',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.href='/board/{{.Post.BoardID}}'; }); }); }
|
||||||
|
function pinPost(){ fetch('/admin/api/posts/'+postId+'/pin',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'pinned={{if .Post.Pinned}}false{{else}}true{{end}}'}).then(r=>r.json()).then(()=>location.reload()); }
|
||||||
|
function replyTo(id,floor,name){ document.getElementById('replyTo').value=id; document.getElementById('replyHint').style.display='block'; document.getElementById('replyHint').textContent='回复 #'+floor+' '+name; document.getElementById('cancelReplyBtn').style.display='inline-block'; }
|
||||||
|
function cancelReply(){ document.getElementById('replyTo').value=''; document.getElementById('replyHint').style.display='none'; document.getElementById('cancelReplyBtn').style.display='none'; }
|
||||||
|
function deleteComment(id){ confirmAction('确定删除?',()=>{ fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin'}).then(()=>location.reload()); }); }
|
||||||
|
document.getElementById('commentForm')?.addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/posts/'+postId+'/comments',this,true).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
31
embed_static/templates/post_edit.html
Normal file
31
embed_static/templates/post_edit.html
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{{define "post_edit.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h3>编辑帖子</h3>
|
||||||
|
<form id="editForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">标题</label>
|
||||||
|
<input type="text" name="title" class="form-control" value="{{.Post.Title}}" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">标签</label>
|
||||||
|
<input type="text" name="tags" class="form-control" value="{{.Post.Tags}}">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">内容</label>
|
||||||
|
<textarea name="content" class="form-control" rows="12" required>{{.Post.Content}}</textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">保存</button>
|
||||||
|
<a href="/post/{{.Post.ID}}" class="btn btn-secondary">取消</a>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
document.getElementById('editForm').addEventListener('submit',function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
fetch('/api/posts/{{.Post.ID}}',{method:'PUT',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{
|
||||||
|
if(d.error){showToast(d.error,'danger');return;}
|
||||||
|
location.href='/post/{{.Post.ID}}';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
37
embed_static/templates/post_new.html
Normal file
37
embed_static/templates/post_new.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{{define "post_new.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<h3>发布新帖</h3>
|
||||||
|
<form id="postForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">选择板块</label>
|
||||||
|
<select name="board_id" class="form-select" required>
|
||||||
|
{{range .Boards}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">标题</label>
|
||||||
|
<input type="text" name="title" class="form-control" required maxlength="256">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">标签(逗号分隔)</label>
|
||||||
|
<input type="text" name="tags" class="form-control" placeholder="Go,技术,交流">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">内容(支持 HTML 富文本)</label>
|
||||||
|
<textarea name="content" class="form-control" rows="12" required></textarea>
|
||||||
|
<div class="form-text">可使用 <b>、<i>、<a>、<img> 等 HTML 标签</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success">发布</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
document.getElementById('postForm').addEventListener('submit',function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
apiPost('/api/posts',this,true).then(d=>{
|
||||||
|
if(d.error){showToast(d.error,'danger');return;}
|
||||||
|
location.href='/post/'+d.post_id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
34
embed_static/templates/profile.html
Normal file
34
embed_static/templates/profile.html
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{{define "profile.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 text-center mb-4">
|
||||||
|
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
|
||||||
|
<h4>{{.ProfileUser.Nickname}}</h4>
|
||||||
|
<p class="text-muted">@{{.ProfileUser.Username}}</p>
|
||||||
|
<form id="avatarForm" enctype="multipart/form-data">
|
||||||
|
<input type="file" name="avatar" accept="image/*" class="form-control form-control-sm mb-2">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">更换头像</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card mb-3"><div class="card-header">修改昵称</div><div class="card-body">
|
||||||
|
<form id="nickForm"><input type="text" name="nickname" class="form-control mb-2" value="{{.ProfileUser.Nickname}}">
|
||||||
|
<button class="btn btn-success btn-sm">保存昵称</button></form>
|
||||||
|
</div></div>
|
||||||
|
<div class="card"><div class="card-header">修改密码</div><div class="card-body">
|
||||||
|
<form id="pwdForm">
|
||||||
|
<input type="password" name="old_password" class="form-control mb-2" placeholder="原密码" required>
|
||||||
|
<input type="password" name="new_password" class="form-control mb-2" placeholder="新密码(至少6位)" required>
|
||||||
|
<button class="btn btn-success btn-sm">修改密码</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
document.getElementById('nickForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/nickname',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
|
||||||
|
document.getElementById('pwdForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/password',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
|
||||||
|
document.getElementById('avatarForm').addEventListener('submit',function(e){ e.preventDefault(); fetch('/api/profile/avatar',{method:'POST',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
40
embed_static/templates/register.html
Normal file
40
embed_static/templates/register.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{{define "register.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<h3 class="text-center mb-4">注册账号</h3>
|
||||||
|
<p class="text-center text-muted small mb-3">本站首个注册用户将自动成为管理员</p>
|
||||||
|
<form id="regForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">用户名(3-32位字母数字下划线)</label>
|
||||||
|
<input type="text" name="username" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">昵称</label>
|
||||||
|
<input type="text" name="nickname" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">密码(至少6位)</label>
|
||||||
|
<input type="password" name="password" class="form-control" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-success w-100">注册</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{define "scripts"}}
|
||||||
|
<script>
|
||||||
|
document.getElementById('regForm').addEventListener('submit', function(e){
|
||||||
|
e.preventDefault();
|
||||||
|
apiPost('/api/register', this, true).then(d=>{
|
||||||
|
if(d.error){ showToast(d.error,'danger'); return; }
|
||||||
|
showToast('注册成功'); location.href='/';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
8
embed_static/templates/user_profile.html
Normal file
8
embed_static/templates/user_profile.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{{define "user_profile.html"}}{{template "layout" .}}{{end}}
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2">
|
||||||
|
<h3>{{.ProfileUser.Nickname}}</h3>
|
||||||
|
<p class="text-muted">@{{.ProfileUser.Username}} · 注册于 {{.ProfileUser.CreatedAt.Format "2006-01-02"}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
20
frontend/components.json
Normal file
20
frontend/components.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/styles/global.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
frontend/index.html
Normal file
35
frontend/index.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4389
frontend/package-lock.json
generated
Normal file
4389
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
frontend/package.json
Normal file
57
frontend/package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "jiang13-forum-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.16",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.16",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.17",
|
||||||
|
"@radix-ui/react-label": "^2.1.9",
|
||||||
|
"@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-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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
70
frontend/src/App.tsx
Normal file
70
frontend/src/App.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
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';
|
||||||
|
|
||||||
|
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||||
|
const PostDetailPage = lazy(() => import('./pages/PostDetailPage'));
|
||||||
|
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||||
|
const RegisterPage = lazy(() => import('./pages/RegisterPage'));
|
||||||
|
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>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
<Toaster />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
frontend/src/api/client.ts
Normal file
169
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
|
||||||
|
|
||||||
|
const BASE = '';
|
||||||
|
|
||||||
|
async function request<T>(url: string, opts: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(BASE + url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...opts,
|
||||||
|
headers: {
|
||||||
|
...(opts.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...opts.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let data: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new Error('服务器响应异常');
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error((data.error as string) || '请求失败');
|
||||||
|
return data as 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, 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}`);
|
||||||
|
},
|
||||||
|
notifications: () => request<{ notifications: Notification[] }>('/api/notifications'),
|
||||||
|
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; 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; 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);
|
||||||
|
return request('/api/profile/nickname', { method: 'POST', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
updatePassword: (oldPassword: string, newPassword: string) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('old_password', oldPassword);
|
||||||
|
fd.append('new_password', newPassword);
|
||||||
|
return request('/api/profile/password', { method: 'POST', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
uploadAvatar: (file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
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);
|
||||||
|
fd.append('title', data.title);
|
||||||
|
fd.append('content', data.content);
|
||||||
|
fd.append('tags', data.tags || '');
|
||||||
|
return request<{ post_id: number }>('/api/posts', { method: 'POST', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
updatePost: (id: number, data: { title: string; content: string; tags?: string }) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('title', data.title);
|
||||||
|
fd.append('content', data.content);
|
||||||
|
fd.append('tags', data.tags || '');
|
||||||
|
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
login: (username: string, password: string) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('username', username);
|
||||||
|
fd.append('password', password);
|
||||||
|
return request('/api/login', { method: 'POST', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
register: (username: string, password: string, nickname: string) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('username', username);
|
||||||
|
fd.append('password', password);
|
||||||
|
fd.append('nickname', nickname);
|
||||||
|
return request('/api/register', { method: 'POST', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
logout: () => request('/api/logout', { method: 'POST' }),
|
||||||
|
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { method: 'POST' }),
|
||||||
|
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
|
||||||
|
addComment: (postId: number, data: {
|
||||||
|
content: string;
|
||||||
|
replyTo?: number;
|
||||||
|
guestNick?: string;
|
||||||
|
guestEmail?: string;
|
||||||
|
guestUrl?: string;
|
||||||
|
isPrivate?: boolean;
|
||||||
|
}) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('content', data.content);
|
||||||
|
if (data.replyTo) fd.append('reply_to', String(data.replyTo));
|
||||||
|
if (data.guestNick) fd.append('guest_nick', data.guestNick);
|
||||||
|
if (data.guestEmail) fd.append('guest_email', data.guestEmail);
|
||||||
|
if (data.guestUrl) fd.append('guest_url', data.guestUrl);
|
||||||
|
if (data.isPrivate) fd.append('is_private', '1');
|
||||||
|
return request<{ message: string; floor: number; id: number }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
ping: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/ping', { method: 'POST' }),
|
||||||
|
};
|
||||||
165
frontend/src/api/types.ts
Normal file
165
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForumStats {
|
||||||
|
users: number;
|
||||||
|
posts: number;
|
||||||
|
boards: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostItem {
|
||||||
|
id: number;
|
||||||
|
board_id: number;
|
||||||
|
user_id: number;
|
||||||
|
title: string;
|
||||||
|
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;
|
||||||
|
user_id: number;
|
||||||
|
floor: number;
|
||||||
|
content: string;
|
||||||
|
reply_to?: number;
|
||||||
|
guest_nick?: string;
|
||||||
|
guest_email?: string;
|
||||||
|
guest_url?: string;
|
||||||
|
is_private?: boolean;
|
||||||
|
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;
|
||||||
|
type: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineUser {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnlineStats {
|
||||||
|
count: number;
|
||||||
|
members: number;
|
||||||
|
guests: number;
|
||||||
|
users: OnlineUser[];
|
||||||
|
}
|
||||||
522
frontend/src/components/ArticleEditor.tsx
Normal file
522
frontend/src/components/ArticleEditor.tsx
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
import {
|
||||||
|
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, Underline as UnderlineIcon, Strikethrough, Link as LinkIcon, Code, Quote,
|
||||||
|
List, ListOrdered, Image as ImageIcon, Minus, LockKeyhole,
|
||||||
|
FileCode, PenLine, Maximize2, Minimize2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
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;
|
||||||
|
isEmpty: () => boolean;
|
||||||
|
focus: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 清空后折叠选区,避免空文档残留全选高亮
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 外部 value 变更时同步到编辑器(如加载已有帖子)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor || mode !== 'rich') return;
|
||||||
|
if (isInternalUpdate.current) {
|
||||||
|
isInternalUpdate.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = sanitizeHtml(value);
|
||||||
|
if (next === lastValueRef.current) return;
|
||||||
|
lastValueRef.current = next;
|
||||||
|
editor.commands.setContent(next || '', { emitUpdate: false });
|
||||||
|
}, [value, editor, mode]);
|
||||||
|
|
||||||
|
// 全屏时锁定页面滚动,Esc 退出
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreen) return undefined;
|
||||||
|
|
||||||
|
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('');
|
||||||
|
}
|
||||||
|
setLinkTarget(target);
|
||||||
|
setLinkDialogOpen(true);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
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 article-editor--${mode}${fullscreen ? ' article-editor--fullscreen' : ''}`}>
|
||||||
|
<div className="article-editor-bar">
|
||||||
|
<div className="article-editor-tools">
|
||||||
|
{renderToolButtons(tools)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ArticleEditor;
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
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 />;
|
||||||
|
}
|
||||||
219
frontend/src/components/CommentBox.tsx
Normal file
219
frontend/src/components/CommentBox.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import type { User, Comment } from '../api/types';
|
||||||
|
import EmojiPicker from './EmojiPicker';
|
||||||
|
import { loadGuestInfo, saveGuestInfo } from '../utils/guest';
|
||||||
|
import { commentNick } from '../utils/comment';
|
||||||
|
|
||||||
|
export interface CommentSubmitData {
|
||||||
|
content: string;
|
||||||
|
guestNick?: string;
|
||||||
|
guestEmail?: string;
|
||||||
|
guestUrl?: string;
|
||||||
|
isPrivate: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
user: User | null;
|
||||||
|
replyTo?: Comment | null;
|
||||||
|
inline?: boolean;
|
||||||
|
submitting?: boolean;
|
||||||
|
submitCount?: number;
|
||||||
|
onSubmit: (data: CommentSubmitData) => void;
|
||||||
|
onCancelReply?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waline 风格评论输入框:登录用户 / 游客双模式 */
|
||||||
|
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
|
||||||
|
const saved = loadGuestInfo();
|
||||||
|
const [content, setContent] = useState('');
|
||||||
|
const [guestNick, setGuestNick] = useState(saved.nick);
|
||||||
|
const [guestEmail, setGuestEmail] = useState(saved.email);
|
||||||
|
const [guestUrl, setGuestUrl] = useState(saved.url);
|
||||||
|
const [isPrivate, setIsPrivate] = useState(false);
|
||||||
|
const [showEmoji, setShowEmoji] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (inline && replyTo) {
|
||||||
|
// preventScroll 避免 focus 与页面 scrollIntoView 争抢滚动位置
|
||||||
|
textareaRef.current?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}, [replyTo?.id, inline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setContent('');
|
||||||
|
setShowEmoji(false);
|
||||||
|
setIsPrivate(false);
|
||||||
|
}, [submitCount]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showEmoji) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (boxRef.current && !boxRef.current.contains(e.target as Node)) {
|
||||||
|
setShowEmoji(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [showEmoji]);
|
||||||
|
|
||||||
|
const insertEmoji = (emoji: string) => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (el) {
|
||||||
|
const start = el.selectionStart ?? content.length;
|
||||||
|
const end = el.selectionEnd ?? content.length;
|
||||||
|
const next = content.slice(0, start) + emoji + content.slice(end);
|
||||||
|
setContent(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
const pos = start + emoji.length;
|
||||||
|
el.setSelectionRange(pos, pos);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setContent((prev) => prev + emoji);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const text = content.trim();
|
||||||
|
if (!text) return;
|
||||||
|
if (!user && !guestNick.trim()) return;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
saveGuestInfo({ nick: guestNick.trim(), email: guestEmail.trim(), url: guestUrl.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit({
|
||||||
|
content: text,
|
||||||
|
guestNick: user ? undefined : guestNick.trim(),
|
||||||
|
guestEmail: user ? undefined : guestEmail.trim(),
|
||||||
|
guestUrl: user ? undefined : guestUrl.trim(),
|
||||||
|
isPrivate,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarInitial = user?.nickname?.[0] || guestNick?.[0] || '?';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="comment-box" ref={boxRef}>
|
||||||
|
<div className="comment-box-avatar">
|
||||||
|
{user?.avatar ? (
|
||||||
|
<img src={user.avatar} alt="" className="comment-box-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
|
||||||
|
{user ? avatarInitial : (
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||||
|
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="comment-box-main">
|
||||||
|
{replyTo && !inline && (
|
||||||
|
<div className="comment-box-reply-hint">
|
||||||
|
<span>回复 #{replyTo.floor} {commentNick(replyTo)}</span>
|
||||||
|
{onCancelReply && (
|
||||||
|
<button type="button" className="comment-box-reply-cancel" onClick={onCancelReply}>取消</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`comment-box-input-wrap ${isPrivate ? 'private-mode' : ''}`}>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
className="comment-box-textarea"
|
||||||
|
placeholder={isPrivate ? '正在隐私评论中...' : '说点什么吧'}
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-box-send"
|
||||||
|
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
title="发送"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!user && (
|
||||||
|
<div className="comment-box-guest-fields">
|
||||||
|
<label className="comment-box-guest-field">
|
||||||
|
<span className="comment-box-guest-label">
|
||||||
|
昵称
|
||||||
|
<em className="comment-box-guest-required">必填</em>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="comment-box-guest-input"
|
||||||
|
placeholder="怎么称呼你"
|
||||||
|
autoComplete="nickname"
|
||||||
|
value={guestNick}
|
||||||
|
onChange={(e) => setGuestNick(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="comment-box-guest-field">
|
||||||
|
<span className="comment-box-guest-label">
|
||||||
|
邮箱
|
||||||
|
<em className="comment-box-guest-optional">选填</em>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="comment-box-guest-input"
|
||||||
|
placeholder="name@example.com"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={guestEmail}
|
||||||
|
onChange={(e) => setGuestEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="comment-box-guest-field">
|
||||||
|
<span className="comment-box-guest-label">
|
||||||
|
网址
|
||||||
|
<em className="comment-box-guest-optional">选填</em>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="comment-box-guest-input"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
type="url"
|
||||||
|
autoComplete="url"
|
||||||
|
value={guestUrl}
|
||||||
|
onChange={(e) => setGuestUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="comment-box-guest-hint">邮箱不会公开展示,仅用于站内记录。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="comment-box-toolbar">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`comment-box-owo ${showEmoji ? 'active' : ''}`}
|
||||||
|
onClick={() => setShowEmoji((v) => !v)}
|
||||||
|
>
|
||||||
|
OwO
|
||||||
|
</button>
|
||||||
|
<label className="comment-box-private">
|
||||||
|
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
|
||||||
|
<span>隐私评论</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEmoji && <EmojiPicker onSelect={insertEmoji} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
frontend/src/components/CommentContent.tsx
Normal file
18
frontend/src/components/CommentContent.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { highlightMentions } from '../utils/content';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
content: string;
|
||||||
|
onMentionClick?: (name: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染评论正文(支持正文内 @ 高亮) */
|
||||||
|
export default function CommentContent({ content, onMentionClick }: Props) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="floor-body"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightMentions(content, onMentionClick),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
frontend/src/components/CommentThreadList.tsx
Normal file
158
frontend/src/components/CommentThreadList.tsx
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { Clock, MessageSquare, X } from 'lucide-react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Comment } from '../api/types';
|
||||||
|
import CommentContent from './CommentContent';
|
||||||
|
import {
|
||||||
|
commentNick,
|
||||||
|
commentInitial,
|
||||||
|
formatCommentDate,
|
||||||
|
isGuestComment,
|
||||||
|
buildCommentTree,
|
||||||
|
type CommentNode,
|
||||||
|
} from '../utils/comment';
|
||||||
|
|
||||||
|
interface ItemProps {
|
||||||
|
node: CommentNode;
|
||||||
|
nested?: boolean;
|
||||||
|
highlightFloor?: number | null;
|
||||||
|
replyToId?: number | null;
|
||||||
|
onReply: (comment: Comment) => void;
|
||||||
|
onCancelReply: () => void;
|
||||||
|
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条评论(支持嵌套子回复 + 内联回复框) */
|
||||||
|
function CommentItem({
|
||||||
|
node,
|
||||||
|
nested,
|
||||||
|
highlightFloor,
|
||||||
|
replyToId,
|
||||||
|
onReply,
|
||||||
|
onCancelReply,
|
||||||
|
renderReplyBox,
|
||||||
|
}: ItemProps) {
|
||||||
|
const c = node.comment;
|
||||||
|
const nick = commentNick(c);
|
||||||
|
const guest = isGuestComment(c);
|
||||||
|
const isHighlighted = highlightFloor === c.floor;
|
||||||
|
const hidden = !!c.content_hidden;
|
||||||
|
const isReplying = replyToId === c.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={`floor-${c.floor}`}
|
||||||
|
className={`waline-comment ${nested ? 'nested' : ''} ${isHighlighted ? 'highlight' : ''}`}
|
||||||
|
>
|
||||||
|
<div className={`waline-comment-avatar ${guest && !c.user?.avatar ? 'guest' : ''}`}>
|
||||||
|
{c.user?.avatar ? (
|
||||||
|
<img src={c.user.avatar} alt="" />
|
||||||
|
) : (
|
||||||
|
commentInitial(c)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="waline-comment-main">
|
||||||
|
<div className="waline-comment-head">
|
||||||
|
{c.guest_url ? (
|
||||||
|
<a href={c.guest_url} target="_blank" rel="noopener noreferrer" className="waline-comment-author">
|
||||||
|
{nick}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="waline-comment-author">{nick}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hidden ? (
|
||||||
|
<div className="waline-comment-private-mask">
|
||||||
|
该评论为私密评论,仅文章作者与评论发起者可见!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="waline-comment-bubble">
|
||||||
|
{c.reply_target && (
|
||||||
|
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
|
||||||
|
)}
|
||||||
|
<CommentContent content={c.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="waline-comment-meta">
|
||||||
|
<span className="waline-comment-date">
|
||||||
|
<Clock size={14} />
|
||||||
|
{formatCommentDate(c.created_at)}
|
||||||
|
</span>
|
||||||
|
{isReplying ? (
|
||||||
|
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||||
|
<X size={14} />
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
||||||
|
<MessageSquare size={14} />
|
||||||
|
回复
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReplying && renderReplyBox && (
|
||||||
|
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
|
||||||
|
{renderReplyBox(c)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{node.children.length > 0 && (
|
||||||
|
<div className="waline-replies">
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<CommentItem
|
||||||
|
key={child.comment.id}
|
||||||
|
node={child}
|
||||||
|
nested
|
||||||
|
highlightFloor={highlightFloor}
|
||||||
|
replyToId={replyToId}
|
||||||
|
onReply={onReply}
|
||||||
|
onCancelReply={onCancelReply}
|
||||||
|
renderReplyBox={renderReplyBox}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
comments: Comment[];
|
||||||
|
highlightFloor?: number | null;
|
||||||
|
replyToId?: number | null;
|
||||||
|
onReply: (comment: Comment) => void;
|
||||||
|
onCancelReply: () => void;
|
||||||
|
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Waline 嵌套楼层评论列表 */
|
||||||
|
export default function CommentThreadList({
|
||||||
|
comments,
|
||||||
|
highlightFloor,
|
||||||
|
replyToId,
|
||||||
|
onReply,
|
||||||
|
onCancelReply,
|
||||||
|
renderReplyBox,
|
||||||
|
}: Props) {
|
||||||
|
const tree = buildCommentTree(comments);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="comment-thread-list">
|
||||||
|
{tree.map((node) => (
|
||||||
|
<CommentItem
|
||||||
|
key={node.comment.id}
|
||||||
|
node={node}
|
||||||
|
highlightFloor={highlightFloor}
|
||||||
|
replyToId={replyToId}
|
||||||
|
onReply={onReply}
|
||||||
|
onCancelReply={onCancelReply}
|
||||||
|
renderReplyBox={renderReplyBox}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/EmojiPicker.tsx
Normal file
23
frontend/src/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { EMOJI_LIST } from '../utils/emojis';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSelect: (emoji: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OwO 表情选择面板 */
|
||||||
|
export default function EmojiPicker({ onSelect }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="emoji-picker">
|
||||||
|
{EMOJI_LIST.map((e) => (
|
||||||
|
<button
|
||||||
|
key={e}
|
||||||
|
type="button"
|
||||||
|
className="emoji-picker-item"
|
||||||
|
onClick={() => onSelect(e)}
|
||||||
|
>
|
||||||
|
{e}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/components/ErrorBoundary.tsx
Normal file
33
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface Props { children: ReactNode }
|
||||||
|
interface State { error: Error | null }
|
||||||
|
|
||||||
|
/** 捕获渲染异常,避免整页白屏 */
|
||||||
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error) {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error('[Jiang13Forum]', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
|
<h3>页面渲染出错</h3>
|
||||||
|
<p style={{ color: 'var(--color-text-3)', fontSize: 13 }}>{this.state.error.message}</p>
|
||||||
|
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||||
|
刷新页面
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/src/components/FeedHeader.tsx
Normal file
58
frontend/src/components/FeedHeader.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { Users, FileText, LayoutGrid } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { Board, ForumStats } from '../api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
boardId: number;
|
||||||
|
keyword: string;
|
||||||
|
boards: Board[];
|
||||||
|
stats: ForumStats | null;
|
||||||
|
postTotal: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const board = boards.find(b => b.id === boardId);
|
||||||
|
|
||||||
|
const title = keyword
|
||||||
|
? `搜索:${keyword}`
|
||||||
|
: (boardId && board ? board.name : '全部帖子');
|
||||||
|
|
||||||
|
const boardHint = boardId && board ? (board.description || '') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
frontend/src/components/PageLoader.tsx
Normal file
9
frontend/src/components/PageLoader.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
|
||||||
|
export default function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="page-loader" role="status" aria-live="polite">
|
||||||
|
<span className="page-loader__dot" />
|
||||||
|
加载中…
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/PostContent.tsx
Normal file
40
frontend/src/components/PostContent.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMemo, useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { renderPostContentHtml } from '../utils/postContent';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
html: string;
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帖子正文渲染(含会员专属区块) */
|
||||||
|
export default function PostContent({ html, isLoggedIn, className = 'post-detail-content' }: Props) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const rendered = useMemo(
|
||||||
|
() => renderPostContentHtml(html, isLoggedIn),
|
||||||
|
[html, isLoggedIn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('[data-members-login]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
nav('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.closest('[data-members-register]')) {
|
||||||
|
e.preventDefault();
|
||||||
|
nav('/register');
|
||||||
|
}
|
||||||
|
}, [nav]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/components/PostListItem.tsx
Normal file
52
frontend/src/components/PostListItem.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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, 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}>
|
||||||
|
<div className="post-avatar">
|
||||||
|
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : initial}
|
||||||
|
</div>
|
||||||
|
<div className="post-body">
|
||||||
|
<div className="post-title">
|
||||||
|
{post.pinned && <PinnedIcon className="mr-1.5" />}
|
||||||
|
{post.title}
|
||||||
|
</div>
|
||||||
|
<div className="post-meta">
|
||||||
|
{post.board && <BoardBadge board={post.board} />}
|
||||||
|
<span>{post.user?.nickname || '匿名'}</span>
|
||||||
|
<span>{timeLabel}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="post-stats">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
frontend/src/components/RightPanel.tsx
Normal file
93
frontend/src/components/RightPanel.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { Flame, Megaphone, Users } from 'lucide-react';
|
||||||
|
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
hot: PostItem[];
|
||||||
|
notifications: Notification[];
|
||||||
|
online: OnlineStats | null;
|
||||||
|
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">
|
||||||
|
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||||
|
热门帖子
|
||||||
|
</div>
|
||||||
|
<div className="widget-card-body">
|
||||||
|
{hotList.length === 0 ? (
|
||||||
|
<div className="widget-empty">暂无数据</div>
|
||||||
|
) : hotList.map((item, i) => (
|
||||||
|
<div key={item.id} className="widget-item" onClick={() => onPostClick(item.id)}>
|
||||||
|
<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">
|
||||||
|
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||||
|
最新动态
|
||||||
|
</div>
|
||||||
|
<div className="widget-card-body">
|
||||||
|
{noticeList.length === 0 ? (
|
||||||
|
<div className="widget-empty">暂无动态</div>
|
||||||
|
) : noticeList.map(item => (
|
||||||
|
<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">
|
||||||
|
<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 className="widget-online-meta">
|
||||||
|
会员 {online?.members ?? 0} · 游客 {online?.guests ?? 0}
|
||||||
|
</div>
|
||||||
|
<div className="widget-online-list">
|
||||||
|
{members.map(u => (
|
||||||
|
<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 className="widget-empty widget-empty--inline">暂无会员在线</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="widget-card widget-card--about">
|
||||||
|
<div className="widget-card-body">
|
||||||
|
<p className="widget-about-text">
|
||||||
|
<strong>姜十三论坛</strong>
|
||||||
|
拾三一隅,自在交流。轻量社区,专为小圈子打造。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
frontend/src/components/Sidebar.tsx
Normal file
107
frontend/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Home, Star, LayoutDashboard,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
|
import type { Board } from '../api/types';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
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'];
|
||||||
|
|
||||||
|
export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||||
|
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||||
|
if (isNeutralSidebarRoute(pathname)) return null;
|
||||||
|
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||||
|
if (pathname.startsWith('/admin')) return 'admin';
|
||||||
|
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
boards: Board[];
|
||||||
|
activeBoard: number;
|
||||||
|
onSelectBoard: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const menuKey = resolveMenuKey(loc.pathname, activeBoard);
|
||||||
|
|
||||||
|
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={key}
|
||||||
|
className={cn('sidebar-nav-item', menuKey != null && menuKey === key && 'active')}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="flex-1 truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<div className="sidebar-section">浏览</div>
|
||||||
|
<nav className="sidebar-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 sidebar-section--boards">板块</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-section" style={{ marginTop: 8 }}>管理</div>
|
||||||
|
<nav className="sidebar-nav">
|
||||||
|
{navItem('admin', '管理后台', <LayoutDashboard />, () => nav('/admin/dashboard'))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/VirtualPostList.tsx
Normal file
134
frontend/src/components/VirtualPostList.tsx
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 72,
|
||||||
|
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 = () => {
|
||||||
|
onScrollTopChange?.(el.scrollTop);
|
||||||
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
|
||||||
|
onLoadMore();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
el.addEventListener('scroll', onScroll);
|
||||||
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
|
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="post-list-scroll" ref={parentRef}>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
frontend/src/components/ui/alert-dialog.tsx
Normal file
101
frontend/src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
32
frontend/src/components/ui/badge.tsx
Normal file
32
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps
|
||||||
|
extends React.HTMLAttributes<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="animate-spin" /> : null}
|
||||||
|
{children}
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
95
frontend/src/components/ui/dialog.tsx
Normal file
95
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root;
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger;
|
||||||
|
const DialogPortal = DialogPrimitive.Portal;
|
||||||
|
const DialogClose = DialogPrimitive.Close;
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">关闭</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
));
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogHeader.displayName = 'DialogHeader';
|
||||||
|
|
||||||
|
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
DialogFooter.displayName = 'DialogFooter';
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
};
|
||||||
171
frontend/src/components/ui/dropdown-menu.tsx
Normal file
171
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||||
|
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||||
|
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||||
|
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||||
|
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||||
|
|
||||||
|
const DropdownMenuSubTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||||
|
>(({ className, inset, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRight className="ml-auto" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
|
));
|
||||||
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSubContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.SubContent
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||||
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Portal>
|
||||||
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
|
));
|
||||||
|
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
>(({ className, children, checked, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
|
));
|
||||||
|
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuRadioItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.RadioItem
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
<Circle className="h-2 w-2 fill-current" />
|
||||||
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
|
));
|
||||||
|
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & { inset?: boolean }
|
||||||
|
>(({ className, inset, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DropdownMenuPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||||
|
<span className={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...props} />
|
||||||
|
);
|
||||||
|
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
};
|
||||||
136
frontend/src/components/ui/form.tsx
Normal file
136
frontend/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
ControllerProps,
|
||||||
|
FieldPath,
|
||||||
|
FieldValues,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState, formState } = useFormContext();
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error('useFormField should be used within <FormField>');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = { id: string };
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId();
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn('space-y-2', className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
FormItem.displayName = 'FormItem';
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
return (
|
||||||
|
<Label ref={ref} className={cn(error && 'text-destructive', className)} htmlFor={formItemId} {...props} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormLabel.displayName = 'FormLabel';
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
FormControl.displayName = 'FormControl';
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
return (
|
||||||
|
<p ref={ref} id={formDescriptionId} className={cn('text-[0.8rem] text-muted-foreground', className)} {...props} />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
FormDescription.displayName = 'FormDescription';
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
||||||
|
({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message) : children;
|
||||||
|
if (!body) return null;
|
||||||
|
return (
|
||||||
|
<p ref={ref} id={formMessageId} className={cn('text-[0.8rem] font-medium text-destructive', className)} {...props}>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
FormMessage.displayName = 'FormMessage';
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
||||||
21
frontend/src/components/ui/input.tsx
Normal file
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
18
frontend/src/components/ui/label.tsx
Normal file
18
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
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 };
|
||||||
20
frontend/src/components/ui/sonner.tsx
Normal file
20
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { Toaster as Sonner } from 'sonner';
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
|
||||||
|
/** 全局 Toast,替代 Arco Message */
|
||||||
|
export function Toaster() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme}
|
||||||
|
position="top-center"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: 'font-sans',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/components/ui/spinner.tsx
Normal file
19
frontend/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = { sm: 'h-4 w-4', md: 'h-5 w-5', lg: 'h-8 w-8' };
|
||||||
|
|
||||||
|
/** 加载指示器,替代 Arco Spin */
|
||||||
|
export function Spinner({ className, size = 'md' }: SpinnerProps) {
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
className={cn('animate-spin text-[var(--j13-green)]', sizeMap[size], className)}
|
||||||
|
aria-label="加载中"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
frontend/src/components/ui/switch.tsx
Normal file
26
frontend/src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--j13-green)] data-[state=unchecked]:bg-input',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
));
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
57
frontend/src/components/ui/table.tsx
Normal file
57
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
|
||||||
|
);
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-10 px-3 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<td ref={ref} className={cn('p-3 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
|
||||||
|
),
|
||||||
|
);
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
|
||||||
|
export { Table, TableHeader, TableBody, TableHead, TableRow, TableCell };
|
||||||
20
frontend/src/components/ui/textarea.tsx
Normal file
20
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
47
frontend/src/hooks/useAuth.tsx
Normal file
47
frontend/src/hooks/useAuth.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { User } from '../api/types';
|
||||||
|
|
||||||
|
interface AuthCtx {
|
||||||
|
user: User | null;
|
||||||
|
loading: boolean;
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthCtx>({
|
||||||
|
user: null, loading: true,
|
||||||
|
refresh: async () => {}, logout: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.me();
|
||||||
|
setUser(data.user ?? null);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 初始化只拉一次用户信息
|
||||||
|
useEffect(() => { refresh(); }, [refresh]);
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, refresh, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext);
|
||||||
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;
|
||||||
|
}
|
||||||
60
frontend/src/hooks/useGlobalWheelScroll.ts
Normal file
60
frontend/src/hooks/useGlobalWheelScroll.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react';
|
||||||
|
|
||||||
|
/** 判断元素是否可在指定方向继续滚动 */
|
||||||
|
function canElementScroll(el: HTMLElement, deltaY: number): boolean {
|
||||||
|
const { overflowY } = getComputedStyle(el);
|
||||||
|
if (overflowY !== 'auto' && overflowY !== 'scroll' && overflowY !== 'overlay') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (el.scrollHeight <= el.clientHeight + 1) return false;
|
||||||
|
if (deltaY < 0) return el.scrollTop > 0;
|
||||||
|
return el.scrollTop + el.clientHeight < el.scrollHeight - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从目标节点向上查找首个可滚动容器 */
|
||||||
|
function findScrollable(start: HTMLElement | null, deltaY: number, boundary: HTMLElement): HTMLElement | null {
|
||||||
|
let el = start;
|
||||||
|
while (el && el !== boundary) {
|
||||||
|
if (canElementScroll(el, deltaY)) return el;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文章页:鼠标在页面任意位置滚轮时,统一滚动主内容区。
|
||||||
|
* 保留 textarea、表情面板等主内容区内部可滚动元素的独立滚动。
|
||||||
|
*/
|
||||||
|
export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, enabled = true) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
const scrollEl = scrollRef.current;
|
||||||
|
if (!scrollEl) return;
|
||||||
|
|
||||||
|
const root = scrollEl.closest('.app-shell') as HTMLElement | null;
|
||||||
|
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;
|
||||||
|
|
||||||
|
const inner = findScrollable(target, e.deltaY, root);
|
||||||
|
// 主内容区内部嵌套滚动(如 textarea、表情面板)保留原生行为
|
||||||
|
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
|
||||||
|
// 鼠标已在主滚动容器上时,交给浏览器原生处理
|
||||||
|
if (inner === scrollEl) return;
|
||||||
|
|
||||||
|
const prev = scrollEl.scrollTop;
|
||||||
|
scrollEl.scrollTop += e.deltaY;
|
||||||
|
if (scrollEl.scrollTop !== prev) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
return () => root.removeEventListener('wheel', onWheel);
|
||||||
|
}, [scrollRef, enabled]);
|
||||||
|
}
|
||||||
35
frontend/src/hooks/useTheme.tsx
Normal file
35
frontend/src/hooks/useTheme.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { createContext, useContext, useLayoutEffect, useState, ReactNode } from 'react';
|
||||||
|
import { applyTheme, getStoredTheme, type Theme } from '../utils/theme';
|
||||||
|
|
||||||
|
const ThemeContext = createContext<{ theme: Theme; toggle: () => void }>({
|
||||||
|
theme: 'light', toggle: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(getStoredTheme);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
applyTheme(theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const toggle = () => setTheme(t => (t === 'light' ? 'dark' : 'light'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggle }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useTheme = () => useContext(ThemeContext);
|
||||||
|
|
||||||
|
export function useMediaQuery(query: string) {
|
||||||
|
const [match, setMatch] = useState(() => window.matchMedia(query).matches);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const m = window.matchMedia(query);
|
||||||
|
const fn = () => setMatch(m.matches);
|
||||||
|
m.addEventListener('change', fn);
|
||||||
|
return () => m.removeEventListener('change', fn);
|
||||||
|
}, [query]);
|
||||||
|
return match;
|
||||||
|
}
|
||||||
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' };
|
||||||
|
}
|
||||||
283
frontend/src/layouts/MainLayout.tsx
Normal file
283
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||||
|
import PageLoader from '../components/PageLoader';
|
||||||
|
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
|
import { Moon, Sun, Search, Plus } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
|
||||||
|
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();
|
||||||
|
const { theme, toggle } = useTheme();
|
||||||
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
|
const nav = useNavigate();
|
||||||
|
const loc = useLocation();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
||||||
|
|
||||||
|
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||||
|
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||||
|
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
|
||||||
|
const [hot, setHot] = useState<PostItem[]>([]);
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const refreshBoards = useCallback(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.boards().then(d => {
|
||||||
|
const next = d.boards ?? [];
|
||||||
|
setBoards(next);
|
||||||
|
setCachedBoards(next);
|
||||||
|
return next;
|
||||||
|
}).catch(() => [] as Board[]),
|
||||||
|
api.stats().then(next => {
|
||||||
|
setStats(next);
|
||||||
|
setCachedStats(next);
|
||||||
|
return next;
|
||||||
|
}).catch(() => null),
|
||||||
|
]).finally(() => setLayoutReady(true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refreshOnline = useCallback(() => {
|
||||||
|
api.online().then(d => {
|
||||||
|
setOnline({
|
||||||
|
count: d.count ?? 0,
|
||||||
|
members: d.members ?? 0,
|
||||||
|
guests: d.guests ?? 0,
|
||||||
|
users: Array.isArray(d.users) ? d.users : [],
|
||||||
|
});
|
||||||
|
}).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshBoards();
|
||||||
|
api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {});
|
||||||
|
api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {});
|
||||||
|
refreshOnline();
|
||||||
|
api.presence().catch(() => {});
|
||||||
|
const onlineTimer = setInterval(refreshOnline, 30000);
|
||||||
|
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
||||||
|
const onRefresh = () => refreshBoards();
|
||||||
|
window.addEventListener('boards-refresh', onRefresh);
|
||||||
|
return () => {
|
||||||
|
clearInterval(onlineTimer);
|
||||||
|
clearInterval(presenceTimer);
|
||||||
|
window.removeEventListener('boards-refresh', onRefresh);
|
||||||
|
};
|
||||||
|
}, [refreshBoards, refreshOnline]);
|
||||||
|
|
||||||
|
const doSearch = () => {
|
||||||
|
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) || '?';
|
||||||
|
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<div className="app-frame">
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="header-inner">
|
||||||
|
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||||
|
<span className="header-logo-mark">姜</span>
|
||||||
|
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCompose && (
|
||||||
|
<div className="header-search-wrap">
|
||||||
|
<Search className="header-search-icon" size={16} />
|
||||||
|
<input
|
||||||
|
className="header-search-input"
|
||||||
|
type="search"
|
||||||
|
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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-search-clear"
|
||||||
|
onClick={() => { setKeyword(''); nav('/'); }}
|
||||||
|
aria-label="清除搜索"
|
||||||
|
>×</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="header-actions">
|
||||||
|
{!isCompose && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-compose-btn"
|
||||||
|
onClick={() => user ? nav('/compose') : nav('/login')}
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{!isMobile && <span>发帖</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="header-action-group">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="header-icon-btn"
|
||||||
|
onClick={toggle}
|
||||||
|
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||||
|
>
|
||||||
|
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{authLoading ? (
|
||||||
|
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
|
||||||
|
) : user ? (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button type="button" className="header-user-btn" title={user.nickname}>
|
||||||
|
{user.avatar
|
||||||
|
? <img src={user.avatar} alt="" className="header-user-avatar" />
|
||||||
|
: <span className="header-user-initial">{userInitial}</span>}
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<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('/admin/dashboard')}>管理后台</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={() => logout().then(() => nav('/login'))}>
|
||||||
|
退出登录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
|
||||||
|
{!isCompose && (
|
||||||
|
<Sidebar
|
||||||
|
boards={boards}
|
||||||
|
activeBoard={boardId}
|
||||||
|
onSelectBoard={setBoardId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
|
||||||
|
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
|
||||||
|
{isMobile && !isCompose && (
|
||||||
|
<div className="mobile-board-bar">
|
||||||
|
<span
|
||||||
|
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||||
|
onClick={() => { setBoardId(0); navigateFeed(nav, buildHomeUrl(0, feedSort)); }}
|
||||||
|
>全部</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 />}>
|
||||||
|
<Outlet context={{
|
||||||
|
boardId,
|
||||||
|
keyword: params.get('keyword') || '',
|
||||||
|
setBoardId,
|
||||||
|
boards,
|
||||||
|
stats,
|
||||||
|
layoutReady,
|
||||||
|
refreshBoards,
|
||||||
|
isMobile,
|
||||||
|
} satisfies LayoutCtx} />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{!isCompose && (
|
||||||
|
<aside className="aside-panel">
|
||||||
|
<RightPanel
|
||||||
|
hot={hot}
|
||||||
|
notifications={notifications}
|
||||||
|
online={online}
|
||||||
|
onPostClick={(id) => nav(`/post/${id}`)}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutCtx = {
|
||||||
|
boardId: number;
|
||||||
|
keyword: string;
|
||||||
|
setBoardId: (id: number) => void;
|
||||||
|
boards: Board[];
|
||||||
|
stats: ForumStats | null;
|
||||||
|
layoutReady: boolean;
|
||||||
|
refreshBoards: () => void;
|
||||||
|
isMobile: boolean;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user