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
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
# 运行时数据(数据库、JWT 密钥、日志、头像)
|
||||
/data/
|
||||
|
||||
# 前端依赖与构建缓存
|
||||
/node_modules/
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/embed_static/static/spa/
|
||||
|
||||
# Go 编译产物
|
||||
/dist/
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# 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
|
||||
# 临时文件
|
||||
tmp-cookie.txt
|
||||
|
||||
# 编辑器 / 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
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
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
|
||||
portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 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.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
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