新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
31
Makefile
31
Makefile
@@ -17,32 +17,37 @@ all: build
|
|||||||
frontend-build:
|
frontend-build:
|
||||||
cd frontend && npm install && npm run build
|
cd frontend && npm install && npm run build
|
||||||
|
|
||||||
## 编译当前平台二进制
|
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||||
build: frontend-build
|
build: frontend-build
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||||
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
@echo "✓ 编译完成: $(BUILD_DIR)/$(APP_NAME)"
|
||||||
|
|
||||||
## Windows amd64
|
## Windows amd64(先打包前端再 embed)
|
||||||
build-windows:
|
build-windows: frontend-build
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||||
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
@echo "✓ Windows: $(BUILD_DIR)/$(APP_NAME).exe"
|
||||||
|
|
||||||
## Linux amd64
|
## Linux amd64(先打包前端再 embed)
|
||||||
build-linux:
|
build-linux: frontend-build
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
CGO_ENABLED=0 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"
|
@echo "✓ Linux: $(BUILD_DIR)/$(APP_NAME)-linux-amd64"
|
||||||
|
|
||||||
## macOS arm64 (Apple Silicon)
|
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
|
||||||
build-darwin:
|
build-darwin: frontend-build
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
CGO_ENABLED=0 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"
|
@echo "✓ macOS: $(BUILD_DIR)/$(APP_NAME)-darwin-arm64"
|
||||||
|
|
||||||
## 跨平台全量编译
|
## 跨平台全量编译(frontend-build 只跑一次)
|
||||||
build-all: build-windows build-linux build-darwin build
|
build-all: frontend-build
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME).exe $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-linux-amd64 $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME)-darwin-arm64 $(MAIN_PKG)
|
||||||
|
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(APP_NAME) $(MAIN_PKG)
|
||||||
@echo "✓ 全平台编译完成"
|
@echo "✓ 全平台编译完成"
|
||||||
|
|
||||||
## 整理依赖
|
## 整理依赖
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -43,7 +43,7 @@
|
|||||||
<td width="50%" align="center">
|
<td width="50%" align="center">
|
||||||
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
||||||
<br><b>浅色主题</b><br>
|
<br><b>浅色主题</b><br>
|
||||||
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线</sub>
|
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/评论</sub>
|
||||||
</td>
|
</td>
|
||||||
<td width="50%" align="center">
|
<td width="50%" align="center">
|
||||||
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
| 特性 | 说明 |
|
| 特性 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/最新评论 |
|
||||||
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
|
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
|
||||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
|
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
|
||||||
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
|
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
|
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
|
||||||
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
|
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
|
||||||
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
|
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
|
||||||
- 点赞、收藏、热门帖、最新动态
|
- 点赞、收藏、热门帖、最新评论
|
||||||
- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
|
- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
|
||||||
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
|
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
|
||||||
|
|
||||||
@@ -174,15 +174,21 @@ cp app.ini.example /opt/jiang13/app.ini
|
|||||||
```ini
|
```ini
|
||||||
[server]
|
[server]
|
||||||
HTTP_PORT = 3000
|
HTTP_PORT = 3000
|
||||||
|
ROOT_URL = https://bbs.iioio.com
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
DATA = data
|
DATA = data
|
||||||
|
|
||||||
[security]
|
[security]
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
|
||||||
|
[oauth]
|
||||||
|
CLIENT_ID = gitea
|
||||||
|
CLIENT_SECRET =
|
||||||
|
REDIRECT_URIS = https://git.iioio.com/user/oauth2/jiang13/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。
|
完整示例见仓库根目录 [`app.ini.example`](app.ini.example)。`ROOT_URL` 与 `[oauth]` 可作首次种子;日常请在管理后台「系统设置 → OIDC / SSO」配置(保存即生效)。
|
||||||
|
|
||||||
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
|
**优先级:** 命令行显式参数 > `app.ini` > 内置默认值。
|
||||||
|
|
||||||
@@ -291,7 +297,7 @@ jiang13-forum/
|
|||||||
├── model/ # GORM 模型与数据库迁移
|
├── model/ # GORM 模型与数据库迁移
|
||||||
├── service/ # 业务逻辑(认证、帖子、评论…)
|
├── service/ # 业务逻辑(认证、帖子、评论…)
|
||||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||||
├── middleware/ # JWT 鉴权、在线状态
|
├── middleware/ # JWT 鉴权
|
||||||
├── router/ # 路由注册
|
├── router/ # 路由注册
|
||||||
├── embed_static/ # go:embed 内嵌的 SPA 与模板
|
├── embed_static/ # go:embed 内嵌的 SPA 与模板
|
||||||
├── frontend/ # React 源码(Vite 构建)
|
├── frontend/ # React 源码(Vite 构建)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
||||||
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
||||||
| 评论系统 | ✅ | 换行显示已修复 |
|
| 评论系统 | ✅ | 换行显示已修复 |
|
||||||
|
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(`ROOT_URL` + `[oauth]`) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ _当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.c
|
|||||||
|
|
||||||
| 优先级 | 功能 | 说明 |
|
| 优先级 | 功能 | 说明 |
|
||||||
|--------|------|------|
|
|--------|------|------|
|
||||||
| 中 | 通知动态优化 | 右栏最新动态的展示与交互 |
|
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
||||||
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||||
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
|
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ _当前无公开认领任务。_
|
|||||||
- [x] 浅色 / 暗色主题切换
|
- [x] 浅色 / 暗色主题切换
|
||||||
- [x] 移动端响应式适配
|
- [x] 移动端响应式适配
|
||||||
- [x] 用户注册登录、JWT 鉴权
|
- [x] 用户注册登录、JWT 鉴权
|
||||||
|
- [x] OIDC Provider(对接 Gitea SSO:Discovery / Authorize / Token / UserInfo)
|
||||||
|
- [x] OAuth 应用管理(密钥哈希、多客户端、登出端点、groups 映射)
|
||||||
- [x] 板块管理、发帖、TipTap 富文本编辑
|
- [x] 板块管理、发帖、TipTap 富文本编辑
|
||||||
- [x] 帖子正文图片本地上传
|
- [x] 帖子正文图片本地上传
|
||||||
- [x] 帖子修订历史与 diff 对比
|
- [x] 帖子修订历史与 diff 对比
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
[server]
|
[server]
|
||||||
HTTP_PORT = 3000
|
HTTP_PORT = 3000
|
||||||
|
; 对外公网根地址(无尾斜杠);也可在管理后台 OIDC 设置中填写
|
||||||
|
; 例:https://bbs.iioio.com
|
||||||
|
ROOT_URL = https://bbs.iioio.com
|
||||||
|
|
||||||
[paths]
|
[paths]
|
||||||
; 相对路径相对于工作目录(默认可执行文件所在目录)
|
; 相对路径相对于工作目录(默认可执行文件所在目录)
|
||||||
@@ -12,3 +15,22 @@ DATA = data
|
|||||||
[security]
|
[security]
|
||||||
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
|
||||||
|
[oauth]
|
||||||
|
; 可选:启动时若管理后台尚未配置,会用此处种子写入数据库一次
|
||||||
|
; 日常请优先在管理后台「系统设置 → OIDC / SSO」修改(保存即生效)
|
||||||
|
; Gitea 认证源名称须与回调路径一致,例如名称 jiang13 对应:
|
||||||
|
; https://git.iioio.com/user/oauth2/jiang13/callback
|
||||||
|
CLIENT_ID = gitea
|
||||||
|
CLIENT_SECRET = 请替换为足够长的随机字符串
|
||||||
|
; 多个回调用逗号分隔
|
||||||
|
REDIRECT_URIS = https://git.iioio.com/user/oauth2/jiang13/callback
|
||||||
|
|
||||||
|
[gitea]
|
||||||
|
; 可选:同步会员公开仓库到侧栏 /projects(优先在管理后台配置)
|
||||||
|
; BASE_URL 例:https://git.iioio.com
|
||||||
|
BASE_URL =
|
||||||
|
; 只读 Access Token(需能列出用户公开仓库)
|
||||||
|
TOKEN =
|
||||||
|
; 首次种子时若 BASE_URL+TOKEN 齐全且此项为 true,则写入并启用同步
|
||||||
|
SYNC_ENABLED = false
|
||||||
|
|||||||
23
build.ps1
23
build.ps1
@@ -39,16 +39,30 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
|||||||
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
|
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
|
||||||
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
# 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
|
||||||
|
$prevCgo = $env:CGO_ENABLED
|
||||||
|
$env:CGO_ENABLED = '0'
|
||||||
|
|
||||||
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
||||||
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
||||||
$OutFile = "$OutFile.exe"
|
$OutFile = "$OutFile.exe"
|
||||||
}
|
}
|
||||||
|
|
||||||
$outPath = Join-Path $BuildDir $OutFile
|
$outPath = Join-Path $BuildDir $OutFile
|
||||||
Write-Host "[go] build -> $outPath" -ForegroundColor Cyan
|
Write-Host "[go] build -> $outPath (CGO_ENABLED=0)" -ForegroundColor Cyan
|
||||||
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
try {
|
||||||
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
|
||||||
Write-Host "[ok] $outPath" -ForegroundColor Green
|
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
||||||
|
Write-Host "[ok] $outPath" -ForegroundColor Green
|
||||||
|
} finally {
|
||||||
|
if ($null -eq $prevCgo) {
|
||||||
|
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||||
|
} else {
|
||||||
|
$env:CGO_ENABLED = $prevCgo
|
||||||
|
}
|
||||||
|
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch ($Target) {
|
switch ($Target) {
|
||||||
@@ -103,6 +117,7 @@ switch ($Target) {
|
|||||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
'build-linux' {
|
'build-linux' {
|
||||||
|
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
|
||||||
Build-Frontend
|
Build-Frontend
|
||||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ type Config struct {
|
|||||||
ConfigFile string
|
ConfigFile string
|
||||||
// 监听端口
|
// 监听端口
|
||||||
Port int
|
Port int
|
||||||
|
// 对外公网根地址(无尾斜杠),OIDC Issuer 使用
|
||||||
|
RootURL string
|
||||||
// 数据目录:SQLite、上传、日志(绝对路径)
|
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||||
DataDir string
|
DataDir string
|
||||||
// JWT 签名密钥
|
// JWT 签名密钥
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
// OIDC 客户端(P0:写死在 app.ini,供 Gitea 对接)
|
||||||
|
OAuthClientID string
|
||||||
|
OAuthClientSecret string
|
||||||
|
OAuthRedirectURIs []string
|
||||||
|
// Gitea API 同步种子(可选,运行时以管理后台为准)
|
||||||
|
GiteaBaseURL string
|
||||||
|
GiteaToken string
|
||||||
|
GiteaSyncEnabled bool
|
||||||
// 日志文件路径
|
// 日志文件路径
|
||||||
LogFile string
|
LogFile string
|
||||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||||
@@ -83,13 +93,20 @@ func Parse() (*Config, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
WorkPath: workPath,
|
WorkPath: workPath,
|
||||||
ConfigFile: configFile,
|
ConfigFile: configFile,
|
||||||
Port: port,
|
Port: port,
|
||||||
DataDir: absData,
|
RootURL: normalizeRootURL(fileCfg.RootURL),
|
||||||
JWTSecret: jwtSecret,
|
DataDir: absData,
|
||||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
JWTSecret: jwtSecret,
|
||||||
ServiceAction: action,
|
OAuthClientID: fileCfg.OAuthClientID,
|
||||||
|
OAuthClientSecret: fileCfg.OAuthClientSecret,
|
||||||
|
OAuthRedirectURIs: splitCSV(fileCfg.OAuthRedirectURIs),
|
||||||
|
GiteaBaseURL: normalizeRootURL(fileCfg.GiteaBaseURL),
|
||||||
|
GiteaToken: fileCfg.GiteaToken,
|
||||||
|
GiteaSyncEnabled: fileCfg.GiteaSyncEnabled,
|
||||||
|
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||||
|
ServiceAction: action,
|
||||||
}
|
}
|
||||||
|
|
||||||
needDirs := action == "" || action == "install"
|
needDirs := action == "" || action == "install"
|
||||||
@@ -97,18 +114,29 @@ func Parse() (*Config, error) {
|
|||||||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||||
if !configExists {
|
if !configExists {
|
||||||
dataRel := resolveDataRelForINI(workPath, absData)
|
dataRel := resolveDataRelForINI(workPath, absData)
|
||||||
if err := writeAppINI(configFile, port, dataRel, ""); err != nil {
|
if err := writeAppINI(configFile, fileSettings{
|
||||||
|
Port: port,
|
||||||
|
DataRel: dataRel,
|
||||||
|
}); err != nil {
|
||||||
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
||||||
} else if action == "install" {
|
} else if action == "install" {
|
||||||
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
||||||
dataRel := resolveDataRelForINI(workPath, absData)
|
dataRel := resolveDataRelForINI(workPath, absData)
|
||||||
iniJWT := ""
|
iniJWT := fileCfg.JWTSecret
|
||||||
if strings.TrimSpace(*jwtFlag) != "" {
|
if strings.TrimSpace(*jwtFlag) != "" {
|
||||||
iniJWT = jwtSecret
|
iniJWT = jwtSecret
|
||||||
}
|
}
|
||||||
if err := writeAppINI(configFile, port, dataRel, iniJWT); err != nil {
|
if err := writeAppINI(configFile, fileSettings{
|
||||||
|
Port: port,
|
||||||
|
DataRel: dataRel,
|
||||||
|
JWTSecret: iniJWT,
|
||||||
|
RootURL: fileCfg.RootURL,
|
||||||
|
OAuthClientID: fileCfg.OAuthClientID,
|
||||||
|
OAuthClientSecret: fileCfg.OAuthClientSecret,
|
||||||
|
OAuthRedirectURIs: fileCfg.OAuthRedirectURIs,
|
||||||
|
}); err != nil {
|
||||||
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +177,7 @@ func ensureDataDirs(dataDir string) error {
|
|||||||
for _, sub := range []string{
|
for _, sub := range []string{
|
||||||
filepath.Join(dataDir, "uploads", "avatars"),
|
filepath.Join(dataDir, "uploads", "avatars"),
|
||||||
filepath.Join(dataDir, "uploads", "posts"),
|
filepath.Join(dataDir, "uploads", "posts"),
|
||||||
|
filepath.Join(dataDir, "uploads", "site"),
|
||||||
} {
|
} {
|
||||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
@@ -198,6 +227,11 @@ func (c *Config) PostImageUploadDir() string {
|
|||||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
|
||||||
|
func (c *Config) SiteUploadDir() string {
|
||||||
|
return filepath.Join(c.DataDir, "uploads", "site")
|
||||||
|
}
|
||||||
|
|
||||||
// UploadDir 返回头像上传目录(兼容旧调用)
|
// UploadDir 返回头像上传目录(兼容旧调用)
|
||||||
func (c *Config) UploadDir() string {
|
func (c *Config) UploadDir() string {
|
||||||
return c.AvatarUploadDir()
|
return c.AvatarUploadDir()
|
||||||
@@ -208,6 +242,29 @@ func (c *Config) FilterWordsPath() string {
|
|||||||
return filepath.Join(c.DataDir, "filter_words.txt")
|
return filepath.Join(c.DataDir, "filter_words.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OIDCEnabled 是否已配置可作为 OIDC Provider
|
||||||
|
func (c *Config) OIDCEnabled() bool {
|
||||||
|
return c.RootURL != "" && c.OAuthClientID != "" && c.OAuthClientSecret != "" && len(c.OAuthRedirectURIs) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRootURL(raw string) string {
|
||||||
|
u := strings.TrimSpace(raw)
|
||||||
|
u = strings.TrimRight(u, "/")
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(raw string) []string {
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func generateRandomSecret(n int) string {
|
func generateRandomSecret(n int) string {
|
||||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
|
|||||||
@@ -18,9 +18,16 @@ const (
|
|||||||
|
|
||||||
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
||||||
type fileSettings struct {
|
type fileSettings struct {
|
||||||
Port int
|
Port int
|
||||||
DataRel string
|
DataRel string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
RootURL string
|
||||||
|
OAuthClientID string
|
||||||
|
OAuthClientSecret string
|
||||||
|
OAuthRedirectURIs string
|
||||||
|
GiteaBaseURL string
|
||||||
|
GiteaToken string
|
||||||
|
GiteaSyncEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultFileSettings() fileSettings {
|
func defaultFileSettings() fileSettings {
|
||||||
@@ -50,6 +57,7 @@ func loadAppINI(path string) (fileSettings, error) {
|
|||||||
}
|
}
|
||||||
out.Port = p
|
out.Port = p
|
||||||
}
|
}
|
||||||
|
out.RootURL = strings.TrimSpace(sec.Key("ROOT_URL").String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if sec, err := cfg.GetSection("paths"); err == nil {
|
if sec, err := cfg.GetSection("paths"); err == nil {
|
||||||
@@ -62,11 +70,23 @@ func loadAppINI(path string) (fileSettings, error) {
|
|||||||
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("oauth"); err == nil {
|
||||||
|
out.OAuthClientID = strings.TrimSpace(sec.Key("CLIENT_ID").String())
|
||||||
|
out.OAuthClientSecret = strings.TrimSpace(sec.Key("CLIENT_SECRET").String())
|
||||||
|
out.OAuthRedirectURIs = strings.TrimSpace(sec.Key("REDIRECT_URIS").String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("gitea"); err == nil {
|
||||||
|
out.GiteaBaseURL = strings.TrimSpace(sec.Key("BASE_URL").String())
|
||||||
|
out.GiteaToken = strings.TrimSpace(sec.Key("TOKEN").String())
|
||||||
|
out.GiteaSyncEnabled = sec.Key("SYNC_ENABLED").MustBool(false)
|
||||||
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeAppINI 写入/覆盖 app.ini(安装服务或首次生成时使用)
|
// writeAppINI 写入/覆盖 app.ini(安装服务或首次生成时使用)
|
||||||
func writeAppINI(path string, port int, dataRel, jwtSecret string) error {
|
func writeAppINI(path string, s fileSettings) error {
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -80,17 +100,36 @@ func writeAppINI(path string, port int, dataRel, jwtSecret string) error {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString("[server]\n")
|
b.WriteString("[server]\n")
|
||||||
b.WriteString("HTTP_PORT = ")
|
b.WriteString("HTTP_PORT = ")
|
||||||
b.WriteString(strconv.Itoa(port))
|
b.WriteString(strconv.Itoa(s.Port))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString("; 对外公网根地址(无尾斜杠),OIDC Issuer / Discovery 依赖此项\n")
|
||||||
|
b.WriteString("; 例:https://bbs.iioio.com\n")
|
||||||
|
b.WriteString("ROOT_URL = ")
|
||||||
|
b.WriteString(s.RootURL)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString("[paths]\n")
|
b.WriteString("[paths]\n")
|
||||||
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
|
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
|
||||||
b.WriteString("DATA = ")
|
b.WriteString("DATA = ")
|
||||||
b.WriteString(dataRel)
|
b.WriteString(s.DataRel)
|
||||||
b.WriteString("\n\n")
|
b.WriteString("\n\n")
|
||||||
b.WriteString("[security]\n")
|
b.WriteString("[security]\n")
|
||||||
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
|
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
|
||||||
b.WriteString("JWT_SECRET = ")
|
b.WriteString("JWT_SECRET = ")
|
||||||
b.WriteString(jwtSecret)
|
b.WriteString(s.JWTSecret)
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
b.WriteString("[oauth]\n")
|
||||||
|
b.WriteString("; 作为 OIDC Provider 时,给 Gitea 等客户端使用的凭据(P0 写死在配置)\n")
|
||||||
|
b.WriteString("; Gitea 认证源名称需与回调路径一致,例如名称 jiang13 对应:\n")
|
||||||
|
b.WriteString("; https://git.iioio.com/user/oauth2/jiang13/callback\n")
|
||||||
|
b.WriteString("CLIENT_ID = ")
|
||||||
|
b.WriteString(s.OAuthClientID)
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString("CLIENT_SECRET = ")
|
||||||
|
b.WriteString(s.OAuthClientSecret)
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString("; 多个回调用逗号分隔\n")
|
||||||
|
b.WriteString("REDIRECT_URIS = ")
|
||||||
|
b.WriteString(s.OAuthRedirectURIs)
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
return os.WriteFile(path, []byte(b.String()), 0644)
|
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ func IsSPARoute(path string) bool {
|
|||||||
strings.HasPrefix(path, "/admin") ||
|
strings.HasPrefix(path, "/admin") ||
|
||||||
strings.HasPrefix(path, "/uploads") ||
|
strings.HasPrefix(path, "/uploads") ||
|
||||||
strings.HasPrefix(path, "/legacy") ||
|
strings.HasPrefix(path, "/legacy") ||
|
||||||
strings.HasPrefix(path, "/assets") {
|
strings.HasPrefix(path, "/assets") ||
|
||||||
|
strings.HasPrefix(path, "/oauth") ||
|
||||||
|
strings.HasPrefix(path, "/.well-known") {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -36,13 +36,6 @@
|
|||||||
<div class="label">评论总数</div>
|
<div class="label">评论总数</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="admin-card">
|
<div class="admin-card">
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table admin-table mb-0">
|
<table class="table admin-table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>注册时间</th><th width="120">操作</th></tr>
|
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>邮箱</th><th>角色</th><th>状态</th><th>上次登录</th><th>登录 IP</th><th>注册时间</th><th width="120">操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .Users}}
|
{{range .Users}}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<td>{{.ID}}</td>
|
<td>{{.ID}}</td>
|
||||||
<td>{{.Username}}</td>
|
<td>{{.Username}}</td>
|
||||||
<td>{{.Nickname}}</td>
|
<td>{{.Nickname}}</td>
|
||||||
|
<td>{{if .Email}}{{.Email}}{{else}}—{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Role "admin"}}
|
{{if eq .Role "admin"}}
|
||||||
<span class="badge badge-admin">管理员</span>
|
<span class="badge badge-admin">管理员</span>
|
||||||
@@ -27,6 +28,8 @@
|
|||||||
<td>
|
<td>
|
||||||
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
|
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{if .LastLoginAt}}{{.LastLoginAt.Format "2006-01-02 15:04"}}{{else}}—{{end}}</td>
|
||||||
|
<td>{{if .LastLoginIP}}{{.LastLoginIP}}{{else}}—{{end}}</td>
|
||||||
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
|
<td>{{.CreatedAt.Format "2006-01-02"}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Role "admin"}}
|
{{if eq .Role "admin"}}
|
||||||
@@ -39,7 +42,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="7" class="admin-empty">暂无用户</td></tr>
|
<tr><td colspan="10" class="admin-empty">暂无用户</td></tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
|
<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>
|
<h4>{{.ProfileUser.Nickname}}</h4>
|
||||||
<p class="text-muted">@{{.ProfileUser.Username}}</p>
|
<p class="text-muted">@{{.ProfileUser.Username}}</p>
|
||||||
|
<p class="text-muted small">{{if .ProfileUser.Email}}{{.ProfileUser.Email}}{{else}}未设置邮箱{{end}}</p>
|
||||||
<form id="avatarForm" enctype="multipart/form-data">
|
<form id="avatarForm" enctype="multipart/form-data">
|
||||||
<input type="file" name="avatar" accept="image/*" class="form-control form-control-sm mb-2">
|
<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>
|
<button type="submit" class="btn btn-outline-primary btn-sm">更换头像</button>
|
||||||
|
|||||||
@@ -5,20 +5,31 @@
|
|||||||
<div class="card shadow-sm">
|
<div class="card shadow-sm">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h3 class="text-center mb-4">注册账号</h3>
|
<h3 class="text-center mb-4">注册账号</h3>
|
||||||
<p class="text-center text-muted small mb-3">本站首个注册用户将自动成为管理员</p>
|
<p id="regTip" class="text-center text-muted small mb-3"></p>
|
||||||
<form id="regForm">
|
<form id="regForm">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">用户名(3-32位字母数字下划线)</label>
|
<label class="form-label">用户名(2-32位,支持中文)</label>
|
||||||
<input type="text" name="username" class="form-control" required>
|
<input type="text" name="username" class="form-control" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">昵称</label>
|
<label class="form-label">昵称</label>
|
||||||
<input type="text" name="nickname" class="form-control">
|
<input type="text" name="nickname" class="form-control">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">邮箱</label>
|
||||||
|
<input type="email" name="email" class="form-control" required>
|
||||||
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">密码(至少6位)</label>
|
<label class="form-label">密码(至少6位)</label>
|
||||||
<input type="password" name="password" class="form-control" required>
|
<input type="password" name="password" class="form-control" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3" id="emailCodeWrap" hidden>
|
||||||
|
<label class="form-label">邮箱验证码</label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<input type="text" name="email_code" class="form-control" autocomplete="one-time-code">
|
||||||
|
<button type="button" class="btn btn-outline-secondary text-nowrap" id="sendCodeBtn">发送验证码</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-success w-100">注册</button>
|
<button type="submit" class="btn btn-success w-100">注册</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
|
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
|
||||||
@@ -29,6 +40,26 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
{{define "scripts"}}
|
{{define "scripts"}}
|
||||||
<script>
|
<script>
|
||||||
|
var regCfg = { require_email_code: false, register_open: true, is_first_user: false };
|
||||||
|
fetch('/api/register/config').then(r=>r.json()).then(d=>{
|
||||||
|
regCfg = d;
|
||||||
|
var tip = document.getElementById('regTip');
|
||||||
|
if (d.is_first_user) tip.textContent = '本站首个注册用户将自动成为管理员';
|
||||||
|
else if (!d.register_open) tip.textContent = '注册暂未开放,请等待管理员配置邮件服务';
|
||||||
|
document.getElementById('emailCodeWrap').hidden = !d.require_email_code;
|
||||||
|
if (!d.register_open) document.getElementById('regForm').hidden = true;
|
||||||
|
});
|
||||||
|
document.getElementById('sendCodeBtn').addEventListener('click', function(){
|
||||||
|
var email = document.querySelector('[name=email]').value;
|
||||||
|
fetch('/api/register/email-code', {
|
||||||
|
method:'POST',
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({email: email})
|
||||||
|
}).then(r=>r.json()).then(d=>{
|
||||||
|
if(d.error){ showToast(d.error,'danger'); return; }
|
||||||
|
showToast(d.message || '已发送');
|
||||||
|
});
|
||||||
|
});
|
||||||
document.getElementById('regForm').addEventListener('submit', function(e){
|
document.getElementById('regForm').addEventListener('submit', function(e){
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
apiPost('/api/register', this, true).then(d=>{
|
apiPost('/api/register', this, true).then(d=>{
|
||||||
|
|||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -30,6 +30,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
@@ -3043,6 +3044,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "11.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
|
||||||
|
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"dompurify": "^3.4.10",
|
"dompurify": "^3.4.10",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
"lucide-react": "^1.18.0",
|
"lucide-react": "^1.18.0",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
|
|||||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||||
|
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
|
||||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||||
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
||||||
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
||||||
@@ -32,8 +33,8 @@ const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
|||||||
const router = createBrowserRouter(
|
const router = createBrowserRouter(
|
||||||
createRoutesFromElements(
|
createRoutesFromElements(
|
||||||
<>
|
<>
|
||||||
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
|
<Route path="/login" element={<Suspense fallback={<PageLoader fullScreen />}><LoginPage /></Suspense>} />
|
||||||
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
|
<Route path="/register" element={<Suspense fallback={<PageLoader fullScreen />}><RegisterPage /></Suspense>} />
|
||||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||||
<Route path="/admin" element={<AdminLayout />}>
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
@@ -51,6 +52,7 @@ const router = createBrowserRouter(
|
|||||||
<Route path="/compose" element={<ComposePage />} />
|
<Route path="/compose" element={<ComposePage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/favorites" element={<FavoritesPage />} />
|
<Route path="/favorites" element={<FavoritesPage />} />
|
||||||
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</>,
|
</>,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
|
import type { User, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, SiteBranding, RegisterConfig } from './types';
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -25,12 +25,23 @@ export const api = {
|
|||||||
me: () => request<{ user: User | null }>('/api/me'),
|
me: () => request<{ user: User | null }>('/api/me'),
|
||||||
stats: () => request<ForumStats>('/api/stats'),
|
stats: () => request<ForumStats>('/api/stats'),
|
||||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||||
|
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
||||||
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
boards: () => request<{ boards: Board[] }>('/api/boards'),
|
||||||
|
projects: (params?: { page?: number; limit?: number }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params?.page) q.set('page', String(params.page));
|
||||||
|
if (params?.limit) q.set('limit', String(params.limit));
|
||||||
|
const qs = q.toString();
|
||||||
|
return request<{ projects: GiteaProject[]; total: number; page: number; total_pages: number }>(
|
||||||
|
`/api/projects${qs ? `?${qs}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
posts: (params: Record<string, string | number>) => {
|
posts: (params: Record<string, string | number>) => {
|
||||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||||
},
|
},
|
||||||
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
||||||
|
tags: (limit = 40) => request<{ tags: TagCount[] }>(`/api/tags?limit=${limit}`),
|
||||||
post: (id: number, opts?: { skipView?: boolean }) => {
|
post: (id: number, opts?: { skipView?: boolean }) => {
|
||||||
const q = opts?.skipView ? '?skip_view=1' : '';
|
const q = opts?.skipView ? '?skip_view=1' : '';
|
||||||
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
||||||
@@ -39,9 +50,7 @@ export const api = {
|
|||||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||||
},
|
},
|
||||||
notifications: () => request<{ notifications: Notification[] }>('/api/notifications'),
|
recentComments: () => request<{ comments: RecentComment[] }>('/api/comments/recent'),
|
||||||
online: () => request<OnlineStats>('/api/online'),
|
|
||||||
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
|
|
||||||
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
|
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
|
||||||
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||||
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
|
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
@@ -72,6 +81,57 @@ export const api = {
|
|||||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||||
method: 'PUT', body: JSON.stringify(body),
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
}),
|
}),
|
||||||
|
adminUpdateMailSettings: (body: MailConfig) =>
|
||||||
|
request<{ message: string; mail: MailConfig }>('/api/admin/settings/mail', {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminUpdateOIDCSettings: (body: OIDCConfig) =>
|
||||||
|
request<{ message: string; oidc: OIDCConfig }>('/api/admin/settings/oidc', {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminUpdateGiteaSettings: (body: GiteaSyncConfig) =>
|
||||||
|
request<{ message: string; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea', {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminSyncGitea: () =>
|
||||||
|
request<{ message: string; count: number; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea/sync', {
|
||||||
|
method: 'POST',
|
||||||
|
}),
|
||||||
|
adminUpdateBranding: (body: SiteBranding) =>
|
||||||
|
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding', {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminUploadBrandingAsset: (kind: 'logo' | 'favicon', file: File) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('kind', kind);
|
||||||
|
fd.append('file', file);
|
||||||
|
return request<{ message: string; url: string; branding: SiteBranding }>(
|
||||||
|
'/api/admin/settings/branding/upload',
|
||||||
|
{ method: 'POST', body: fd, headers: {} },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
adminClearBrandingAsset: (kind: 'logo' | 'favicon') =>
|
||||||
|
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding/clear', {
|
||||||
|
method: 'POST', body: JSON.stringify({ kind }),
|
||||||
|
}),
|
||||||
|
adminListOAuthClients: () =>
|
||||||
|
request<{ clients: OAuthClient[] }>('/api/admin/oauth/clients'),
|
||||||
|
adminCreateOAuthClient: (body: OAuthClientInput) =>
|
||||||
|
request<{ message: string; client: OAuthClient; oidc: OIDCConfig }>('/api/admin/oauth/clients', {
|
||||||
|
method: 'POST', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminUpdateOAuthClient: (id: number, body: OAuthClientInput) =>
|
||||||
|
request<{ message: string; client: OAuthClient; oidc: OIDCConfig }>(`/api/admin/oauth/clients/${id}`, {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminDeleteOAuthClient: (id: number) =>
|
||||||
|
request<{ message: string; oidc: OIDCConfig }>(`/api/admin/oauth/clients/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}),
|
||||||
|
adminTestMail: (to: string) =>
|
||||||
|
request<{ message: string }>('/api/admin/settings/mail/test', {
|
||||||
|
method: 'POST', body: JSON.stringify({ to }),
|
||||||
|
}),
|
||||||
adminUpdateFilterWords: (content: string) =>
|
adminUpdateFilterWords: (content: string) =>
|
||||||
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
||||||
method: 'PUT', body: JSON.stringify({ content }),
|
method: 'PUT', body: JSON.stringify({ content }),
|
||||||
@@ -132,19 +192,35 @@ export const api = {
|
|||||||
fd.append('tags', data.tags || '');
|
fd.append('tags', data.tags || '');
|
||||||
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
|
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||||
},
|
},
|
||||||
|
deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }),
|
||||||
login: (username: string, password: string) => {
|
login: (username: string, password: string) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('username', username);
|
fd.append('username', username);
|
||||||
fd.append('password', password);
|
fd.append('password', password);
|
||||||
return request('/api/login', { method: 'POST', body: fd, headers: {} });
|
return request('/api/login', { method: 'POST', body: fd, headers: {} });
|
||||||
},
|
},
|
||||||
register: (username: string, password: string, nickname: string) => {
|
register: (data: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nickname: string;
|
||||||
|
email: string;
|
||||||
|
emailCode?: string;
|
||||||
|
}) => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('username', username);
|
fd.append('username', data.username);
|
||||||
fd.append('password', password);
|
fd.append('password', data.password);
|
||||||
fd.append('nickname', nickname);
|
fd.append('nickname', data.nickname);
|
||||||
|
fd.append('email', data.email);
|
||||||
|
if (data.emailCode) fd.append('email_code', data.emailCode);
|
||||||
return request('/api/register', { method: 'POST', body: fd, headers: {} });
|
return request('/api/register', { method: 'POST', body: fd, headers: {} });
|
||||||
},
|
},
|
||||||
|
registerConfig: () => request<RegisterConfig>('/api/register/config'),
|
||||||
|
sendRegisterEmailCode: (email: string) =>
|
||||||
|
request<{ message: string }>('/api/register/email-code', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
}),
|
||||||
|
captcha: () => request<{ id: string; image: string }>('/api/captcha'),
|
||||||
logout: () => request('/api/logout', { method: 'POST' }),
|
logout: () => request('/api/logout', { method: 'POST' }),
|
||||||
like: (id: number) => request<{ liked: boolean; like_count: number }>(`/api/posts/${id}/like`, { 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' }),
|
favorite: (id: number) => request<{ favorited: boolean }>(`/api/posts/${id}/favorite`, { method: 'POST' }),
|
||||||
@@ -165,5 +241,10 @@ export const api = {
|
|||||||
if (data.isPrivate) fd.append('is_private', '1');
|
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: {} });
|
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' }),
|
updateComment: (id: number, content: string) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('content', content);
|
||||||
|
return request<{ message: string; content: string }>(`/api/comments/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||||
|
},
|
||||||
|
deleteComment: (id: number) => request<{ message: string }>(`/api/comments/${id}`, { method: 'DELETE' }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string;
|
||||||
nickname: string;
|
nickname: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
role: 'user' | 'admin';
|
role: 'user' | 'admin';
|
||||||
banned?: boolean;
|
banned?: boolean;
|
||||||
|
banned_at?: string;
|
||||||
|
last_login_at?: string;
|
||||||
|
last_login_ip?: string;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Board {
|
export interface Board {
|
||||||
@@ -24,6 +29,12 @@ export interface ForumStats {
|
|||||||
boards: number;
|
boards: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 标签云单项 */
|
||||||
|
export interface TagCount {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostItem {
|
export interface PostItem {
|
||||||
id: number;
|
id: number;
|
||||||
board_id: number;
|
board_id: number;
|
||||||
@@ -78,6 +89,7 @@ export interface Comment {
|
|||||||
is_private?: boolean;
|
is_private?: boolean;
|
||||||
content_hidden?: boolean;
|
content_hidden?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
user?: User;
|
user?: User;
|
||||||
post?: PostItem;
|
post?: PostItem;
|
||||||
reply_target?: Comment;
|
reply_target?: Comment;
|
||||||
@@ -88,7 +100,6 @@ export interface AdminDashboard {
|
|||||||
posts: number;
|
posts: number;
|
||||||
boards: number;
|
boards: number;
|
||||||
comments: number;
|
comments: number;
|
||||||
online: number;
|
|
||||||
recent_posts: PostItem[];
|
recent_posts: PostItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,11 +117,10 @@ export interface ForumLimits {
|
|||||||
search_keyword_min: number;
|
search_keyword_min: number;
|
||||||
search_keyword_max: number;
|
search_keyword_max: number;
|
||||||
page_size_default: number;
|
page_size_default: number;
|
||||||
page_size_max: number;
|
|
||||||
feed_max_pages: number;
|
|
||||||
feed_max_items: number;
|
|
||||||
password_min_len: number;
|
password_min_len: number;
|
||||||
avatar_max_mb: number;
|
avatar_max_mb: number;
|
||||||
|
open_posts_in_new_tab: boolean;
|
||||||
|
open_content_links_in_new_tab: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForumLimitsPublic {
|
export interface ForumLimitsPublic {
|
||||||
@@ -121,10 +131,19 @@ export interface ForumLimitsPublic {
|
|||||||
search_keyword_min: number;
|
search_keyword_min: number;
|
||||||
search_keyword_max: number;
|
search_keyword_max: number;
|
||||||
page_size_default: number;
|
page_size_default: number;
|
||||||
feed_max_pages: number;
|
|
||||||
feed_max_items: number;
|
|
||||||
password_min_len: number;
|
password_min_len: number;
|
||||||
avatar_max_mb: number;
|
avatar_max_mb: number;
|
||||||
|
open_posts_in_new_tab: boolean;
|
||||||
|
open_content_links_in_new_tab: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SiteBranding {
|
||||||
|
name: string;
|
||||||
|
name_en: string;
|
||||||
|
slogan: string;
|
||||||
|
logo_mark: string;
|
||||||
|
logo: string;
|
||||||
|
favicon: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminSettings {
|
export interface AdminSettings {
|
||||||
@@ -133,10 +152,91 @@ export interface AdminSettings {
|
|||||||
db_path: string;
|
db_path: string;
|
||||||
port: number;
|
port: number;
|
||||||
limits: ForumLimits;
|
limits: ForumLimits;
|
||||||
|
mail: MailConfig;
|
||||||
|
oidc: OIDCConfig;
|
||||||
|
oauth_clients: OAuthClient[];
|
||||||
|
gitea?: GiteaSyncConfig;
|
||||||
|
branding?: SiteBranding;
|
||||||
filter_words: string;
|
filter_words: string;
|
||||||
filter_word_count: number;
|
filter_word_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MailConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
from: string;
|
||||||
|
from_name: string;
|
||||||
|
encryption: 'none' | 'starttls' | 'ssl';
|
||||||
|
has_password: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OIDCConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
root_url: string;
|
||||||
|
ready: boolean;
|
||||||
|
discovery_url?: string;
|
||||||
|
authorize_url?: string;
|
||||||
|
logout_url?: string;
|
||||||
|
group_claim: string;
|
||||||
|
admin_group: string;
|
||||||
|
user_group: string;
|
||||||
|
client_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthClient {
|
||||||
|
id: number;
|
||||||
|
client_id: string;
|
||||||
|
name: string;
|
||||||
|
redirect_uris: string;
|
||||||
|
enabled: boolean;
|
||||||
|
has_secret: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
client_secret?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OAuthClientInput {
|
||||||
|
client_id?: string;
|
||||||
|
name: string;
|
||||||
|
redirect_uris: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
client_secret?: string;
|
||||||
|
rotate_secret?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaProject {
|
||||||
|
id: number;
|
||||||
|
gitea_id: number;
|
||||||
|
owner_login: string;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
description: string;
|
||||||
|
html_url: string;
|
||||||
|
updated_at_remote?: string | null;
|
||||||
|
forum_user_id?: number;
|
||||||
|
synced_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaSyncConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
base_url: string;
|
||||||
|
token?: string;
|
||||||
|
has_token: boolean;
|
||||||
|
sync_interval_min: number;
|
||||||
|
ready: boolean;
|
||||||
|
repo_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterConfig {
|
||||||
|
is_first_user: boolean;
|
||||||
|
mail_ready: boolean;
|
||||||
|
require_email_code: boolean;
|
||||||
|
register_open: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Paginated<T> {
|
export interface Paginated<T> {
|
||||||
total: number;
|
total: number;
|
||||||
page: number;
|
page: number;
|
||||||
@@ -144,22 +244,12 @@ export interface Paginated<T> {
|
|||||||
items: T;
|
items: T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Notification {
|
export interface RecentComment {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
post_id: number;
|
||||||
type: string;
|
author: string;
|
||||||
|
avatar: string;
|
||||||
|
excerpt: string;
|
||||||
|
post_title: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OnlineUser {
|
|
||||||
id: number;
|
|
||||||
nickname: string;
|
|
||||||
avatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OnlineStats {
|
|
||||||
count: number;
|
|
||||||
members: number;
|
|
||||||
guests: number;
|
|
||||||
users: OnlineUser[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ import {
|
|||||||
FileCode, PenLine, Maximize2, Minimize2,
|
FileCode, PenLine, Maximize2, Minimize2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||||
import { renderPostContentHtml } from '../utils/postContent';
|
|
||||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||||
|
import PostContent from './PostContent';
|
||||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||||
import {
|
import {
|
||||||
wrapMarkdownSelection,
|
wrapMarkdownSelection,
|
||||||
@@ -362,7 +362,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
}, [markdownSource, handleMarkdownChange]);
|
}, [markdownSource, handleMarkdownChange]);
|
||||||
|
|
||||||
const markdownPreviewHtml = useMemo(
|
const markdownPreviewHtml = useMemo(
|
||||||
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
|
() => sanitizeHtml(markdownToHtml(markdownSource)),
|
||||||
[markdownSource],
|
[markdownSource],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -384,7 +384,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
{
|
{
|
||||||
icon: <LockKeyhole size={15} />,
|
icon: <LockKeyhole size={15} />,
|
||||||
title: '登录可见',
|
title: '登录可见',
|
||||||
hint: '独立输入区;Ctrl+Enter 退出',
|
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||||
active: editor.isActive('membersOnly'),
|
active: editor.isActive('membersOnly'),
|
||||||
className: 'article-tool-btn--members',
|
className: 'article-tool-btn--members',
|
||||||
action: wrapMembersOnly,
|
action: wrapMembersOnly,
|
||||||
@@ -451,9 +451,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
|||||||
</div>
|
</div>
|
||||||
<div className="article-editor-markdown-preview">
|
<div className="article-editor-markdown-preview">
|
||||||
<div className="article-editor-markdown-preview-label">预览</div>
|
<div className="article-editor-markdown-preview-label">预览</div>
|
||||||
<div
|
<PostContent
|
||||||
|
html={markdownPreviewHtml}
|
||||||
|
isLoggedIn
|
||||||
className="article-editor-markdown-preview-body post-detail-content"
|
className="article-editor-markdown-preview-body post-detail-content"
|
||||||
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
149
frontend/src/components/ArticleOutline.tsx
Normal file
149
frontend/src/components/ArticleOutline.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ListTree } from 'lucide-react';
|
||||||
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
headings: PostHeading[];
|
||||||
|
/** 滚动容器;不传则用 viewport */
|
||||||
|
scrollRoot?: HTMLElement | null;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据滚动位置取当前应高亮的标题 id */
|
||||||
|
function resolveActiveHeadingId(
|
||||||
|
headings: PostHeading[],
|
||||||
|
root: HTMLElement | null,
|
||||||
|
offsetPx = 28,
|
||||||
|
): string {
|
||||||
|
if (headings.length === 0) return '';
|
||||||
|
|
||||||
|
const rootTop = root ? root.getBoundingClientRect().top : 0;
|
||||||
|
const marker = rootTop + offsetPx;
|
||||||
|
|
||||||
|
let current = headings[0].id;
|
||||||
|
for (const h of headings) {
|
||||||
|
const el = document.getElementById(h.id);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.getBoundingClientRect().top <= marker) {
|
||||||
|
current = h.id;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文章目录树:点击跳转,滚动时高亮当前标题 */
|
||||||
|
export default function ArticleOutline({
|
||||||
|
headings,
|
||||||
|
scrollRoot,
|
||||||
|
title = '文章目录',
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const [activeId, setActiveId] = useState(headings[0]?.id ?? '');
|
||||||
|
/** 点击跳转期间锁定高亮,避免 Intersection/滚动回调来回抢 */
|
||||||
|
const lockUntilRef = useRef(0);
|
||||||
|
const lockIdRef = useRef('');
|
||||||
|
const rafRef = useRef(0);
|
||||||
|
|
||||||
|
const minLevel = useMemo(
|
||||||
|
() => (headings.length ? Math.min(...headings.map(h => h.level)) : 2),
|
||||||
|
[headings],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveId(headings[0]?.id ?? '');
|
||||||
|
lockUntilRef.current = 0;
|
||||||
|
lockIdRef.current = '';
|
||||||
|
}, [headings]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headings.length === 0) return undefined;
|
||||||
|
|
||||||
|
const root: HTMLElement | Window = scrollRoot ?? window;
|
||||||
|
|
||||||
|
const syncActive = () => {
|
||||||
|
if (Date.now() < lockUntilRef.current) {
|
||||||
|
if (lockIdRef.current) setActiveId(lockIdRef.current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||||
|
if (next) setActiveId(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(syncActive);
|
||||||
|
};
|
||||||
|
|
||||||
|
syncActive();
|
||||||
|
root.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
window.addEventListener('resize', onScroll, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
root.removeEventListener('scroll', onScroll);
|
||||||
|
window.removeEventListener('resize', onScroll);
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [headings, scrollRoot]);
|
||||||
|
|
||||||
|
const jumpTo = (id: string) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
// 立即高亮并锁定一段时间,覆盖 smooth 滚动过程中的中间态
|
||||||
|
setActiveId(id);
|
||||||
|
lockIdRef.current = id;
|
||||||
|
lockUntilRef.current = Date.now() + 900;
|
||||||
|
|
||||||
|
const root = scrollRoot;
|
||||||
|
if (root) {
|
||||||
|
const rootRect = root.getBoundingClientRect();
|
||||||
|
const elRect = el.getBoundingClientRect();
|
||||||
|
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||||
|
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||||
|
} else {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动结束后再按位置校正一次(若用户中途手动滑会自然解锁)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (lockIdRef.current !== id) return;
|
||||||
|
lockUntilRef.current = 0;
|
||||||
|
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||||
|
if (next) setActiveId(next);
|
||||||
|
}, 920);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('article-outline', className)}>
|
||||||
|
<div className="sidebar-section article-outline-head">
|
||||||
|
<ListTree size={12} aria-hidden />
|
||||||
|
<span>{title}</span>
|
||||||
|
</div>
|
||||||
|
{headings.length === 0 ? (
|
||||||
|
<p className="article-outline-empty">本文暂无标题结构</p>
|
||||||
|
) : (
|
||||||
|
<nav className="article-outline-nav" aria-label="文章目录">
|
||||||
|
{headings.map(h => (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'article-outline-item',
|
||||||
|
`article-outline-item--l${Math.min(6, Math.max(1, h.level - minLevel + 1))}`,
|
||||||
|
activeId === h.id && 'active',
|
||||||
|
)}
|
||||||
|
onClick={() => jumpTo(h.id)}
|
||||||
|
title={h.text}
|
||||||
|
>
|
||||||
|
{h.text}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,19 @@
|
|||||||
import { Clock, MessageSquare, X } from 'lucide-react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import type { Comment } from '../api/types';
|
import type { Comment, User } from '../api/types';
|
||||||
import CommentContent from './CommentContent';
|
import CommentContent from './CommentContent';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
commentNick,
|
commentNick,
|
||||||
commentInitial,
|
commentInitial,
|
||||||
@@ -10,33 +22,75 @@ import {
|
|||||||
buildCommentTree,
|
buildCommentTree,
|
||||||
type CommentNode,
|
type CommentNode,
|
||||||
} from '../utils/comment';
|
} from '../utils/comment';
|
||||||
|
import { isTimeDiffSignificant } from '../utils/content';
|
||||||
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
|
|
||||||
|
function canManageComment(c: Comment, user?: User | null): boolean {
|
||||||
|
if (!user) return false;
|
||||||
|
if (user.role === 'admin') return true;
|
||||||
|
return c.user_id > 0 && c.user_id === user.id;
|
||||||
|
}
|
||||||
|
|
||||||
interface ItemProps {
|
interface ItemProps {
|
||||||
node: CommentNode;
|
node: CommentNode;
|
||||||
nested?: boolean;
|
nested?: boolean;
|
||||||
highlightFloor?: number | null;
|
highlightFloor?: number | null;
|
||||||
replyToId?: number | null;
|
replyToId?: number | null;
|
||||||
|
editingId?: number | null;
|
||||||
|
currentUser?: User | null;
|
||||||
onReply: (comment: Comment) => void;
|
onReply: (comment: Comment) => void;
|
||||||
onCancelReply: () => void;
|
onCancelReply: () => void;
|
||||||
|
onStartEdit: (comment: Comment) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||||
|
onDelete: (comment: Comment) => Promise<void>;
|
||||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单条评论(支持嵌套子回复 + 内联回复框) */
|
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */
|
||||||
function CommentItem({
|
function CommentItem({
|
||||||
node,
|
node,
|
||||||
nested,
|
nested,
|
||||||
highlightFloor,
|
highlightFloor,
|
||||||
replyToId,
|
replyToId,
|
||||||
|
editingId,
|
||||||
|
currentUser,
|
||||||
onReply,
|
onReply,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onDelete,
|
||||||
renderReplyBox,
|
renderReplyBox,
|
||||||
}: ItemProps) {
|
}: ItemProps) {
|
||||||
|
const { limits } = useForumLimits();
|
||||||
const c = node.comment;
|
const c = node.comment;
|
||||||
const nick = commentNick(c);
|
const nick = commentNick(c);
|
||||||
const guest = isGuestComment(c);
|
const guest = isGuestComment(c);
|
||||||
const isHighlighted = highlightFloor === c.floor;
|
const isHighlighted = highlightFloor === c.floor;
|
||||||
const hidden = !!c.content_hidden;
|
const hidden = !!c.content_hidden;
|
||||||
const isReplying = replyToId === c.id;
|
const isReplying = replyToId === c.id;
|
||||||
|
const isEditing = editingId === c.id;
|
||||||
|
const manageable = canManageComment(c, currentUser);
|
||||||
|
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
|
||||||
|
const [editText, setEditText] = useState(c.content);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing) setEditText(c.content);
|
||||||
|
}, [isEditing, c.content, c.id]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const next = editText.trim();
|
||||||
|
if (!next) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSaveEdit(c, next);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -66,6 +120,29 @@ function CommentItem({
|
|||||||
<div className="waline-comment-private-mask">
|
<div className="waline-comment-private-mask">
|
||||||
该评论为私密评论,仅文章作者与评论发起者可见!
|
该评论为私密评论,仅文章作者与评论发起者可见!
|
||||||
</div>
|
</div>
|
||||||
|
) : isEditing ? (
|
||||||
|
<div className="waline-comment-edit">
|
||||||
|
<textarea
|
||||||
|
className="waline-comment-edit-input"
|
||||||
|
value={editText}
|
||||||
|
onChange={e => setEditText(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
maxLength={limits.comment_max > 0 ? limits.comment_max : undefined}
|
||||||
|
/>
|
||||||
|
<div className="waline-comment-edit-actions">
|
||||||
|
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelEdit} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="waline-comment-reply-btn"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !editText.trim()}
|
||||||
|
>
|
||||||
|
{saving ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="waline-comment-bubble">
|
<div className="waline-comment-bubble">
|
||||||
{c.reply_target && (
|
{c.reply_target && (
|
||||||
@@ -79,18 +156,58 @@ function CommentItem({
|
|||||||
<span className="waline-comment-date">
|
<span className="waline-comment-date">
|
||||||
<Clock size={14} />
|
<Clock size={14} />
|
||||||
{formatCommentDate(c.created_at)}
|
{formatCommentDate(c.created_at)}
|
||||||
|
{showEdited && <span className="waline-comment-edited"> · 已编辑</span>}
|
||||||
</span>
|
</span>
|
||||||
{isReplying ? (
|
{!hidden && !isEditing && (
|
||||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
isReplying ? (
|
||||||
<X size={14} />
|
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||||
取消
|
<X size={14} />
|
||||||
</button>
|
取消
|
||||||
) : (
|
</button>
|
||||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
) : (
|
||||||
<MessageSquare size={14} />
|
<button type="button" className="waline-comment-reply-btn" onClick={() => onReply(c)}>
|
||||||
回复
|
<MessageSquare size={14} />
|
||||||
|
回复
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{!hidden && !isEditing && manageable && (
|
||||||
|
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
编辑
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{!hidden && !isEditing && manageable && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await onDelete(c);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isReplying && renderReplyBox && (
|
{isReplying && renderReplyBox && (
|
||||||
@@ -108,8 +225,14 @@ function CommentItem({
|
|||||||
nested
|
nested
|
||||||
highlightFloor={highlightFloor}
|
highlightFloor={highlightFloor}
|
||||||
replyToId={replyToId}
|
replyToId={replyToId}
|
||||||
|
editingId={editingId}
|
||||||
|
currentUser={currentUser}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onCancelReply={onCancelReply}
|
onCancelReply={onCancelReply}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
renderReplyBox={renderReplyBox}
|
renderReplyBox={renderReplyBox}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -124,8 +247,14 @@ interface Props {
|
|||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
highlightFloor?: number | null;
|
highlightFloor?: number | null;
|
||||||
replyToId?: number | null;
|
replyToId?: number | null;
|
||||||
|
editingId?: number | null;
|
||||||
|
currentUser?: User | null;
|
||||||
onReply: (comment: Comment) => void;
|
onReply: (comment: Comment) => void;
|
||||||
onCancelReply: () => void;
|
onCancelReply: () => void;
|
||||||
|
onStartEdit: (comment: Comment) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||||
|
onDelete: (comment: Comment) => Promise<void>;
|
||||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,8 +263,14 @@ export default function CommentThreadList({
|
|||||||
comments,
|
comments,
|
||||||
highlightFloor,
|
highlightFloor,
|
||||||
replyToId,
|
replyToId,
|
||||||
|
editingId,
|
||||||
|
currentUser,
|
||||||
onReply,
|
onReply,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
|
onStartEdit,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
onDelete,
|
||||||
renderReplyBox,
|
renderReplyBox,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const tree = buildCommentTree(comments);
|
const tree = buildCommentTree(comments);
|
||||||
@@ -148,8 +283,14 @@ export default function CommentThreadList({
|
|||||||
node={node}
|
node={node}
|
||||||
highlightFloor={highlightFloor}
|
highlightFloor={highlightFloor}
|
||||||
replyToId={replyToId}
|
replyToId={replyToId}
|
||||||
|
editingId={editingId}
|
||||||
|
currentUser={currentUser}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
onCancelReply={onCancelReply}
|
onCancelReply={onCancelReply}
|
||||||
|
onStartEdit={onStartEdit}
|
||||||
|
onCancelEdit={onCancelEdit}
|
||||||
|
onSaveEdit={onSaveEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
renderReplyBox={renderReplyBox}
|
renderReplyBox={renderReplyBox}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import PostListSkeleton from './PostListSkeleton';
|
||||||
|
|
||||||
|
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
|
||||||
|
export default function FeedPageSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
|
||||||
|
<div className="feed-panel">
|
||||||
|
<div className="feed-top">
|
||||||
|
<div className="feed-head">
|
||||||
|
<div className="feed-head__title">
|
||||||
|
<Skeleton className="skeleton--feed-title" />
|
||||||
|
<div className="feed-head__stats">
|
||||||
|
<Skeleton className="skeleton--stat-chip" />
|
||||||
|
<Skeleton className="skeleton--stat-chip" />
|
||||||
|
<Skeleton className="skeleton--stat-chip" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
|
||||||
|
<Skeleton className="skeleton--sort-tab" />
|
||||||
|
<Skeleton className="skeleton--sort-tab" />
|
||||||
|
<Skeleton className="skeleton--sort-tab" />
|
||||||
|
<span className="feed-toolbar__spacer" />
|
||||||
|
<Skeleton className="skeleton--count" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="post-list-scroll">
|
||||||
|
<PostListSkeleton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
frontend/src/components/FeedPagination.tsx
Normal file
149
frontend/src/components/FeedPagination.tsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
postTotal: number;
|
||||||
|
loading?: boolean;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成页码窗口:两端 + 当前邻页,中间用省略号 */
|
||||||
|
function buildPageItems(current: number, total: number): Array<number | 'gap'> {
|
||||||
|
if (total <= 7) {
|
||||||
|
return Array.from({ length: total }, (_, i) => i + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = new Set<number>();
|
||||||
|
set.add(1);
|
||||||
|
set.add(total);
|
||||||
|
for (let i = current - 1; i <= current + 1; i++) {
|
||||||
|
if (i >= 1 && i <= total) set.add(i);
|
||||||
|
}
|
||||||
|
// 靠近端点时多露出几页,避免 1 … 2 3 这种浪费
|
||||||
|
if (current <= 3) {
|
||||||
|
set.add(2);
|
||||||
|
set.add(3);
|
||||||
|
set.add(4);
|
||||||
|
}
|
||||||
|
if (current >= total - 2) {
|
||||||
|
set.add(total - 1);
|
||||||
|
set.add(total - 2);
|
||||||
|
set.add(total - 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = [...set].sort((a, b) => a - b);
|
||||||
|
const items: Array<number | 'gap'> = [];
|
||||||
|
for (let i = 0; i < sorted.length; i++) {
|
||||||
|
if (i > 0 && sorted[i] - sorted[i - 1] > 1) items.push('gap');
|
||||||
|
items.push(sorted[i]);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FeedPagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
postTotal,
|
||||||
|
loading = false,
|
||||||
|
onPageChange,
|
||||||
|
}: Props) {
|
||||||
|
const [jumpInput, setJumpInput] = useState(String(page));
|
||||||
|
const pageItems = buildPageItems(page, totalPages);
|
||||||
|
const showJump = totalPages > 5;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setJumpInput(String(page));
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
const commitJump = () => {
|
||||||
|
if (loading) return;
|
||||||
|
const n = Number.parseInt(jumpInput, 10);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
setJumpInput(String(page));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = Math.min(totalPages, Math.max(1, n));
|
||||||
|
setJumpInput(String(target));
|
||||||
|
if (target !== page) onPageChange(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="feed-pagination" aria-label="帖子分页">
|
||||||
|
<p className="feed-pagination__meta" aria-live="polite">
|
||||||
|
共 <strong>{postTotal}</strong> 条
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="feed-pagination__pages">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="feed-pagination__nav"
|
||||||
|
disabled={loading || page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
<ChevronLeft aria-hidden size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pageItems.map((item, idx) =>
|
||||||
|
item === 'gap' ? (
|
||||||
|
<span key={`gap-${idx}`} className="feed-pagination__gap" aria-hidden>
|
||||||
|
…
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className={cn('feed-pagination__page', item === page && 'is-active')}
|
||||||
|
disabled={loading || item === page}
|
||||||
|
aria-label={`第 ${item} 页`}
|
||||||
|
aria-current={item === page ? 'page' : undefined}
|
||||||
|
onClick={() => onPageChange(item)}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="feed-pagination__nav"
|
||||||
|
disabled={loading || page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
<ChevronRight aria-hidden size={16} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showJump && (
|
||||||
|
<form
|
||||||
|
className="feed-pagination__jump"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
commitJump();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label htmlFor="feed-page-jump" className="feed-pagination__jump-label">
|
||||||
|
跳至
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="feed-page-jump"
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
value={jumpInput}
|
||||||
|
disabled={loading}
|
||||||
|
onChange={(e) => setJumpInput(e.target.value.replace(/\D/g, ''))}
|
||||||
|
onBlur={commitJump}
|
||||||
|
className="feed-pagination__jump-input"
|
||||||
|
aria-label={`跳转到指定页,共 ${totalPages} 页`}
|
||||||
|
/>
|
||||||
|
<span className="feed-pagination__jump-suffix">/ {totalPages}</span>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,20 @@
|
|||||||
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
export default function PageLoader() {
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
type PageLoaderProps = {
|
||||||
|
/** 独立全屏路由(登录/注册)占满视口居中 */
|
||||||
|
fullScreen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
|
||||||
|
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="page-loader" role="status" aria-live="polite">
|
<div
|
||||||
<span className="page-loader__dot" />
|
className={cn('page-loader', fullScreen && 'page-loader--viewport')}
|
||||||
加载中…
|
aria-busy="true"
|
||||||
|
aria-label="加载中"
|
||||||
|
>
|
||||||
|
<Spinner size="lg" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,72 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { renderPostContentHtml } from '../utils/postContent';
|
import { renderPostContentHtml } from '../utils/postContent';
|
||||||
|
import { extractHeadingsFromHtml, type PostHeading } from '../utils/postHeadings';
|
||||||
|
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||||
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
html: string;
|
html: string;
|
||||||
isLoggedIn: boolean;
|
isLoggedIn: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 正文标题树变化时回调(用于侧栏目录) */
|
||||||
|
onHeadingsChange?: (headings: PostHeading[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 帖子正文渲染(含会员专属区块) */
|
/** 帖子正文渲染(含会员专属区块、代码块美化) */
|
||||||
export default function PostContent({ html, isLoggedIn, className = 'post-detail-content' }: Props) {
|
export default function PostContent({
|
||||||
|
html,
|
||||||
|
isLoggedIn,
|
||||||
|
className = 'post-detail-content',
|
||||||
|
onHeadingsChange,
|
||||||
|
}: Props) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const { limits } = useForumLimits();
|
||||||
|
|
||||||
const rendered = useMemo(
|
const prepared = useMemo(() => {
|
||||||
() => renderPostContentHtml(html, isLoggedIn),
|
const rendered = renderPostContentHtml(html, isLoggedIn, {
|
||||||
[html, isLoggedIn],
|
openLinksInNewTab: limits.open_content_links_in_new_tab,
|
||||||
);
|
});
|
||||||
|
return {
|
||||||
|
html: rendered,
|
||||||
|
headings: extractHeadingsFromHtml(rendered),
|
||||||
|
};
|
||||||
|
}, [html, isLoggedIn, limits.open_content_links_in_new_tab]);
|
||||||
|
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
useEffect(() => {
|
||||||
|
onHeadingsChange?.(prepared.headings);
|
||||||
|
}, [prepared.headings, onHeadingsChange]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(async (e: React.MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('[data-members-login]')) {
|
if (target.closest('[data-members-login]')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
nav('/login');
|
nav(loginPath());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.closest('[data-members-register]')) {
|
if (target.closest('[data-members-register]')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
nav('/register');
|
nav(registerPath());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
|
||||||
|
if (copyBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const block = copyBtn.closest('.md-codeblock');
|
||||||
|
const text = block?.querySelector('pre')?.textContent ?? '';
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
const prev = copyBtn.textContent;
|
||||||
|
copyBtn.textContent = '已复制';
|
||||||
|
copyBtn.classList.add('is-copied');
|
||||||
|
window.setTimeout(() => {
|
||||||
|
copyBtn.textContent = prev || '复制';
|
||||||
|
copyBtn.classList.remove('is-copied');
|
||||||
|
}, 1600);
|
||||||
|
} catch {
|
||||||
|
notify.error('复制失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [nav]);
|
}, [nav]);
|
||||||
|
|
||||||
@@ -34,7 +74,7 @@ export default function PostContent({ html, isLoggedIn, className = 'post-detail
|
|||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||||
import BoardBadge from '@/components/BoardBadge';
|
import BoardBadge from '@/components/BoardBadge';
|
||||||
import PinnedIcon from '@/components/PinnedIcon';
|
import PinnedIcon from '@/components/PinnedIcon';
|
||||||
@@ -8,10 +9,10 @@ import { formatTime } from '../utils/content';
|
|||||||
interface Props {
|
interface Props {
|
||||||
post: PostItem;
|
post: PostItem;
|
||||||
sort?: FeedSort;
|
sort?: FeedSort;
|
||||||
onClick: () => void;
|
onSelect: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PostListItem({ post, sort = 'latest', onClick }: Props) {
|
function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||||
const initial = post.user?.nickname?.[0] || '?';
|
const initial = post.user?.nickname?.[0] || '?';
|
||||||
const timeLabel = sort === 'reply'
|
const timeLabel = sort === 'reply'
|
||||||
? (post.last_reply_at
|
? (post.last_reply_at
|
||||||
@@ -22,7 +23,7 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
|||||||
const likeCount = post.like_count ?? 0;
|
const likeCount = post.like_count ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" className="post-row" onClick={onClick}>
|
<button type="button" className="post-row" onClick={() => onSelect(post.id)}>
|
||||||
<div className="post-avatar">
|
<div className="post-avatar">
|
||||||
{post.user?.avatar
|
{post.user?.avatar
|
||||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
@@ -52,3 +53,5 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default memo(PostListItem);
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { Flame, Megaphone, Users } from 'lucide-react';
|
import { Flame, MessageCircle, Tags } from 'lucide-react';
|
||||||
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import type { PostItem, RecentComment, TagCount } from '../api/types';
|
||||||
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import TagCloud from './TagCloud';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
hot: PostItem[];
|
hot: PostItem[];
|
||||||
notifications: Notification[];
|
recentComments: RecentComment[];
|
||||||
online: OnlineStats | null;
|
tags?: TagCount[];
|
||||||
|
tagsLoading?: boolean;
|
||||||
onPostClick: (id: number) => void;
|
onPostClick: (id: number) => void;
|
||||||
/** 首次拉取中,避免空态闪烁 */
|
/** 首次拉取中,显示骨架避免空态闪烁 */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,16 +22,46 @@ function hotRankClass(index: number): string {
|
|||||||
return 'widget-rank';
|
return 'widget-rank';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HotSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="widget-skeleton" aria-busy="true" aria-label="热门加载中">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className="widget-item widget-item--skeleton">
|
||||||
|
<Skeleton className="skeleton--widget-rank" />
|
||||||
|
<Skeleton className="skeleton--widget-title" style={{ width: `${62 + (i % 4) * 8}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommentSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="widget-skeleton" aria-busy="true" aria-label="评论加载中">
|
||||||
|
{Array.from({ length: 5 }, (_, i) => (
|
||||||
|
<div key={i} className="widget-item widget-item--comment widget-item--skeleton">
|
||||||
|
<Skeleton className="skeleton--widget-avatar" />
|
||||||
|
<Skeleton className="skeleton--widget-title" style={{ width: `${55 + (i % 3) * 12}%` }} />
|
||||||
|
<Skeleton className="skeleton--widget-time" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RightPanel({
|
export default function RightPanel({
|
||||||
hot,
|
hot,
|
||||||
notifications,
|
recentComments,
|
||||||
online,
|
tags = [],
|
||||||
|
tagsLoading = false,
|
||||||
onPostClick,
|
onPostClick,
|
||||||
loading = false,
|
loading = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const { branding } = useSiteBranding();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const activeTag = params.get('keyword') || '';
|
||||||
const hotList = hot?.slice(0, 8) ?? [];
|
const hotList = hot?.slice(0, 8) ?? [];
|
||||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||||
const members = online?.users ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="aside-panel-inner">
|
<div className="aside-panel-inner">
|
||||||
@@ -37,7 +72,7 @@ export default function RightPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="widget-card-body">
|
<div className="widget-card-body">
|
||||||
{loading && hotList.length === 0 ? (
|
{loading && hotList.length === 0 ? (
|
||||||
<div className="widget-empty">加载中…</div>
|
<HotSkeleton />
|
||||||
) : hotList.length === 0 ? (
|
) : hotList.length === 0 ? (
|
||||||
<div className="widget-empty">暂无数据</div>
|
<div className="widget-empty">暂无数据</div>
|
||||||
) : hotList.map((item, i) => (
|
) : hotList.map((item, i) => (
|
||||||
@@ -54,65 +89,53 @@ export default function RightPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="widget-card widget-card--tags">
|
||||||
|
<div className="widget-card-head">
|
||||||
|
<Tags className="widget-card-icon widget-card-icon--tags" aria-hidden />
|
||||||
|
标签云
|
||||||
|
</div>
|
||||||
|
<div className="widget-card-body widget-card-body--tags">
|
||||||
|
<TagCloud tags={tags} loading={tagsLoading} activeTag={activeTag} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="widget-card">
|
<div className="widget-card">
|
||||||
<div className="widget-card-head">
|
<div className="widget-card-head">
|
||||||
<Megaphone className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||||
最新动态
|
最新评论
|
||||||
</div>
|
</div>
|
||||||
<div className="widget-card-body">
|
<div className="widget-card-body">
|
||||||
{loading && noticeList.length === 0 ? (
|
{loading && commentList.length === 0 ? (
|
||||||
<div className="widget-empty">加载中…</div>
|
<CommentSkeleton />
|
||||||
) : noticeList.length === 0 ? (
|
) : commentList.length === 0 ? (
|
||||||
<div className="widget-empty">暂无动态</div>
|
<div className="widget-empty">暂无评论</div>
|
||||||
) : noticeList.map(item => (
|
) : commentList.map(item => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="widget-item widget-item--notice"
|
className="widget-item widget-item--comment"
|
||||||
onClick={() => onPostClick(item.id)}
|
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
|
||||||
|
onClick={() => onPostClick(item.post_id)}
|
||||||
>
|
>
|
||||||
<span className="widget-item-title">{item.title}</span>
|
<span className="widget-item-avatar" aria-hidden>
|
||||||
|
{item.avatar
|
||||||
|
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
|
: (item.author?.[0] || '?')}
|
||||||
|
</span>
|
||||||
|
<span className="widget-item-title">{item.excerpt}</span>
|
||||||
<span className="widget-item-time">{item.created_at}</span>
|
<span className="widget-item-time">{item.created_at}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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">
|
|
||||||
{loading && online == null ? (
|
|
||||||
<span className="widget-empty widget-empty--inline">加载中…</span>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{members.map(u => (
|
|
||||||
<span key={u.id} className="widget-online-avatar" title={u.nickname}>
|
|
||||||
{u.avatar
|
|
||||||
? <img src={u.avatar} alt="" loading="lazy" decoding="async" />
|
|
||||||
: (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 widget-card--about">
|
||||||
<div className="widget-card-body">
|
<div className="widget-card-body">
|
||||||
<p className="widget-about-text">
|
<p className="widget-about-text">
|
||||||
<strong>姜十三论坛</strong>
|
<strong>{branding.name}</strong>
|
||||||
拾三一隅,自在交流。轻量社区,专为小圈子打造。
|
{branding.slogan
|
||||||
|
? `${branding.slogan}${branding.name_en ? ` · ${branding.name_en}` : ''}`
|
||||||
|
: (branding.name_en || '轻量社区')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
Home, Star, LayoutDashboard,
|
Home, Star, LayoutDashboard, FolderGit2, ArrowLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||||
import type { Board } from '../api/types';
|
import type { Board } from '../api/types';
|
||||||
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||||
import { navigateFeed } from '../utils/feedCache';
|
import { navigateFeed } from '../utils/feedCache';
|
||||||
import BoardIconDisplay from './BoardIconDisplay';
|
import BoardIconDisplay from './BoardIconDisplay';
|
||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||||
|
import ArticleOutline from './ArticleOutline';
|
||||||
|
|
||||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||||
@@ -20,6 +23,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
|
|||||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||||
if (isNeutralSidebarRoute(pathname)) return null;
|
if (isNeutralSidebarRoute(pathname)) return null;
|
||||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||||
|
if (pathname.startsWith('/projects')) return 'projects';
|
||||||
if (pathname.startsWith('/admin')) return 'admin';
|
if (pathname.startsWith('/admin')) return 'admin';
|
||||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||||
}
|
}
|
||||||
@@ -28,9 +32,25 @@ interface Props {
|
|||||||
boards: Board[];
|
boards: Board[];
|
||||||
activeBoard: number;
|
activeBoard: number;
|
||||||
onSelectBoard: (id: number) => void;
|
onSelectBoard: (id: number) => void;
|
||||||
|
/** 板块列表首次拉取中 */
|
||||||
|
boardsLoading?: boolean;
|
||||||
|
/** 帖子详情:左侧切换为文章目录 */
|
||||||
|
outlineMode?: boolean;
|
||||||
|
outlineHeadings?: PostHeading[];
|
||||||
|
outlineScrollRoot?: HTMLElement | null;
|
||||||
|
outlineTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
export default function Sidebar({
|
||||||
|
boards,
|
||||||
|
activeBoard,
|
||||||
|
onSelectBoard,
|
||||||
|
boardsLoading = false,
|
||||||
|
outlineMode = false,
|
||||||
|
outlineHeadings = [],
|
||||||
|
outlineScrollRoot = null,
|
||||||
|
outlineTitle,
|
||||||
|
}: Props) {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const loc = useLocation();
|
const loc = useLocation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
@@ -52,15 +72,48 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (outlineMode) {
|
||||||
|
return (
|
||||||
|
<aside className="sidebar sidebar--outline">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-nav-item sidebar-outline-back"
|
||||||
|
onClick={() => navigateFeed(nav, '/')}
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden />
|
||||||
|
<span className="flex-1 truncate">返回首页</span>
|
||||||
|
</button>
|
||||||
|
<ArticleOutline
|
||||||
|
headings={outlineHeadings}
|
||||||
|
scrollRoot={outlineScrollRoot}
|
||||||
|
title={outlineTitle || '文章目录'}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-section">浏览</div>
|
<div className="sidebar-section">浏览</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||||
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
||||||
|
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{boards.length > 0 && (
|
{(boardsLoading && boards.length === 0) ? (
|
||||||
|
<>
|
||||||
|
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||||
|
<nav className="sidebar-nav sidebar-nav--skeleton" aria-busy="true" aria-label="板块加载中">
|
||||||
|
{Array.from({ length: 4 }, (_, i) => (
|
||||||
|
<div key={i} className="sidebar-nav-item sidebar-nav-item--skeleton">
|
||||||
|
<Skeleton className="skeleton--sidebar-icon" />
|
||||||
|
<Skeleton className="skeleton--sidebar-label" style={{ width: `${58 + (i % 3) * 12}%` }} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
) : boards.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="sidebar-section sidebar-section--boards">板块</div>
|
<div className="sidebar-section sidebar-section--boards">板块</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
@@ -92,7 +145,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</>
|
</>
|
||||||
)}
|
) : null}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
26
frontend/src/components/SiteBrandMark.tsx
Normal file
26
frontend/src/components/SiteBrandMark.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { SiteBranding } from '../api/types';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
branding: SiteBranding;
|
||||||
|
/** CSS 类:header-logo-mark / logo-mark / admin-topbar-mark */
|
||||||
|
className?: string;
|
||||||
|
/** 有 Logo 图时用的额外类名 */
|
||||||
|
imgClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 站点字标或 Logo 图 */
|
||||||
|
export default function SiteBrandMark({ branding, className, imgClassName }: Props) {
|
||||||
|
if (branding.logo) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={branding.logo}
|
||||||
|
alt={branding.name}
|
||||||
|
className={cn(className, 'site-brand-logo-img', imgClassName)}
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className={className}>{branding.logo_mark || branding.name.charAt(0) || '?'}</span>;
|
||||||
|
}
|
||||||
114
frontend/src/components/TagCloud.tsx
Normal file
114
frontend/src/components/TagCloud.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { TagCount } from '../api/types';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tags: TagCount[];
|
||||||
|
loading?: boolean;
|
||||||
|
activeTag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagTone = 0 | 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
|
/** 稳定哈希,让同一标签颜色固定 */
|
||||||
|
function hashTone(name: string): TagTone {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < name.length; i++) {
|
||||||
|
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||||
|
}
|
||||||
|
return (h % 6) as TagTone;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 权重档位 0–4,驱动字号与透明度 */
|
||||||
|
function weightTier(count: number, min: number, max: number): number {
|
||||||
|
if (max <= min) return 2;
|
||||||
|
const t = (count - min) / (max - min);
|
||||||
|
return Math.min(4, Math.max(0, Math.round(t * 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打散排序:热门标签穿插分布,避免「大标签全挤在顶上」。
|
||||||
|
* 用名称哈希做次级键,视觉更像云而非排行榜。
|
||||||
|
*/
|
||||||
|
function layoutTags(tags: TagCount[]): TagCount[] {
|
||||||
|
const ranked = [...tags].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh'));
|
||||||
|
const top = ranked.slice(0, Math.min(6, ranked.length));
|
||||||
|
const rest = ranked.slice(top.length);
|
||||||
|
const out: TagCount[] = [];
|
||||||
|
let i = 0;
|
||||||
|
let j = 0;
|
||||||
|
while (i < top.length || j < rest.length) {
|
||||||
|
if (j < rest.length) out.push(rest[j++]);
|
||||||
|
if (i < top.length) out.push(top[i++]);
|
||||||
|
if (j < rest.length) out.push(rest[j++]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右侧栏标签云:按热度缩放,色调错落 */
|
||||||
|
export default function TagCloud({ tags, loading = false, activeTag = '' }: Props) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
|
||||||
|
const { items, min, max } = useMemo(() => {
|
||||||
|
if (tags.length === 0) return { items: [] as TagCount[], min: 1, max: 1 };
|
||||||
|
let lo = tags[0].count;
|
||||||
|
let hi = tags[0].count;
|
||||||
|
for (const t of tags) {
|
||||||
|
if (t.count < lo) lo = t.count;
|
||||||
|
if (t.count > hi) hi = t.count;
|
||||||
|
}
|
||||||
|
return { items: layoutTags(tags), min: lo, max: hi };
|
||||||
|
}, [tags]);
|
||||||
|
|
||||||
|
if (loading && tags.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
|
||||||
|
{Array.from({ length: 10 }, (_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="skeleton--tag-cloud"
|
||||||
|
style={{
|
||||||
|
width: `${42 + (i % 5) * 16}px`,
|
||||||
|
height: `${20 + (i % 3) * 4}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <div className="tag-cloud-empty">暂无标签</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tag-cloud" role="list" aria-label="标签云">
|
||||||
|
{items.map((tag, index) => {
|
||||||
|
const active = activeTag.trim().toLowerCase() === tag.name.toLowerCase();
|
||||||
|
const tier = weightTier(tag.count, min, max);
|
||||||
|
const tone = hashTone(tag.name);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.name}
|
||||||
|
type="button"
|
||||||
|
role="listitem"
|
||||||
|
className={cn(
|
||||||
|
'tag-cloud-item',
|
||||||
|
`tag-cloud-item--w${tier}`,
|
||||||
|
`tag-cloud-item--t${tone}`,
|
||||||
|
`tag-cloud-item--r${index % 5}`,
|
||||||
|
active && 'active',
|
||||||
|
)}
|
||||||
|
title={`${tag.name} · ${tag.count} 篇`}
|
||||||
|
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
|
||||||
|
>
|
||||||
|
<span className="tag-cloud-item__name">{tag.name}</span>
|
||||||
|
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Inbox } from 'lucide-react';
|
import { Inbox } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import PostListItem from './PostListItem';
|
import PostListItem from './PostListItem';
|
||||||
import PostListSkeleton from './PostListSkeleton';
|
import PostListSkeleton from './PostListSkeleton';
|
||||||
|
import FeedPagination from './FeedPagination';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import type { FeedSort } from './FeedSortBar';
|
import type { FeedSort } from './FeedSortBar';
|
||||||
|
|
||||||
@@ -11,15 +15,16 @@ interface Props {
|
|||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
sort?: FeedSort;
|
sort?: FeedSort;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
/** 当前页之后是否还有更多 */
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
/** 是否允许滚动触底自动加载(达到上限后为 false) */
|
/** 是否显示底部分页控件 */
|
||||||
canAutoLoad: boolean;
|
showPagination: boolean;
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
onLoadMore: () => void;
|
onPageChange: (page: number) => void;
|
||||||
onSelect: (id: number) => void;
|
onSelect: (id: number) => void;
|
||||||
/** 返回列表时恢复的滚动位置 */
|
|
||||||
restoreScrollTop?: number | null;
|
restoreScrollTop?: number | null;
|
||||||
/** 递增时强制回到列表顶部(主动刷新导航) */
|
|
||||||
resetScrollKey?: number;
|
resetScrollKey?: number;
|
||||||
onScrollTopChange?: (top: number) => void;
|
onScrollTopChange?: (top: number) => void;
|
||||||
onScrollRestored?: () => void;
|
onScrollRestored?: () => void;
|
||||||
@@ -30,17 +35,25 @@ export default function VirtualPostList({
|
|||||||
sort = 'latest',
|
sort = 'latest',
|
||||||
loading,
|
loading,
|
||||||
hasMore,
|
hasMore,
|
||||||
canAutoLoad,
|
showPagination,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
postTotal,
|
postTotal,
|
||||||
onLoadMore,
|
onPageChange,
|
||||||
onSelect,
|
onSelect,
|
||||||
restoreScrollTop,
|
restoreScrollTop,
|
||||||
resetScrollKey = 0,
|
resetScrollKey = 0,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
onScrollRestored,
|
onScrollRestored,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const { user } = useAuth();
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const restoredRef = useRef(false);
|
const restoredRef = useRef(false);
|
||||||
|
const onScrollTopChangeRef = useRef(onScrollTopChange);
|
||||||
|
const onScrollRestoredRef = useRef(onScrollRestored);
|
||||||
|
onScrollTopChangeRef.current = onScrollTopChange;
|
||||||
|
onScrollRestoredRef.current = onScrollRestored;
|
||||||
|
|
||||||
const virtualizer = useVirtualizer({
|
const virtualizer = useVirtualizer({
|
||||||
count: posts.length,
|
count: posts.length,
|
||||||
@@ -53,10 +66,8 @@ export default function VirtualPostList({
|
|||||||
: undefined,
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
|
||||||
const isInitialLoad = loading && posts.length === 0;
|
const isInitialLoad = loading && posts.length === 0;
|
||||||
const isLoadingMore = loading && posts.length > 0;
|
|
||||||
const isEmpty = !loading && posts.length === 0;
|
const isEmpty = !loading && posts.length === 0;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -67,15 +78,15 @@ export default function VirtualPostList({
|
|||||||
virtualizer.scrollToOffset(0);
|
virtualizer.scrollToOffset(0);
|
||||||
}
|
}
|
||||||
restoredRef.current = true;
|
restoredRef.current = true;
|
||||||
onScrollTopChange?.(0);
|
onScrollTopChangeRef.current?.(0);
|
||||||
}, [resetScrollKey, virtualizer, onScrollTopChange]);
|
}, [resetScrollKey, virtualizer]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||||
virtualizer.scrollToOffset(restoreScrollTop);
|
virtualizer.scrollToOffset(restoreScrollTop);
|
||||||
restoredRef.current = true;
|
restoredRef.current = true;
|
||||||
onScrollRestored?.();
|
onScrollRestoredRef.current?.();
|
||||||
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
|
}, [restoreScrollTop, posts.length, virtualizer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
restoredRef.current = false;
|
restoredRef.current = false;
|
||||||
@@ -85,33 +96,42 @@ export default function VirtualPostList({
|
|||||||
const el = parentRef.current;
|
const el = parentRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
onScrollTopChange?.(el.scrollTop);
|
onScrollTopChangeRef.current?.(el.scrollTop);
|
||||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
|
|
||||||
onLoadMore();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
el.addEventListener('scroll', onScroll);
|
el.addEventListener('scroll', onScroll);
|
||||||
return () => el.removeEventListener('scroll', onScroll);
|
return () => el.removeEventListener('scroll', onScroll);
|
||||||
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="post-list-scroll" ref={parentRef}>
|
<div className="post-list-scroll" ref={parentRef}>
|
||||||
{isInitialLoad ? (
|
{isInitialLoad ? (
|
||||||
<PostListSkeleton />
|
<PostListSkeleton />
|
||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<div className="empty-feed">
|
<div className="empty-feed" role="status">
|
||||||
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
<p>暂无帖子</p>
|
<p>暂无帖子</p>
|
||||||
<p className="empty-feed-hint">换个板块看看,或发第一篇内容</p>
|
<p className="empty-feed-hint">换个板块看看,或发第一篇内容</p>
|
||||||
|
<div className="empty-feed-actions">
|
||||||
|
{user ? (
|
||||||
|
<Button type="button" size="sm" onClick={() => nav('/compose')}>
|
||||||
|
发第一帖
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button type="button" size="sm" onClick={() => nav(loginPath('/compose'))}>
|
||||||
|
登录后发帖
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
<div className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
{virtualizer.getVirtualItems().map(vi => {
|
{virtualizer.getVirtualItems().map(vi => {
|
||||||
const post = posts[vi.index];
|
const post = posts[vi.index];
|
||||||
|
if (!post) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={post.id}
|
key={vi.key}
|
||||||
data-index={vi.index}
|
data-index={vi.index}
|
||||||
ref={virtualizer.measureElement}
|
ref={virtualizer.measureElement}
|
||||||
style={{
|
style={{
|
||||||
@@ -122,20 +142,20 @@ export default function VirtualPostList({
|
|||||||
transform: `translateY(${vi.start}px)`,
|
transform: `translateY(${vi.start}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
<PostListItem post={post} sort={sort} onSelect={onSelect} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingMore && <PostListSkeleton count={2} />}
|
{showPagination && (
|
||||||
{showHistoryPrompt && (
|
<div className="feed-list-footer feed-list-footer--pagination">
|
||||||
<div className="feed-list-footer feed-list-footer--history">
|
<FeedPagination
|
||||||
<p className="feed-list-footer__hint">
|
page={page}
|
||||||
已显示 {posts.length} / {postTotal} 条
|
totalPages={totalPages}
|
||||||
</p>
|
postTotal={postTotal}
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
|
loading={loading}
|
||||||
加载更多历史
|
onPageChange={onPageChange}
|
||||||
</Button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showEnd && (
|
{showEnd && (
|
||||||
|
|||||||
@@ -1,62 +1,106 @@
|
|||||||
import { Node, mergeAttributes } from '@tiptap/core';
|
import { Node, mergeAttributes } from '@tiptap/core';
|
||||||
|
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
import {
|
import {
|
||||||
ReactNodeViewRenderer,
|
ReactNodeViewRenderer,
|
||||||
NodeViewWrapper,
|
NodeViewWrapper,
|
||||||
NodeViewContent,
|
NodeViewContent,
|
||||||
type NodeViewProps,
|
type NodeViewProps,
|
||||||
} from '@tiptap/react';
|
} from '@tiptap/react';
|
||||||
import { LockKeyhole, LogOut } from 'lucide-react';
|
import { LockKeyhole, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
/** 查找光标所在的登录可见节点深度 */
|
/** 查找光标所在的登录可见节点深度 */
|
||||||
function findMembersOnlyDepth($pos: { depth: number; node: (d: number) => { type: { name: string } } }): number {
|
function findMembersOnlyDepth($pos: {
|
||||||
|
depth: number;
|
||||||
|
node: (d: number) => { type: { name: string }; nodeSize: number };
|
||||||
|
before: (d: number) => number;
|
||||||
|
start: (d: number) => number;
|
||||||
|
}): number {
|
||||||
for (let d = $pos.depth; d > 0; d -= 1) {
|
for (let d = $pos.depth; d > 0; d -= 1) {
|
||||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||||
}
|
}
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 登录可见区块是否无实质文字 */
|
||||||
|
function isMembersOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||||
|
return node.textContent.trim().length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
/** 编辑态「登录可见」区块视图 */
|
/** 编辑态「登录可见」区块视图 */
|
||||||
function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
function MembersOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||||
const handleExit = () => {
|
const empty = isMembersOnlyEmpty(node);
|
||||||
editor.chain().focus().exitMembersOnly().run();
|
|
||||||
|
/** 按 NodeView 自身位置删除,不依赖光标是否仍在块内 */
|
||||||
|
const deleteThisBlock = () => {
|
||||||
|
const pos = getPos();
|
||||||
|
if (typeof pos !== 'number') {
|
||||||
|
editor.chain().focus().removeMembersOnly().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.command(({ tr, dispatch }) => {
|
||||||
|
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnwrap = () => {
|
const handleUnwrap = () => {
|
||||||
editor.chain().focus().unwrapMembersOnly().run();
|
const pos = getPos();
|
||||||
|
if (typeof pos !== 'number') {
|
||||||
|
editor.chain().focus().unwrapMembersOnly().run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.command(({ tr, dispatch }) => {
|
||||||
|
if (isMembersOnlyEmpty(node)) {
|
||||||
|
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||||
|
} else if (dispatch) {
|
||||||
|
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.run();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper
|
<NodeViewWrapper
|
||||||
as="members-only"
|
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}${empty ? ' editor-members-only--empty' : ''}`}
|
||||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
|
|
||||||
>
|
>
|
||||||
<div className="post-members-only__badge" contentEditable={false}>
|
<div className="post-members-only__badge" contentEditable={false}>
|
||||||
<span className="post-members-only__badge-icon" aria-hidden="true">
|
<span className="post-members-only__badge-icon" aria-hidden="true">
|
||||||
<LockKeyhole size={12} />
|
<LockKeyhole size={12} />
|
||||||
</span>
|
</span>
|
||||||
<span>登录可见</span>
|
<span>登录可见</span>
|
||||||
<button
|
<div className="post-members-only__badge-actions">
|
||||||
type="button"
|
{!empty && (
|
||||||
className="post-members-only__exit-btn"
|
<button
|
||||||
title="Ctrl+Enter 退出到公开区域"
|
type="button"
|
||||||
onMouseDown={e => e.preventDefault()}
|
className="post-members-only__unwrap-btn"
|
||||||
onClick={handleExit}
|
title="取消登录可见包裹,保留正文"
|
||||||
>
|
onMouseDown={e => e.preventDefault()}
|
||||||
<LogOut size={11} />
|
onClick={handleUnwrap}
|
||||||
退出
|
>
|
||||||
</button>
|
取消包裹
|
||||||
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退出</span>
|
</button>
|
||||||
<button
|
)}
|
||||||
type="button"
|
<button
|
||||||
className="post-members-only__unwrap-btn"
|
type="button"
|
||||||
title="取消登录可见包裹,保留正文"
|
className="post-members-only__remove-btn"
|
||||||
onMouseDown={e => e.preventDefault()}
|
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
|
||||||
onClick={handleUnwrap}
|
onMouseDown={e => e.preventDefault()}
|
||||||
>
|
onClick={deleteThisBlock}
|
||||||
取消包裹
|
>
|
||||||
</button>
|
<Trash2 size={11} />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NodeViewContent className="post-members-only__body" />
|
<NodeViewContent className="post-members-only__body" data-placeholder="此处内容游客不可见…" />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,6 +112,7 @@ declare module '@tiptap/core' {
|
|||||||
wrapMembersOnly: () => ReturnType;
|
wrapMembersOnly: () => ReturnType;
|
||||||
exitMembersOnly: () => ReturnType;
|
exitMembersOnly: () => ReturnType;
|
||||||
unwrapMembersOnly: () => ReturnType;
|
unwrapMembersOnly: () => ReturnType;
|
||||||
|
removeMembersOnly: () => ReturnType;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,6 +139,37 @@ export const MembersOnly = Node.create({
|
|||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
|
// 空区块内 Backspace / Delete:整块删除
|
||||||
|
Backspace: ({ editor }) => {
|
||||||
|
const { $from, empty } = editor.state.selection;
|
||||||
|
if (!empty) return false;
|
||||||
|
|
||||||
|
const depth = findMembersOnlyDepth($from);
|
||||||
|
if (depth < 0) return false;
|
||||||
|
|
||||||
|
const node = $from.node(depth);
|
||||||
|
if (!isMembersOnlyEmpty(node)) {
|
||||||
|
// 有内容时:在区块首字位置再按 Backspace 则解除包裹(与常见编辑器一致)
|
||||||
|
if ($from.parentOffset !== 0) return false;
|
||||||
|
const start = $from.start(depth);
|
||||||
|
if ($from.pos !== start) return false;
|
||||||
|
return editor.commands.unwrapMembersOnly();
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor.commands.removeMembersOnly();
|
||||||
|
},
|
||||||
|
Delete: ({ editor }) => {
|
||||||
|
const { $from, empty } = editor.state.selection;
|
||||||
|
if (!empty) return false;
|
||||||
|
|
||||||
|
const depth = findMembersOnlyDepth($from);
|
||||||
|
if (depth < 0) return false;
|
||||||
|
|
||||||
|
const node = $from.node(depth);
|
||||||
|
if (!isMembersOnlyEmpty(node)) return false;
|
||||||
|
|
||||||
|
return editor.commands.removeMembersOnly();
|
||||||
|
},
|
||||||
// 在区块末尾空行按 Enter 时退出到公开区域
|
// 在区块末尾空行按 Enter 时退出到公开区域
|
||||||
Enter: ({ editor }) => {
|
Enter: ({ editor }) => {
|
||||||
const { $from, empty } = editor.state.selection;
|
const { $from, empty } = editor.state.selection;
|
||||||
@@ -107,6 +183,12 @@ export const MembersOnly = Node.create({
|
|||||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||||
|
|
||||||
|
// 整块为空时直接删除,避免退出后仍残留空登录可见壳
|
||||||
|
const membersNode = $from.node(depth);
|
||||||
|
if (isMembersOnlyEmpty(membersNode) && membersNode.childCount <= 1) {
|
||||||
|
return editor.commands.removeMembersOnly();
|
||||||
|
}
|
||||||
|
|
||||||
return editor.commands.exitMembersOnly();
|
return editor.commands.exitMembersOnly();
|
||||||
},
|
},
|
||||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||||
@@ -162,7 +244,25 @@ export const MembersOnly = Node.create({
|
|||||||
|
|
||||||
const pos = $from.before(depth);
|
const pos = $from.before(depth);
|
||||||
const node = $from.node(depth);
|
const node = $from.node(depth);
|
||||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
|
||||||
|
// 空区块:直接删除,避免留下空段落套壳
|
||||||
|
if (isMembersOnlyEmpty(node)) {
|
||||||
|
tr.delete(pos, pos + node.nodeSize);
|
||||||
|
} else {
|
||||||
|
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||||
|
}
|
||||||
|
if (dispatch) dispatch(tr);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMembersOnly: () => ({ 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.delete(pos, pos + node.nodeSize);
|
||||||
if (dispatch) dispatch(tr);
|
if (dispatch) dispatch(tr);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ const AlertDialogOverlay = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
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',
|
/* 需高于全屏编辑器 (z-120),否则未保存提示会被挡住 */
|
||||||
|
'fixed inset-0 z-[200] 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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -31,7 +32,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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',
|
'fixed left-[50%] top-[50%] z-[200] 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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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',
|
/* 需高于全屏编辑器 (z-120) */
|
||||||
|
'fixed inset-0 z-[200] 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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -32,7 +33,7 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
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',
|
'fixed left-[50%] top-[50%] z-[200] 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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -10,14 +10,16 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
|||||||
search_keyword_min: 1,
|
search_keyword_min: 1,
|
||||||
search_keyword_max: 50,
|
search_keyword_max: 50,
|
||||||
page_size_default: 30,
|
page_size_default: 30,
|
||||||
feed_max_pages: 10,
|
|
||||||
feed_max_items: 300,
|
|
||||||
password_min_len: 6,
|
password_min_len: 6,
|
||||||
avatar_max_mb: 2,
|
avatar_max_mb: 2,
|
||||||
|
open_posts_in_new_tab: true,
|
||||||
|
open_content_links_in_new_tab: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: ForumLimitsPublic | null = null;
|
let cached: ForumLimitsPublic | null = null;
|
||||||
let inflight: Promise<ForumLimitsPublic> | null = null;
|
let inflight: Promise<ForumLimitsPublic> | null = null;
|
||||||
|
let cacheEpoch = 0;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||||
if (cached) return Promise.resolve(cached);
|
if (cached) return Promise.resolve(cached);
|
||||||
@@ -27,7 +29,7 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
|||||||
cached = limits;
|
cached = limits;
|
||||||
return limits;
|
return limits;
|
||||||
})
|
})
|
||||||
.catch(() => DEFAULT_LIMITS)
|
.catch(() => cached ?? DEFAULT_LIMITS)
|
||||||
.finally(() => { inflight = null; });
|
.finally(() => { inflight = null; });
|
||||||
return inflight;
|
return inflight;
|
||||||
}
|
}
|
||||||
@@ -36,14 +38,34 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
|||||||
export function useForumLimits() {
|
export function useForumLimits() {
|
||||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
||||||
const [loading, setLoading] = useState(!cached);
|
const [loading, setLoading] = useState(!cached);
|
||||||
|
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchLimits().then(setLimits).finally(() => setLoading(false));
|
const onInvalidate = () => setEpoch(cacheEpoch);
|
||||||
|
listeners.add(onInvalidate);
|
||||||
|
return () => { listeners.delete(onInvalidate); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
// 无缓存时显示加载中,避免首页用默认 30/300 误拉全量
|
||||||
|
if (!cached) setLoading(true);
|
||||||
|
fetchLimits()
|
||||||
|
.then(next => {
|
||||||
|
if (!cancelled) setLimits(next);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [epoch]);
|
||||||
|
|
||||||
return { limits, loading };
|
return { limits, loading };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||||
export function invalidateForumLimitsCache() {
|
export function invalidateForumLimitsCache() {
|
||||||
cached = null;
|
cached = null;
|
||||||
|
cacheEpoch += 1;
|
||||||
|
listeners.forEach(fn => fn());
|
||||||
}
|
}
|
||||||
|
|||||||
92
frontend/src/hooks/useSiteBranding.ts
Normal file
92
frontend/src/hooks/useSiteBranding.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { SiteBranding } from '../api/types';
|
||||||
|
|
||||||
|
export const DEFAULT_BRANDING: SiteBranding = {
|
||||||
|
name: '姜十三论坛',
|
||||||
|
name_en: 'Jiang13 Forum',
|
||||||
|
slogan: '拾三一隅,自在交流',
|
||||||
|
logo_mark: '姜',
|
||||||
|
logo: '',
|
||||||
|
favicon: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
let cached: SiteBranding | null = null;
|
||||||
|
let inflight: Promise<SiteBranding> | null = null;
|
||||||
|
let cacheEpoch = 0;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function fetchBranding(): Promise<SiteBranding> {
|
||||||
|
if (cached) return Promise.resolve(cached);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = api.siteBranding()
|
||||||
|
.then(b => {
|
||||||
|
cached = { ...DEFAULT_BRANDING, ...b };
|
||||||
|
return cached;
|
||||||
|
})
|
||||||
|
.catch(() => cached ?? DEFAULT_BRANDING)
|
||||||
|
.finally(() => { inflight = null; });
|
||||||
|
return inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDocumentBrand(brand: SiteBranding) {
|
||||||
|
const title = brand.name_en ? `${brand.name} ${brand.name_en}` : brand.name;
|
||||||
|
if (document.title !== title) document.title = title;
|
||||||
|
|
||||||
|
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||||
|
if (brand.favicon) {
|
||||||
|
if (!link) {
|
||||||
|
link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
if (link.href !== new URL(brand.favicon, window.location.origin).href) {
|
||||||
|
link.href = brand.favicon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取站点品牌配置(名称、Logo 等) */
|
||||||
|
export function useSiteBranding() {
|
||||||
|
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
||||||
|
const [loading, setLoading] = useState(!cached);
|
||||||
|
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onInvalidate = () => setEpoch(cacheEpoch);
|
||||||
|
listeners.add(onInvalidate);
|
||||||
|
return () => { listeners.delete(onInvalidate); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!cached) setLoading(true);
|
||||||
|
fetchBranding()
|
||||||
|
.then(next => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setBranding(next);
|
||||||
|
applyDocumentBrand(next);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [epoch]);
|
||||||
|
|
||||||
|
return { branding, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||||
|
export function invalidateSiteBrandingCache() {
|
||||||
|
cached = null;
|
||||||
|
cacheEpoch += 1;
|
||||||
|
listeners.forEach(fn => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用管理端刚保存的值立即更新缓存与文档标题 */
|
||||||
|
export function seedSiteBrandingCache(brand: SiteBranding) {
|
||||||
|
cached = { ...DEFAULT_BRANDING, ...brand };
|
||||||
|
applyDocumentBrand(cached);
|
||||||
|
cacheEpoch += 1;
|
||||||
|
listeners.forEach(fn => fn());
|
||||||
|
}
|
||||||
@@ -10,6 +10,9 @@ import { useOverlayA11y } from '../hooks/useOverlayA11y';
|
|||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import BackToTop from '../components/BackToTop';
|
import BackToTop from '../components/BackToTop';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import SiteBrandMark from '../components/SiteBrandMark';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||||
@@ -24,6 +27,7 @@ const NAV = [
|
|||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
|
const { branding } = useSiteBranding();
|
||||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -38,7 +42,7 @@ export default function AdminLayout() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
nav('/login');
|
nav(loginPath('/admin/dashboard'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (user.role !== 'admin') {
|
if (user.role !== 'admin') {
|
||||||
@@ -91,9 +95,9 @@ export default function AdminLayout() {
|
|||||||
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
|
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="admin-topbar-mark">姜</div>
|
<SiteBrandMark branding={branding} className="admin-topbar-mark" />
|
||||||
<div>
|
<div>
|
||||||
<div className="admin-topbar-title">姜十三论坛</div>
|
<div className="admin-topbar-title">{branding.name}</div>
|
||||||
<div className="admin-topbar-sub">管理后台</div>
|
<div className="admin-topbar-sub">管理后台</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||||
import PageLoader from '../components/PageLoader';
|
import PageLoader from '../components/PageLoader';
|
||||||
|
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
|
import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -13,8 +14,9 @@ import { useAuth } from '../hooks/useAuth';
|
|||||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||||
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
|
import type { Board, PostItem, RecentComment, ForumStats, TagCount } from '../api/types';
|
||||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
|
import { getCachedBoards, getCachedStats, getCachedHot, getCachedRecentComments, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedHot, setCachedRecentComments, setCachedTags } from '../utils/layoutCache';
|
||||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||||
import RightPanel from '../components/RightPanel';
|
import RightPanel from '../components/RightPanel';
|
||||||
import BackToTop from '../components/BackToTop';
|
import BackToTop from '../components/BackToTop';
|
||||||
@@ -24,10 +26,15 @@ import { navigateFeed } from '../utils/feedCache';
|
|||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
import { openForumPost } from '../utils/openPost';
|
||||||
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import SiteBrandMark from '../components/SiteBrandMark';
|
||||||
|
|
||||||
export default function MainLayout() {
|
export default function MainLayout() {
|
||||||
const { user, loading: authLoading, logout } = useAuth();
|
const { user, loading: authLoading, logout } = useAuth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
|
const { branding } = useSiteBranding();
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -37,11 +44,18 @@ export default function MainLayout() {
|
|||||||
|
|
||||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||||
const [hot, setHot] = useState<PostItem[]>([]);
|
const [hot, setHot] = useState<PostItem[]>(() => getCachedHot());
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
|
||||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
||||||
|
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
||||||
|
const [postOutline, setPostOutline] = useState<{
|
||||||
|
headings: PostHeading[];
|
||||||
|
scrollRoot: HTMLElement | null;
|
||||||
|
title?: string;
|
||||||
|
} | null>(null);
|
||||||
const [asideOpen, setAsideOpen] = useState(false);
|
const [asideOpen, setAsideOpen] = useState(false);
|
||||||
const [asideLoading, setAsideLoading] = useState(false);
|
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
||||||
|
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||||
const asideEverLoaded = useRef(false);
|
const asideEverLoaded = useRef(false);
|
||||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||||
@@ -60,6 +74,9 @@ export default function MainLayout() {
|
|||||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||||
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
|
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
||||||
|
}, [loc.pathname]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hideAside) setAsideOpen(false);
|
if (!hideAside) setAsideOpen(false);
|
||||||
}, [hideAside]);
|
}, [hideAside]);
|
||||||
@@ -71,7 +88,7 @@ export default function MainLayout() {
|
|||||||
}, [asideOpen]);
|
}, [asideOpen]);
|
||||||
|
|
||||||
const refreshBoards = useCallback(() => {
|
const refreshBoards = useCallback(() => {
|
||||||
Promise.all([
|
return Promise.all([
|
||||||
api.boards().then(d => {
|
api.boards().then(d => {
|
||||||
const next = d.boards ?? [];
|
const next = d.boards ?? [];
|
||||||
setBoards(next);
|
setBoards(next);
|
||||||
@@ -83,18 +100,9 @@ export default function MainLayout() {
|
|||||||
setCachedStats(next);
|
setCachedStats(next);
|
||||||
return next;
|
return next;
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
]);
|
]).finally(() => {
|
||||||
}, []);
|
setBoardsLoading(false);
|
||||||
|
});
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
@@ -104,25 +112,51 @@ export default function MainLayout() {
|
|||||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||||
}, [refreshBoards]);
|
}, [refreshBoards]);
|
||||||
|
|
||||||
|
// 标签云:非编辑页拉取(左侧栏常显)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCompose) return;
|
if (isCompose) return;
|
||||||
api.presence().catch(() => {});
|
let cancelled = false;
|
||||||
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
const loadTags = () => {
|
||||||
return () => clearInterval(presenceTimer);
|
if (getCachedTags().length === 0) setTagsLoading(true);
|
||||||
|
api.tags(40).then(d => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const next = Array.isArray(d.tags) ? d.tags : [];
|
||||||
|
setTags(next);
|
||||||
|
setCachedTags(next);
|
||||||
|
}).catch(() => {}).finally(() => {
|
||||||
|
if (!cancelled) setTagsLoading(false);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
loadTags();
|
||||||
|
const onRefresh = () => loadTags();
|
||||||
|
window.addEventListener('posts-refresh', onRefresh);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.removeEventListener('posts-refresh', onRefresh);
|
||||||
|
};
|
||||||
}, [isCompose]);
|
}, [isCompose]);
|
||||||
|
|
||||||
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!needAsideData) return;
|
if (!needAsideData) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
if (!asideEverLoaded.current) setAsideLoading(true);
|
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
||||||
|
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||||
|
setAsideLoading(true);
|
||||||
|
}
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.hotPosts().then(d => {
|
api.hotPosts().then(d => {
|
||||||
if (!cancelled) setHot(Array.isArray(d.posts) ? d.posts : []);
|
if (cancelled) return;
|
||||||
|
const next = Array.isArray(d.posts) ? d.posts : [];
|
||||||
|
setHot(next);
|
||||||
|
setCachedHot(next);
|
||||||
}).catch(() => {}),
|
}).catch(() => {}),
|
||||||
api.notifications().then(d => {
|
api.recentComments().then(d => {
|
||||||
if (!cancelled) setNotifications(Array.isArray(d.notifications) ? d.notifications : []);
|
if (cancelled) return;
|
||||||
|
const next = Array.isArray(d.comments) ? d.comments : [];
|
||||||
|
setRecentComments(next);
|
||||||
|
setCachedRecentComments(next);
|
||||||
}).catch(() => {}),
|
}).catch(() => {}),
|
||||||
]).finally(() => {
|
]).finally(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -131,13 +165,10 @@ export default function MainLayout() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
refreshOnline();
|
|
||||||
const onlineTimer = setInterval(refreshOnline, 30000);
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
clearInterval(onlineTimer);
|
|
||||||
};
|
};
|
||||||
}, [needAsideData, refreshOnline]);
|
}, [needAsideData]);
|
||||||
|
|
||||||
const doSearch = () => {
|
const doSearch = () => {
|
||||||
const kw = keyword.trim();
|
const kw = keyword.trim();
|
||||||
@@ -157,10 +188,10 @@ export default function MainLayout() {
|
|||||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openPost = (id: number) => {
|
const openPost = useCallback((id: number) => {
|
||||||
setAsideOpen(false);
|
setAsideOpen(false);
|
||||||
nav(`/post/${id}`);
|
openForumPost(nav, id, forumLimits.open_posts_in_new_tab);
|
||||||
};
|
}, [nav, forumLimits.open_posts_in_new_tab]);
|
||||||
|
|
||||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||||
const isFeedHome = loc.pathname === '/';
|
const isFeedHome = loc.pathname === '/';
|
||||||
@@ -169,6 +200,22 @@ export default function MainLayout() {
|
|||||||
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
|
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
|
||||||
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
|
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
|
||||||
|
|
||||||
|
const outletKeyword = params.get('keyword') || '';
|
||||||
|
const isPostDetail = /^\/post\/\d+\/?$/.test(loc.pathname);
|
||||||
|
const setPostOutlineSafe = useCallback((outline: LayoutCtx['postOutline']) => {
|
||||||
|
setPostOutline(outline);
|
||||||
|
}, []);
|
||||||
|
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
||||||
|
boardId,
|
||||||
|
keyword: outletKeyword,
|
||||||
|
setBoardId,
|
||||||
|
boards,
|
||||||
|
stats,
|
||||||
|
refreshBoards,
|
||||||
|
isMobile,
|
||||||
|
setPostOutline: setPostOutlineSafe,
|
||||||
|
}), [boardId, outletKeyword, boards, stats, refreshBoards, isMobile, setPostOutlineSafe]);
|
||||||
|
|
||||||
const selectBoardChip = (id: number) => {
|
const selectBoardChip = (id: number) => {
|
||||||
setBoardId(id);
|
setBoardId(id);
|
||||||
navigateFeed(nav, buildHomeUrl(id, feedSort));
|
navigateFeed(nav, buildHomeUrl(id, feedSort));
|
||||||
@@ -191,8 +238,8 @@ export default function MainLayout() {
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="header-inner">
|
<div className="header-inner">
|
||||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||||
<span className="header-logo-mark">姜</span>
|
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!isCompose && (
|
{!isCompose && (
|
||||||
@@ -224,7 +271,7 @@ export default function MainLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-compose-btn"
|
className="header-compose-btn"
|
||||||
onClick={() => user ? nav('/compose') : nav('/login')}
|
onClick={() => user ? nav('/compose') : nav(loginPath('/compose'))}
|
||||||
aria-label="发帖"
|
aria-label="发帖"
|
||||||
>
|
>
|
||||||
<Plus size={16} aria-hidden />
|
<Plus size={16} aria-hidden />
|
||||||
@@ -288,7 +335,7 @@ export default function MainLayout() {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : (
|
) : (
|
||||||
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
|
<button type="button" className="header-login-btn" onClick={() => nav(loginPath())}>
|
||||||
登录
|
登录
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -303,6 +350,11 @@ export default function MainLayout() {
|
|||||||
boards={boards}
|
boards={boards}
|
||||||
activeBoard={boardId}
|
activeBoard={boardId}
|
||||||
onSelectBoard={setBoardId}
|
onSelectBoard={setBoardId}
|
||||||
|
boardsLoading={boardsLoading}
|
||||||
|
outlineMode={isPostDetail}
|
||||||
|
outlineHeadings={postOutline?.headings ?? []}
|
||||||
|
outlineScrollRoot={postOutline?.scrollRoot ?? null}
|
||||||
|
outlineTitle={postOutline?.title}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -346,16 +398,8 @@ export default function MainLayout() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Suspense fallback={<PageLoader />}>
|
<Suspense fallback={isFeedHome ? <FeedPageSkeleton /> : <PageLoader />}>
|
||||||
<Outlet context={{
|
<Outlet context={layoutCtx} />
|
||||||
boardId,
|
|
||||||
keyword: params.get('keyword') || '',
|
|
||||||
setBoardId,
|
|
||||||
boards,
|
|
||||||
stats,
|
|
||||||
refreshBoards,
|
|
||||||
isMobile,
|
|
||||||
} satisfies LayoutCtx} />
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -363,8 +407,9 @@ export default function MainLayout() {
|
|||||||
<aside className="aside-panel">
|
<aside className="aside-panel">
|
||||||
<RightPanel
|
<RightPanel
|
||||||
hot={hot}
|
hot={hot}
|
||||||
notifications={notifications}
|
recentComments={recentComments}
|
||||||
online={online}
|
tags={tags}
|
||||||
|
tagsLoading={tagsLoading}
|
||||||
loading={asideLoading}
|
loading={asideLoading}
|
||||||
onPostClick={openPost}
|
onPostClick={openPost}
|
||||||
/>
|
/>
|
||||||
@@ -406,8 +451,9 @@ export default function MainLayout() {
|
|||||||
<div className="aside-drawer-body">
|
<div className="aside-drawer-body">
|
||||||
<RightPanel
|
<RightPanel
|
||||||
hot={hot}
|
hot={hot}
|
||||||
notifications={notifications}
|
recentComments={recentComments}
|
||||||
online={online}
|
tags={tags}
|
||||||
|
tagsLoading={tagsLoading}
|
||||||
loading={asideLoading}
|
loading={asideLoading}
|
||||||
onPostClick={openPost}
|
onPostClick={openPost}
|
||||||
/>
|
/>
|
||||||
@@ -429,4 +475,10 @@ export type LayoutCtx = {
|
|||||||
stats: ForumStats | null;
|
stats: ForumStats | null;
|
||||||
refreshBoards: () => void;
|
refreshBoards: () => void;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
|
/** 详情页上报文章目录,供左侧栏展示 */
|
||||||
|
setPostOutline: (outline: {
|
||||||
|
headings: PostHeading[];
|
||||||
|
scrollRoot: HTMLElement | null;
|
||||||
|
title?: string;
|
||||||
|
} | null) => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
@@ -14,6 +14,13 @@ import TagInput, { serializeTags, parseTags } from '../components/TagInput';
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getCachedBoards } from '../utils/layoutCache';
|
import { getCachedBoards } from '../utils/layoutCache';
|
||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
import {
|
||||||
|
loadComposeDraft,
|
||||||
|
saveComposeDraft,
|
||||||
|
clearComposeDraft,
|
||||||
|
draftHasContent,
|
||||||
|
} from '../utils/composeDraft';
|
||||||
|
|
||||||
interface ComposeBaseline {
|
interface ComposeBaseline {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -27,6 +34,22 @@ function resolveBoards(ctxBoards?: Board[]): Board[] {
|
|||||||
return getCachedBoards();
|
return getCachedBoards();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 格式化剩余可编辑时间 */
|
||||||
|
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||||
|
if (windowHours <= 0) return '';
|
||||||
|
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
|
||||||
|
const ms = deadline - Date.now();
|
||||||
|
if (ms <= 0) return '可编辑时限已到';
|
||||||
|
const hours = Math.floor(ms / 3600_000);
|
||||||
|
const mins = Math.floor((ms % 3600_000) / 60_000);
|
||||||
|
if (hours >= 24) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `还可编辑约 ${days} 天`;
|
||||||
|
}
|
||||||
|
if (hours > 0) return `还可编辑约 ${hours} 小时 ${mins} 分`;
|
||||||
|
return `还可编辑约 ${mins} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ComposePage() {
|
export default function ComposePage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { id: editIdParam } = useParams();
|
const { id: editIdParam } = useParams();
|
||||||
@@ -50,13 +73,21 @@ export default function ComposePage() {
|
|||||||
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
|
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
|
||||||
);
|
);
|
||||||
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
|
||||||
|
const [editWindowHint, setEditWindowHint] = useState('');
|
||||||
|
const [draftHint, setDraftHint] = useState('');
|
||||||
|
const draftReadyRef = useRef(false);
|
||||||
|
const draftTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!user) { nav('/login'); return; }
|
if (!user) {
|
||||||
|
nav(loginPath(isEdit ? `/post/${editId}/edit` : '/compose'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
draftReadyRef.current = false;
|
||||||
const cached = resolveBoards(layoutCtx?.boards);
|
const cached = resolveBoards(layoutCtx?.boards);
|
||||||
const boardsPromise = cached.length > 0
|
const boardsPromise = cached.length > 0
|
||||||
? Promise.resolve({ boards: cached })
|
? Promise.resolve({ boards: cached })
|
||||||
@@ -78,16 +109,43 @@ export default function ComposePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const loadedBoardId = String(post.board_id);
|
const loadedBoardId = String(post.board_id);
|
||||||
setBoardId(loadedBoardId);
|
const serverBaseline: ComposeBaseline = {
|
||||||
setTitle(post.title);
|
|
||||||
setTags(post.tags ?? '');
|
|
||||||
setContent(post.content ?? '');
|
|
||||||
setBaseline({
|
|
||||||
title: post.title,
|
title: post.title,
|
||||||
tags: post.tags ?? '',
|
tags: post.tags ?? '',
|
||||||
content: post.content ?? '',
|
content: post.content ?? '',
|
||||||
boardId: loadedBoardId,
|
boardId: loadedBoardId,
|
||||||
});
|
};
|
||||||
|
setBoardId(loadedBoardId);
|
||||||
|
setBaseline(serverBaseline);
|
||||||
|
|
||||||
|
const windowHours = postData.post_edit_window_hours ?? 0;
|
||||||
|
if (user.role !== 'admin' && windowHours > 0) {
|
||||||
|
setEditWindowHint(formatEditRemaining(post.created_at, windowHours));
|
||||||
|
} else {
|
||||||
|
setEditWindowHint('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const draft = loadComposeDraft(editId);
|
||||||
|
const useDraft = draft
|
||||||
|
&& draftHasContent(draft)
|
||||||
|
&& (
|
||||||
|
draft.title !== serverBaseline.title
|
||||||
|
|| draft.tags !== serverBaseline.tags
|
||||||
|
|| draft.content !== serverBaseline.content
|
||||||
|
);
|
||||||
|
if (useDraft && draft) {
|
||||||
|
setTitle(draft.title);
|
||||||
|
setTags(draft.tags);
|
||||||
|
setContent(draft.content);
|
||||||
|
setDraftHint('已恢复未保存的编辑草稿');
|
||||||
|
notify.success('已恢复未保存的编辑草稿');
|
||||||
|
} else {
|
||||||
|
setTitle(serverBaseline.title);
|
||||||
|
setTags(serverBaseline.tags);
|
||||||
|
setContent(serverBaseline.content);
|
||||||
|
setDraftHint('');
|
||||||
|
}
|
||||||
|
draftReadyRef.current = true;
|
||||||
})
|
})
|
||||||
.catch((e: unknown) => {
|
.catch((e: unknown) => {
|
||||||
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
||||||
@@ -97,40 +155,77 @@ export default function ComposePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
draftReadyRef.current = false;
|
||||||
|
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
|
||||||
|
setBoards(list);
|
||||||
|
if (!defaultBoard) setBoardId(initialBoardId);
|
||||||
|
const boardForBaseline = defaultBoard || initialBoardId;
|
||||||
|
setBoardId(prev => prev || boardForBaseline);
|
||||||
|
|
||||||
|
const draft = loadComposeDraft(null);
|
||||||
|
if (draft && draftHasContent(draft)) {
|
||||||
|
setTitle(draft.title);
|
||||||
|
setTags(draft.tags);
|
||||||
|
setContent(draft.content);
|
||||||
|
if (draft.boardId && list.some(b => String(b.id) === draft.boardId)) {
|
||||||
|
setBoardId(draft.boardId);
|
||||||
|
}
|
||||||
|
setBaseline({
|
||||||
|
title: '',
|
||||||
|
tags: '',
|
||||||
|
content: '',
|
||||||
|
boardId: draft.boardId || boardForBaseline,
|
||||||
|
});
|
||||||
|
setDraftHint('已恢复本地草稿');
|
||||||
|
notify.success('已恢复本地草稿');
|
||||||
|
} else {
|
||||||
|
setBaseline({
|
||||||
|
title: '',
|
||||||
|
tags: '',
|
||||||
|
content: '',
|
||||||
|
boardId: boardForBaseline,
|
||||||
|
});
|
||||||
|
setDraftHint('');
|
||||||
|
}
|
||||||
|
draftReadyRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
const list = resolveBoards(layoutCtx?.boards);
|
const list = resolveBoards(layoutCtx?.boards);
|
||||||
if (list.length > 0) {
|
if (list.length > 0) {
|
||||||
setBoards(list);
|
|
||||||
setBoardsReady(true);
|
|
||||||
const initialBoardId = defaultBoard || String(list[0].id);
|
const initialBoardId = defaultBoard || String(list[0].id);
|
||||||
if (!defaultBoard) setBoardId(initialBoardId);
|
applyNewBaseline(list, initialBoardId);
|
||||||
setBaseline({
|
setBoardsReady(true);
|
||||||
title: '',
|
|
||||||
tags: '',
|
|
||||||
content: '',
|
|
||||||
boardId: initialBoardId,
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBoardsReady(false);
|
setBoardsReady(false);
|
||||||
api.boards().then(d => {
|
api.boards().then(d => {
|
||||||
const next = d.boards ?? [];
|
const next = d.boards ?? [];
|
||||||
setBoards(next);
|
|
||||||
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
|
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
|
||||||
if (!defaultBoard && next.length > 0) {
|
applyNewBaseline(next, initialBoardId);
|
||||||
setBoardId(initialBoardId);
|
|
||||||
}
|
|
||||||
setBaseline({
|
|
||||||
title: '',
|
|
||||||
tags: '',
|
|
||||||
content: '',
|
|
||||||
boardId: initialBoardId,
|
|
||||||
});
|
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
setBoards([]);
|
setBoards([]);
|
||||||
}).finally(() => setBoardsReady(true));
|
}).finally(() => setBoardsReady(true));
|
||||||
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
|
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
|
||||||
|
|
||||||
|
// 防抖自动保存草稿
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draftReadyRef.current || !user) return;
|
||||||
|
clearTimeout(draftTimerRef.current);
|
||||||
|
draftTimerRef.current = setTimeout(() => {
|
||||||
|
saveComposeDraft(isEdit ? editId : null, {
|
||||||
|
title,
|
||||||
|
tags,
|
||||||
|
content,
|
||||||
|
boardId,
|
||||||
|
});
|
||||||
|
if (title.trim() || tags.trim() || content.trim()) {
|
||||||
|
setDraftHint('草稿已自动保存');
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
return () => clearTimeout(draftTimerRef.current);
|
||||||
|
}, [title, tags, content, boardId, isEdit, editId, user]);
|
||||||
|
|
||||||
const isDirty = useMemo(() => {
|
const isDirty = useMemo(() => {
|
||||||
if (!baseline) return false;
|
if (!baseline) return false;
|
||||||
return (
|
return (
|
||||||
@@ -206,11 +301,13 @@ export default function ComposePage() {
|
|||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await api.updatePost(editId!, payload);
|
await api.updatePost(editId!, payload);
|
||||||
notify.success('帖子已更新');
|
notify.success('帖子已更新');
|
||||||
|
clearComposeDraft(editId);
|
||||||
markSaved();
|
markSaved();
|
||||||
nav(`/post/${editId}`);
|
nav(`/post/${editId}`);
|
||||||
} else {
|
} else {
|
||||||
const res = await api.createPost({ board_id: boardId, ...payload });
|
const res = await api.createPost({ board_id: boardId, ...payload });
|
||||||
notify.success('发帖成功');
|
notify.success('发帖成功');
|
||||||
|
clearComposeDraft(null);
|
||||||
markSaved();
|
markSaved();
|
||||||
nav(`/post/${res.post_id}`);
|
nav(`/post/${res.post_id}`);
|
||||||
}
|
}
|
||||||
@@ -230,12 +327,20 @@ export default function ComposePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="compose-back"
|
className="compose-back"
|
||||||
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
|
onClick={() => requestLeave(() => {
|
||||||
|
if (isEdit) nav(`/post/${editId}`);
|
||||||
|
else nav(-1);
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<ArrowLeft size={16} />
|
<ArrowLeft size={16} />
|
||||||
<span>返回</span>
|
<span>返回</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="compose-header-actions">
|
<div className="compose-header-actions">
|
||||||
|
{(draftHint || editWindowHint) && (
|
||||||
|
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
|
||||||
|
{editWindowHint || draftHint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="compose-publish-btn"
|
className="compose-publish-btn"
|
||||||
@@ -287,6 +392,9 @@ export default function ComposePage() {
|
|||||||
{currentBoard && (
|
{currentBoard && (
|
||||||
<div className="compose-subtitle">
|
<div className="compose-subtitle">
|
||||||
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
|
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
|
||||||
|
{editWindowHint && (
|
||||||
|
<span className="compose-edit-window"> · {editWindowHint}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ArticleEditor
|
<ArticleEditor
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { api } from '../api/client';
|
|||||||
import type { PostItem } from '../api/types';
|
import type { PostItem } from '../api/types';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import PostListItem from '../components/PostListItem';
|
import PostListItem from '../components/PostListItem';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
|
import { openForumPost } from '../utils/openPost';
|
||||||
|
|
||||||
interface FavItem {
|
interface FavItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -19,12 +22,13 @@ interface FavItem {
|
|||||||
export default function FavoritesPage() {
|
export default function FavoritesPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { user, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
const { limits } = useForumLimits();
|
||||||
const [list, setList] = useState<FavItem[]>([]);
|
const [list, setList] = useState<FavItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
if (!user) { nav('/login'); return; }
|
if (!user) { nav(loginPath('/favorites')); return; }
|
||||||
api.favorites()
|
api.favorites()
|
||||||
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
|
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
|
||||||
.catch(e => notify.error(e.message))
|
.catch(e => notify.error(e.message))
|
||||||
@@ -58,14 +62,14 @@ export default function FavoritesPage() {
|
|||||||
<PostListItem
|
<PostListItem
|
||||||
key={fav.id}
|
key={fav.id}
|
||||||
post={fav.post}
|
post={fav.post}
|
||||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
key={fav.id}
|
key={fav.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="post-row"
|
className="post-row"
|
||||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
onClick={() => openForumPost(nav, fav.post_id, limits.open_posts_in_new_tab)}
|
||||||
>
|
>
|
||||||
<div className="post-body">
|
<div className="post-body">
|
||||||
<div className="post-title">帖子已删除</div>
|
<div className="post-title">帖子已删除</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
@@ -6,6 +6,7 @@ import type { PostItem } from '../api/types';
|
|||||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
import VirtualPostList from '../components/VirtualPostList';
|
import VirtualPostList from '../components/VirtualPostList';
|
||||||
import FeedHeader from '../components/FeedHeader';
|
import FeedHeader from '../components/FeedHeader';
|
||||||
|
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import {
|
import {
|
||||||
@@ -16,44 +17,41 @@ import {
|
|||||||
FEED_RESET_EVENT,
|
FEED_RESET_EVENT,
|
||||||
type FeedNavState,
|
type FeedNavState,
|
||||||
} from '../utils/feedCache';
|
} from '../utils/feedCache';
|
||||||
|
import { openForumPost } from '../utils/openPost';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const ctx = useOutletContext<LayoutCtx>();
|
const ctx = useOutletContext<LayoutCtx>();
|
||||||
const { limits } = useForumLimits();
|
const { limits, loading: limitsLoading } = useForumLimits();
|
||||||
const pageSize = limits.page_size_default;
|
const pageSize = Math.max(1, limits.page_size_default);
|
||||||
const feedMaxPages = limits.feed_max_pages;
|
|
||||||
const feedMaxItems = limits.feed_max_items;
|
|
||||||
|
|
||||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||||
const keyword = params.get('keyword') || '';
|
const keyword = params.get('keyword') || '';
|
||||||
const sort = parseFeedSort(params.get('sort'));
|
const sort = parseFeedSort(params.get('sort'));
|
||||||
const initialCache = getFeedCache(boardId, keyword, sort);
|
|
||||||
|
|
||||||
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||||
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
const [postTotal, setPostTotal] = useState(0);
|
||||||
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [loading, setLoading] = useState(() => !initialCache);
|
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
|
||||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
|
||||||
const [listResetKey, setListResetKey] = useState(0);
|
const [listResetKey, setListResetKey] = useState(0);
|
||||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
|
||||||
const pageWrapRef = useRef<HTMLDivElement>(null);
|
|
||||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
|
||||||
const skipCacheSaveRef = useRef(false);
|
|
||||||
|
|
||||||
const canAutoLoad = useMemo(
|
const scrollTopRef = useRef(0);
|
||||||
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
|
const skipCacheSaveRef = useRef(false);
|
||||||
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
|
const loadingRef = useRef(false);
|
||||||
);
|
const pageRef = useRef(1);
|
||||||
|
pageRef.current = page;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
|
const showPagination = totalPages > 1 && posts.length > 0;
|
||||||
|
const hasMore = page < totalPages;
|
||||||
|
|
||||||
const resetFeedView = useCallback(() => {
|
const resetFeedView = useCallback(() => {
|
||||||
setRestoreScrollTop(null);
|
setRestoreScrollTop(null);
|
||||||
scrollTopRef.current = 0;
|
scrollTopRef.current = 0;
|
||||||
setListResetKey(k => k + 1);
|
setListResetKey(k => k + 1);
|
||||||
pageWrapRef.current?.scrollTo(0);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const beginFeedRefresh = useCallback(() => {
|
const beginFeedRefresh = useCallback(() => {
|
||||||
@@ -62,7 +60,9 @@ export default function HomePage() {
|
|||||||
resetFeedView();
|
resetFeedView();
|
||||||
}, [resetFeedView]);
|
}, [resetFeedView]);
|
||||||
|
|
||||||
const load = useCallback(async (p: number, reset = false) => {
|
const fetchPage = useCallback(async (p: number) => {
|
||||||
|
if (loadingRef.current) return;
|
||||||
|
loadingRef.current = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.posts({
|
const data = await api.posts({
|
||||||
@@ -73,67 +73,77 @@ export default function HomePage() {
|
|||||||
sort: sort === 'latest' ? '' : sort,
|
sort: sort === 'latest' ? '' : sort,
|
||||||
});
|
});
|
||||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
const total = data.total ?? 0;
|
||||||
setPostTotal(data.total ?? 0);
|
setPosts(batch);
|
||||||
setHasMore(!!data.has_more);
|
setPostTotal(total);
|
||||||
setPage(p);
|
setPage(p);
|
||||||
|
pageRef.current = p;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||||
if (reset) setPosts([]);
|
setPosts([]);
|
||||||
|
setPostTotal(0);
|
||||||
|
setPage(1);
|
||||||
|
pageRef.current = 1;
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingRef.current = false;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [boardId, keyword, sort, pageSize]);
|
}, [boardId, keyword, sort, pageSize]);
|
||||||
|
|
||||||
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
|
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||||
const revalidate = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.posts({
|
|
||||||
page: 1,
|
|
||||||
size: pageSize,
|
|
||||||
board_id: boardId || '',
|
|
||||||
keyword,
|
|
||||||
sort: sort === 'latest' ? '' : sort,
|
|
||||||
});
|
|
||||||
const fresh = Array.isArray(data.posts) ? data.posts : [];
|
|
||||||
const freshIds = new Set(fresh.map(p => p.id));
|
|
||||||
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
|
|
||||||
setPostTotal(data.total ?? 0);
|
|
||||||
setHasMore(!!data.has_more);
|
|
||||||
} catch {
|
|
||||||
// 静默失败,保留缓存数据
|
|
||||||
}
|
|
||||||
}, [boardId, keyword, sort, pageSize]);
|
|
||||||
|
|
||||||
const loadNextPage = useCallback(() => {
|
const goToPage = useCallback((p: number) => {
|
||||||
if (loading || !hasMore) return;
|
if (loadingRef.current) return;
|
||||||
load(page + 1);
|
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||||
}, [loading, hasMore, page, load]);
|
if (p < 1 || p > maxPage) return;
|
||||||
|
if (p === pageRef.current) return;
|
||||||
|
resetFeedView();
|
||||||
|
fetchPage(p);
|
||||||
|
}, [fetchPage, postTotal, pageSize, resetFeedView]);
|
||||||
|
|
||||||
|
const handleSelectPost = useCallback((id: number) => {
|
||||||
|
openForumPost(nav, id, limits.open_posts_in_new_tab);
|
||||||
|
}, [nav, limits.open_posts_in_new_tab]);
|
||||||
|
|
||||||
|
// 等限制就绪后再拉列表;筛选变化时重载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (limitsLoading) return;
|
||||||
|
|
||||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||||
if (forceRefresh) {
|
if (forceRefresh) {
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
load(1, true);
|
loadFirst();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = getFeedCache(boardId, keyword, sort);
|
const cached = getFeedCache(boardId, keyword, sort);
|
||||||
if (cached) {
|
if (cached && cached.posts.length > 0) {
|
||||||
setPosts(cached.posts);
|
setPosts(cached.posts);
|
||||||
setPostTotal(cached.postTotal);
|
setPostTotal(cached.postTotal);
|
||||||
setPage(cached.page);
|
setPage(cached.page);
|
||||||
setHasMore(cached.hasMore);
|
pageRef.current = cached.page;
|
||||||
setRestoreScrollTop(cached.scrollTop);
|
setRestoreScrollTop(cached.scrollTop);
|
||||||
scrollTopRef.current = cached.scrollTop;
|
scrollTopRef.current = cached.scrollTop;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
revalidate();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRestoreScrollTop(null);
|
setRestoreScrollTop(null);
|
||||||
scrollTopRef.current = 0;
|
scrollTopRef.current = 0;
|
||||||
load(1, true);
|
loadFirst();
|
||||||
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
}, [
|
||||||
|
limitsLoading,
|
||||||
|
pageSize,
|
||||||
|
boardId,
|
||||||
|
keyword,
|
||||||
|
sort,
|
||||||
|
location.key,
|
||||||
|
location.state,
|
||||||
|
loadFirst,
|
||||||
|
beginFeedRefresh,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 离开当前筛选条件时写入内存缓存
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||||
@@ -141,22 +151,17 @@ export default function HomePage() {
|
|||||||
posts,
|
posts,
|
||||||
postTotal,
|
postTotal,
|
||||||
page,
|
page,
|
||||||
hasMore,
|
|
||||||
scrollTop: scrollTopRef.current,
|
scrollTop: scrollTopRef.current,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
}, [boardId, keyword, sort, posts, postTotal, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && posts.length > 0) {
|
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||||
skipCacheSaveRef.current = false;
|
|
||||||
}
|
|
||||||
}, [loading, posts.length]);
|
}, [loading, posts.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFeedReset = () => {
|
const onFeedReset = () => beginFeedRefresh();
|
||||||
beginFeedRefresh();
|
|
||||||
};
|
|
||||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||||
}, [beginFeedRefresh]);
|
}, [beginFeedRefresh]);
|
||||||
@@ -164,16 +169,16 @@ export default function HomePage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fn = () => {
|
const fn = () => {
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
load(1, true);
|
loadFirst();
|
||||||
};
|
};
|
||||||
window.addEventListener('posts-refresh', fn);
|
window.addEventListener('posts-refresh', fn);
|
||||||
return () => window.removeEventListener('posts-refresh', fn);
|
return () => window.removeEventListener('posts-refresh', fn);
|
||||||
}, [beginFeedRefresh, load]);
|
}, [beginFeedRefresh, loadFirst]);
|
||||||
|
|
||||||
const handleSortChange = (next: FeedSort) => {
|
const handleSortChange = (next: FeedSort) => {
|
||||||
if (next === sort) {
|
if (next === sort) {
|
||||||
beginFeedRefresh();
|
beginFeedRefresh();
|
||||||
load(1, true);
|
loadFirst();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||||
@@ -181,8 +186,13 @@ export default function HomePage() {
|
|||||||
|
|
||||||
const showSortBar = !keyword;
|
const showSortBar = !keyword;
|
||||||
|
|
||||||
|
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
||||||
|
if ((loading || limitsLoading) && posts.length === 0) {
|
||||||
|
return <FeedPageSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-wrap" ref={pageWrapRef}>
|
<div className="page-wrap page-wrap--feed">
|
||||||
<div className="feed-panel">
|
<div className="feed-panel">
|
||||||
<div className="feed-top">
|
<div className="feed-top">
|
||||||
<FeedHeader
|
<FeedHeader
|
||||||
@@ -197,19 +207,21 @@ export default function HomePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<VirtualPostList
|
<VirtualPostList
|
||||||
posts={posts}
|
posts={posts}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
loading={loading}
|
loading={loading || limitsLoading}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
canAutoLoad={canAutoLoad}
|
showPagination={showPagination}
|
||||||
postTotal={postTotal}
|
page={page}
|
||||||
onLoadMore={loadNextPage}
|
totalPages={totalPages}
|
||||||
onSelect={(id) => nav(`/post/${id}`)}
|
postTotal={postTotal}
|
||||||
restoreScrollTop={restoreScrollTop}
|
onPageChange={goToPage}
|
||||||
resetScrollKey={listResetKey}
|
onSelect={handleSelectPost}
|
||||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
restoreScrollTop={restoreScrollTop}
|
||||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
resetScrollKey={listResetKey}
|
||||||
/>
|
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||||
|
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
|||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { resolveAuthRedirect, registerPath, navigateAfterAuth } from '../utils/authRedirect';
|
||||||
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import SiteBrandMark from '../components/SiteBrandMark';
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
username: z.string().min(1, '请输入用户名'),
|
username: z.string().min(1, '请输入用户名'),
|
||||||
@@ -19,8 +22,11 @@ type FormValues = z.infer<typeof schema>;
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { refresh } = useAuth();
|
const { refresh } = useAuth();
|
||||||
|
const { branding } = useSiteBranding();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const redirectTo = resolveAuthRedirect(searchParams);
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { username: '', password: '' },
|
defaultValues: { username: '', password: '' },
|
||||||
@@ -32,7 +38,7 @@ export default function LoginPage() {
|
|||||||
await api.login(values.username, values.password);
|
await api.login(values.username, values.password);
|
||||||
await refresh();
|
await refresh();
|
||||||
notify.success('登录成功');
|
notify.success('登录成功');
|
||||||
nav('/', { replace: true });
|
navigateAfterAuth(nav, redirectTo);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '登录失败');
|
notify.error(e instanceof Error ? e.message : '登录失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -43,9 +49,9 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-box">
|
<div className="auth-box">
|
||||||
<div className="logo-mark">姜</div>
|
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||||
<h1>登录姜十三论坛</h1>
|
<h1>登录{branding.name}</h1>
|
||||||
<p className="subtitle">拾三一隅,自在交流</p>
|
<p className="subtitle">{branding.slogan || '欢迎回来'}</p>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormField
|
<FormField
|
||||||
@@ -80,7 +86,7 @@ export default function LoginPage() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
<p className="auth-footer">
|
<p className="auth-footer">
|
||||||
没有账号?<Link to="/register">注册</Link>
|
没有账号?<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}>注册</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
|
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion, Trash2 } from 'lucide-react';
|
||||||
import PinnedIcon from '@/components/PinnedIcon';
|
import PinnedIcon from '@/components/PinnedIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import BoardBadge from '@/components/BoardBadge';
|
import BoardBadge from '@/components/BoardBadge';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { notify } from '@/lib/notify';
|
import { notify } from '@/lib/notify';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { PostItem, Comment } from '../api/types';
|
import type { PostItem, Comment } from '../api/types';
|
||||||
@@ -13,23 +24,42 @@ import CommentThreadList from '../components/CommentThreadList';
|
|||||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||||
import PostContent from '../components/PostContent';
|
import PostContent from '../components/PostContent';
|
||||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||||
|
import ArticleOutline from '../components/ArticleOutline';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||||
import { clearAllFeedCache } from '../utils/feedCache';
|
import { clearAllFeedCache } from '../utils/feedCache';
|
||||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||||
|
import type { PostHeading } from '../utils/postHeadings';
|
||||||
|
|
||||||
|
/** 格式化剩余可编辑时间 */
|
||||||
|
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||||
|
if (windowHours <= 0) return '';
|
||||||
|
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
|
||||||
|
const ms = deadline - Date.now();
|
||||||
|
if (ms <= 0) return '';
|
||||||
|
const hours = Math.floor(ms / 3600_000);
|
||||||
|
const mins = Math.floor((ms % 3600_000) / 60_000);
|
||||||
|
if (hours >= 24) return `还可编辑约 ${Math.floor(hours / 24)} 天`;
|
||||||
|
if (hours > 0) return `还可编辑约 ${hours} 小时`;
|
||||||
|
return `还可编辑约 ${mins} 分钟`;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PostDetailPage() {
|
export default function PostDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const postId = Number(id);
|
const postId = Number(id);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const { user, refresh } = useAuth();
|
const { user, refresh } = useAuth();
|
||||||
|
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||||
|
|
||||||
const [post, setPost] = useState<PostItem | null>(null);
|
const [post, setPost] = useState<PostItem | null>(null);
|
||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
const [favorited, setFavorited] = useState(false);
|
const [favorited, setFavorited] = useState(false);
|
||||||
const [replyTo, setReplyTo] = useState<Comment | null>(null);
|
const [replyTo, setReplyTo] = useState<Comment | null>(null);
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||||
@@ -37,7 +67,10 @@ export default function PostDetailPage() {
|
|||||||
const [canEdit, setCanEdit] = useState(false);
|
const [canEdit, setCanEdit] = useState(false);
|
||||||
const [isEdited, setIsEdited] = useState(false);
|
const [isEdited, setIsEdited] = useState(false);
|
||||||
const [editBlockReason, setEditBlockReason] = useState('');
|
const [editBlockReason, setEditBlockReason] = useState('');
|
||||||
|
const [editWindowHours, setEditWindowHours] = useState(0);
|
||||||
const [showRevisions, setShowRevisions] = useState(false);
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
|
const [deletingPost, setDeletingPost] = useState(false);
|
||||||
|
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
||||||
|
|
||||||
const pageRef = useRef<HTMLDivElement>(null);
|
const pageRef = useRef<HTMLDivElement>(null);
|
||||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -46,18 +79,37 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||||
|
|
||||||
|
const handleHeadingsChange = useCallback((next: PostHeading[]) => {
|
||||||
|
setHeadings(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !post) {
|
||||||
|
setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' });
|
||||||
|
return () => setPostOutline(null);
|
||||||
|
}
|
||||||
|
setPostOutline({
|
||||||
|
headings,
|
||||||
|
scrollRoot: pageRef.current,
|
||||||
|
title: '文章目录',
|
||||||
|
});
|
||||||
|
return () => setPostOutline(null);
|
||||||
|
}, [headings, loading, post, setPostOutline]);
|
||||||
|
|
||||||
const loadSeq = useRef(0);
|
const loadSeq = useRef(0);
|
||||||
|
const postPath = `/post/${postId}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!postId) return;
|
if (!postId) return;
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setHeadings([]);
|
||||||
const seq = ++loadSeq.current;
|
const seq = ++loadSeq.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setPost(null);
|
setPost(null);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
|
|
||||||
const myIds = user ? [] : loadMyCommentIds();
|
const myIds = user ? [] : loadMyCommentIds();
|
||||||
const [detail, comm] = await Promise.all([
|
const [detail, comm] = await Promise.all([
|
||||||
api.post(postId),
|
api.post(postId),
|
||||||
@@ -70,8 +122,8 @@ export default function PostDetailPage() {
|
|||||||
setCanEdit(detail.can_edit ?? false);
|
setCanEdit(detail.can_edit ?? false);
|
||||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||||
|
setEditWindowHours(detail.post_edit_window_hours ?? 0);
|
||||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||||
// 会话刷新与正文展示解耦;勿作为 effect 依赖
|
|
||||||
void refresh();
|
void refresh();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (seq !== loadSeq.current) return;
|
if (seq !== loadSeq.current) return;
|
||||||
@@ -81,16 +133,15 @@ export default function PostDetailPage() {
|
|||||||
if (seq === loadSeq.current) setLoading(false);
|
if (seq === loadSeq.current) setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// 仅 postId 变化时加载;user/refresh 变化不得重跑
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
|
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
// 发评后局部刷新评论列表(不整页重载)
|
|
||||||
const reloadComments = useCallback(async () => {
|
const reloadComments = useCallback(async () => {
|
||||||
const myIds = user ? [] : loadMyCommentIds();
|
const myIds = user ? [] : loadMyCommentIds();
|
||||||
const comm = await api.comments(postId, myIds);
|
const comm = await api.comments(postId, myIds);
|
||||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||||
}, [postId, user]);
|
}, [postId, user]);
|
||||||
|
|
||||||
const jumpToFloor = useCallback((floor: number) => {
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
const el = document.getElementById(`floor-${floor}`);
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -100,7 +151,13 @@ export default function PostDetailPage() {
|
|||||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const requireLogin = (actionLabel: string) => {
|
||||||
|
notify.warning(`登录后即可${actionLabel}`);
|
||||||
|
nav(loginPath(postPath));
|
||||||
|
};
|
||||||
|
|
||||||
const handleReplyTo = (comment: Comment) => {
|
const handleReplyTo = (comment: Comment) => {
|
||||||
|
setEditingCommentId(null);
|
||||||
if (replyTo?.id === comment.id) {
|
if (replyTo?.id === comment.id) {
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
return;
|
return;
|
||||||
@@ -108,7 +165,6 @@ export default function PostDetailPage() {
|
|||||||
setReplyTo(comment);
|
setReplyTo(comment);
|
||||||
};
|
};
|
||||||
|
|
||||||
// DOM 提交后再滚动,避免 setTimeout 与 focus 抢滚动导致概率性错位
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!replyTo) return;
|
if (!replyTo) return;
|
||||||
const el = document.getElementById(`reply-box-${replyTo.id}`);
|
const el = document.getElementById(`reply-box-${replyTo.id}`);
|
||||||
@@ -120,7 +176,7 @@ export default function PostDetailPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLike = async () => {
|
const handleLike = async () => {
|
||||||
if (!user) { nav('/login'); return; }
|
if (!user) { requireLogin('点赞'); return; }
|
||||||
try {
|
try {
|
||||||
const r = await api.like(postId);
|
const r = await api.like(postId);
|
||||||
setLiked(r.liked);
|
setLiked(r.liked);
|
||||||
@@ -131,7 +187,7 @@ export default function PostDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
if (!user) { nav('/login'); return; }
|
if (!user) { requireLogin('收藏'); return; }
|
||||||
try {
|
try {
|
||||||
const r = await api.favorite(postId);
|
const r = await api.favorite(postId);
|
||||||
setFavorited(r.favorited);
|
setFavorited(r.favorited);
|
||||||
@@ -165,6 +221,50 @@ export default function PostDetailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveComment = async (comment: Comment, content: string) => {
|
||||||
|
try {
|
||||||
|
const r = await api.updateComment(comment.id, content);
|
||||||
|
setComments(list => list.map(c => (
|
||||||
|
c.id === comment.id
|
||||||
|
? { ...c, content: r.content || content, updated_at: new Date().toISOString() }
|
||||||
|
: c
|
||||||
|
)));
|
||||||
|
setEditingCommentId(null);
|
||||||
|
notify.success('评论已更新');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteComment = async (comment: Comment) => {
|
||||||
|
try {
|
||||||
|
await api.deleteComment(comment.id);
|
||||||
|
setComments(list => list.filter(c => c.id !== comment.id));
|
||||||
|
if (replyTo?.id === comment.id) setReplyTo(null);
|
||||||
|
if (editingCommentId === comment.id) setEditingCommentId(null);
|
||||||
|
notify.success('评论已删除');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeletePost = async () => {
|
||||||
|
setDeletingPost(true);
|
||||||
|
try {
|
||||||
|
await api.deletePost(postId);
|
||||||
|
clearAllFeedCache();
|
||||||
|
window.dispatchEvent(new Event('posts-refresh'));
|
||||||
|
notify.success('帖子已删除');
|
||||||
|
nav('/', { replace: true });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||||
|
} finally {
|
||||||
|
setDeletingPost(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const commentBoxProps = {
|
const commentBoxProps = {
|
||||||
user,
|
user,
|
||||||
submitting,
|
submitting,
|
||||||
@@ -184,9 +284,12 @@ export default function PostDetailPage() {
|
|||||||
|
|
||||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||||
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
||||||
const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
|
const isOwnerOrAdmin = !!(user && (user.role === 'admin' || user.id === post.user_id));
|
||||||
const isAdmin = user?.role === 'admin';
|
const isAdmin = user?.role === 'admin';
|
||||||
const showEdited = isEdited && post.updated_at;
|
const showEdited = isEdited && post.updated_at;
|
||||||
|
const editRemaining = canEdit && user?.role !== 'admin'
|
||||||
|
? formatEditRemaining(post.created_at, editWindowHours)
|
||||||
|
: '';
|
||||||
|
|
||||||
const handlePin = async () => {
|
const handlePin = async () => {
|
||||||
if (!post) return;
|
if (!post) return;
|
||||||
@@ -264,14 +367,41 @@ export default function PostDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PostContent html={post.content || ''} isLoggedIn={!!user} />
|
{isMobile && headings.length > 0 && (
|
||||||
|
<details className="post-detail-toc-mobile">
|
||||||
|
<summary>文章目录({headings.length})</summary>
|
||||||
|
<ArticleOutline
|
||||||
|
headings={headings}
|
||||||
|
scrollRoot={pageRef.current}
|
||||||
|
title="目录"
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PostContent
|
||||||
|
html={post.content || ''}
|
||||||
|
isLoggedIn={!!user}
|
||||||
|
onHeadingsChange={handleHeadingsChange}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="post-detail-actions">
|
<div className="post-detail-actions">
|
||||||
<Button variant={liked ? 'default' : 'outline'} size="sm" onClick={handleLike}>
|
<Button
|
||||||
|
variant={liked ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleLike}
|
||||||
|
title={!user ? '登录后可点赞' : undefined}
|
||||||
|
className={!user ? 'post-action-guest' : undefined}
|
||||||
|
>
|
||||||
<ThumbsUp />
|
<ThumbsUp />
|
||||||
点赞 {post.like_count}
|
点赞 {post.like_count}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant={favorited ? 'default' : 'outline'} size="sm" onClick={handleFavorite}>
|
<Button
|
||||||
|
variant={favorited ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={handleFavorite}
|
||||||
|
title={!user ? '登录后可收藏' : undefined}
|
||||||
|
className={!user ? 'post-action-guest' : undefined}
|
||||||
|
>
|
||||||
<Star />
|
<Star />
|
||||||
{favorited ? '已收藏' : '收藏'}
|
{favorited ? '已收藏' : '收藏'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -287,6 +417,29 @@ export default function PostDetailPage() {
|
|||||||
编辑历史
|
编辑历史
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={deletingPost}>
|
||||||
|
<Trash2 />
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeletePost}>删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)}
|
||||||
|
{editRemaining && (
|
||||||
|
<span className="post-detail-edit-hint">{editRemaining}</span>
|
||||||
|
)}
|
||||||
{isOwnerOrAdmin && !canEdit && editBlockReason && (
|
{isOwnerOrAdmin && !canEdit && editBlockReason && (
|
||||||
<span className="post-detail-edit-hint" title={editBlockReason}>
|
<span className="post-detail-edit-hint" title={editBlockReason}>
|
||||||
{editBlockReason}
|
{editBlockReason}
|
||||||
@@ -338,8 +491,17 @@ export default function PostDetailPage() {
|
|||||||
comments={comments}
|
comments={comments}
|
||||||
highlightFloor={highlightFloor}
|
highlightFloor={highlightFloor}
|
||||||
replyToId={replyTo?.id ?? null}
|
replyToId={replyTo?.id ?? null}
|
||||||
|
editingId={editingCommentId}
|
||||||
|
currentUser={user}
|
||||||
onReply={handleReplyTo}
|
onReply={handleReplyTo}
|
||||||
onCancelReply={() => setReplyTo(null)}
|
onCancelReply={() => setReplyTo(null)}
|
||||||
|
onStartEdit={(c) => {
|
||||||
|
setReplyTo(null);
|
||||||
|
setEditingCommentId(c.id);
|
||||||
|
}}
|
||||||
|
onCancelEdit={() => setEditingCommentId(null)}
|
||||||
|
onSaveEdit={handleSaveComment}
|
||||||
|
onDelete={handleDeleteComment}
|
||||||
renderReplyBox={(c) => (
|
renderReplyBox={(c) => (
|
||||||
<CommentBox
|
<CommentBox
|
||||||
key={c.id}
|
key={c.id}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { api } from '../api/client';
|
|||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import AvatarCropDialog from '../components/AvatarCropDialog';
|
import AvatarCropDialog from '../components/AvatarCropDialog';
|
||||||
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||||
|
import { loginPath } from '../utils/authRedirect';
|
||||||
|
|
||||||
const nickSchema = z.object({
|
const nickSchema = z.object({
|
||||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||||
@@ -61,7 +62,7 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authLoading && !user) {
|
if (!authLoading && !user) {
|
||||||
nav('/login');
|
nav(loginPath('/profile'));
|
||||||
}
|
}
|
||||||
}, [authLoading, user, nav]);
|
}, [authLoading, user, nav]);
|
||||||
|
|
||||||
@@ -317,6 +318,12 @@ export default function ProfilePage() {
|
|||||||
<Input value={user.username} disabled />
|
<Input value={user.username} disabled />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>邮箱</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input value={user.email || '未设置'} disabled />
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
<FormField
|
<FormField
|
||||||
control={nickForm.control}
|
control={nickForm.control}
|
||||||
name="nickname"
|
name="nickname"
|
||||||
|
|||||||
119
frontend/src/pages/ProjectsPage.tsx
Normal file
119
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { ArrowLeft, ExternalLink, FolderGit2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { GiteaProject } from '../api/types';
|
||||||
|
|
||||||
|
function formatRemoteTime(raw?: string | null): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
const d = new Date(raw);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectsPage() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [list, setList] = useState<GiteaProject[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api.projects({ page, limit: 30 })
|
||||||
|
.then(d => {
|
||||||
|
setList(Array.isArray(d.projects) ? d.projects : []);
|
||||||
|
setTotal(d.total ?? 0);
|
||||||
|
setTotalPages(d.total_pages ?? 0);
|
||||||
|
})
|
||||||
|
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-wrap">
|
||||||
|
<div className="page-inner-wide">
|
||||||
|
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||||
|
<ArrowLeft />
|
||||||
|
返回
|
||||||
|
</Button>
|
||||||
|
<h1 className="page-title">开源码桶</h1>
|
||||||
|
<p className="page-desc">
|
||||||
|
论坛会员在 Gitea 上的公开仓库
|
||||||
|
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||||
|
) : list.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||||
|
<p>暂无同步到的公开项目</p>
|
||||||
|
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||||
|
管理员可在「系统设置 → Gitea 同步」配置后执行同步
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="content-surface projects-list">
|
||||||
|
{list.map(p => (
|
||||||
|
<article key={p.id} className="project-row">
|
||||||
|
<div className="project-row-body">
|
||||||
|
<h2 className="project-row-title">{p.full_name || p.name}</h2>
|
||||||
|
{p.description ? (
|
||||||
|
<p className="project-row-desc">{p.description}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="project-row-meta">
|
||||||
|
<span>{p.owner_login}</span>
|
||||||
|
{p.updated_at_remote && (
|
||||||
|
<span>更新于 {formatRemoteTime(p.updated_at_remote)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
className="project-row-link"
|
||||||
|
href={p.html_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
在 Gitea 打开
|
||||||
|
<ExternalLink size={14} aria-hidden />
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="projects-pager">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="projects-pager-info">{page} / {totalPages}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -10,32 +10,96 @@ import { notify } from '@/lib/notify';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { useForumLimits } from '../hooks/useForumLimits';
|
import { useForumLimits } from '../hooks/useForumLimits';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { resolveAuthRedirect, loginPath, navigateAfterAuth } from '../utils/authRedirect';
|
||||||
|
import type { RegisterConfig } from '../api/types';
|
||||||
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import SiteBrandMark from '../components/SiteBrandMark';
|
||||||
|
|
||||||
const schema = (minLen: number) => z.object({
|
const schema = (minLen: number) => z.object({
|
||||||
username: z.string().min(1, '请输入用户名'),
|
username: z.string().min(2, '用户名至少 2 位').max(32, '用户名最多 32 位'),
|
||||||
nickname: z.string().optional(),
|
nickname: z.string().optional(),
|
||||||
|
email: z.string().min(1, '请输入邮箱').email('请输入有效邮箱'),
|
||||||
password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
||||||
|
email_code: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type FormValues = z.infer<ReturnType<typeof schema>>;
|
type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
|
const { branding } = useSiteBranding();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { refresh } = useAuth();
|
const { refresh } = useAuth();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sendingCode, setSendingCode] = useState(false);
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [regConfig, setRegConfig] = useState<RegisterConfig | null>(null);
|
||||||
|
const redirectTo = resolveAuthRedirect(searchParams);
|
||||||
|
const requireCode = !!regConfig?.require_email_code;
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema(limits.password_min_len)),
|
resolver: zodResolver(schema(limits.password_min_len)),
|
||||||
defaultValues: { username: '', nickname: '', password: '' },
|
defaultValues: { username: '', nickname: '', email: '', password: '', email_code: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.registerConfig()
|
||||||
|
.then(setRegConfig)
|
||||||
|
.catch(() => setRegConfig({
|
||||||
|
is_first_user: false,
|
||||||
|
mail_ready: false,
|
||||||
|
require_email_code: false,
|
||||||
|
register_open: false,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return;
|
||||||
|
const t = window.setTimeout(() => setCountdown(c => c - 1), 1000);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
const sendCode = async () => {
|
||||||
|
const email = form.getValues('email');
|
||||||
|
const parsed = z.string().email().safeParse(email);
|
||||||
|
if (!parsed.success) {
|
||||||
|
form.setError('email', { message: '请先填写有效邮箱' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSendingCode(true);
|
||||||
|
try {
|
||||||
|
const r = await api.sendRegisterEmailCode(email);
|
||||||
|
notify.success(r.message);
|
||||||
|
setCountdown(60);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||||
|
} finally {
|
||||||
|
setSendingCode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
if (regConfig && !regConfig.register_open) {
|
||||||
|
notify.error('论坛暂未开放注册,请联系管理员配置邮件服务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requireCode && !values.email_code?.trim()) {
|
||||||
|
form.setError('email_code', { message: '请输入邮箱验证码' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.register(values.username, values.password, values.nickname || values.username);
|
await api.register({
|
||||||
|
username: values.username,
|
||||||
|
password: values.password,
|
||||||
|
nickname: values.nickname || values.username,
|
||||||
|
email: values.email,
|
||||||
|
emailCode: values.email_code,
|
||||||
|
});
|
||||||
await refresh();
|
await refresh();
|
||||||
notify.success('注册成功');
|
notify.success('注册成功');
|
||||||
nav('/', { replace: true });
|
navigateAfterAuth(nav, redirectTo);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
notify.error(e instanceof Error ? e.message : '注册失败');
|
notify.error(e instanceof Error ? e.message : '注册失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -43,61 +107,124 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subtitle = (() => {
|
||||||
|
if (!regConfig) return branding.slogan || '欢迎加入';
|
||||||
|
if (regConfig.is_first_user) return '首个注册用户自动成为管理员';
|
||||||
|
if (!regConfig.register_open) return '注册暂未开放,请等待管理员配置邮件服务';
|
||||||
|
return branding.slogan || '欢迎加入';
|
||||||
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="auth-page">
|
<div className="auth-page">
|
||||||
<div className="auth-box">
|
<div className="auth-box">
|
||||||
<div className="logo-mark">姜</div>
|
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||||
<h1>注册账号</h1>
|
<h1>注册账号</h1>
|
||||||
<p className="subtitle">首个注册用户自动成为管理员</p>
|
<p className="subtitle">{subtitle}</p>
|
||||||
<Form {...form}>
|
{regConfig && !regConfig.register_open ? (
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<p className="auth-footer">
|
||||||
<FormField
|
已有账号?<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}>登录</Link>
|
||||||
control={form.control}
|
</p>
|
||||||
name="username"
|
) : (
|
||||||
render={({ field }) => (
|
<>
|
||||||
<FormItem>
|
<Form {...form}>
|
||||||
<FormLabel>用户名</FormLabel>
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<FormControl>
|
<FormField
|
||||||
<Input placeholder="3-32 位字母数字下划线" autoComplete="username" {...field} />
|
control={form.control}
|
||||||
</FormControl>
|
name="username"
|
||||||
<FormMessage />
|
render={({ field }) => (
|
||||||
</FormItem>
|
<FormItem>
|
||||||
)}
|
<FormLabel>用户名</FormLabel>
|
||||||
/>
|
<FormControl>
|
||||||
<FormField
|
<Input placeholder="2-32 位,支持中文" autoComplete="username" {...field} />
|
||||||
control={form.control}
|
</FormControl>
|
||||||
name="nickname"
|
<FormMessage />
|
||||||
render={({ field }) => (
|
</FormItem>
|
||||||
<FormItem>
|
)}
|
||||||
<FormLabel>昵称</FormLabel>
|
/>
|
||||||
<FormControl>
|
<FormField
|
||||||
<Input placeholder="显示名称(可选)" autoComplete="nickname" {...field} />
|
control={form.control}
|
||||||
</FormControl>
|
name="nickname"
|
||||||
<FormMessage />
|
render={({ field }) => (
|
||||||
</FormItem>
|
<FormItem>
|
||||||
)}
|
<FormLabel>昵称</FormLabel>
|
||||||
/>
|
<FormControl>
|
||||||
<FormField
|
<Input placeholder="显示名称(可选)" autoComplete="nickname" {...field} />
|
||||||
control={form.control}
|
</FormControl>
|
||||||
name="password"
|
<FormMessage />
|
||||||
render={({ field }) => (
|
</FormItem>
|
||||||
<FormItem>
|
)}
|
||||||
<FormLabel>密码</FormLabel>
|
/>
|
||||||
<FormControl>
|
<FormField
|
||||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
control={form.control}
|
||||||
</FormControl>
|
name="email"
|
||||||
<FormMessage />
|
render={({ field }) => (
|
||||||
</FormItem>
|
<FormItem>
|
||||||
)}
|
<FormLabel>邮箱</FormLabel>
|
||||||
/>
|
<FormControl>
|
||||||
<Button type="submit" className="w-full" loading={loading}>
|
<Input type="email" placeholder="用于接收验证码" autoComplete="email" {...field} />
|
||||||
注册
|
</FormControl>
|
||||||
</Button>
|
<FormMessage />
|
||||||
</form>
|
</FormItem>
|
||||||
</Form>
|
)}
|
||||||
<p className="auth-footer">
|
/>
|
||||||
已有账号?<Link to="/login">登录</Link>
|
<FormField
|
||||||
</p>
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>密码</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{requireCode && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email_code"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>邮箱验证码</FormLabel>
|
||||||
|
<div className="auth-captcha-row">
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="6 位数字验证码"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
inputMode="numeric"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="auth-code-btn"
|
||||||
|
loading={sendingCode}
|
||||||
|
disabled={countdown > 0}
|
||||||
|
onClick={() => void sendCode()}
|
||||||
|
>
|
||||||
|
{countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{regConfig?.is_first_user && !regConfig.mail_ready && (
|
||||||
|
<p className="auth-hint">首次注册无需邮箱验证码,请注册后到后台配置 SMTP。</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" className="w-full" loading={loading}>
|
||||||
|
注册
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
<p className="auth-footer">
|
||||||
|
已有账号?<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}>登录</Link>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export default function AdminDashboardPage() {
|
|||||||
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
|
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
|
||||||
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
|
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
|
||||||
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
|
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
|
||||||
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -58,14 +58,18 @@ export default function AdminUsersPage() {
|
|||||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
<div className="admin-table-scroll">
|
||||||
<table className="admin-table">
|
<table className="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>用户名</th>
|
<th>用户名</th>
|
||||||
<th>昵称</th>
|
<th>昵称</th>
|
||||||
|
<th>邮箱</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
|
<th>上次登录</th>
|
||||||
|
<th>登录 IP</th>
|
||||||
<th>注册时间</th>
|
<th>注册时间</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -76,12 +80,15 @@ export default function AdminUsersPage() {
|
|||||||
<td>{u.id}</td>
|
<td>{u.id}</td>
|
||||||
<td>{u.username}</td>
|
<td>{u.username}</td>
|
||||||
<td>{u.nickname}</td>
|
<td>{u.nickname}</td>
|
||||||
|
<td className="admin-table-email">{u.email || '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
{u.role === 'admin'
|
{u.role === 'admin'
|
||||||
? <Badge variant="orange">管理员</Badge>
|
? <Badge variant="orange">管理员</Badge>
|
||||||
: <Badge variant="secondary">用户</Badge>}
|
: <Badge variant="secondary">用户</Badge>}
|
||||||
</td>
|
</td>
|
||||||
<td>{u.banned ? <Badge variant="destructive">已禁言</Badge> : '正常'}</td>
|
<td>{u.banned ? <Badge variant="destructive">已禁言</Badge> : '正常'}</td>
|
||||||
|
<td>{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'}</td>
|
||||||
|
<td className="admin-table-mono">{u.last_login_ip || '—'}</td>
|
||||||
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
|
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
|
||||||
<td>
|
<td>
|
||||||
{u.role !== 'admin' && (
|
{u.role !== 'admin' && (
|
||||||
@@ -94,6 +101,7 @@ export default function AdminUsersPage() {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
|
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="admin-pagination">
|
<div className="admin-pagination">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
49
frontend/src/utils/authRedirect.ts
Normal file
49
frontend/src/utils/authRedirect.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/** 构造带回跳的登录路径 */
|
||||||
|
export function loginPath(from?: string): string {
|
||||||
|
const path = sanitizeReturnPath(from ?? currentPath());
|
||||||
|
if (!path) return '/login';
|
||||||
|
return `/login?from=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造带回跳的注册路径 */
|
||||||
|
export function registerPath(from?: string): string {
|
||||||
|
const path = sanitizeReturnPath(from ?? currentPath());
|
||||||
|
if (!path) return '/register';
|
||||||
|
return `/register?from=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从查询参数解析登录/注册成功后的回跳地址 */
|
||||||
|
export function resolveAuthRedirect(search: string | URLSearchParams, fallback = '/'): string {
|
||||||
|
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
|
||||||
|
return sanitizeReturnPath(params.get('from') ?? '') || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth/OIDC 协议路径需整页跳转,不能走 React Router */
|
||||||
|
export function isProtocolReturnPath(path: string): boolean {
|
||||||
|
return path.startsWith('/oauth/') || path.startsWith('/.well-known/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录/注册成功后回跳(协议路径用 location 整页导航) */
|
||||||
|
export function navigateAfterAuth(
|
||||||
|
nav: (to: string, opts?: { replace?: boolean }) => void,
|
||||||
|
redirectTo: string,
|
||||||
|
): void {
|
||||||
|
if (isProtocolReturnPath(redirectTo)) {
|
||||||
|
window.location.assign(redirectTo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav(redirectTo, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPath(): string {
|
||||||
|
if (typeof window === 'undefined') return '/';
|
||||||
|
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅允许站内相对路径,避免开放重定向 */
|
||||||
|
function sanitizeReturnPath(raw: string): string {
|
||||||
|
const path = raw.trim();
|
||||||
|
if (!path.startsWith('/') || path.startsWith('//')) return '';
|
||||||
|
if (path.startsWith('/login') || path.startsWith('/register')) return '';
|
||||||
|
return path;
|
||||||
|
}
|
||||||
61
frontend/src/utils/composeDraft.ts
Normal file
61
frontend/src/utils/composeDraft.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
export interface ComposeDraft {
|
||||||
|
title: string;
|
||||||
|
tags: string;
|
||||||
|
content: string;
|
||||||
|
boardId: string;
|
||||||
|
savedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PREFIX = 'j13-compose-draft:';
|
||||||
|
|
||||||
|
function draftKey(editId: number | null): string {
|
||||||
|
return editId == null ? `${PREFIX}new` : `${PREFIX}edit:${editId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取发帖/编辑草稿 */
|
||||||
|
export function loadComposeDraft(editId: number | null): ComposeDraft | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(draftKey(editId));
|
||||||
|
if (!raw) return null;
|
||||||
|
const data = JSON.parse(raw) as ComposeDraft;
|
||||||
|
if (!data || typeof data !== 'object') return null;
|
||||||
|
return {
|
||||||
|
title: typeof data.title === 'string' ? data.title : '',
|
||||||
|
tags: typeof data.tags === 'string' ? data.tags : '',
|
||||||
|
content: typeof data.content === 'string' ? data.content : '',
|
||||||
|
boardId: typeof data.boardId === 'string' ? data.boardId : '',
|
||||||
|
savedAt: typeof data.savedAt === 'number' ? data.savedAt : 0,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入发帖/编辑草稿 */
|
||||||
|
export function saveComposeDraft(editId: number | null, draft: Omit<ComposeDraft, 'savedAt'>): void {
|
||||||
|
try {
|
||||||
|
const payload: ComposeDraft = { ...draft, savedAt: Date.now() };
|
||||||
|
localStorage.setItem(draftKey(editId), JSON.stringify(payload));
|
||||||
|
} catch {
|
||||||
|
// 配额不足等场景静默忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除发帖/编辑草稿 */
|
||||||
|
export function clearComposeDraft(editId: number | null): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(draftKey(editId));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 草稿是否相对 baseline 有实质内容 */
|
||||||
|
export function draftHasContent(draft: ComposeDraft): boolean {
|
||||||
|
return Boolean(
|
||||||
|
draft.title.trim()
|
||||||
|
|| draft.tags.trim()
|
||||||
|
|| draft.content.trim()
|
||||||
|
|| draft.boardId.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import hljs from 'highlight.js/lib/common';
|
||||||
|
|
||||||
|
/** 从 class / data-lang 中解析作者标注的语言标识 */
|
||||||
|
function detectLang(...els: Element[]): string {
|
||||||
|
for (const el of els) {
|
||||||
|
const data = el.getAttribute('data-lang') || el.getAttribute('data-language');
|
||||||
|
if (data?.trim()) return data.trim().toLowerCase();
|
||||||
|
const cls = el.getAttribute('class') || '';
|
||||||
|
const m = cls.match(/(?:language|lang)-([a-z0-9_+-]+)/i);
|
||||||
|
if (m?.[1]) return m[1].toLowerCase();
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
|
||||||
|
export function enhanceCodeBlocks(root: ParentNode): void {
|
||||||
|
root.querySelectorAll('pre').forEach(pre => {
|
||||||
|
if (pre.closest('.md-codeblock')) return;
|
||||||
|
const code = pre.querySelector('code') || pre;
|
||||||
|
const raw = code.textContent || '';
|
||||||
|
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
||||||
|
const declaredLang = detectLang(code, pre);
|
||||||
|
let label = declaredLang || 'code';
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||||
|
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||||
|
code.innerHTML = result.value;
|
||||||
|
code.classList.add('hljs', `language-${declaredLang}`);
|
||||||
|
} else if (declaredLang) {
|
||||||
|
// 未收录语言(如 aardio):保留原文与标签,不做自动猜测
|
||||||
|
code.classList.add('hljs', `language-${declaredLang}`);
|
||||||
|
} else if (raw.length >= 24) {
|
||||||
|
const result = hljs.highlightAuto(raw);
|
||||||
|
code.innerHTML = result.value;
|
||||||
|
code.classList.add('hljs');
|
||||||
|
if (result.language) {
|
||||||
|
label = result.language;
|
||||||
|
code.classList.add(`language-${result.language}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
code.classList.add('hljs');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
code.textContent = raw;
|
||||||
|
code.classList.add('hljs');
|
||||||
|
if (declaredLang) code.classList.add(`language-${declaredLang}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wrap = pre.ownerDocument.createElement('div');
|
||||||
|
wrap.className = 'md-codeblock';
|
||||||
|
wrap.setAttribute('data-lang', label);
|
||||||
|
|
||||||
|
const head = pre.ownerDocument.createElement('div');
|
||||||
|
head.className = 'md-codeblock__head';
|
||||||
|
head.innerHTML = `
|
||||||
|
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
||||||
|
<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
pre.parentNode?.insertBefore(wrap, pre);
|
||||||
|
wrap.appendChild(head);
|
||||||
|
wrap.appendChild(pre);
|
||||||
|
pre.classList.add('md-codeblock__pre');
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,111 +5,33 @@ import type { FeedSort } from '../components/FeedSortBar';
|
|||||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||||
export type FeedNavState = { refreshFeed?: boolean };
|
export type FeedNavState = { refreshFeed?: boolean };
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export type FeedCache = {
|
export type FeedCache = {
|
||||||
|
|
||||||
posts: PostItem[];
|
posts: PostItem[];
|
||||||
|
|
||||||
postTotal: number;
|
postTotal: number;
|
||||||
|
|
||||||
page: number;
|
page: number;
|
||||||
|
|
||||||
hasMore: boolean;
|
|
||||||
|
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||||
|
const store = new Map<string, FeedCache>();
|
||||||
const PREFIX = 'j13-feed-cache:';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||||
|
return `${boardId}:${keyword}:${sort}`;
|
||||||
return `${PREFIX}${boardId}:${keyword}:${sort}`;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||||
|
|
||||||
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
|
|
||||||
|
|
||||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
||||||
|
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
|
||||||
try {
|
|
||||||
|
|
||||||
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
|
|
||||||
|
|
||||||
return raw ? (JSON.parse(raw) as FeedCache) : null;
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
return null;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** 保存帖子列表缓存 */
|
/** 保存帖子列表缓存 */
|
||||||
|
|
||||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||||
|
store.set(cacheKey(boardId, keyword, sort), data);
|
||||||
try {
|
|
||||||
|
|
||||||
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
// sessionStorage 不可用时忽略
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清除所有帖子列表缓存 */
|
||||||
|
|
||||||
/** 清除指定筛选条件下的列表缓存 */
|
|
||||||
|
|
||||||
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
|
|
||||||
|
|
||||||
try {
|
|
||||||
|
|
||||||
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
// ignore
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
|
||||||
export function clearAllFeedCache() {
|
export function clearAllFeedCache() {
|
||||||
|
store.clear();
|
||||||
try {
|
|
||||||
|
|
||||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
|
||||||
|
|
||||||
const key = sessionStorage.key(i);
|
|
||||||
|
|
||||||
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
|
||||||
|
|
||||||
// ignore
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Board, ForumStats } from '../api/types';
|
import type { Board, ForumStats, RecentComment, PostItem, TagCount } from '../api/types';
|
||||||
|
|
||||||
const BOARDS_KEY = 'j13-cache-boards';
|
const BOARDS_KEY = 'j13-cache-boards';
|
||||||
const STATS_KEY = 'j13-cache-stats';
|
const STATS_KEY = 'j13-cache-stats';
|
||||||
|
const HOT_KEY = 'j13-cache-hot';
|
||||||
|
const RECENT_COMMENTS_KEY = 'j13-cache-recent-comments';
|
||||||
|
const TAGS_KEY = 'j13-cache-tags';
|
||||||
|
|
||||||
function readJson<T>(key: string): T | null {
|
function readJson<T>(key: string): T | null {
|
||||||
try {
|
try {
|
||||||
@@ -30,6 +33,33 @@ export function getCachedStats(): ForumStats | null {
|
|||||||
return readJson<ForumStats>(STATS_KEY);
|
return readJson<ForumStats>(STATS_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取缓存的热门帖子,避免右栏/抽屉首屏高度跳动 */
|
||||||
|
export function getCachedHot(): PostItem[] {
|
||||||
|
const list = readJson<PostItem[]>(HOT_KEY);
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取缓存的最新评论,避免右栏/抽屉首屏高度跳动 */
|
||||||
|
export function getCachedRecentComments(): RecentComment[] {
|
||||||
|
const list = readJson<RecentComment[]>(RECENT_COMMENTS_KEY);
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取缓存的标签云 */
|
||||||
|
export function getCachedTags(): TagCount[] {
|
||||||
|
const list = readJson<TagCount[]>(TAGS_KEY);
|
||||||
|
return Array.isArray(list) ? list : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 右栏是否已有可展示的 session 缓存(含空列表) */
|
||||||
|
export function hasCachedAside(): boolean {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(HOT_KEY) != null || sessionStorage.getItem(RECENT_COMMENTS_KEY) != null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function setCachedBoards(boards: Board[]) {
|
export function setCachedBoards(boards: Board[]) {
|
||||||
writeJson(BOARDS_KEY, boards);
|
writeJson(BOARDS_KEY, boards);
|
||||||
}
|
}
|
||||||
@@ -37,3 +67,15 @@ export function setCachedBoards(boards: Board[]) {
|
|||||||
export function setCachedStats(stats: ForumStats) {
|
export function setCachedStats(stats: ForumStats) {
|
||||||
writeJson(STATS_KEY, stats);
|
writeJson(STATS_KEY, stats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setCachedHot(posts: PostItem[]) {
|
||||||
|
writeJson(HOT_KEY, posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedRecentComments(list: RecentComment[]) {
|
||||||
|
writeJson(RECENT_COMMENTS_KEY, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCachedTags(tags: TagCount[]) {
|
||||||
|
writeJson(TAGS_KEY, tags);
|
||||||
|
}
|
||||||
|
|||||||
15
frontend/src/utils/openPost.ts
Normal file
15
frontend/src/utils/openPost.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { NavigateFunction } from 'react-router-dom';
|
||||||
|
|
||||||
|
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
|
||||||
|
export function openForumPost(
|
||||||
|
nav: NavigateFunction,
|
||||||
|
postId: number,
|
||||||
|
openInNewTab: boolean,
|
||||||
|
) {
|
||||||
|
const path = `/post/${postId}`;
|
||||||
|
if (openInNewTab) {
|
||||||
|
window.open(path, '_blank', 'noopener,noreferrer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
nav(path);
|
||||||
|
}
|
||||||
@@ -1,52 +1,34 @@
|
|||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
import type { Config } from 'dompurify';
|
||||||
|
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
|
||||||
|
import { enhanceHeadingAnchors } from './postHeadings';
|
||||||
|
|
||||||
/** DOMPurify 配置:允许会员专属自定义标签 */
|
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */
|
||||||
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
|
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||||
ADD_TAGS: ['members-only'],
|
ADD_TAGS: ['members-only'],
|
||||||
ADD_ATTR: ['data-locked', 'data-length'],
|
ADD_ATTR: ['data-locked', 'data-length', 'target', 'rel', 'data-code-copy', 'data-lang'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const VISIBLE_BADGE_HTML = `
|
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
||||||
<div class="post-members-only__badge">
|
|
||||||
<span class="post-members-only__badge-icon" aria-hidden="true">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
|
||||||
</span>
|
|
||||||
<span>登录可见</span>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
const LOCK_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`;
|
/** 游客看到的锁定区块:流内嵌条 + 登录引导(精简高度) */
|
||||||
|
|
||||||
/** 游客看到的锁定区块:模糊占位 + 登录引导 */
|
|
||||||
function buildLockedGateHtml(charLength: number): string {
|
function buildLockedGateHtml(charLength: number): string {
|
||||||
const lineCount = charLength > 0
|
|
||||||
? Math.min(6, Math.max(3, Math.ceil(charLength / 42)))
|
|
||||||
: 4;
|
|
||||||
const lines = Array.from({ length: lineCount }, (_, i) => {
|
|
||||||
const mod = i % 3;
|
|
||||||
const widthClass = mod === 1 ? ' post-members-only__preview-line--medium'
|
|
||||||
: mod === 2 ? ' post-members-only__preview-line--short' : '';
|
|
||||||
return `<div class="post-members-only__preview-line${widthClass}"></div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const lengthHint = charLength > 0
|
const lengthHint = charLength > 0
|
||||||
? `约 ${charLength} 字的`
|
? `约 ${charLength} 字`
|
||||||
: '一段';
|
: '专属内容';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="post-members-only__locked-wrap">
|
<div class="post-members-only__locked-wrap">
|
||||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
|
||||||
<span class="post-members-only__badge-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
|
||||||
<span>登录可见</span>
|
|
||||||
</div>
|
|
||||||
<div class="post-members-only__preview" aria-hidden="true">
|
|
||||||
${lines}
|
|
||||||
</div>
|
|
||||||
<div class="post-members-only__gate">
|
<div class="post-members-only__gate">
|
||||||
<div class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</div>
|
<span class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||||
<p class="post-members-only__gate-title">此处有${lengthHint}专属内容</p>
|
<div class="post-members-only__gate-text">
|
||||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
<p class="post-members-only__gate-title">登录后可见(${lengthHint})</p>
|
||||||
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
<p class="post-members-only__gate-desc">作者将此段设为仅登录用户可读</p>
|
||||||
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span>
|
</div>
|
||||||
|
<div class="post-members-only__gate-actions">
|
||||||
|
<button type="button" class="post-members-only__gate-btn" data-members-login>登录查看</button>
|
||||||
|
<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
@@ -55,18 +37,22 @@ function buildLockedGateHtml(charLength: number): string {
|
|||||||
export function isHtmlEmpty(html: string): boolean {
|
export function isHtmlEmpty(html: string): boolean {
|
||||||
if (!html.trim()) return true;
|
if (!html.trim()) return true;
|
||||||
const doc = new DOMParser().parseFromString(
|
const doc = new DOMParser().parseFromString(
|
||||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||||
'text/html',
|
'text/html',
|
||||||
);
|
);
|
||||||
return (doc.body.textContent ?? '').trim().length === 0;
|
return (doc.body.textContent ?? '').trim().length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 根据登录状态渲染帖子正文 HTML */
|
/** 根据登录状态渲染帖子正文 HTML */
|
||||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
export function renderPostContentHtml(
|
||||||
|
html: string,
|
||||||
|
isLoggedIn: boolean,
|
||||||
|
opts?: { openLinksInNewTab?: boolean },
|
||||||
|
): string {
|
||||||
if (!html.trim()) return '';
|
if (!html.trim()) return '';
|
||||||
|
|
||||||
const doc = new DOMParser().parseFromString(
|
const doc = new DOMParser().parseFromString(
|
||||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||||
'text/html',
|
'text/html',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -87,8 +73,9 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
|||||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
||||||
el.className = 'post-members-only post-members-only--visible';
|
el.className = 'post-members-only post-members-only--visible';
|
||||||
el.innerHTML = `${VISIBLE_BADGE_HTML}<div class="post-members-only__body">${innerHtml}</div>`;
|
el.innerHTML = `<div class="post-members-only__body">${innerHtml}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
doc.querySelectorAll('img').forEach(img => {
|
doc.querySelectorAll('img').forEach(img => {
|
||||||
@@ -96,5 +83,20 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
|||||||
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
if (!img.getAttribute('decoding')) img.setAttribute('decoding', 'async');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts?.openLinksInNewTab) {
|
||||||
|
doc.querySelectorAll('a[href]').forEach(a => {
|
||||||
|
const href = a.getAttribute('href') || '';
|
||||||
|
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return;
|
||||||
|
a.setAttribute('target', '_blank');
|
||||||
|
const rel = new Set((a.getAttribute('rel') || '').split(/\s+/).filter(Boolean));
|
||||||
|
rel.add('noopener');
|
||||||
|
rel.add('noreferrer');
|
||||||
|
a.setAttribute('rel', Array.from(rel).join(' '));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
enhanceHeadingAnchors(doc.body);
|
||||||
|
enhanceCodeBlocks(doc.body);
|
||||||
|
|
||||||
return doc.body.innerHTML;
|
return doc.body.innerHTML;
|
||||||
}
|
}
|
||||||
|
|||||||
50
frontend/src/utils/postHeadings.ts
Normal file
50
frontend/src/utils/postHeadings.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/** 正文标题节点(用于文章目录树) */
|
||||||
|
export interface PostHeading {
|
||||||
|
id: string;
|
||||||
|
level: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为标题补全锚点 id,并返回目录树数据 */
|
||||||
|
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
||||||
|
const headings: PostHeading[] = [];
|
||||||
|
const used = new Map<string, number>();
|
||||||
|
|
||||||
|
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el, index) => {
|
||||||
|
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const level = Number(el.tagName.slice(1)) || 2;
|
||||||
|
let id = el.getAttribute('id')?.trim() || '';
|
||||||
|
if (!id) {
|
||||||
|
id = `heading-${index + 1}`;
|
||||||
|
}
|
||||||
|
const n = (used.get(id) || 0) + 1;
|
||||||
|
used.set(id, n);
|
||||||
|
if (n > 1) id = `${id}-${n}`;
|
||||||
|
el.setAttribute('id', id);
|
||||||
|
el.classList.add('post-heading-anchor');
|
||||||
|
|
||||||
|
headings.push({ id, level, text });
|
||||||
|
});
|
||||||
|
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从已渲染 HTML 中读取目录(假定 id 已由 enhanceHeadingAnchors 写入) */
|
||||||
|
export function extractHeadingsFromHtml(html: string): PostHeading[] {
|
||||||
|
if (!html.trim()) return [];
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
const headings: PostHeading[] = [];
|
||||||
|
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
|
||||||
|
const id = el.getAttribute('id')?.trim();
|
||||||
|
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||||
|
if (!id || !text) return;
|
||||||
|
headings.push({
|
||||||
|
id,
|
||||||
|
level: Number(el.tagName.slice(1)) || 2,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return headings;
|
||||||
|
}
|
||||||
11
go.mod
11
go.mod
@@ -4,11 +4,11 @@ go 1.26
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/kardianos/service v1.2.2
|
github.com/kardianos/service v1.2.2
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.31.0
|
||||||
gopkg.in/ini.v1 v1.67.3
|
gopkg.in/ini.v1 v1.67.3
|
||||||
gorm.io/driver/sqlite v1.5.7
|
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,22 +17,25 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
@@ -41,4 +44,8 @@ require (
|
|||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.21.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.22.5 // indirect
|
||||||
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
modernc.org/sqlite v1.23.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
29
go.sum
29
go.sum
@@ -9,12 +9,18 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||||
|
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -30,6 +36,10 @@ github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
|
|||||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||||
|
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -46,8 +56,6 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -57,6 +65,9 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -88,8 +99,8 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
|||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
@@ -99,9 +110,15 @@ gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
|
||||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||||
|
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||||
|
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||||
|
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||||
|
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ func (h *Handlers) AdminDashboard(c *gin.Context) {
|
|||||||
"PostCount": postCount,
|
"PostCount": postCount,
|
||||||
"BoardCount": boardCount,
|
"BoardCount": boardCount,
|
||||||
"CommentCount": commentCount,
|
"CommentCount": commentCount,
|
||||||
"OnlineCount": h.Online.Count(),
|
|
||||||
"RecentPosts": recentPosts,
|
"RecentPosts": recentPosts,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -223,7 +222,7 @@ func (h *Handlers) AdminAPILogin(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, user, err := h.Auth.Login(req.Username, req.Password)
|
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
382
handler/api.go
382
handler/api.go
@@ -2,13 +2,16 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
"git.iioio.com/freefire/jiang13-forum/service"
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
)
|
)
|
||||||
@@ -22,11 +25,13 @@ func (h *Handlers) APIMe(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
user, err := h.User.GetByID(uid)
|
user, err := h.User.GetByID(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 账号已删或不存在:清掉失效 cookie,与未登录态一致
|
||||||
|
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"user": user,
|
"user": user.ToSelf(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +130,7 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||||
"comments": commentCount, "online": h.Online.Count(),
|
"comments": commentCount,
|
||||||
"recent_posts": recentPosts,
|
"recent_posts": recentPosts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -242,7 +247,7 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
|
|||||||
users = []model.User{}
|
users = []model.User{}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"users": users, "total": total, "page": page,
|
"users": model.UsersToAdmin(users), "total": total, "page": page,
|
||||||
"total_pages": calcTotalPages(total, size),
|
"total_pages": calcTotalPages(total, size),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -298,17 +303,110 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
|
|||||||
func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
||||||
limits := h.Settings.Limits()
|
limits := h.Settings.Limits()
|
||||||
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
||||||
|
clients, _ := h.Settings.ListOAuthClients()
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"filter_path": h.Cfg.FilterWordsPath(),
|
"filter_path": h.Cfg.FilterWordsPath(),
|
||||||
"data_dir": h.Cfg.DataDir,
|
"data_dir": h.Cfg.DataDir,
|
||||||
"db_path": h.Cfg.DBPath(),
|
"db_path": h.Cfg.DBPath(),
|
||||||
"port": h.Cfg.Port,
|
"port": h.Cfg.Port,
|
||||||
"limits": limits,
|
"limits": limits,
|
||||||
"filter_words": filterContent,
|
"mail": h.Settings.MailConfigPublic(),
|
||||||
|
"oidc": h.Settings.OIDCConfigPublic(),
|
||||||
|
"oauth_clients": clients,
|
||||||
|
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||||
|
"branding": h.Settings.SiteBranding(),
|
||||||
|
"filter_words": filterContent,
|
||||||
"filter_word_count": service.CountFilterWords(filterContent),
|
"filter_word_count": service.CountFilterWords(filterContent),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APISiteBranding 前台公开的站点品牌配置
|
||||||
|
func (h *Handlers) APISiteBranding(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, h.Settings.SiteBranding())
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateBranding 更新站点品牌文案
|
||||||
|
func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
|
||||||
|
var req service.SiteBranding
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.UpdateSiteBranding(req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "站点品牌已保存",
|
||||||
|
"branding": h.Settings.SiteBranding(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUploadBrandingAsset 上传 Logo 或 Favicon(form: file + kind=logo|favicon)
|
||||||
|
func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
|
||||||
|
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||||
|
if kind != "logo" && kind != "favicon" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxBytes = 2 * 1024 * 1024
|
||||||
|
if file.Size > maxBytes {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
url, err := service.SaveUploadedImage(file, h.Cfg.SiteUploadDir(), "/uploads/site", kind)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prev := h.Settings.SiteBranding()
|
||||||
|
if kind == "logo" {
|
||||||
|
_ = h.Settings.SetSiteLogo(url)
|
||||||
|
h.removeSiteUploadIfLocal(prev.Logo)
|
||||||
|
} else {
|
||||||
|
_ = h.Settings.SetSiteFavicon(url)
|
||||||
|
h.removeSiteUploadIfLocal(prev.Favicon)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "上传成功",
|
||||||
|
"url": url,
|
||||||
|
"branding": h.Settings.SiteBranding(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminClearBrandingAsset 清除 Logo 或 Favicon
|
||||||
|
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kind := strings.TrimSpace(req.Kind)
|
||||||
|
brand := h.Settings.SiteBranding()
|
||||||
|
switch kind {
|
||||||
|
case "logo":
|
||||||
|
_ = h.Settings.SetSiteLogo("")
|
||||||
|
h.removeSiteUploadIfLocal(brand.Logo)
|
||||||
|
case "favicon":
|
||||||
|
_ = h.Settings.SetSiteFavicon("")
|
||||||
|
h.removeSiteUploadIfLocal(brand.Favicon)
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "已清除",
|
||||||
|
"branding": h.Settings.SiteBranding(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// APIAdminUpdateForumSettings 更新论坛设置
|
// APIAdminUpdateForumSettings 更新论坛设置
|
||||||
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
|
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
|
||||||
var req service.ForumLimits
|
var req service.ForumLimits
|
||||||
@@ -326,6 +424,206 @@ func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateMailSettings 更新邮件 SMTP 配置
|
||||||
|
func (h *Handlers) APIAdminUpdateMailSettings(c *gin.Context) {
|
||||||
|
var req service.MailConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.UpdateMailConfig(req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "邮件设置已保存",
|
||||||
|
"mail": h.Settings.MailConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateOIDCSettings 更新 OIDC Provider 全局配置
|
||||||
|
func (h *Handlers) APIAdminUpdateOIDCSettings(c *gin.Context) {
|
||||||
|
var req service.OIDCConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.UpdateOIDCConfig(req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "OIDC 设置已保存",
|
||||||
|
"oidc": h.Settings.OIDCConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIProjects 会员公开 Gitea 项目列表(本地缓存)
|
||||||
|
func (h *Handlers) APIProjects(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("limit", c.DefaultQuery("size", "30")))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 {
|
||||||
|
size = 30
|
||||||
|
}
|
||||||
|
if size > 100 {
|
||||||
|
size = 100
|
||||||
|
}
|
||||||
|
if h.Gitea == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"projects": []any{}, "total": 0, "page": page, "total_pages": 0})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, total, err := h.Gitea.ListPublic(page, size)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"projects": list,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"total_pages": calcTotalPages(total, size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateGiteaSettings 更新 Gitea 同步配置
|
||||||
|
func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
|
||||||
|
var req service.GiteaSyncConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.UpdateGiteaSyncConfig(req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "Gitea 同步设置已保存",
|
||||||
|
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminSyncGitea 立即同步 Gitea 公开仓库
|
||||||
|
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
|
||||||
|
if h.Gitea == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrGiteaNotConfigured.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.Gitea.SyncRepos()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": fmt.Sprintf("同步完成,共更新 %d 个仓库", n),
|
||||||
|
"count": n,
|
||||||
|
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminListOAuthClients 列出 OAuth 应用
|
||||||
|
func (h *Handlers) APIAdminListOAuthClients(c *gin.Context) {
|
||||||
|
list, err := h.Settings.ListOAuthClients()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"clients": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminCreateOAuthClient 创建 OAuth 应用
|
||||||
|
func (h *Handlers) APIAdminCreateOAuthClient(c *gin.Context) {
|
||||||
|
var req service.OAuthClientInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.Settings.CreateOAuthClient(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "应用已创建,请立即保存客户端密钥(仅显示一次)",
|
||||||
|
"client": view,
|
||||||
|
"oidc": h.Settings.OIDCConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateOAuthClient 更新 OAuth 应用
|
||||||
|
func (h *Handlers) APIAdminUpdateOAuthClient(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.OAuthClientInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.Settings.UpdateOAuthClient(uint(id), req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "应用已更新"
|
||||||
|
if view.ClientSecret != "" {
|
||||||
|
msg = "应用已更新,新密钥仅显示一次,请立即保存"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": msg,
|
||||||
|
"client": view,
|
||||||
|
"oidc": h.Settings.OIDCConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminDeleteOAuthClient 删除 OAuth 应用
|
||||||
|
func (h *Handlers) APIAdminDeleteOAuthClient(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.DeleteOAuthClient(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "应用已删除",
|
||||||
|
"oidc": h.Settings.OIDCConfigPublic(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminTestMail 发送测试邮件
|
||||||
|
func (h *Handlers) APIAdminTestMail(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
To string `json:"to" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写收件邮箱"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := service.ValidateEmail(req.To); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.Settings.MailReady() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
siteName := h.Settings.SiteBranding().Name
|
||||||
|
err := h.Mail.Send(service.NormalizeEmail(req.To), "邮件配置测试",
|
||||||
|
fmt.Sprintf("这是一封来自%s的测试邮件,说明 SMTP 配置正常。", siteName))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "测试邮件已发送"})
|
||||||
|
}
|
||||||
|
|
||||||
// APIAdminFilterWords 读取敏感词配置
|
// APIAdminFilterWords 读取敏感词配置
|
||||||
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
|
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
|
||||||
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
content, err := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
||||||
@@ -458,42 +756,28 @@ func (h *Handlers) APIHotPosts(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"posts": items})
|
c.JSON(http.StatusOK, gin.H{"posts": items})
|
||||||
}
|
}
|
||||||
|
|
||||||
// APINotifications 最新动态通知
|
// APITags 标签云(按使用次数聚合)
|
||||||
func (h *Handlers) APINotifications(c *gin.Context) {
|
func (h *Handlers) APITags(c *gin.Context) {
|
||||||
posts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
|
||||||
type notice struct {
|
tags, err := h.Post.PopularTags(limit)
|
||||||
ID uint `json:"id"`
|
if err != nil {
|
||||||
Title string `json:"title"`
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
Type string `json:"type"`
|
return
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
}
|
||||||
list := make([]notice, 0, len(posts))
|
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||||
for _, p := range posts {
|
|
||||||
list = append(list, notice{
|
|
||||||
ID: p.ID, Title: p.Title, Type: "post",
|
|
||||||
CreatedAt: p.CreatedAt.Format("01-02 15:04"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, gin.H{"notifications": list})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIOnline 当前浏览统计
|
// APIRecentComments 最新公开评论
|
||||||
func (h *Handlers) APIOnline(c *gin.Context) {
|
func (h *Handlers) APIRecentComments(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{
|
list, err := h.Comment.ListRecentPublic(8)
|
||||||
"count": h.Online.Count(),
|
if err != nil {
|
||||||
"members": h.Online.CountMembers(),
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
"guests": h.Online.CountGuests(),
|
return
|
||||||
"users": h.Online.List(20),
|
}
|
||||||
})
|
if list == nil {
|
||||||
}
|
list = []service.RecentCommentItem{}
|
||||||
|
}
|
||||||
// APIPresence 上报浏览心跳(会员与游客均可)
|
c.JSON(http.StatusOK, gin.H{"comments": list})
|
||||||
func (h *Handlers) APIPresence(c *gin.Context) {
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"count": h.Online.Count(),
|
|
||||||
"members": h.Online.CountMembers(),
|
|
||||||
"guests": h.Online.CountGuests(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIFavorites 我的收藏
|
// APIFavorites 我的收藏
|
||||||
@@ -556,8 +840,18 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) APIPing(c *gin.Context) {
|
// removeSiteUploadIfLocal 删除本站 uploads/site 下的旧资源文件
|
||||||
h.APIPresence(c)
|
func (h *Handlers) removeSiteUploadIfLocal(urlPath string) {
|
||||||
|
urlPath = strings.TrimSpace(urlPath)
|
||||||
|
const prefix = "/uploads/site/"
|
||||||
|
if !strings.HasPrefix(urlPath, prefix) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := filepath.Base(urlPath)
|
||||||
|
if name == "" || name == "." || name == ".." {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = os.Remove(filepath.Join(h.Cfg.SiteUploadDir(), name))
|
||||||
}
|
}
|
||||||
|
|
||||||
func isClientLimitError(err error) bool {
|
func isClientLimitError(err error) bool {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -15,17 +16,21 @@ import (
|
|||||||
|
|
||||||
// Handlers 聚合所有 HTTP 处理器
|
// Handlers 聚合所有 HTTP 处理器
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Auth *service.AuthService
|
Auth *service.AuthService
|
||||||
User *service.UserService
|
User *service.UserService
|
||||||
Board *service.BoardService
|
Board *service.BoardService
|
||||||
Post *service.PostService
|
Post *service.PostService
|
||||||
Comment *service.CommentService
|
Comment *service.CommentService
|
||||||
Backup *service.BackupService
|
Backup *service.BackupService
|
||||||
Filter *service.SensitiveFilter
|
Filter *service.SensitiveFilter
|
||||||
Limiter *service.RateLimiter
|
Limiter *service.RateLimiter
|
||||||
Online *service.OnlineService
|
Settings *service.ForumSettingsService
|
||||||
Settings *service.ForumSettingsService
|
Captcha *service.CaptchaService
|
||||||
|
Mail *service.MailService
|
||||||
|
EmailCode *service.EmailCodeService
|
||||||
|
OIDC *service.OIDCService
|
||||||
|
Gitea *service.GiteaService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
|
func (h *Handlers) setAuthCookie(c *gin.Context, token string) {
|
||||||
@@ -70,8 +75,9 @@ func (h *Handlers) pageData(c *gin.Context, title string, data gin.H) gin.H {
|
|||||||
data = gin.H{}
|
data = gin.H{}
|
||||||
}
|
}
|
||||||
data["Title"] = title
|
data["Title"] = title
|
||||||
data["SiteName"] = "姜十三论坛"
|
brand := h.Settings.SiteBranding()
|
||||||
data["SiteEN"] = "Jiang13 Forum"
|
data["SiteName"] = brand.Name
|
||||||
|
data["SiteEN"] = brand.NameEN
|
||||||
if uid := h.currentUserID(c); uid > 0 {
|
if uid := h.currentUserID(c); uid > 0 {
|
||||||
data["CurrentUserID"] = uid
|
data["CurrentUserID"] = uid
|
||||||
if u, err := h.User.GetByID(uid); err == nil {
|
if u, err := h.User.GetByID(uid); err == nil {
|
||||||
@@ -170,22 +176,82 @@ func (h *Handlers) FavoritesPage(c *gin.Context) {
|
|||||||
|
|
||||||
// --- API ---
|
// --- API ---
|
||||||
|
|
||||||
func (h *Handlers) APIRegister(c *gin.Context) {
|
func (h *Handlers) APICaptcha(c *gin.Context) {
|
||||||
|
id, svg, err := h.Captcha.Generate()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "验证码生成失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"id": id,
|
||||||
|
"image": "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(svg)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIRegisterConfig 注册页所需公开配置
|
||||||
|
func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||||
|
userCount := h.Auth.UserCount()
|
||||||
|
mailReady := h.Settings.MailReady()
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"is_first_user": userCount == 0,
|
||||||
|
"mail_ready": mailReady,
|
||||||
|
"require_email_code": mailReady,
|
||||||
|
"register_open": userCount == 0 || mailReady,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APISendRegisterEmailCode 发送注册邮箱验证码
|
||||||
|
func (h *Handlers) APISendRegisterEmailCode(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Username string `json:"username" form:"username" binding:"required"`
|
Email string `json:"email" form:"email" binding:"required"`
|
||||||
Password string `json:"password" form:"password" binding:"required"`
|
|
||||||
Nickname string `json:"nickname" form:"nickname"`
|
|
||||||
}
|
}
|
||||||
if err := c.ShouldBind(&req); err != nil {
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname)
|
if !h.Settings.MailReady() {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrMailNotConfigured.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.EmailCode.SendRegisterCode(req.Email); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "验证码已发送"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) APIRegister(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Username string `json:"username" form:"username" binding:"required"`
|
||||||
|
Password string `json:"password" form:"password" binding:"required"`
|
||||||
|
Nickname string `json:"nickname" form:"nickname"`
|
||||||
|
Email string `json:"email" form:"email" binding:"required"`
|
||||||
|
EmailCode string `json:"email_code" form:"email_code"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userCount := h.Auth.UserCount()
|
||||||
|
mailReady := h.Settings.MailReady()
|
||||||
|
if userCount > 0 && !mailReady {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrRegisterClosed.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mailReady {
|
||||||
|
if !h.EmailCode.Verify(req.Email, req.EmailCode) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": service.ErrEmailCodeInvalid.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := h.Auth.Register(req.Username, req.Password, req.Nickname, req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, _, _ := h.Auth.Login(req.Username, req.Password)
|
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||||
h.setAuthCookie(c, token)
|
h.setAuthCookie(c, token)
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
|
c.JSON(http.StatusOK, gin.H{"message": "注册成功", "user_id": user.ID})
|
||||||
}
|
}
|
||||||
@@ -199,7 +265,7 @@ func (h *Handlers) APILogin(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, user, err := h.Auth.Login(req.Username, req.Password)
|
token, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -220,7 +286,11 @@ func (h *Handlers) APIUpdateProfile(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": user})
|
var userView any
|
||||||
|
if user != nil {
|
||||||
|
userView = user.ToSelf()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "昵称已更新", "user": userView})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) APIUpdatePassword(c *gin.Context) {
|
func (h *Handlers) APIUpdatePassword(c *gin.Context) {
|
||||||
@@ -368,3 +438,14 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||||
|
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
content := c.PostForm("content")
|
||||||
|
saved, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "评论已更新", "content": saved})
|
||||||
|
}
|
||||||
|
|||||||
224
handler/oidc.go
Normal file
224
handler/oidc.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDCDiscovery OpenID Provider 元数据
|
||||||
|
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
|
||||||
|
if h.OIDC == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := h.OIDC.Discovery()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCJWKS JSON Web Key Set
|
||||||
|
func (h *Handlers) OIDCJWKS(c *gin.Context) {
|
||||||
|
if h.OIDC == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
doc, err := h.OIDC.JWKS()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
|
||||||
|
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
|
||||||
|
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||||
|
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := service.AuthorizeRequest{
|
||||||
|
ClientID: c.Query("client_id"),
|
||||||
|
RedirectURI: c.Query("redirect_uri"),
|
||||||
|
ResponseType: c.Query("response_type"),
|
||||||
|
Scope: c.Query("scope"),
|
||||||
|
State: c.Query("state"),
|
||||||
|
Nonce: c.Query("nonce"),
|
||||||
|
CodeChallenge: c.Query("code_challenge"),
|
||||||
|
CodeChallengeMethod: c.Query("code_challenge_method"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.OIDC.ValidateAuthorize(req); err != nil {
|
||||||
|
// redirect_uri 未通过校验时不能重定向,避免开放重定向
|
||||||
|
if errors.Is(err, service.ErrOIDCInvalidRedirect) || errors.Is(err, service.ErrOIDCInvalidClient) {
|
||||||
|
c.String(http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
uid := h.currentUserID(c)
|
||||||
|
if uid == 0 {
|
||||||
|
from := c.Request.URL.RequestURI()
|
||||||
|
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
callback, err := h.OIDC.IssueAuthCode(uid, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrOIDCUserBanned) {
|
||||||
|
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
|
||||||
|
if redirectURI == "" {
|
||||||
|
c.String(http.StatusBadRequest, desc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := url.Parse(redirectURI)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, desc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("error", code)
|
||||||
|
q.Set("error_description", desc)
|
||||||
|
if state != "" {
|
||||||
|
q.Set("state", state)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
c.Redirect(http.StatusFound, u.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCToken 令牌端点
|
||||||
|
func (h *Handlers) OIDCToken(c *gin.Context) {
|
||||||
|
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
|
||||||
|
if clientID == "" && clientSecret == "" {
|
||||||
|
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
|
||||||
|
clientID, clientSecret = id, secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.OIDC.ExchangeCode(service.TokenRequest{
|
||||||
|
GrantType: c.PostForm("grant_type"),
|
||||||
|
Code: c.PostForm("code"),
|
||||||
|
RedirectURI: c.PostForm("redirect_uri"),
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
CodeVerifier: c.PostForm("code_verifier"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
code := "invalid_grant"
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrOIDCInvalidClient):
|
||||||
|
status = http.StatusUnauthorized
|
||||||
|
code = "invalid_client"
|
||||||
|
case errors.Is(err, service.ErrOIDCInvalidRequest):
|
||||||
|
code = "invalid_request"
|
||||||
|
case errors.Is(err, service.ErrOIDCPKCEFailed):
|
||||||
|
code = "invalid_grant"
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCUserInfo 用户信息端点
|
||||||
|
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
|
||||||
|
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := extractBearer(c)
|
||||||
|
if token == "" {
|
||||||
|
c.Header("WWW-Authenticate", `Bearer`)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := h.OIDC.UserInfo(token)
|
||||||
|
if err != nil {
|
||||||
|
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCLogout RP-Initiated Logout:清除论坛会话并可选跳回客户端
|
||||||
|
func (h *Handlers) OIDCLogout(c *gin.Context) {
|
||||||
|
postLogout := c.Query("post_logout_redirect_uri")
|
||||||
|
if postLogout == "" {
|
||||||
|
postLogout = c.PostForm("post_logout_redirect_uri")
|
||||||
|
}
|
||||||
|
state := c.Query("state")
|
||||||
|
if state == "" {
|
||||||
|
state = c.PostForm("state")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||||
|
|
||||||
|
if h.OIDC == nil {
|
||||||
|
c.Redirect(http.StatusFound, "/")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractBearer(c *gin.Context) string {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if strings.HasPrefix(auth, "Bearer ") {
|
||||||
|
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||||
|
}
|
||||||
|
return c.Query("access_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBasicAuth(header string) (user, pass string, ok bool) {
|
||||||
|
const prefix = "Basic "
|
||||||
|
if !strings.HasPrefix(header, prefix) {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(string(raw), ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// client_id / client_secret 可能被 URL 编码
|
||||||
|
uid, err1 := url.QueryUnescape(parts[0])
|
||||||
|
sec, err2 := url.QueryUnescape(parts[1])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return parts[0], parts[1], true
|
||||||
|
}
|
||||||
|
return uid, sec, true
|
||||||
|
}
|
||||||
@@ -25,15 +25,21 @@ func NewAuthMiddleware(auth *service.AuthService) *AuthMiddleware {
|
|||||||
return &AuthMiddleware{auth: auth}
|
return &AuthMiddleware{auth: auth}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截
|
// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截。
|
||||||
|
// 用户已删除或不存在时清除失效 cookie,避免前端误显示为已登录。
|
||||||
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token := extractToken(c)
|
token := extractToken(c)
|
||||||
if token != "" {
|
if token != "" {
|
||||||
if claims, err := m.auth.ParseToken(token); err == nil {
|
if claims, err := m.auth.ParseToken(token); err == nil {
|
||||||
c.Set(CtxUserID, claims.UserID)
|
var user model.User
|
||||||
c.Set(CtxUsername, claims.Username)
|
if err := model.DB.Select("id", "username", "role").First(&user, claims.UserID).Error; err != nil {
|
||||||
c.Set(CtxRole, claims.Role)
|
c.SetCookie(CookieName, "", -1, "/", "", false, true)
|
||||||
|
} else {
|
||||||
|
c.Set(CtxUserID, user.ID)
|
||||||
|
c.Set(CtxUsername, user.Username)
|
||||||
|
c.Set(CtxRole, user.Role)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
const VisitorCookieName = "j13_vid"
|
|
||||||
|
|
||||||
// PresenceMiddleware 记录当前请求的浏览活跃(会员或游客)
|
|
||||||
func PresenceMiddleware(online *service.OnlineService) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
if uid, ok := c.Get(CtxUserID); ok {
|
|
||||||
if id, ok := uid.(uint); ok && id > 0 {
|
|
||||||
online.Ping(id)
|
|
||||||
c.Next()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vid, err := c.Cookie(VisitorCookieName)
|
|
||||||
if err != nil || vid == "" {
|
|
||||||
vid = newVisitorID()
|
|
||||||
c.SetCookie(VisitorCookieName, vid, 86400, "/", "", false, true)
|
|
||||||
}
|
|
||||||
online.PingGuest(vid)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newVisitorID() string {
|
|
||||||
b := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
return hex.EncodeToString([]byte("fallback-visitor-id"))
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
"github.com/glebarez/sqlite" // 纯 Go,支持 CGO_ENABLED=0 交叉编译
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
@@ -36,6 +36,8 @@ func InitDB(dbPath string) error {
|
|||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&User{}, &Board{}, &Post{}, &Comment{},
|
&User{}, &Board{}, &Post{}, &Comment{},
|
||||||
&PostLike{}, &PostFavorite{}, &PostRevision{}, &ForumSetting{},
|
&PostLike{}, &PostFavorite{}, &PostRevision{}, &ForumSetting{},
|
||||||
|
&OAuthClient{}, &OAuthAuthCode{},
|
||||||
|
&GiteaRepo{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
24
model/gitea.go
Normal file
24
model/gitea.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
|
||||||
|
type GiteaRepo struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
|
||||||
|
OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
|
||||||
|
Name string `gorm:"size:255;not null" json:"name"`
|
||||||
|
FullName string `gorm:"size:512;not null" json:"full_name"`
|
||||||
|
Description string `gorm:"size:2048;default:''" json:"description"`
|
||||||
|
HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
|
||||||
|
Private bool `gorm:"default:false" json:"private"`
|
||||||
|
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||||
|
ForumUserID *uint `gorm:"index" json:"forum_user_id"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (GiteaRepo) TableName() string {
|
||||||
|
return "gitea_repos"
|
||||||
|
}
|
||||||
@@ -15,18 +15,23 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// User 用户表
|
// User 用户表
|
||||||
|
// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
|
||||||
|
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"uniqueIndex;size:64;not null" json:"username"`
|
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
||||||
Password string `gorm:"size:128;not null" json:"-"`
|
Email string `gorm:"index;size:128;default:''" json:"-"`
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
Password string `gorm:"size:128;not null" json:"-"`
|
||||||
Avatar string `gorm:"size:256" json:"avatar"`
|
Nickname string `gorm:"size:64" json:"nickname"`
|
||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Avatar string `gorm:"size:256" json:"avatar"`
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
LastLoginAt *time.Time `json:"-"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Board 论坛板块
|
// Board 论坛板块
|
||||||
@@ -80,7 +85,7 @@ type PostRevision struct {
|
|||||||
// ForumSetting 论坛全局设置(键值对)
|
// ForumSetting 论坛全局设置(键值对)
|
||||||
type ForumSetting struct {
|
type ForumSetting struct {
|
||||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||||
Value string `gorm:"size:256" json:"value"`
|
Value string `gorm:"size:2048" json:"value"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Comment 楼层评论
|
// Comment 楼层评论
|
||||||
|
|||||||
31
model/oauth.go
Normal file
31
model/oauth.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// OAuthClient OIDC/OAuth2 客户端应用(密钥仅存 bcrypt 哈希)
|
||||||
|
type OAuthClient struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
ClientID string `gorm:"uniqueIndex;size:128;not null" json:"client_id"`
|
||||||
|
ClientSecretHash string `gorm:"size:128;not null" json:"-"`
|
||||||
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
|
RedirectURIs string `gorm:"size:2048;not null" json:"redirect_uris"`
|
||||||
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthAuthCode 一次性授权码(OIDC Authorization Code Flow)
|
||||||
|
type OAuthAuthCode struct {
|
||||||
|
ID uint `gorm:"primaryKey"`
|
||||||
|
Code string `gorm:"uniqueIndex;size:64;not null"`
|
||||||
|
ClientID string `gorm:"size:128;not null;index"`
|
||||||
|
UserID uint `gorm:"not null;index"`
|
||||||
|
RedirectURI string `gorm:"size:512;not null"`
|
||||||
|
Scope string `gorm:"size:256;default:''"`
|
||||||
|
Nonce string `gorm:"size:128;default:''"`
|
||||||
|
CodeChallenge string `gorm:"size:128;default:''"`
|
||||||
|
CodeChallengeMethod string `gorm:"size:16;default:''"`
|
||||||
|
ExpiresAt time.Time `gorm:"not null;index"`
|
||||||
|
Used bool `gorm:"default:false"`
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
76
model/user_view.go
Normal file
76
model/user_view.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// UserSelf 当前登录用户视图(含邮箱,不含登录 IP)
|
||||||
|
type UserSelf struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Banned bool `json:"banned"`
|
||||||
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserAdmin 后台用户管理视图(含邮箱与上次登录信息)
|
||||||
|
type UserAdmin struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Role Role `json:"role"`
|
||||||
|
Banned bool `json:"banned"`
|
||||||
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
|
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||||
|
LastLoginIP string `json:"last_login_ip,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToSelf 转为个人中心 /api/me 视图
|
||||||
|
func (u *User) ToSelf() UserSelf {
|
||||||
|
return UserSelf{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
Role: u.Role,
|
||||||
|
Banned: u.Banned,
|
||||||
|
BannedAt: u.BannedAt,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToAdmin 转为后台用户列表视图
|
||||||
|
func (u *User) ToAdmin() UserAdmin {
|
||||||
|
return UserAdmin{
|
||||||
|
ID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Nickname: u.Nickname,
|
||||||
|
Avatar: u.Avatar,
|
||||||
|
Role: u.Role,
|
||||||
|
Banned: u.Banned,
|
||||||
|
BannedAt: u.BannedAt,
|
||||||
|
LastLoginAt: u.LastLoginAt,
|
||||||
|
LastLoginIP: u.LastLoginIP,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
UpdatedAt: u.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsersToAdmin 批量转换后台用户列表
|
||||||
|
func UsersToAdmin(users []User) []UserAdmin {
|
||||||
|
out := make([]UserAdmin, 0, len(users))
|
||||||
|
for i := range users {
|
||||||
|
out = append(out, users[i].ToAdmin())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package router
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
@@ -27,49 +28,79 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
filter.LoadFromFile(cfg.FilterWordsPath())
|
filter.LoadFromFile(cfg.FilterWordsPath())
|
||||||
|
|
||||||
settingsSvc := service.NewForumSettingsService()
|
settingsSvc := service.NewForumSettingsService()
|
||||||
|
settingsSvc.SeedOIDCFromINI(
|
||||||
|
cfg.RootURL,
|
||||||
|
cfg.OAuthClientID,
|
||||||
|
cfg.OAuthClientSecret,
|
||||||
|
strings.Join(cfg.OAuthRedirectURIs, ","),
|
||||||
|
)
|
||||||
|
settingsSvc.MigrateLegacyOIDCClient()
|
||||||
|
settingsSvc.SeedGiteaFromINI(cfg.GiteaBaseURL, cfg.GiteaToken, cfg.GiteaSyncEnabled)
|
||||||
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
authSvc := service.NewAuthService(cfg.JWTSecret, filter, settingsSvc)
|
||||||
userSvc := service.NewUserService(filter, settingsSvc)
|
userSvc := service.NewUserService(filter, settingsSvc)
|
||||||
boardSvc := service.NewBoardService()
|
boardSvc := service.NewBoardService()
|
||||||
postSvc := service.NewPostService(filter, settingsSvc)
|
postSvc := service.NewPostService(filter, settingsSvc)
|
||||||
commentSvc := service.NewCommentService(filter, settingsSvc)
|
commentSvc := service.NewCommentService(filter, settingsSvc)
|
||||||
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
||||||
onlineSvc := service.NewOnlineService()
|
|
||||||
limiter := service.NewRateLimiter(settingsSvc)
|
limiter := service.NewRateLimiter(settingsSvc)
|
||||||
|
captchaSvc := service.NewCaptchaService()
|
||||||
|
mailSvc := service.NewMailService(settingsSvc)
|
||||||
|
emailCodeSvc := service.NewEmailCodeService(mailSvc)
|
||||||
|
oidcSvc, err := service.NewOIDCService(cfg, settingsSvc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
giteaSvc := service.NewGiteaService(settingsSvc)
|
||||||
|
giteaSvc.StartBackgroundSync()
|
||||||
|
|
||||||
h := &handler.Handlers{
|
h := &handler.Handlers{
|
||||||
Cfg: cfg, Auth: authSvc, User: userSvc, Board: boardSvc,
|
Cfg: cfg, Auth: authSvc, User: userSvc, Board: boardSvc,
|
||||||
Post: postSvc, Comment: commentSvc, Backup: backupSvc,
|
Post: postSvc, Comment: commentSvc, Backup: backupSvc,
|
||||||
Filter: filter, Limiter: limiter, Online: onlineSvc,
|
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
||||||
Settings: settingsSvc,
|
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
||||||
|
OIDC: oidcSvc, Gitea: giteaSvc,
|
||||||
}
|
}
|
||||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||||
|
|
||||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||||
|
|
||||||
|
// OIDC Provider(Gitea 等外部站点 SSO)
|
||||||
|
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||||
|
r.GET("/oauth/jwks", h.OIDCJWKS)
|
||||||
|
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
|
||||||
|
r.POST("/oauth/token", h.OIDCToken)
|
||||||
|
r.GET("/oauth/userinfo", h.OIDCUserInfo)
|
||||||
|
r.POST("/oauth/userinfo", h.OIDCUserInfo)
|
||||||
|
r.GET("/oauth/logout", h.OIDCLogout)
|
||||||
|
r.POST("/oauth/logout", h.OIDCLogout)
|
||||||
|
|
||||||
// 公开 JSON API(可选登录)
|
// 公开 JSON API(可选登录)
|
||||||
pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.PresenceMiddleware(onlineSvc))
|
pubAPI := r.Group("/api", authMW.OptionalAuth())
|
||||||
{
|
{
|
||||||
pubAPI.GET("/me", h.APIMe)
|
pubAPI.GET("/me", h.APIMe)
|
||||||
pubAPI.GET("/boards", h.APIBoards)
|
pubAPI.GET("/boards", h.APIBoards)
|
||||||
pubAPI.GET("/stats", h.APIStats)
|
pubAPI.GET("/stats", h.APIStats)
|
||||||
pubAPI.GET("/forum-limits", h.APIForumLimits)
|
pubAPI.GET("/forum-limits", h.APIForumLimits)
|
||||||
|
pubAPI.GET("/site-branding", h.APISiteBranding)
|
||||||
|
pubAPI.GET("/captcha", h.APICaptcha)
|
||||||
|
pubAPI.GET("/register/config", h.APIRegisterConfig)
|
||||||
|
pubAPI.POST("/register/email-code", middleware.RateLimitMiddleware(limiter, "register"), h.APISendRegisterEmailCode)
|
||||||
pubAPI.GET("/posts", h.APIPosts)
|
pubAPI.GET("/posts", h.APIPosts)
|
||||||
pubAPI.GET("/posts/hot", h.APIHotPosts)
|
pubAPI.GET("/posts/hot", h.APIHotPosts)
|
||||||
pubAPI.GET("/notifications", h.APINotifications)
|
pubAPI.GET("/tags", h.APITags)
|
||||||
pubAPI.GET("/online", h.APIOnline)
|
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
||||||
pubAPI.POST("/presence", h.APIPresence)
|
|
||||||
pubAPI.GET("/posts/:id", h.APIPostDetail)
|
pubAPI.GET("/posts/:id", h.APIPostDetail)
|
||||||
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
|
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
|
||||||
pubAPI.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
|
pubAPI.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, "comment"), h.APICreateComment)
|
||||||
|
pubAPI.GET("/projects", h.APIProjects)
|
||||||
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, "register"), h.APIRegister)
|
pubAPI.POST("/register", middleware.RateLimitMiddleware(limiter, "register"), h.APIRegister)
|
||||||
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, "login"), h.APILogin)
|
pubAPI.POST("/login", middleware.RateLimitMiddleware(limiter, "login"), h.APILogin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需登录 API
|
// 需登录 API
|
||||||
api := r.Group("/api", authMW.RequireAuth(), middleware.PresenceMiddleware(onlineSvc))
|
api := r.Group("/api", authMW.RequireAuth())
|
||||||
{
|
{
|
||||||
api.POST("/logout", h.APILogout)
|
api.POST("/logout", h.APILogout)
|
||||||
api.POST("/ping", h.APIPing)
|
|
||||||
api.GET("/favorites", h.APIFavorites)
|
api.GET("/favorites", h.APIFavorites)
|
||||||
api.POST("/profile/nickname", h.APIUpdateProfile)
|
api.POST("/profile/nickname", h.APIUpdateProfile)
|
||||||
api.POST("/profile/password", h.APIUpdatePassword)
|
api.POST("/profile/password", h.APIUpdatePassword)
|
||||||
@@ -83,6 +114,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
api.POST("/posts/:id/like", h.APIToggleLike)
|
api.POST("/posts/:id/like", h.APIToggleLike)
|
||||||
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
api.POST("/posts/:id/favorite", h.APIToggleFavorite)
|
||||||
api.DELETE("/comments/:id", h.APIDeleteComment)
|
api.DELETE("/comments/:id", h.APIDeleteComment)
|
||||||
|
api.PUT("/comments/:id", h.APIUpdateComment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 管理员 API(React SPA 后台统一使用 JSON)
|
// 管理员 API(React SPA 后台统一使用 JSON)
|
||||||
@@ -91,6 +123,18 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
||||||
adminAPI.GET("/settings", h.APIAdminSettings)
|
adminAPI.GET("/settings", h.APIAdminSettings)
|
||||||
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
|
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
|
||||||
|
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
|
||||||
|
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
|
||||||
|
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
|
||||||
|
adminAPI.PUT("/settings/gitea", h.APIAdminUpdateGiteaSettings)
|
||||||
|
adminAPI.POST("/settings/gitea/sync", h.APIAdminSyncGitea)
|
||||||
|
adminAPI.PUT("/settings/branding", h.APIAdminUpdateBranding)
|
||||||
|
adminAPI.POST("/settings/branding/upload", h.APIAdminUploadBrandingAsset)
|
||||||
|
adminAPI.POST("/settings/branding/clear", h.APIAdminClearBrandingAsset)
|
||||||
|
adminAPI.GET("/oauth/clients", h.APIAdminListOAuthClients)
|
||||||
|
adminAPI.POST("/oauth/clients", h.APIAdminCreateOAuthClient)
|
||||||
|
adminAPI.PUT("/oauth/clients/:id", h.APIAdminUpdateOAuthClient)
|
||||||
|
adminAPI.DELETE("/oauth/clients/:id", h.APIAdminDeleteOAuthClient)
|
||||||
adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
|
adminAPI.GET("/settings/filter-words", h.APIAdminFilterWords)
|
||||||
adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
|
adminAPI.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
|
||||||
adminAPI.POST("/boards", h.APIAdminCreateBoard)
|
adminAPI.POST("/boards", h.APIAdminCreateBoard)
|
||||||
|
|||||||
@@ -27,18 +27,34 @@ func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSe
|
|||||||
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserCount 当前用户数
|
||||||
|
func (s *AuthService) UserCount() int64 {
|
||||||
|
var n int64
|
||||||
|
model.DB.Model(&model.User{}).Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// Register 用户注册
|
// Register 用户注册
|
||||||
func (s *AuthService) Register(username, password, nickname string) (*model.User, error) {
|
func (s *AuthService) Register(username, password, nickname, email string) (*model.User, error) {
|
||||||
if err := ValidateUsername(username); err != nil {
|
if err := ValidateUsername(username); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
email = NormalizeEmail(email)
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
var exist model.User
|
var exist model.User
|
||||||
if err := model.DB.Where("username = ?", username).First(&exist).Error; err == nil {
|
if err := model.DB.Where("username = ?", username).First(&exist).Error; err == nil {
|
||||||
return nil, ErrUserExists
|
return nil, ErrUserExists
|
||||||
}
|
}
|
||||||
|
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||||
|
return nil, ErrEmailExists
|
||||||
|
}
|
||||||
|
|
||||||
hash, err := HashPassword(password)
|
hash, err := HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -50,14 +66,13 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
|||||||
|
|
||||||
// 首个注册用户自动成为管理员
|
// 首个注册用户自动成为管理员
|
||||||
role := model.RoleUser
|
role := model.RoleUser
|
||||||
var userCount int64
|
if s.UserCount() == 0 {
|
||||||
model.DB.Model(&model.User{}).Count(&userCount)
|
|
||||||
if userCount == 0 {
|
|
||||||
role = model.RoleAdmin
|
role = model.RoleAdmin
|
||||||
}
|
}
|
||||||
|
|
||||||
user := &model.User{
|
user := &model.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
|
Email: email,
|
||||||
Password: hash,
|
Password: hash,
|
||||||
Nickname: nickname,
|
Nickname: nickname,
|
||||||
Role: role,
|
Role: role,
|
||||||
@@ -68,8 +83,8 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 用户登录,返回 JWT token
|
// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
|
||||||
func (s *AuthService) Login(username, password string) (string, *model.User, error) {
|
func (s *AuthService) Login(username, password, clientIP string) (string, *model.User, error) {
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||||
return "", nil, ErrInvalidCred
|
return "", nil, ErrInvalidCred
|
||||||
@@ -80,10 +95,26 @@ func (s *AuthService) Login(username, password string) (string, *model.User, err
|
|||||||
if !CheckPassword(user.Password, password) {
|
if !CheckPassword(user.Password, password) {
|
||||||
return "", nil, ErrInvalidCred
|
return "", nil, ErrInvalidCred
|
||||||
}
|
}
|
||||||
|
s.recordLogin(&user, clientIP)
|
||||||
token, err := s.GenerateToken(&user)
|
token, err := s.GenerateToken(&user)
|
||||||
return token, &user, err
|
return token, &user, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recordLogin 记录上次登录时间与 IP(失败不影响登录)
|
||||||
|
func (s *AuthService) recordLogin(user *model.User, clientIP string) {
|
||||||
|
now := time.Now()
|
||||||
|
ip := clientIP
|
||||||
|
if len(ip) > 45 {
|
||||||
|
ip = ip[:45]
|
||||||
|
}
|
||||||
|
_ = model.DB.Model(user).Updates(map[string]interface{}{
|
||||||
|
"last_login_at": now,
|
||||||
|
"last_login_ip": ip,
|
||||||
|
}).Error
|
||||||
|
user.LastLoginAt = &now
|
||||||
|
user.LastLoginIP = ip
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateToken 生成 JWT
|
// GenerateToken 生成 JWT
|
||||||
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
|
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
|
|||||||
158
service/captcha.go
Normal file
158
service/captcha.go
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
captchaLen = 4
|
||||||
|
captchaTTL = 5 * time.Minute
|
||||||
|
captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
captchaWidth = 120
|
||||||
|
captchaHeight = 40
|
||||||
|
)
|
||||||
|
|
||||||
|
type captchaEntry struct {
|
||||||
|
answer string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptchaService 内存图形验证码
|
||||||
|
type CaptchaService struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]captchaEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaptchaService() *CaptchaService {
|
||||||
|
s := &CaptchaService{entries: make(map[string]captchaEntry)}
|
||||||
|
go s.cleanup()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 生成验证码,返回 id 与 SVG 图片
|
||||||
|
func (s *CaptchaService) Generate() (id, svg string, err error) {
|
||||||
|
answer, err := randomCaptchaText(captchaLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
rawID := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(rawID); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
id = hex.EncodeToString(rawID)
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.entries[id] = captchaEntry{
|
||||||
|
answer: strings.ToUpper(answer),
|
||||||
|
expiresAt: time.Now().Add(captchaTTL),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return id, renderCaptchaSVG(answer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 校验验证码(一次性,大小写不敏感)
|
||||||
|
func (s *CaptchaService) Verify(id, answer string) bool {
|
||||||
|
if id == "" || answer == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry, ok := s.entries[id]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.entries, id)
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(entry.answer, strings.TrimSpace(answer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptchaService) cleanup() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
for id, entry := range s.entries {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(s.entries, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomCaptchaText(n int) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(n)
|
||||||
|
max := big.NewInt(int64(len(captchaChars)))
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
idx, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteByte(captchaChars[idx.Int64()])
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCaptchaSVG(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
|
||||||
|
captchaWidth, captchaHeight, captchaWidth, captchaHeight,
|
||||||
|
))
|
||||||
|
b.WriteString(`<rect width="100%" height="100%" fill="#f4f6f5"/>`)
|
||||||
|
|
||||||
|
// 干扰线
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
x1, _ := randInt(0, captchaWidth)
|
||||||
|
y1, _ := randInt(0, captchaHeight)
|
||||||
|
x2, _ := randInt(0, captchaWidth)
|
||||||
|
y2, _ := randInt(0, captchaHeight)
|
||||||
|
color := noiseColor()
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`,
|
||||||
|
x1, y1, x2, y2, color,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
step := captchaWidth / (len(text) + 1)
|
||||||
|
for i, ch := range text {
|
||||||
|
x := step*(i+1) - 6
|
||||||
|
y, _ := randInt(26, 34)
|
||||||
|
rot, _ := randInt(-18, 18)
|
||||||
|
b.WriteString(fmt.Sprintf(
|
||||||
|
`<text x="%d" y="%d" fill="#2d5a45" font-size="22" font-family="monospace" font-weight="700" transform="rotate(%d %d %d)">%c</text>`,
|
||||||
|
x, y, rot, x+6, y-6, ch,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(`</svg>`)
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func randInt(min, max int) (int, error) {
|
||||||
|
if max <= min {
|
||||||
|
return min, nil
|
||||||
|
}
|
||||||
|
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||||
|
if err != nil {
|
||||||
|
return min, err
|
||||||
|
}
|
||||||
|
return min + int(n.Int64()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func noiseColor() string {
|
||||||
|
r, _ := randInt(160, 210)
|
||||||
|
g, _ := randInt(170, 220)
|
||||||
|
b, _ := randInt(160, 210)
|
||||||
|
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
)
|
)
|
||||||
@@ -166,16 +167,114 @@ func (s *CommentService) Delete(userID, commentID uint, isAdmin bool) error {
|
|||||||
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||||
return ErrCommentNotFound
|
return ErrCommentNotFound
|
||||||
}
|
}
|
||||||
if !isAdmin && comment.UserID != userID {
|
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||||
return ErrPermissionDenied
|
return ErrPermissionDenied
|
||||||
}
|
}
|
||||||
return model.DB.Delete(&comment).Error
|
return model.DB.Delete(&comment).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *CommentService) Update(userID, commentID uint, isAdmin bool, content string) (string, error) {
|
||||||
|
var comment model.Comment
|
||||||
|
if err := model.DB.First(&comment, commentID).Error; err != nil {
|
||||||
|
return "", ErrCommentNotFound
|
||||||
|
}
|
||||||
|
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||||
|
return "", ErrPermissionDenied
|
||||||
|
}
|
||||||
|
if !isAdmin {
|
||||||
|
window := s.settings.PostEditWindowHours()
|
||||||
|
if window > 0 && time.Since(comment.CreatedAt) > time.Duration(window)*time.Hour {
|
||||||
|
return "", errors.New("已超过可编辑时限")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
content = s.filter.Filter(strings.TrimSpace(content))
|
||||||
|
if content == "" {
|
||||||
|
return "", errors.New("评论内容不能为空")
|
||||||
|
}
|
||||||
|
if err := s.settings.ValidateTextLength(content, s.settings.CommentMax(), ErrCommentTooLong); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := model.DB.Model(&comment).Update("content", content).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *CommentService) AdminDelete(commentID uint) error {
|
func (s *CommentService) AdminDelete(commentID uint) error {
|
||||||
return model.DB.Delete(&model.Comment{}, commentID).Error
|
return model.DB.Delete(&model.Comment{}, commentID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RecentCommentItem 右栏「最新评论」条目
|
||||||
|
type RecentCommentItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
PostID uint `json:"post_id"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Excerpt string `json:"excerpt"`
|
||||||
|
PostTitle string `json:"post_title"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRecentPublic 前台最新公开评论(排除私密、已删帖)
|
||||||
|
func (s *CommentService) ListRecentPublic(limit int) ([]RecentCommentItem, error) {
|
||||||
|
if limit < 1 {
|
||||||
|
limit = 8
|
||||||
|
}
|
||||||
|
var comments []model.Comment
|
||||||
|
err := model.DB.Preload("User").Preload("Post").
|
||||||
|
Where("is_private = ?", false).
|
||||||
|
Order("id desc").Limit(limit * 2). // 多取一些以跳过已删帖
|
||||||
|
Find(&comments).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]RecentCommentItem, 0, limit)
|
||||||
|
for _, c := range comments {
|
||||||
|
if c.Post.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
author := "游客"
|
||||||
|
avatar := ""
|
||||||
|
if c.UserID > 0 && c.User.Nickname != "" {
|
||||||
|
author = c.User.Nickname
|
||||||
|
avatar = c.User.Avatar
|
||||||
|
} else if c.GuestNick != "" {
|
||||||
|
author = c.GuestNick
|
||||||
|
}
|
||||||
|
excerpt := StripHTMLForSearch(c.Content)
|
||||||
|
excerpt = truncateRunes(excerpt, 64)
|
||||||
|
if excerpt == "" {
|
||||||
|
excerpt = "发表了评论"
|
||||||
|
}
|
||||||
|
out = append(out, RecentCommentItem{
|
||||||
|
ID: c.ID,
|
||||||
|
PostID: c.PostID,
|
||||||
|
Author: author,
|
||||||
|
Avatar: avatar,
|
||||||
|
Excerpt: excerpt,
|
||||||
|
PostTitle: c.Post.Title,
|
||||||
|
CreatedAt: c.CreatedAt.Format("01-02 15:04"),
|
||||||
|
})
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateRunes(s string, n int) string {
|
||||||
|
if n <= 0 || s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:n]) + "…"
|
||||||
|
}
|
||||||
|
|
||||||
// ListRecent 管理员查看最近评论
|
// ListRecent 管理员查看最近评论
|
||||||
func (s *CommentService) ListRecent(page, size int) ([]model.Comment, int64, error) {
|
func (s *CommentService) ListRecent(page, size int) ([]model.Comment, int64, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
|
|||||||
@@ -3,38 +3,44 @@ package service
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrUserExists = errors.New("用户名已存在")
|
ErrUserExists = errors.New("用户名已存在")
|
||||||
ErrInvalidCred = errors.New("用户名或密码错误")
|
ErrEmailExists = errors.New("邮箱已被注册")
|
||||||
ErrUserBanned = errors.New("账号已被禁言")
|
ErrInvalidCred = errors.New("用户名或密码错误")
|
||||||
ErrWeakPassword = errors.New("密码至少 6 位")
|
ErrUserBanned = errors.New("账号已被禁言")
|
||||||
ErrInvalidUsername = errors.New("用户名 3-32 位字母数字下划线")
|
ErrWeakPassword = errors.New("密码至少 6 位")
|
||||||
ErrPostNotFound = errors.New("帖子不存在")
|
ErrInvalidUsername = errors.New("用户名 2-32 位,支持中文、字母、数字与下划线")
|
||||||
ErrCommentNotFound = errors.New("评论不存在")
|
ErrInvalidEmail = errors.New("邮箱格式不正确")
|
||||||
ErrPermissionDenied = errors.New("无权操作")
|
ErrPostNotFound = errors.New("帖子不存在")
|
||||||
ErrBoardNotFound = errors.New("板块不存在")
|
ErrCommentNotFound = errors.New("评论不存在")
|
||||||
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
ErrPermissionDenied = errors.New("无权操作")
|
||||||
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
ErrBoardNotFound = errors.New("板块不存在")
|
||||||
ErrRevisionNotFound = errors.New("历史版本不存在")
|
ErrPostEditLocked = errors.New("帖子已被管理员锁定,无法编辑")
|
||||||
ErrInvalidSetting = errors.New("无效的设置值")
|
ErrPostEditExpired = errors.New("已超过可编辑时限")
|
||||||
|
ErrRevisionNotFound = errors.New("历史版本不存在")
|
||||||
|
ErrInvalidSetting = errors.New("无效的设置值")
|
||||||
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
ErrSearchKeywordTooShort = errors.New("搜索关键词过短")
|
||||||
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
ErrSearchKeywordTooLong = errors.New("搜索关键词过长")
|
||||||
ErrPostTitleTooLong = errors.New("标题过长")
|
ErrPostTitleTooLong = errors.New("标题过长")
|
||||||
ErrPostTagsTooLong = errors.New("标签过长")
|
ErrPostTagsTooLong = errors.New("标签过长")
|
||||||
ErrPostContentTooLong = errors.New("正文过长")
|
ErrPostContentTooLong = errors.New("正文过长")
|
||||||
ErrCommentTooLong = errors.New("评论内容过长")
|
ErrCommentTooLong = errors.New("评论内容过长")
|
||||||
|
ErrCaptchaInvalid = errors.New("验证码错误或已过期")
|
||||||
|
ErrEmailCodeInvalid = errors.New("邮箱验证码错误或已过期")
|
||||||
|
ErrEmailCodeCooldown = errors.New("发送过于频繁,请稍后再试")
|
||||||
|
ErrMailNotConfigured = errors.New("邮件服务未配置或未启用")
|
||||||
|
ErrRegisterClosed = errors.New("论坛暂未开放注册,请联系管理员配置邮件服务")
|
||||||
)
|
)
|
||||||
|
|
||||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,32}$`)
|
|
||||||
|
|
||||||
// HashPassword 使用 bcrypt 加密密码
|
// HashPassword 使用 bcrypt 加密密码
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
@@ -46,11 +52,36 @@ func CheckPassword(hash, password string) bool {
|
|||||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateUsername 校验用户名格式
|
// ValidateUsername 校验用户名:中文/字母/数字/下划线,2-32 个字符
|
||||||
func ValidateUsername(username string) error {
|
func ValidateUsername(username string) error {
|
||||||
if !usernameRe.MatchString(username) {
|
n := utf8.RuneCountInString(username)
|
||||||
|
if n < 2 || n > 32 {
|
||||||
return ErrInvalidUsername
|
return ErrInvalidUsername
|
||||||
}
|
}
|
||||||
|
for _, r := range username {
|
||||||
|
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return ErrInvalidUsername
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeEmail 规范化邮箱(小写去空格)
|
||||||
|
func NormalizeEmail(email string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(email))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateEmail 校验邮箱格式
|
||||||
|
func ValidateEmail(email string) error {
|
||||||
|
email = NormalizeEmail(email)
|
||||||
|
if email == "" {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
|
addr, err := mail.ParseAddress(email)
|
||||||
|
if err != nil || addr.Address != email {
|
||||||
|
return ErrInvalidEmail
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
128
service/email_code.go
Normal file
128
service/email_code.go
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
emailCodeLen = 6
|
||||||
|
emailCodeTTL = 10 * time.Minute
|
||||||
|
emailCodeCooldown = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type emailCodeEntry struct {
|
||||||
|
code string
|
||||||
|
expiresAt time.Time
|
||||||
|
sentAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmailCodeService 注册邮箱验证码
|
||||||
|
type EmailCodeService struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
entries map[string]emailCodeEntry
|
||||||
|
mail *MailService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEmailCodeService(mail *MailService) *EmailCodeService {
|
||||||
|
s := &EmailCodeService{
|
||||||
|
entries: make(map[string]emailCodeEntry),
|
||||||
|
mail: mail,
|
||||||
|
}
|
||||||
|
go s.cleanup()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRegisterCode 向邮箱发送注册验证码
|
||||||
|
func (s *EmailCodeService) SendRegisterCode(email string) error {
|
||||||
|
email = NormalizeEmail(email)
|
||||||
|
if err := ValidateEmail(email); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var exist model.User
|
||||||
|
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||||
|
return ErrEmailExists
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
if prev, ok := s.entries[email]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return ErrEmailCodeCooldown
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
code, err := randomDigits(emailCodeLen)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := "注册验证码"
|
||||||
|
body := fmt.Sprintf("您的注册验证码是:%s\n\n%d 分钟内有效,如非本人操作请忽略。", code, int(emailCodeTTL.Minutes()))
|
||||||
|
if err := s.mail.Send(email, subject, body); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.entries[email] = emailCodeEntry{
|
||||||
|
code: code,
|
||||||
|
expiresAt: time.Now().Add(emailCodeTTL),
|
||||||
|
sentAt: time.Now(),
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 校验邮箱验证码(一次性)
|
||||||
|
func (s *EmailCodeService) Verify(email, code string) bool {
|
||||||
|
email = NormalizeEmail(email)
|
||||||
|
code = strings.TrimSpace(code)
|
||||||
|
if email == "" || code == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
entry, ok := s.entries[email]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.entries, email)
|
||||||
|
if time.Now().After(entry.expiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return entry.code == code
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailCodeService) cleanup() {
|
||||||
|
ticker := time.NewTicker(time.Minute)
|
||||||
|
for range ticker.C {
|
||||||
|
now := time.Now()
|
||||||
|
s.mu.Lock()
|
||||||
|
for email, entry := range s.entries {
|
||||||
|
if now.After(entry.expiresAt) {
|
||||||
|
delete(s.entries, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomDigits(n int) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(n)
|
||||||
|
max := big.NewInt(10)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
v, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteByte(byte('0' + v.Int64()))
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
342
service/gitea.go
Normal file
342
service/gitea.go
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrGiteaNotConfigured = errors.New("Gitea 同步未配置或未启用")
|
||||||
|
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
|
||||||
|
)
|
||||||
|
|
||||||
|
// GiteaRepoView 前台展示
|
||||||
|
type GiteaRepoView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
GiteaID int64 `json:"gitea_id"`
|
||||||
|
OwnerLogin string `json:"owner_login"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||||
|
ForumUserID *uint `json:"forum_user_id,omitempty"`
|
||||||
|
SyncedAt time.Time `json:"synced_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiteaService 从 Gitea API 同步会员公开仓库
|
||||||
|
type GiteaService struct {
|
||||||
|
settings *ForumSettingsService
|
||||||
|
client *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
syncing bool
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGiteaService(settings *ForumSettingsService) *GiteaService {
|
||||||
|
return &GiteaService{
|
||||||
|
settings: settings,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackgroundSync 按配置间隔后台同步;失败只记日志
|
||||||
|
func (g *GiteaService) StartBackgroundSync() {
|
||||||
|
g.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer g.wg.Done()
|
||||||
|
// 启动后稍等再首次尝试,避免拖慢启动
|
||||||
|
timer := time.NewTimer(15 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-g.stopCh:
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
if _, err := g.SyncRepos(); err != nil && !errors.Is(err, ErrGiteaNotConfigured) && !errors.Is(err, ErrGiteaSyncBusy) {
|
||||||
|
log.Printf("[gitea] 后台同步失败: %v", err)
|
||||||
|
}
|
||||||
|
cfg := g.settings.GiteaSyncConfig()
|
||||||
|
interval := time.Duration(cfg.SyncIntervalMin) * time.Minute
|
||||||
|
if interval < 5*time.Minute {
|
||||||
|
interval = 5 * time.Minute
|
||||||
|
}
|
||||||
|
timer.Reset(interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止后台同步
|
||||||
|
func (g *GiteaService) Stop() {
|
||||||
|
select {
|
||||||
|
case <-g.stopCh:
|
||||||
|
default:
|
||||||
|
close(g.stopCh)
|
||||||
|
}
|
||||||
|
g.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPublic 列出已同步的公开仓库
|
||||||
|
func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 {
|
||||||
|
size = 30
|
||||||
|
}
|
||||||
|
if size > 100 {
|
||||||
|
size = 100
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
q := model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false)
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var rows []model.GiteaRepo
|
||||||
|
err := model.DB.Where("private = ?", false).
|
||||||
|
Order("updated_at_remote desc, id desc").
|
||||||
|
Offset((page - 1) * size).
|
||||||
|
Limit(size).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
out := make([]GiteaRepoView, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, toGiteaRepoView(r))
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
|
||||||
|
func (g *GiteaService) SyncRepos() (int, error) {
|
||||||
|
cfg := g.settings.GiteaSyncConfig()
|
||||||
|
if !cfg.Ready {
|
||||||
|
return 0, ErrGiteaNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.Lock()
|
||||||
|
if g.syncing {
|
||||||
|
g.mu.Unlock()
|
||||||
|
return 0, ErrGiteaSyncBusy
|
||||||
|
}
|
||||||
|
g.syncing = true
|
||||||
|
g.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
g.mu.Lock()
|
||||||
|
g.syncing = false
|
||||||
|
g.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var users []model.User
|
||||||
|
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[int64]struct{})
|
||||||
|
syncedOwners := make(map[string]struct{})
|
||||||
|
now := time.Now()
|
||||||
|
upserted := 0
|
||||||
|
|
||||||
|
for _, u := range users {
|
||||||
|
username := strings.TrimSpace(u.Username)
|
||||||
|
if username == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repos, err := g.fetchUserPublicRepos(cfg.BaseURL, cfg.Token, username)
|
||||||
|
if err != nil {
|
||||||
|
// 用户在 Gitea 不存在等:跳过,不中断整次同步
|
||||||
|
log.Printf("[gitea] 跳过用户 %s: %v", username, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
syncedOwners[strings.ToLower(username)] = struct{}{}
|
||||||
|
uid := u.ID
|
||||||
|
for _, gr := range repos {
|
||||||
|
if gr.Private {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[gr.ID] = struct{}{}
|
||||||
|
owner := gr.Owner.Login
|
||||||
|
if owner == "" {
|
||||||
|
owner = username
|
||||||
|
}
|
||||||
|
row := model.GiteaRepo{
|
||||||
|
GiteaID: gr.ID,
|
||||||
|
OwnerLogin: owner,
|
||||||
|
Name: gr.Name,
|
||||||
|
FullName: gr.FullName,
|
||||||
|
Description: truncStr(gr.Description, 2048),
|
||||||
|
HTMLURL: gr.HTMLURL,
|
||||||
|
Private: false,
|
||||||
|
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
|
||||||
|
ForumUserID: &uid,
|
||||||
|
SyncedAt: now,
|
||||||
|
}
|
||||||
|
var existing model.GiteaRepo
|
||||||
|
err := model.DB.Where("gitea_id = ?", gr.ID).First(&existing).Error
|
||||||
|
if err != nil {
|
||||||
|
if err := model.DB.Create(&row).Error; err != nil {
|
||||||
|
log.Printf("[gitea] 创建仓库失败 %s: %v", gr.FullName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.ID = existing.ID
|
||||||
|
if err := model.DB.Model(&existing).Updates(map[string]any{
|
||||||
|
"owner_login": row.OwnerLogin,
|
||||||
|
"name": row.Name,
|
||||||
|
"full_name": row.FullName,
|
||||||
|
"description": row.Description,
|
||||||
|
"html_url": row.HTMLURL,
|
||||||
|
"private": false,
|
||||||
|
"updated_at_remote": row.UpdatedAtRemote,
|
||||||
|
"forum_user_id": row.ForumUserID,
|
||||||
|
"synced_at": row.SyncedAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
upserted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅清理本次成功同步到的 owner 下、却未再出现的旧记录
|
||||||
|
if len(syncedOwners) > 0 {
|
||||||
|
var all []model.GiteaRepo
|
||||||
|
if err := model.DB.Where("private = ?", false).Find(&all).Error; err == nil {
|
||||||
|
for _, r := range all {
|
||||||
|
if _, ok := syncedOwners[strings.ToLower(r.OwnerLogin)]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[r.GiteaID]; !ok {
|
||||||
|
_ = model.DB.Delete(&r).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[gitea] 同步完成:upsert %d 个公开仓库", upserted)
|
||||||
|
return upserted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type giteaAPIRepo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
FullName string `json:"full_name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Private bool `json:"private"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
Owner struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
} `json:"owner"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *GiteaService) fetchUserPublicRepos(baseURL, token, username string) ([]giteaAPIRepo, error) {
|
||||||
|
var all []giteaAPIRepo
|
||||||
|
page := 1
|
||||||
|
for {
|
||||||
|
u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/api/v1/users/" + url.PathEscape(username) + "/repos")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("page", strconv.Itoa(page))
|
||||||
|
q.Set("limit", "50")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+token)
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := g.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return nil, fmt.Errorf("用户不存在")
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pageRepos []giteaAPIRepo
|
||||||
|
if err := json.Unmarshal(body, &pageRepos); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pageRepos) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
all = append(all, pageRepos...)
|
||||||
|
if len(pageRepos) < 50 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
if page > 20 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView {
|
||||||
|
return GiteaRepoView{
|
||||||
|
ID: r.ID,
|
||||||
|
GiteaID: r.GiteaID,
|
||||||
|
OwnerLogin: r.OwnerLogin,
|
||||||
|
Name: r.Name,
|
||||||
|
FullName: r.FullName,
|
||||||
|
Description: r.Description,
|
||||||
|
HTMLURL: r.HTMLURL,
|
||||||
|
UpdatedAtRemote: r.UpdatedAtRemote,
|
||||||
|
ForumUserID: r.ForumUserID,
|
||||||
|
SyncedAt: r.SyncedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGiteaTime(raw string) *time.Time {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
layouts := []string{
|
||||||
|
time.RFC3339Nano,
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02T15:04:05Z",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if t, err := time.Parse(layout, raw); err == nil {
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncStr(s string, max int) string {
|
||||||
|
if max <= 0 || len(s) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:max]
|
||||||
|
}
|
||||||
180
service/mail.go
Normal file
180
service/mail.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MailConfig 邮件 SMTP 配置
|
||||||
|
type MailConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password,omitempty"` // 更新时传入;回显时为空
|
||||||
|
From string `json:"from"`
|
||||||
|
FromName string `json:"from_name"`
|
||||||
|
Encryption string `json:"encryption"`
|
||||||
|
HasPassword bool `json:"has_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailService 基于 SMTP 发信
|
||||||
|
type MailService struct {
|
||||||
|
settings *ForumSettingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMailService(settings *ForumSettingsService) *MailService {
|
||||||
|
return &MailService{settings: settings}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 发送纯文本邮件
|
||||||
|
func (m *MailService) Send(to, subject, body string) error {
|
||||||
|
cfg := m.settings.MailConfig()
|
||||||
|
if !m.settings.MailReady() {
|
||||||
|
return ErrMailNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
from := strings.TrimSpace(cfg.From)
|
||||||
|
fromHeader := from
|
||||||
|
if name := strings.TrimSpace(cfg.FromName); name != "" {
|
||||||
|
fromHeader = fmt.Sprintf("%s <%s>", encodeMailHeader(name), from)
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := strings.Join([]string{
|
||||||
|
"From: " + fromHeader,
|
||||||
|
"To: " + to,
|
||||||
|
"Subject: " + encodeMailHeader(subject),
|
||||||
|
"MIME-Version: 1.0",
|
||||||
|
"Content-Type: text/plain; charset=UTF-8",
|
||||||
|
"Content-Transfer-Encoding: 8bit",
|
||||||
|
"",
|
||||||
|
body,
|
||||||
|
}, "\r\n")
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||||
|
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||||
|
|
||||||
|
switch normalizeEncryption(cfg.Encryption) {
|
||||||
|
case "ssl":
|
||||||
|
return sendSMTPWithTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg), true)
|
||||||
|
case "starttls":
|
||||||
|
return sendSMTPStartTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg))
|
||||||
|
default:
|
||||||
|
return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEncryption(v string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "ssl", "tls":
|
||||||
|
return "ssl"
|
||||||
|
case "starttls":
|
||||||
|
return "starttls"
|
||||||
|
default:
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSMTPWithTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte, implicitTLS bool) error {
|
||||||
|
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||||
|
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsCfg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接邮件服务器失败: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
client, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
if ok, _ := client.Extension("AUTH"); ok {
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("邮件认证失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Mail(from); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rcpt := range to {
|
||||||
|
if err := client.Rcpt(rcpt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = implicitTLS
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSMTPStartTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 15*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("连接邮件服务器失败: %w", err)
|
||||||
|
}
|
||||||
|
client, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||||
|
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||||
|
if err := client.StartTLS(tlsCfg); err != nil {
|
||||||
|
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if auth != nil {
|
||||||
|
if ok, _ := client.Extension("AUTH"); ok {
|
||||||
|
if err := client.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("邮件认证失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := client.Mail(from); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rcpt := range to {
|
||||||
|
if err := client.Rcpt(rcpt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w, err := client.Data()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := w.Write(msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return client.Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeMailHeader 简单编码含非 ASCII 的邮件头
|
||||||
|
func encodeMailHeader(s string) string {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > 127 {
|
||||||
|
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
253
service/oauth_clients.go
Normal file
253
service/oauth_clients.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrOAuthClientNotFound = errors.New("OAuth 应用不存在")
|
||||||
|
ErrOAuthClientExists = errors.New("client_id 已存在")
|
||||||
|
ErrOAuthClientInvalid = errors.New("OAuth 应用参数无效")
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuthClientView 管理端展示(不含密钥哈希)
|
||||||
|
type OAuthClientView struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RedirectURIs string `json:"redirect_uris"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
HasSecret bool `json:"has_secret"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
// ClientSecret 仅在创建或轮换时返回一次明文
|
||||||
|
ClientSecret string `json:"client_secret,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuthClientInput 创建/更新请求
|
||||||
|
type OAuthClientInput struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
RedirectURIs string `json:"redirect_uris"`
|
||||||
|
Enabled *bool `json:"enabled"`
|
||||||
|
// ClientSecret 留空:创建时自动生成;更新时表示不改
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
// RotateSecret 更新时为 true 则重新生成密钥
|
||||||
|
RotateSecret bool `json:"rotate_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOAuthClients 列出全部 OAuth 应用
|
||||||
|
func (s *ForumSettingsService) ListOAuthClients() ([]OAuthClientView, error) {
|
||||||
|
var rows []model.OAuthClient
|
||||||
|
if err := model.DB.Order("id asc").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]OAuthClientView, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, toOAuthClientView(r, ""))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOAuthClient 创建应用;返回含明文密钥的视图
|
||||||
|
func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthClientView, error) {
|
||||||
|
clientID := strings.TrimSpace(in.ClientID)
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
uris := normalizeRedirectURIs(in.RedirectURIs)
|
||||||
|
if clientID == "" || name == "" || uris == "" {
|
||||||
|
return nil, ErrOAuthClientInvalid
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
model.DB.Model(&model.OAuthClient{}).Where("client_id = ?", clientID).Count(&n)
|
||||||
|
if n > 0 {
|
||||||
|
return nil, ErrOAuthClientExists
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := strings.TrimSpace(in.ClientSecret)
|
||||||
|
if plain == "" {
|
||||||
|
var err error
|
||||||
|
plain, err = generateClientSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(plain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
enabled := true
|
||||||
|
if in.Enabled != nil {
|
||||||
|
enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
row := model.OAuthClient{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecretHash: hash,
|
||||||
|
Name: name,
|
||||||
|
RedirectURIs: uris,
|
||||||
|
Enabled: enabled,
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v := toOAuthClientView(row, plain)
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOAuthClient 更新应用
|
||||||
|
func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (*OAuthClientView, error) {
|
||||||
|
var row model.OAuthClient
|
||||||
|
if err := model.DB.First(&row, id).Error; err != nil {
|
||||||
|
return nil, ErrOAuthClientNotFound
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
uris := normalizeRedirectURIs(in.RedirectURIs)
|
||||||
|
if name == "" || uris == "" {
|
||||||
|
return nil, ErrOAuthClientInvalid
|
||||||
|
}
|
||||||
|
row.Name = name
|
||||||
|
row.RedirectURIs = uris
|
||||||
|
if in.Enabled != nil {
|
||||||
|
row.Enabled = *in.Enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := ""
|
||||||
|
if in.RotateSecret {
|
||||||
|
var err error
|
||||||
|
plain, err = generateClientSecret()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
hash, err := HashPassword(plain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.ClientSecretHash = hash
|
||||||
|
} else if strings.TrimSpace(in.ClientSecret) != "" {
|
||||||
|
plain = strings.TrimSpace(in.ClientSecret)
|
||||||
|
hash, err := HashPassword(plain)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.ClientSecretHash = hash
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := model.DB.Save(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
v := toOAuthClientView(row, plain)
|
||||||
|
return &v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOAuthClient 删除应用
|
||||||
|
func (s *ForumSettingsService) DeleteOAuthClient(id uint) error {
|
||||||
|
res := model.DB.Delete(&model.OAuthClient{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrOAuthClientNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindEnabledOAuthClient 按 client_id 查找已启用应用
|
||||||
|
func FindEnabledOAuthClient(clientID string) (*model.OAuthClient, error) {
|
||||||
|
var row model.OAuthClient
|
||||||
|
if err := model.DB.Where("client_id = ? AND enabled = ?", clientID, true).First(&row).Error; err != nil {
|
||||||
|
return nil, ErrOIDCInvalidClient
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyOAuthClientSecret 校验客户端密钥(支持 bcrypt;兼容尚未哈希的历史明文)
|
||||||
|
func VerifyOAuthClientSecret(row *model.OAuthClient, secret string) bool {
|
||||||
|
if row == nil || secret == "" || row.ClientSecretHash == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
hash := row.ClientSecretHash
|
||||||
|
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
|
||||||
|
return CheckPassword(hash, secret)
|
||||||
|
}
|
||||||
|
// 遗留明文:校验通过后就地升级为哈希
|
||||||
|
if hash == secret {
|
||||||
|
if newHash, err := HashPassword(secret); err == nil {
|
||||||
|
_ = model.DB.Model(row).Update("client_secret_hash", newHash).Error
|
||||||
|
row.ClientSecretHash = newHash
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func toOAuthClientView(row model.OAuthClient, plainSecret string) OAuthClientView {
|
||||||
|
return OAuthClientView{
|
||||||
|
ID: row.ID,
|
||||||
|
ClientID: row.ClientID,
|
||||||
|
Name: row.Name,
|
||||||
|
RedirectURIs: row.RedirectURIs,
|
||||||
|
Enabled: row.Enabled,
|
||||||
|
HasSecret: row.ClientSecretHash != "",
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
UpdatedAt: row.UpdatedAt,
|
||||||
|
ClientSecret: plainSecret,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateClientSecret() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountEnabledOAuthClients 已启用客户端数量
|
||||||
|
func CountEnabledOAuthClients() int64 {
|
||||||
|
var n int64
|
||||||
|
model.DB.Model(&model.OAuthClient{}).Where("enabled = ?", true).Count(&n)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrateLegacyOIDCClient 将旧版 ForumSetting 单客户端迁入 oauth_clients(仅一次)
|
||||||
|
func (s *ForumSettingsService) MigrateLegacyOIDCClient() {
|
||||||
|
if CountEnabledOAuthClients() > 0 {
|
||||||
|
// 仍清理遗留明文密钥字段
|
||||||
|
s.clearLegacyOAuthSecrets()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientID := strings.TrimSpace(s.getString(SettingOAuthClientID, ""))
|
||||||
|
secret := s.getString(SettingOAuthClientSecret, "")
|
||||||
|
uris := normalizeRedirectURIs(s.getString(SettingOAuthRedirectURIs, ""))
|
||||||
|
if clientID == "" || secret == "" || uris == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := secret
|
||||||
|
if !(strings.HasPrefix(secret, "$2a$") || strings.HasPrefix(secret, "$2b$") || strings.HasPrefix(secret, "$2y$")) {
|
||||||
|
h, err := HashPassword(secret)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash = h
|
||||||
|
}
|
||||||
|
_ = model.DB.Create(&model.OAuthClient{
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecretHash: hash,
|
||||||
|
Name: "Gitea",
|
||||||
|
RedirectURIs: uris,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error
|
||||||
|
s.clearLegacyOAuthSecrets()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) clearLegacyOAuthSecrets() {
|
||||||
|
// 清空遗留明文,避免双源配置
|
||||||
|
if s.getString(SettingOAuthClientSecret, "") != "" {
|
||||||
|
_ = s.setString(SettingOAuthClientSecret, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
634
service/oidc.go
Normal file
634
service/oidc.go
Normal file
@@ -0,0 +1,634 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
oidcAuthCodeTTL = 5 * time.Minute
|
||||||
|
oidcAccessTokenTTL = time.Hour
|
||||||
|
oidcIDTokenTTL = time.Hour
|
||||||
|
oidcRSABits = 2048
|
||||||
|
oidcKeyID = "jiang13-oidc-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrOIDCNotConfigured = errors.New("OIDC 未配置(请在管理后台启用并至少创建一个 OAuth 应用)")
|
||||||
|
ErrOIDCInvalidClient = errors.New("无效的 client_id 或 client_secret")
|
||||||
|
ErrOIDCInvalidRedirect = errors.New("redirect_uri 未登记")
|
||||||
|
ErrOIDCInvalidRequest = errors.New("授权请求参数无效")
|
||||||
|
ErrOIDCInvalidGrant = errors.New("授权码无效或已过期")
|
||||||
|
ErrOIDCInvalidToken = errors.New("access_token 无效")
|
||||||
|
ErrOIDCUserBanned = errors.New("账号已被禁言,无法授权")
|
||||||
|
ErrOIDCPKCEFailed = errors.New("PKCE 校验失败")
|
||||||
|
ErrOIDCInvalidLogout = errors.New("post_logout_redirect_uri 未登记")
|
||||||
|
)
|
||||||
|
|
||||||
|
// OIDCService 论坛作为 OpenID Connect Provider
|
||||||
|
type OIDCService struct {
|
||||||
|
cfg *config.Config
|
||||||
|
settings *ForumSettingsService
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
privateKey *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOIDCService 创建并加载/生成 RSA 密钥
|
||||||
|
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
|
||||||
|
s := &OIDCService{cfg: cfg, settings: settings}
|
||||||
|
if err := s.loadOrCreateKey(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) runtime() OIDCConfig {
|
||||||
|
if s.settings != nil {
|
||||||
|
return s.settings.OIDCConfig()
|
||||||
|
}
|
||||||
|
return OIDCConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) loadOrCreateKey() error {
|
||||||
|
keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
|
||||||
|
if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
|
||||||
|
block, _ := pem.Decode(data)
|
||||||
|
if block == nil {
|
||||||
|
return fmt.Errorf("解析 OIDC RSA 密钥失败")
|
||||||
|
}
|
||||||
|
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err2 != nil {
|
||||||
|
return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
|
||||||
|
}
|
||||||
|
var ok bool
|
||||||
|
key, ok = parsed.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("OIDC 密钥不是 RSA")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.privateKey = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
|
||||||
|
}
|
||||||
|
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "RSA PRIVATE KEY",
|
||||||
|
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
|
||||||
|
return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
|
||||||
|
}
|
||||||
|
s.privateKey = key
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled 是否可对外提供 OIDC
|
||||||
|
func (s *OIDCService) Enabled() bool {
|
||||||
|
return s.runtime().Ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issuer 返回 OIDC issuer
|
||||||
|
func (s *OIDCService) Issuer() string {
|
||||||
|
return s.runtime().RootURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discovery 返回 OpenID Provider Metadata
|
||||||
|
func (s *OIDCService) Discovery() (map[string]any, error) {
|
||||||
|
rt := s.runtime()
|
||||||
|
if !rt.Ready {
|
||||||
|
return nil, ErrOIDCNotConfigured
|
||||||
|
}
|
||||||
|
base := rt.RootURL
|
||||||
|
return map[string]any{
|
||||||
|
"issuer": base,
|
||||||
|
"authorization_endpoint": base + "/oauth/authorize",
|
||||||
|
"token_endpoint": base + "/oauth/token",
|
||||||
|
"userinfo_endpoint": base + "/oauth/userinfo",
|
||||||
|
"jwks_uri": base + "/oauth/jwks",
|
||||||
|
"end_session_endpoint": base + "/oauth/logout",
|
||||||
|
"response_types_supported": []string{"code"},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||||
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
|
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||||
|
"claims_supported": []string{
|
||||||
|
"sub", "name", "preferred_username", "email", "email_verified", "picture", "groups",
|
||||||
|
},
|
||||||
|
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWKS 返回 JSON Web Key Set
|
||||||
|
func (s *OIDCService) JWKS() (map[string]any, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
key := s.privateKey
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if key == nil {
|
||||||
|
return nil, ErrOIDCNotConfigured
|
||||||
|
}
|
||||||
|
pub := key.PublicKey
|
||||||
|
return map[string]any{
|
||||||
|
"keys": []map[string]string{
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"alg": "RS256",
|
||||||
|
"kid": oidcKeyID,
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||||
|
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func bigIntBytes(e int) []byte {
|
||||||
|
if e == 0 {
|
||||||
|
return []byte{0}
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for v := e; v > 0; v >>= 8 {
|
||||||
|
b = append([]byte{byte(v & 0xff)}, b...)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorizeRequest 授权端点查询参数
|
||||||
|
type AuthorizeRequest struct {
|
||||||
|
ClientID string
|
||||||
|
RedirectURI string
|
||||||
|
ResponseType string
|
||||||
|
Scope string
|
||||||
|
State string
|
||||||
|
Nonce string
|
||||||
|
CodeChallenge string
|
||||||
|
CodeChallengeMethod string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateAuthorize 校验授权请求(不要求已登录)
|
||||||
|
func (s *OIDCService) ValidateAuthorize(req AuthorizeRequest) error {
|
||||||
|
rt := s.runtime()
|
||||||
|
if !rt.Ready {
|
||||||
|
return ErrOIDCNotConfigured
|
||||||
|
}
|
||||||
|
client, err := FindEnabledOAuthClient(req.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
return ErrOIDCInvalidClient
|
||||||
|
}
|
||||||
|
if req.ResponseType != "code" {
|
||||||
|
return ErrOIDCInvalidRequest
|
||||||
|
}
|
||||||
|
if !redirectAllowed(client.RedirectURIs, req.RedirectURI) {
|
||||||
|
return ErrOIDCInvalidRedirect
|
||||||
|
}
|
||||||
|
if !hasScope(req.Scope, "openid") {
|
||||||
|
return ErrOIDCInvalidRequest
|
||||||
|
}
|
||||||
|
if req.CodeChallenge != "" {
|
||||||
|
m := strings.ToUpper(req.CodeChallengeMethod)
|
||||||
|
if m == "" {
|
||||||
|
m = "PLAIN"
|
||||||
|
}
|
||||||
|
if m != "S256" && m != "PLAIN" {
|
||||||
|
return ErrOIDCInvalidRequest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasScope(scope, want string) bool {
|
||||||
|
for _, p := range strings.Fields(scope) {
|
||||||
|
if p == want {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func redirectAllowed(redirectURIsCSV, uri string) bool {
|
||||||
|
for _, allowed := range splitRedirectURIs(redirectURIsCSV) {
|
||||||
|
if allowed == uri {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueAuthCode 已登录用户签发授权码,返回带 code/state 的回调 URL
|
||||||
|
func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string, error) {
|
||||||
|
if err := s.ValidateAuthorize(req); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := model.DB.First(&user, userID).Error; err != nil {
|
||||||
|
return "", ErrOIDCInvalidRequest
|
||||||
|
}
|
||||||
|
if user.Banned {
|
||||||
|
return "", ErrOIDCUserBanned
|
||||||
|
}
|
||||||
|
|
||||||
|
code, err := randomToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
method := strings.ToUpper(req.CodeChallengeMethod)
|
||||||
|
if req.CodeChallenge != "" && method == "" {
|
||||||
|
method = "PLAIN"
|
||||||
|
}
|
||||||
|
rec := &model.OAuthAuthCode{
|
||||||
|
Code: code,
|
||||||
|
ClientID: req.ClientID,
|
||||||
|
UserID: user.ID,
|
||||||
|
RedirectURI: req.RedirectURI,
|
||||||
|
Scope: req.Scope,
|
||||||
|
Nonce: req.Nonce,
|
||||||
|
CodeChallenge: req.CodeChallenge,
|
||||||
|
CodeChallengeMethod: method,
|
||||||
|
ExpiresAt: time.Now().Add(oidcAuthCodeTTL),
|
||||||
|
}
|
||||||
|
if err := model.DB.Create(rec).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(req.RedirectURI)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrOIDCInvalidRedirect
|
||||||
|
}
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("code", code)
|
||||||
|
if req.State != "" {
|
||||||
|
q.Set("state", req.State)
|
||||||
|
}
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenRequest 换票请求
|
||||||
|
type TokenRequest struct {
|
||||||
|
GrantType string
|
||||||
|
Code string
|
||||||
|
RedirectURI string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
CodeVerifier string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenResponse OAuth token 响应
|
||||||
|
type TokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
IDToken string `json:"id_token,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExchangeCode 授权码换 token
|
||||||
|
func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
|
||||||
|
rt := s.runtime()
|
||||||
|
if !rt.Ready {
|
||||||
|
return nil, ErrOIDCNotConfigured
|
||||||
|
}
|
||||||
|
if req.GrantType != "authorization_code" {
|
||||||
|
return nil, ErrOIDCInvalidRequest
|
||||||
|
}
|
||||||
|
client, err := FindEnabledOAuthClient(req.ClientID)
|
||||||
|
if err != nil || !VerifyOAuthClientSecret(client, req.ClientSecret) {
|
||||||
|
return nil, ErrOIDCInvalidClient
|
||||||
|
}
|
||||||
|
|
||||||
|
var rec model.OAuthAuthCode
|
||||||
|
if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil {
|
||||||
|
return nil, ErrOIDCInvalidGrant
|
||||||
|
}
|
||||||
|
if rec.Used || time.Now().After(rec.ExpiresAt) {
|
||||||
|
// 重放:作废同用户同客户端未过期码
|
||||||
|
if rec.Used {
|
||||||
|
_ = model.DB.Model(&model.OAuthAuthCode{}).
|
||||||
|
Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?",
|
||||||
|
rec.ClientID, rec.UserID, false, time.Now()).
|
||||||
|
Update("used", true).Error
|
||||||
|
}
|
||||||
|
return nil, ErrOIDCInvalidGrant
|
||||||
|
}
|
||||||
|
if rec.ClientID != req.ClientID || rec.RedirectURI != req.RedirectURI {
|
||||||
|
return nil, ErrOIDCInvalidGrant
|
||||||
|
}
|
||||||
|
if err := verifyPKCE(rec.CodeChallenge, rec.CodeChallengeMethod, req.CodeVerifier); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rec.Used = true
|
||||||
|
_ = model.DB.Save(&rec).Error
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned {
|
||||||
|
return nil, ErrOIDCInvalidGrant
|
||||||
|
}
|
||||||
|
|
||||||
|
access, err := s.signAccessToken(&user, rec.Scope, req.ClientID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
idToken, err := s.signIDToken(&user, rec.Scope, req.ClientID, rec.Nonce)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &TokenResponse{
|
||||||
|
AccessToken: access,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: int(oidcAccessTokenTTL.Seconds()),
|
||||||
|
IDToken: idToken,
|
||||||
|
Scope: rec.Scope,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyPKCE(challenge, method, verifier string) error {
|
||||||
|
if challenge == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if verifier == "" {
|
||||||
|
return ErrOIDCPKCEFailed
|
||||||
|
}
|
||||||
|
switch strings.ToUpper(method) {
|
||||||
|
case "S256":
|
||||||
|
sum := sha256.Sum256([]byte(verifier))
|
||||||
|
calc := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||||
|
if calc != challenge {
|
||||||
|
return ErrOIDCPKCEFailed
|
||||||
|
}
|
||||||
|
case "PLAIN", "":
|
||||||
|
if verifier != challenge {
|
||||||
|
return ErrOIDCPKCEFailed
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return ErrOIDCPKCEFailed
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcAccessClaims struct {
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
type oidcIDClaims struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
EmailVerified bool `json:"email_verified,omitempty"`
|
||||||
|
Picture string `json:"picture,omitempty"`
|
||||||
|
Groups []string `json:"groups,omitempty"`
|
||||||
|
Nonce string `json:"nonce,omitempty"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
issuer := s.Issuer()
|
||||||
|
claims := oidcAccessClaims{
|
||||||
|
Scope: scope,
|
||||||
|
ClientID: clientID,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: issuer,
|
||||||
|
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||||
|
Audience: []string{clientID},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(oidcAccessTokenTTL)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
t.Header["kid"] = oidcKeyID
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return t.SignedString(s.privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
issuer := s.Issuer()
|
||||||
|
claims := oidcIDClaims{
|
||||||
|
Nonce: nonce,
|
||||||
|
Groups: s.userGroups(user),
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: issuer,
|
||||||
|
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||||
|
Audience: []string{clientID},
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(oidcIDTokenTTL)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if hasScope(scope, "profile") || scope == "" || hasScope(scope, "openid") {
|
||||||
|
claims.Name = user.Nickname
|
||||||
|
if claims.Name == "" {
|
||||||
|
claims.Name = user.Username
|
||||||
|
}
|
||||||
|
claims.PreferredUsername = user.Username
|
||||||
|
claims.Picture = s.absoluteURL(user.Avatar)
|
||||||
|
}
|
||||||
|
if hasScope(scope, "email") || hasScope(scope, "openid") {
|
||||||
|
claims.Email = user.Email
|
||||||
|
claims.EmailVerified = user.Email != ""
|
||||||
|
}
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
t.Header["kid"] = oidcKeyID
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return t.SignedString(s.privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) userGroups(user *model.User) []string {
|
||||||
|
rt := s.runtime()
|
||||||
|
groups := make([]string, 0, 2)
|
||||||
|
if rt.UserGroup != "" {
|
||||||
|
groups = append(groups, rt.UserGroup)
|
||||||
|
}
|
||||||
|
if user.Role == model.RoleAdmin && rt.AdminGroup != "" {
|
||||||
|
groups = append(groups, rt.AdminGroup)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserInfo 根据 access_token 返回用户声明
|
||||||
|
func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) {
|
||||||
|
claims, err := s.parseAccessToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
uid, err := strconv.ParseUint(claims.Subject, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrOIDCInvalidToken
|
||||||
|
}
|
||||||
|
var user model.User
|
||||||
|
if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned {
|
||||||
|
return nil, ErrOIDCInvalidToken
|
||||||
|
}
|
||||||
|
rt := s.runtime()
|
||||||
|
out := map[string]any{
|
||||||
|
"sub": strconv.FormatUint(uint64(user.ID), 10),
|
||||||
|
}
|
||||||
|
if hasScope(claims.Scope, "profile") || claims.Scope == "" {
|
||||||
|
name := user.Nickname
|
||||||
|
if name == "" {
|
||||||
|
name = user.Username
|
||||||
|
}
|
||||||
|
out["name"] = name
|
||||||
|
out["preferred_username"] = user.Username
|
||||||
|
if pic := s.absoluteURL(user.Avatar); pic != "" {
|
||||||
|
out["picture"] = pic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasScope(claims.Scope, "email") || hasScope(claims.Scope, "openid") {
|
||||||
|
if user.Email != "" {
|
||||||
|
out["email"] = user.Email
|
||||||
|
out["email_verified"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := out["preferred_username"]; !ok {
|
||||||
|
out["preferred_username"] = user.Username
|
||||||
|
out["name"] = user.Nickname
|
||||||
|
if out["name"] == "" {
|
||||||
|
out["name"] = user.Username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups := s.userGroups(&user)
|
||||||
|
if len(groups) > 0 {
|
||||||
|
claim := rt.GroupClaim
|
||||||
|
if claim == "" {
|
||||||
|
claim = "groups"
|
||||||
|
}
|
||||||
|
out[claim] = groups
|
||||||
|
if claim != "groups" {
|
||||||
|
out["groups"] = groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLogoutRedirect 校验并返回登出后跳转地址(空表示回首页)
|
||||||
|
func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string) (string, error) {
|
||||||
|
uri := strings.TrimSpace(postLogoutRedirectURI)
|
||||||
|
if uri == "" {
|
||||||
|
return "/", nil
|
||||||
|
}
|
||||||
|
var clients []model.OAuthClient
|
||||||
|
if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
allowed := false
|
||||||
|
for _, c := range clients {
|
||||||
|
if redirectAllowed(c.RedirectURIs, uri) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 允许同 host 下任意已登记前缀的登出回调(Gitea 常用 / 根路径)
|
||||||
|
for _, reg := range splitRedirectURIs(c.RedirectURIs) {
|
||||||
|
if sameOrigin(reg, uri) {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allowed {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
return "", ErrOIDCInvalidLogout
|
||||||
|
}
|
||||||
|
u, err := url.Parse(uri)
|
||||||
|
if err != nil {
|
||||||
|
return "", ErrOIDCInvalidLogout
|
||||||
|
}
|
||||||
|
if state != "" {
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("state", state)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameOrigin(a, b string) bool {
|
||||||
|
ua, err1 := url.Parse(a)
|
||||||
|
ub, err2 := url.Parse(b)
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) parseAccessToken(tokenStr string) (*oidcAccessClaims, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
key := s.privateKey
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if key == nil {
|
||||||
|
return nil, ErrOIDCNotConfigured
|
||||||
|
}
|
||||||
|
tok, err := jwt.ParseWithClaims(tokenStr, &oidcAccessClaims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method != jwt.SigningMethodRS256 {
|
||||||
|
return nil, ErrOIDCInvalidToken
|
||||||
|
}
|
||||||
|
return &key.PublicKey, nil
|
||||||
|
})
|
||||||
|
if err != nil || !tok.Valid {
|
||||||
|
return nil, ErrOIDCInvalidToken
|
||||||
|
}
|
||||||
|
claims, ok := tok.Claims.(*oidcAccessClaims)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrOIDCInvalidToken
|
||||||
|
}
|
||||||
|
if claims.Issuer != s.Issuer() {
|
||||||
|
return nil, ErrOIDCInvalidToken
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *OIDCService) absoluteURL(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
base := s.Issuer()
|
||||||
|
if base == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken(nBytes int) (string, error) {
|
||||||
|
b := make([]byte, nBytes)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
const onlineTTL = 5 * time.Minute
|
|
||||||
|
|
||||||
// OnlineService 在线浏览追踪(内存):登录会员 + 游客
|
|
||||||
type OnlineService struct {
|
|
||||||
mu sync.RWMutex
|
|
||||||
seen map[uint]time.Time // 登录用户
|
|
||||||
guests map[string]time.Time // 游客访客标识
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewOnlineService() *OnlineService {
|
|
||||||
s := &OnlineService{
|
|
||||||
seen: make(map[uint]time.Time),
|
|
||||||
guests: make(map[string]time.Time),
|
|
||||||
}
|
|
||||||
go s.cleanup()
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) Ping(userID uint) {
|
|
||||||
if userID == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.seen[userID] = time.Now()
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) PingGuest(visitorID string) {
|
|
||||||
if visitorID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.mu.Lock()
|
|
||||||
s.guests[visitorID] = time.Now()
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnlineUser struct {
|
|
||||||
ID uint `json:"id"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) List(limit int) []OnlineUser {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
cutoff := time.Now().Add(-onlineTTL)
|
|
||||||
s.mu.RLock()
|
|
||||||
var ids []uint
|
|
||||||
for id, t := range s.seen {
|
|
||||||
if t.After(cutoff) {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.RUnlock()
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var users []model.User
|
|
||||||
model.DB.Where("id IN ?", ids).Limit(limit).Find(&users)
|
|
||||||
out := make([]OnlineUser, 0, len(users))
|
|
||||||
for _, u := range users {
|
|
||||||
out = append(out, OnlineUser{ID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) CountMembers() int {
|
|
||||||
return s.countSeen(s.seen)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) CountGuests() int {
|
|
||||||
return s.countSeenString(s.guests)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count 当前浏览总人数(会员 + 游客)
|
|
||||||
func (s *OnlineService) Count() int {
|
|
||||||
return s.CountMembers() + s.CountGuests()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) countSeen(m map[uint]time.Time) int {
|
|
||||||
cutoff := time.Now().Add(-onlineTTL)
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
n := 0
|
|
||||||
for _, t := range m {
|
|
||||||
if t.After(cutoff) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) countSeenString(m map[string]time.Time) int {
|
|
||||||
cutoff := time.Now().Add(-onlineTTL)
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
n := 0
|
|
||||||
for _, t := range m {
|
|
||||||
if t.After(cutoff) {
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *OnlineService) cleanup() {
|
|
||||||
ticker := time.NewTicker(time.Minute)
|
|
||||||
for range ticker.C {
|
|
||||||
cutoff := time.Now().Add(-onlineTTL * 2)
|
|
||||||
s.mu.Lock()
|
|
||||||
for id, t := range s.seen {
|
|
||||||
if t.Before(cutoff) {
|
|
||||||
delete(s.seen, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id, t := range s.guests {
|
|
||||||
if t.Before(cutoff) {
|
|
||||||
delete(s.guests, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -133,6 +134,60 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TagCount 标签及其出现次数
|
||||||
|
type TagCount struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PopularTags 聚合帖子标签,按热度降序返回
|
||||||
|
func (s *PostService) PopularTags(limit int) ([]TagCount, error) {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 40
|
||||||
|
}
|
||||||
|
var rows []struct{ Tags string }
|
||||||
|
if err := model.DB.Model(&model.Post{}).
|
||||||
|
Select("tags").
|
||||||
|
Where("tags <> '' AND tags IS NOT NULL").
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int)
|
||||||
|
// 保留首次出现的原始大小写作为展示名
|
||||||
|
display := make(map[string]string)
|
||||||
|
for _, row := range rows {
|
||||||
|
for _, part := range strings.FieldsFunc(row.Tags, func(r rune) bool {
|
||||||
|
return r == ',' || r == ','
|
||||||
|
}) {
|
||||||
|
name := strings.TrimSpace(part)
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(name)
|
||||||
|
counts[key]++
|
||||||
|
if _, ok := display[key]; !ok {
|
||||||
|
display[key] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]TagCount, 0, len(counts))
|
||||||
|
for key, n := range counts {
|
||||||
|
list = append(list, TagCount{Name: display[key], Count: n})
|
||||||
|
}
|
||||||
|
sort.Slice(list, func(i, j int) bool {
|
||||||
|
if list[i].Count != list[j].Count {
|
||||||
|
return list[i].Count > list[j].Count
|
||||||
|
}
|
||||||
|
return strings.ToLower(list[i].Name) < strings.ToLower(list[j].Name)
|
||||||
|
})
|
||||||
|
if len(list) > limit {
|
||||||
|
list = list[:limit]
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *PostService) CommentCount(postID uint) int {
|
func (s *PostService) CommentCount(postID uint) int {
|
||||||
var count int64
|
var count int64
|
||||||
model.DB.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&count)
|
model.DB.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&count)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
@@ -27,13 +28,46 @@ const (
|
|||||||
SettingSearchKeywordMax = "search_keyword_max"
|
SettingSearchKeywordMax = "search_keyword_max"
|
||||||
|
|
||||||
SettingPageSizeDefault = "page_size_default"
|
SettingPageSizeDefault = "page_size_default"
|
||||||
SettingPageSizeMax = "page_size_max"
|
|
||||||
|
|
||||||
SettingFeedMaxPages = "feed_max_pages"
|
|
||||||
SettingFeedMaxItems = "feed_max_items"
|
|
||||||
|
|
||||||
SettingPasswordMinLen = "password_min_len"
|
SettingPasswordMinLen = "password_min_len"
|
||||||
SettingAvatarMaxMB = "avatar_max_mb"
|
SettingAvatarMaxMB = "avatar_max_mb"
|
||||||
|
|
||||||
|
SettingOpenPostsInNewTab = "open_posts_in_new_tab"
|
||||||
|
SettingOpenContentLinksInNewTab = "open_content_links_in_new_tab"
|
||||||
|
|
||||||
|
SettingSMTPEnabled = "smtp_enabled"
|
||||||
|
SettingSMTPHost = "smtp_host"
|
||||||
|
SettingSMTPPort = "smtp_port"
|
||||||
|
SettingSMTPUsername = "smtp_username"
|
||||||
|
SettingSMTPPassword = "smtp_password"
|
||||||
|
SettingSMTPFrom = "smtp_from"
|
||||||
|
SettingSMTPFromName = "smtp_from_name"
|
||||||
|
SettingSMTPEncryption = "smtp_encryption"
|
||||||
|
|
||||||
|
SettingOIDCEnabled = "oidc_enabled"
|
||||||
|
SettingOIDCRootURL = "oidc_root_url"
|
||||||
|
SettingOIDCGroupClaim = "oidc_group_claim"
|
||||||
|
SettingOIDCAdminGroup = "oidc_admin_group"
|
||||||
|
SettingOIDCUserGroup = "oidc_user_group"
|
||||||
|
// 遗留单客户端字段(仅用于迁移到 oauth_clients)
|
||||||
|
SettingOAuthClientID = "oauth_client_id"
|
||||||
|
SettingOAuthClientSecret = "oauth_client_secret"
|
||||||
|
SettingOAuthRedirectURIs = "oauth_redirect_uris"
|
||||||
|
|
||||||
|
SettingGiteaSyncEnabled = "gitea_sync_enabled"
|
||||||
|
SettingGiteaBaseURL = "gitea_base_url"
|
||||||
|
SettingGiteaToken = "gitea_token"
|
||||||
|
SettingGiteaSyncIntervalMin = "gitea_sync_interval_min"
|
||||||
|
|
||||||
|
SettingSiteName = "site_name"
|
||||||
|
SettingSiteNameEN = "site_name_en"
|
||||||
|
SettingSiteSlogan = "site_slogan"
|
||||||
|
SettingSiteLogoMark = "site_logo_mark"
|
||||||
|
SettingSiteLogo = "site_logo"
|
||||||
|
SettingSiteFavicon = "site_favicon"
|
||||||
|
|
||||||
|
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||||||
|
pageSizeAPIMax = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
// ForumLimits 论坛可配置限制(API 传输结构)
|
// ForumLimits 论坛可配置限制(API 传输结构)
|
||||||
@@ -56,28 +90,28 @@ type ForumLimits struct {
|
|||||||
SearchKeywordMax int `json:"search_keyword_max"`
|
SearchKeywordMax int `json:"search_keyword_max"`
|
||||||
|
|
||||||
PageSizeDefault int `json:"page_size_default"`
|
PageSizeDefault int `json:"page_size_default"`
|
||||||
PageSizeMax int `json:"page_size_max"`
|
|
||||||
|
|
||||||
FeedMaxPages int `json:"feed_max_pages"`
|
|
||||||
FeedMaxItems int `json:"feed_max_items"`
|
|
||||||
|
|
||||||
PasswordMinLen int `json:"password_min_len"`
|
PasswordMinLen int `json:"password_min_len"`
|
||||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||||
|
|
||||||
|
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||||
|
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||||
type ForumLimitsPublic struct {
|
type ForumLimitsPublic struct {
|
||||||
PostTitleMax int `json:"post_title_max"`
|
PostTitleMax int `json:"post_title_max"`
|
||||||
PostTagsMax int `json:"post_tags_max"`
|
PostTagsMax int `json:"post_tags_max"`
|
||||||
PostContentMax int `json:"post_content_max"`
|
PostContentMax int `json:"post_content_max"`
|
||||||
CommentMax int `json:"comment_max"`
|
CommentMax int `json:"comment_max"`
|
||||||
SearchKeywordMin int `json:"search_keyword_min"`
|
SearchKeywordMin int `json:"search_keyword_min"`
|
||||||
SearchKeywordMax int `json:"search_keyword_max"`
|
SearchKeywordMax int `json:"search_keyword_max"`
|
||||||
PageSizeDefault int `json:"page_size_default"`
|
PageSizeDefault int `json:"page_size_default"`
|
||||||
FeedMaxPages int `json:"feed_max_pages"`
|
PasswordMinLen int `json:"password_min_len"`
|
||||||
FeedMaxItems int `json:"feed_max_items"`
|
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||||
PasswordMinLen int `json:"password_min_len"`
|
|
||||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||||
|
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type settingDef struct {
|
type settingDef struct {
|
||||||
@@ -105,14 +139,86 @@ var forumSettingDefs = []settingDef{
|
|||||||
{SettingSearchKeywordMin, "1", 0, 100},
|
{SettingSearchKeywordMin, "1", 0, 100},
|
||||||
{SettingSearchKeywordMax, "50", 1, 200},
|
{SettingSearchKeywordMax, "50", 1, 200},
|
||||||
|
|
||||||
{SettingPageSizeDefault, "30", 1, 200},
|
{SettingPageSizeDefault, "30", 1, pageSizeAPIMax},
|
||||||
{SettingPageSizeMax, "50", 1, 200},
|
|
||||||
|
|
||||||
{SettingFeedMaxPages, "10", 1, 100},
|
|
||||||
{SettingFeedMaxItems, "300", 1, 5000},
|
|
||||||
|
|
||||||
{SettingPasswordMinLen, "6", 4, 128},
|
{SettingPasswordMinLen, "6", 4, 128},
|
||||||
{SettingAvatarMaxMB, "2", 1, 20},
|
{SettingAvatarMaxMB, "2", 1, 20},
|
||||||
|
|
||||||
|
{SettingOpenPostsInNewTab, "1", 0, 1},
|
||||||
|
{SettingOpenContentLinksInNewTab, "1", 0, 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
var mailSettingDefaults = map[string]string{
|
||||||
|
SettingSMTPEnabled: "0",
|
||||||
|
SettingSMTPHost: "",
|
||||||
|
SettingSMTPPort: "465",
|
||||||
|
SettingSMTPUsername: "",
|
||||||
|
SettingSMTPPassword: "",
|
||||||
|
SettingSMTPFrom: "",
|
||||||
|
SettingSMTPFromName: "姜十三论坛",
|
||||||
|
SettingSMTPEncryption: "ssl",
|
||||||
|
}
|
||||||
|
|
||||||
|
var oidcSettingDefaults = map[string]string{
|
||||||
|
SettingOIDCEnabled: "0",
|
||||||
|
SettingOIDCRootURL: "",
|
||||||
|
SettingOIDCGroupClaim: "groups",
|
||||||
|
SettingOIDCAdminGroup: "gitea-admin",
|
||||||
|
SettingOIDCUserGroup: "gitea-users",
|
||||||
|
SettingOAuthClientID: "",
|
||||||
|
SettingOAuthClientSecret: "",
|
||||||
|
SettingOAuthRedirectURIs: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var giteaSettingDefaults = map[string]string{
|
||||||
|
SettingGiteaSyncEnabled: "0",
|
||||||
|
SettingGiteaBaseURL: "",
|
||||||
|
SettingGiteaToken: "",
|
||||||
|
SettingGiteaSyncIntervalMin: "60",
|
||||||
|
}
|
||||||
|
|
||||||
|
var siteBrandingDefaults = map[string]string{
|
||||||
|
SettingSiteName: "姜十三论坛",
|
||||||
|
SettingSiteNameEN: "Jiang13 Forum",
|
||||||
|
SettingSiteSlogan: "拾三一隅,自在交流",
|
||||||
|
SettingSiteLogoMark: "姜",
|
||||||
|
SettingSiteLogo: "",
|
||||||
|
SettingSiteFavicon: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteBranding 站点品牌配置(名称、Logo、Favicon 等)
|
||||||
|
type SiteBranding struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
NameEN string `json:"name_en"`
|
||||||
|
Slogan string `json:"slogan"`
|
||||||
|
LogoMark string `json:"logo_mark"`
|
||||||
|
Logo string `json:"logo"`
|
||||||
|
Favicon string `json:"favicon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiteaSyncConfig Gitea 仓库同步配置
|
||||||
|
type GiteaSyncConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
Token string `json:"token,omitempty"` // 更新时传入;回显时为空
|
||||||
|
HasToken bool `json:"has_token"`
|
||||||
|
SyncIntervalMin int `json:"sync_interval_min"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
RepoCount int64 `json:"repo_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
|
||||||
|
type OIDCConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RootURL string `json:"root_url"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
DiscoveryURL string `json:"discovery_url,omitempty"`
|
||||||
|
AuthorizeURL string `json:"authorize_url,omitempty"`
|
||||||
|
LogoutURL string `json:"logout_url,omitempty"`
|
||||||
|
GroupClaim string `json:"group_claim"`
|
||||||
|
AdminGroup string `json:"admin_group"`
|
||||||
|
UserGroup string `json:"user_group"`
|
||||||
|
ClientCount int64 `json:"client_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForumSettingsService 论坛全局设置
|
// ForumSettingsService 论坛全局设置
|
||||||
@@ -134,6 +240,48 @@ func (s *ForumSettingsService) ensureDefaults() {
|
|||||||
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
|
model.DB.Create(&model.ForumSetting{Key: def.key, Value: def.defaultVal})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key, val := range mailSettingDefaults {
|
||||||
|
var count int64
|
||||||
|
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, val := range oidcSettingDefaults {
|
||||||
|
var count int64
|
||||||
|
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, val := range giteaSettingDefaults {
|
||||||
|
var count int64
|
||||||
|
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, val := range siteBrandingDefaults {
|
||||||
|
var count int64
|
||||||
|
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) getString(key, fallback string) string {
|
||||||
|
var setting model.ForumSetting
|
||||||
|
if err := model.DB.First(&setting, "`key` = ?", key).Error; err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return setting.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) setString(key, value string) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return model.DB.Save(&model.ForumSetting{Key: key, Value: value}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ForumSettingsService) getInt(key string, fallback int) int {
|
func (s *ForumSettingsService) getInt(key string, fallback int) int {
|
||||||
@@ -186,13 +334,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
|||||||
SearchKeywordMax: s.SearchKeywordMax(),
|
SearchKeywordMax: s.SearchKeywordMax(),
|
||||||
|
|
||||||
PageSizeDefault: s.PageSizeDefault(),
|
PageSizeDefault: s.PageSizeDefault(),
|
||||||
PageSizeMax: s.PageSizeMax(),
|
|
||||||
|
|
||||||
FeedMaxPages: s.FeedMaxPages(),
|
|
||||||
FeedMaxItems: s.FeedMaxItems(),
|
|
||||||
|
|
||||||
PasswordMinLen: s.PasswordMinLen(),
|
PasswordMinLen: s.PasswordMinLen(),
|
||||||
AvatarMaxMB: s.AvatarMaxMB(),
|
AvatarMaxMB: s.AvatarMaxMB(),
|
||||||
|
|
||||||
|
OpenPostsInNewTab: s.OpenPostsInNewTab(),
|
||||||
|
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,10 +353,11 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
|||||||
SearchKeywordMin: limits.SearchKeywordMin,
|
SearchKeywordMin: limits.SearchKeywordMin,
|
||||||
SearchKeywordMax: limits.SearchKeywordMax,
|
SearchKeywordMax: limits.SearchKeywordMax,
|
||||||
PageSizeDefault: limits.PageSizeDefault,
|
PageSizeDefault: limits.PageSizeDefault,
|
||||||
FeedMaxPages: limits.FeedMaxPages,
|
|
||||||
FeedMaxItems: limits.FeedMaxItems,
|
|
||||||
PasswordMinLen: limits.PasswordMinLen,
|
PasswordMinLen: limits.PasswordMinLen,
|
||||||
AvatarMaxMB: limits.AvatarMaxMB,
|
AvatarMaxMB: limits.AvatarMaxMB,
|
||||||
|
|
||||||
|
OpenPostsInNewTab: limits.OpenPostsInNewTab,
|
||||||
|
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,23 +376,30 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
|||||||
SettingSearchKeywordMin: in.SearchKeywordMin,
|
SettingSearchKeywordMin: in.SearchKeywordMin,
|
||||||
SettingSearchKeywordMax: in.SearchKeywordMax,
|
SettingSearchKeywordMax: in.SearchKeywordMax,
|
||||||
SettingPageSizeDefault: in.PageSizeDefault,
|
SettingPageSizeDefault: in.PageSizeDefault,
|
||||||
SettingPageSizeMax: in.PageSizeMax,
|
|
||||||
SettingFeedMaxPages: in.FeedMaxPages,
|
|
||||||
SettingFeedMaxItems: in.FeedMaxItems,
|
|
||||||
SettingPasswordMinLen: in.PasswordMinLen,
|
SettingPasswordMinLen: in.PasswordMinLen,
|
||||||
SettingAvatarMaxMB: in.AvatarMaxMB,
|
SettingAvatarMaxMB: in.AvatarMaxMB,
|
||||||
}
|
}
|
||||||
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
|
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
|
||||||
return ErrInvalidSetting
|
return ErrInvalidSetting
|
||||||
}
|
}
|
||||||
if in.PageSizeDefault > in.PageSizeMax {
|
|
||||||
return ErrInvalidSetting
|
|
||||||
}
|
|
||||||
for key, val := range updates {
|
for key, val := range updates {
|
||||||
if err := s.setInt(key, val); err != nil {
|
if err := s.setInt(key, val); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
boolUpdates := map[string]bool{
|
||||||
|
SettingOpenPostsInNewTab: in.OpenPostsInNewTab,
|
||||||
|
SettingOpenContentLinksInNewTab: in.OpenContentLinksInNewTab,
|
||||||
|
}
|
||||||
|
for key, on := range boolUpdates {
|
||||||
|
v := "0"
|
||||||
|
if on {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
if err := s.setString(key, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,14 +435,370 @@ func (s *ForumSettingsService) SearchKeywordMin() int { return s.getInt(SettingS
|
|||||||
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
|
func (s *ForumSettingsService) SearchKeywordMax() int { return s.getInt(SettingSearchKeywordMax, 50) }
|
||||||
|
|
||||||
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
|
func (s *ForumSettingsService) PageSizeDefault() int { return s.getInt(SettingPageSizeDefault, 30) }
|
||||||
func (s *ForumSettingsService) PageSizeMax() int { return s.getInt(SettingPageSizeMax, 50) }
|
|
||||||
|
|
||||||
func (s *ForumSettingsService) FeedMaxPages() int { return s.getInt(SettingFeedMaxPages, 10) }
|
|
||||||
func (s *ForumSettingsService) FeedMaxItems() int { return s.getInt(SettingFeedMaxItems, 300) }
|
|
||||||
|
|
||||||
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
|
func (s *ForumSettingsService) PasswordMinLen() int { return s.getInt(SettingPasswordMinLen, 6) }
|
||||||
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
|
func (s *ForumSettingsService) AvatarMaxMB() int { return s.getInt(SettingAvatarMaxMB, 2) }
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) OpenPostsInNewTab() bool {
|
||||||
|
return s.getString(SettingOpenPostsInNewTab, "1") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) OpenContentLinksInNewTab() bool {
|
||||||
|
return s.getString(SettingOpenContentLinksInNewTab, "1") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailConfig 读取 SMTP 配置(密码不回显明文)
|
||||||
|
func (s *ForumSettingsService) MailConfig() MailConfig {
|
||||||
|
port, _ := strconv.Atoi(s.getString(SettingSMTPPort, "465"))
|
||||||
|
if port <= 0 {
|
||||||
|
port = 465
|
||||||
|
}
|
||||||
|
password := s.getString(SettingSMTPPassword, "")
|
||||||
|
return MailConfig{
|
||||||
|
Enabled: s.getString(SettingSMTPEnabled, "0") == "1",
|
||||||
|
Host: s.getString(SettingSMTPHost, ""),
|
||||||
|
Port: port,
|
||||||
|
Username: s.getString(SettingSMTPUsername, ""),
|
||||||
|
Password: password,
|
||||||
|
From: s.getString(SettingSMTPFrom, ""),
|
||||||
|
FromName: s.getString(SettingSMTPFromName, "姜十三论坛"),
|
||||||
|
Encryption: normalizeEncryption(s.getString(SettingSMTPEncryption, "ssl")),
|
||||||
|
HasPassword: password != "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailConfigPublic 管理端回显(不含密码明文)
|
||||||
|
func (s *ForumSettingsService) MailConfigPublic() MailConfig {
|
||||||
|
cfg := s.MailConfig()
|
||||||
|
cfg.Password = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// MailReady 邮件服务是否可用于发信
|
||||||
|
func (s *ForumSettingsService) MailReady() bool {
|
||||||
|
cfg := s.MailConfig()
|
||||||
|
return cfg.Enabled &&
|
||||||
|
strings.TrimSpace(cfg.Host) != "" &&
|
||||||
|
cfg.Port > 0 &&
|
||||||
|
strings.TrimSpace(cfg.From) != "" &&
|
||||||
|
strings.TrimSpace(cfg.Username) != "" &&
|
||||||
|
cfg.HasPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMailConfig 更新 SMTP 配置;密码为空表示保持原值
|
||||||
|
func (s *ForumSettingsService) UpdateMailConfig(in MailConfig) error {
|
||||||
|
enc := normalizeEncryption(in.Encryption)
|
||||||
|
if enc != "none" && enc != "starttls" && enc != "ssl" {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
port := in.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 465
|
||||||
|
}
|
||||||
|
enabled := "0"
|
||||||
|
if in.Enabled {
|
||||||
|
enabled = "1"
|
||||||
|
}
|
||||||
|
updates := map[string]string{
|
||||||
|
SettingSMTPEnabled: enabled,
|
||||||
|
SettingSMTPHost: strings.TrimSpace(in.Host),
|
||||||
|
SettingSMTPPort: strconv.Itoa(port),
|
||||||
|
SettingSMTPUsername: strings.TrimSpace(in.Username),
|
||||||
|
SettingSMTPFrom: strings.TrimSpace(in.From),
|
||||||
|
SettingSMTPFromName: strings.TrimSpace(in.FromName),
|
||||||
|
SettingSMTPEncryption: enc,
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Password) != "" {
|
||||||
|
updates[SettingSMTPPassword] = in.Password
|
||||||
|
}
|
||||||
|
for key, val := range updates {
|
||||||
|
if err := s.setString(key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCConfig 读取 OIDC 全局配置
|
||||||
|
func (s *ForumSettingsService) OIDCConfig() OIDCConfig {
|
||||||
|
root := normalizeRootURL(s.getString(SettingOIDCRootURL, ""))
|
||||||
|
clientCount := CountEnabledOAuthClients()
|
||||||
|
cfg := OIDCConfig{
|
||||||
|
Enabled: s.getString(SettingOIDCEnabled, "0") == "1",
|
||||||
|
RootURL: root,
|
||||||
|
GroupClaim: strings.TrimSpace(s.getString(SettingOIDCGroupClaim, "groups")),
|
||||||
|
AdminGroup: strings.TrimSpace(s.getString(SettingOIDCAdminGroup, "gitea-admin")),
|
||||||
|
UserGroup: strings.TrimSpace(s.getString(SettingOIDCUserGroup, "gitea-users")),
|
||||||
|
ClientCount: clientCount,
|
||||||
|
}
|
||||||
|
if cfg.GroupClaim == "" {
|
||||||
|
cfg.GroupClaim = "groups"
|
||||||
|
}
|
||||||
|
cfg.Ready = cfg.Enabled && cfg.RootURL != "" && clientCount > 0
|
||||||
|
if root != "" {
|
||||||
|
cfg.DiscoveryURL = root + "/.well-known/openid-configuration"
|
||||||
|
cfg.AuthorizeURL = root + "/oauth/authorize"
|
||||||
|
cfg.LogoutURL = root + "/oauth/logout"
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// OIDCConfigPublic 管理端回显
|
||||||
|
func (s *ForumSettingsService) OIDCConfigPublic() OIDCConfig {
|
||||||
|
return s.OIDCConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateOIDCConfig 更新 OIDC 全局配置(不含 OAuth 应用凭证)
|
||||||
|
func (s *ForumSettingsService) UpdateOIDCConfig(in OIDCConfig) error {
|
||||||
|
root := normalizeRootURL(in.RootURL)
|
||||||
|
if root != "" && !strings.HasPrefix(root, "http://") && !strings.HasPrefix(root, "https://") {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
enabled := "0"
|
||||||
|
if in.Enabled {
|
||||||
|
enabled = "1"
|
||||||
|
}
|
||||||
|
groupClaim := strings.TrimSpace(in.GroupClaim)
|
||||||
|
if groupClaim == "" {
|
||||||
|
groupClaim = "groups"
|
||||||
|
}
|
||||||
|
adminGroup := strings.TrimSpace(in.AdminGroup)
|
||||||
|
userGroup := strings.TrimSpace(in.UserGroup)
|
||||||
|
updates := map[string]string{
|
||||||
|
SettingOIDCEnabled: enabled,
|
||||||
|
SettingOIDCRootURL: root,
|
||||||
|
SettingOIDCGroupClaim: groupClaim,
|
||||||
|
SettingOIDCAdminGroup: adminGroup,
|
||||||
|
SettingOIDCUserGroup: userGroup,
|
||||||
|
}
|
||||||
|
for key, val := range updates {
|
||||||
|
if err := s.setString(key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedOIDCFromINI 若库中尚未配置,则用 app.ini 种子一次(便于迁移)
|
||||||
|
func (s *ForumSettingsService) SeedOIDCFromINI(rootURL, clientID, clientSecret, redirectURIsCSV string) {
|
||||||
|
rootURL = normalizeRootURL(rootURL)
|
||||||
|
clientID = strings.TrimSpace(clientID)
|
||||||
|
clientSecret = strings.TrimSpace(clientSecret)
|
||||||
|
uris := normalizeRedirectURIs(redirectURIsCSV)
|
||||||
|
if rootURL == "" && clientID == "" && clientSecret == "" && uris == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.getString(SettingOIDCRootURL, "") == "" && rootURL != "" {
|
||||||
|
_ = s.setString(SettingOIDCRootURL, rootURL)
|
||||||
|
}
|
||||||
|
if s.getString(SettingOAuthClientID, "") == "" && clientID != "" {
|
||||||
|
_ = s.setString(SettingOAuthClientID, clientID)
|
||||||
|
}
|
||||||
|
if s.getString(SettingOAuthClientSecret, "") == "" && clientSecret != "" {
|
||||||
|
_ = s.setString(SettingOAuthClientSecret, clientSecret)
|
||||||
|
}
|
||||||
|
if s.getString(SettingOAuthRedirectURIs, "") == "" && uris != "" {
|
||||||
|
_ = s.setString(SettingOAuthRedirectURIs, uris)
|
||||||
|
}
|
||||||
|
s.MigrateLegacyOIDCClient()
|
||||||
|
if s.getString(SettingOIDCEnabled, "0") == "0" &&
|
||||||
|
s.getString(SettingOIDCRootURL, "") != "" &&
|
||||||
|
CountEnabledOAuthClients() > 0 {
|
||||||
|
_ = s.setString(SettingOIDCEnabled, "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiteaSyncConfig 读取 Gitea 同步配置(含 Token 明文,供服务内部使用)
|
||||||
|
func (s *ForumSettingsService) GiteaSyncConfig() GiteaSyncConfig {
|
||||||
|
interval, _ := strconv.Atoi(s.getString(SettingGiteaSyncIntervalMin, "60"))
|
||||||
|
if interval < 5 {
|
||||||
|
interval = 5
|
||||||
|
}
|
||||||
|
if interval > 24*60 {
|
||||||
|
interval = 24 * 60
|
||||||
|
}
|
||||||
|
token := s.getString(SettingGiteaToken, "")
|
||||||
|
base := normalizeRootURL(s.getString(SettingGiteaBaseURL, ""))
|
||||||
|
cfg := GiteaSyncConfig{
|
||||||
|
Enabled: s.getString(SettingGiteaSyncEnabled, "0") == "1",
|
||||||
|
BaseURL: base,
|
||||||
|
Token: token,
|
||||||
|
HasToken: token != "",
|
||||||
|
SyncIntervalMin: interval,
|
||||||
|
}
|
||||||
|
cfg.Ready = cfg.Enabled && base != "" && cfg.HasToken
|
||||||
|
var n int64
|
||||||
|
model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false).Count(&n)
|
||||||
|
cfg.RepoCount = n
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// GiteaSyncConfigPublic 管理端回显(不含 Token 明文)
|
||||||
|
func (s *ForumSettingsService) GiteaSyncConfigPublic() GiteaSyncConfig {
|
||||||
|
cfg := s.GiteaSyncConfig()
|
||||||
|
cfg.Token = ""
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateGiteaSyncConfig 更新同步配置;Token 为空表示保持原值
|
||||||
|
func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
|
||||||
|
base := normalizeRootURL(in.BaseURL)
|
||||||
|
if base != "" && !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
interval := in.SyncIntervalMin
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 60
|
||||||
|
}
|
||||||
|
if interval < 5 {
|
||||||
|
interval = 5
|
||||||
|
}
|
||||||
|
if interval > 24*60 {
|
||||||
|
interval = 24 * 60
|
||||||
|
}
|
||||||
|
enabled := "0"
|
||||||
|
if in.Enabled {
|
||||||
|
enabled = "1"
|
||||||
|
}
|
||||||
|
updates := map[string]string{
|
||||||
|
SettingGiteaSyncEnabled: enabled,
|
||||||
|
SettingGiteaBaseURL: base,
|
||||||
|
SettingGiteaSyncIntervalMin: strconv.Itoa(interval),
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(in.Token) != "" {
|
||||||
|
updates[SettingGiteaToken] = strings.TrimSpace(in.Token)
|
||||||
|
}
|
||||||
|
for key, val := range updates {
|
||||||
|
if err := s.setString(key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedGiteaFromINI 若库中尚未配置,则用 app.ini 种子一次
|
||||||
|
func (s *ForumSettingsService) SeedGiteaFromINI(baseURL, token string, enabled bool) {
|
||||||
|
baseURL = normalizeRootURL(baseURL)
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if baseURL == "" && token == "" && !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.getString(SettingGiteaBaseURL, "") == "" && baseURL != "" {
|
||||||
|
_ = s.setString(SettingGiteaBaseURL, baseURL)
|
||||||
|
}
|
||||||
|
if s.getString(SettingGiteaToken, "") == "" && token != "" {
|
||||||
|
_ = s.setString(SettingGiteaToken, token)
|
||||||
|
}
|
||||||
|
if enabled && s.getString(SettingGiteaSyncEnabled, "0") == "0" &&
|
||||||
|
s.getString(SettingGiteaBaseURL, "") != "" &&
|
||||||
|
s.getString(SettingGiteaToken, "") != "" {
|
||||||
|
_ = s.setString(SettingGiteaSyncEnabled, "1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteBranding 读取站点品牌配置
|
||||||
|
func (s *ForumSettingsService) SiteBranding() SiteBranding {
|
||||||
|
name := strings.TrimSpace(s.getString(SettingSiteName, siteBrandingDefaults[SettingSiteName]))
|
||||||
|
if name == "" {
|
||||||
|
name = siteBrandingDefaults[SettingSiteName]
|
||||||
|
}
|
||||||
|
mark := strings.TrimSpace(s.getString(SettingSiteLogoMark, siteBrandingDefaults[SettingSiteLogoMark]))
|
||||||
|
if mark == "" {
|
||||||
|
mark = siteBrandingDefaults[SettingSiteLogoMark]
|
||||||
|
}
|
||||||
|
// 字标取首个字符(支持中文)
|
||||||
|
runes := []rune(mark)
|
||||||
|
if len(runes) > 1 {
|
||||||
|
mark = string(runes[0])
|
||||||
|
}
|
||||||
|
return SiteBranding{
|
||||||
|
Name: name,
|
||||||
|
NameEN: strings.TrimSpace(s.getString(SettingSiteNameEN, siteBrandingDefaults[SettingSiteNameEN])),
|
||||||
|
Slogan: strings.TrimSpace(s.getString(SettingSiteSlogan, siteBrandingDefaults[SettingSiteSlogan])),
|
||||||
|
LogoMark: mark,
|
||||||
|
Logo: strings.TrimSpace(s.getString(SettingSiteLogo, "")),
|
||||||
|
Favicon: strings.TrimSpace(s.getString(SettingSiteFavicon, "")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSiteBranding 更新品牌文案;Logo/Favicon URL 由上传接口单独写入
|
||||||
|
func (s *ForumSettingsService) UpdateSiteBranding(in SiteBranding) error {
|
||||||
|
name := strings.TrimSpace(in.Name)
|
||||||
|
if name == "" {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
if len([]rune(name)) > 64 {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
mark := strings.TrimSpace(in.LogoMark)
|
||||||
|
if mark == "" {
|
||||||
|
mark = siteBrandingDefaults[SettingSiteLogoMark]
|
||||||
|
}
|
||||||
|
runes := []rune(mark)
|
||||||
|
if len(runes) > 1 {
|
||||||
|
mark = string(runes[0])
|
||||||
|
}
|
||||||
|
nameEN := strings.TrimSpace(in.NameEN)
|
||||||
|
if len([]rune(nameEN)) > 64 {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
slogan := strings.TrimSpace(in.Slogan)
|
||||||
|
if len([]rune(slogan)) > 200 {
|
||||||
|
return ErrInvalidSetting
|
||||||
|
}
|
||||||
|
updates := map[string]string{
|
||||||
|
SettingSiteName: name,
|
||||||
|
SettingSiteNameEN: nameEN,
|
||||||
|
SettingSiteSlogan: slogan,
|
||||||
|
SettingSiteLogoMark: mark,
|
||||||
|
}
|
||||||
|
for key, val := range updates {
|
||||||
|
if err := s.setString(key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSiteLogo 写入 Logo URL(空串表示清除)
|
||||||
|
func (s *ForumSettingsService) SetSiteLogo(url string) error {
|
||||||
|
return s.setString(SettingSiteLogo, strings.TrimSpace(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSiteFavicon 写入 Favicon URL(空串表示清除)
|
||||||
|
func (s *ForumSettingsService) SetSiteFavicon(url string) error {
|
||||||
|
return s.setString(SettingSiteFavicon, strings.TrimSpace(url))
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRootURL(raw string) string {
|
||||||
|
return strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRedirectURIs(raw string) string {
|
||||||
|
parts := splitRedirectURIs(raw)
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitRedirectURIs(raw string) []string {
|
||||||
|
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||||||
|
raw = strings.ReplaceAll(raw, "\n", ",")
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[p]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[p] = struct{}{}
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
|
// NormalizeSearchKeyword 校验并规范化搜索关键词,空字符串表示无搜索
|
||||||
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
|
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
|
||||||
kw := trimRunes(keyword)
|
kw := trimRunes(keyword)
|
||||||
@@ -311,9 +822,8 @@ func (s *ForumSettingsService) NormalizePageSize(size int) int {
|
|||||||
if size < 1 {
|
if size < 1 {
|
||||||
return s.PageSizeDefault()
|
return s.PageSizeDefault()
|
||||||
}
|
}
|
||||||
maxSize := s.PageSizeMax()
|
if size > pageSizeAPIMax {
|
||||||
if maxSize > 0 && size > maxSize {
|
return pageSizeAPIMax
|
||||||
return maxSize
|
|
||||||
}
|
}
|
||||||
return size
|
return size
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user