新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -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 "✓ 全平台编译完成"
## 整理依赖 ## 整理依赖

View File

@@ -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 构建)

View File

@@ -14,6 +14,7 @@
| 前台 SPAReact | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 | | 前台 SPAReact | ✅ | 浏览、发帖、回复、管理操作已统一在 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 SSODiscovery / Authorize / Token / UserInfo
- [x] OAuth 应用管理密钥哈希、多客户端、登出端点、groups 映射)
- [x] 板块管理、发帖、TipTap 富文本编辑 - [x] 板块管理、发帖、TipTap 富文本编辑
- [x] 帖子正文图片本地上传 - [x] 帖子正文图片本地上传
- [x] 帖子修订历史与 diff 对比 - [x] 帖子修订历史与 diff 对比

View File

@@ -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

View File

@@ -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 SQLiteglebarez交叉编译无需 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
try {
go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg go build -trimpath -ldflags $Ldlags -o $outPath $MainPkg
if ($LASTEXITCODE -ne 0) { throw 'go build failed' } if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
Write-Host "[ok] $outPath" -ForegroundColor Green 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'
} }

View File

@@ -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空表示正常运行
@@ -86,8 +96,15 @@ func Parse() (*Config, error) {
WorkPath: workPath, WorkPath: workPath,
ConfigFile: configFile, ConfigFile: configFile,
Port: port, Port: port,
RootURL: normalizeRootURL(fileCfg.RootURL),
DataDir: absData, DataDir: absData,
JWTSecret: jwtSecret, JWTSecret: jwtSecret,
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"), LogFile: filepath.Join(absData, "jiang13.log"),
ServiceAction: action, ServiceAction: action,
} }
@@ -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)

View File

@@ -21,6 +21,13 @@ 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)

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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=>{

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
</>, </>,
), ),

View File

@@ -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' }),
}; };

View File

@@ -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[];
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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,8 +156,10 @@ 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 && (
isReplying ? (
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}> <button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
<X size={14} /> <X size={14} />
@@ -90,6 +169,44 @@ function CommentItem({
<MessageSquare size={14} /> <MessageSquare size={14} />
</button> </button>
)
)}
{!hidden && !isEditing && manageable && (
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
<Pencil size={14} />
</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>
@@ -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}
/> />
))} ))}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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 }}
/> />
); );
} }

View File

@@ -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);

View File

@@ -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>

View File

@@ -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 && (
<> <>

View 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>;
}

View 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;
}
/** 权重档位 04驱动字号与透明度 */
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>
);
}

View File

@@ -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 && (

View File

@@ -1,51 +1,83 @@
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 = () => {
const pos = getPos();
if (typeof pos !== 'number') {
editor.chain().focus().unwrapMembersOnly().run(); 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"
title="Ctrl+Enter 退出到公开区域"
onMouseDown={e => e.preventDefault()}
onClick={handleExit}
>
<LogOut size={11} />
退
</button>
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退</span>
<button <button
type="button" type="button"
className="post-members-only__unwrap-btn" className="post-members-only__unwrap-btn"
@@ -55,8 +87,20 @@ function MembersOnlyView({ selected, editor }: NodeViewProps) {
> >
</button> </button>
)}
<button
type="button"
className="post-members-only__remove-btn"
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
onMouseDown={e => e.preventDefault()}
onClick={deleteThisBlock}
>
<Trash2 size={11} />
</button>
</div> </div>
<NodeViewContent className="post-members-only__body" /> </div>
<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);
// 空区块:直接删除,避免留下空段落套壳
if (isMembersOnlyEmpty(node)) {
tr.delete(pos, pos + node.nodeSize);
} else {
tr.replaceWith(pos, pos + node.nodeSize, node.content); 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;
}, },

View File

@@ -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}

View File

@@ -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}

View File

@@ -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());
} }

View 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());
}

View File

@@ -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>

View File

@@ -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;
}; };

View File

@@ -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;
} }
const list = resolveBoards(layoutCtx?.boards); draftReadyRef.current = false;
if (list.length > 0) { const applyNewBaseline = (list: Board[], initialBoardId: string) => {
setBoards(list); setBoards(list);
setBoardsReady(true);
const initialBoardId = defaultBoard || String(list[0].id);
if (!defaultBoard) setBoardId(initialBoardId); 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({ setBaseline({
title: '', title: '',
tags: '', tags: '',
content: '', content: '',
boardId: initialBoardId, boardId: draft.boardId || boardForBaseline,
}); });
setDraftHint('已恢复本地草稿');
notify.success('已恢复本地草稿');
} else {
setBaseline({
title: '',
tags: '',
content: '',
boardId: boardForBaseline,
});
setDraftHint('');
}
draftReadyRef.current = true;
};
const list = resolveBoards(layoutCtx?.boards);
if (list.length > 0) {
const initialBoardId = defaultBoard || String(list[0].id);
applyNewBaseline(list, initialBoardId);
setBoardsReady(true);
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

View File

@@ -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>

View File

@@ -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
@@ -199,12 +209,14 @@ export default function HomePage() {
<VirtualPostList <VirtualPostList
posts={posts} posts={posts}
sort={sort} sort={sort}
loading={loading} loading={loading || limitsLoading}
hasMore={hasMore} hasMore={hasMore}
canAutoLoad={canAutoLoad} showPagination={showPagination}
page={page}
totalPages={totalPages}
postTotal={postTotal} postTotal={postTotal}
onLoadMore={loadNextPage} onPageChange={goToPage}
onSelect={(id) => nav(`/post/${id}`)} onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop} restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey} resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }} onScrollTopChange={(top) => { scrollTopRef.current = top; }}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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"

View 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>
);
}

View File

@@ -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,12 +107,25 @@ 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>
{regConfig && !regConfig.register_open ? (
<p className="auth-footer">
<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}></Link>
</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
@@ -58,7 +135,7 @@ export default function RegisterPage() {
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<Input placeholder="3-32 位字母数字下划线" autoComplete="username" {...field} /> <Input placeholder="2-32 位,支持中文" autoComplete="username" {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -77,6 +154,19 @@ export default function RegisterPage() {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="email" placeholder="用于接收验证码" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="password" name="password"
@@ -90,14 +180,51 @@ export default function RegisterPage() {
</FormItem> </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 type="submit" className="w-full" loading={loading}>
</Button> </Button>
</form> </form>
</Form> </Form>
<p className="auth-footer"> <p className="auth-footer">
<Link to="/login"></Link> <Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}></Link>
</p> </p>
</>
)}
</div> </div>
</div> </div>
); );

View File

@@ -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

View File

@@ -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

View 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;
}

View 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(),
);
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
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');
});
}

View File

@@ -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
}
} }
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */ /** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */

View File

@@ -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);
}

View 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);
}

View File

@@ -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>
<p class="post-members-only__gate-desc">作者将此段设为仅登录用户可读</p>
</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-btn" data-members-login>登录查看</button>
<span class="post-members-only__gate-alt">还没有账号?<button type="button" class="post-members-only__gate-link" data-members-register>免费注册</button></span> <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;
} }

View 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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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,
"mail": h.Settings.MailConfigPublic(),
"oidc": h.Settings.OIDCConfigPublic(),
"oauth_clients": clients,
"gitea": h.Settings.GiteaSyncConfigPublic(),
"branding": h.Settings.SiteBranding(),
"filter_words": filterContent, "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 或 Faviconform: 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 {
// APIPresence 上报浏览心跳(会员与游客均可) list = []service.RecentCommentItem{}
func (h *Handlers) APIPresence(c *gin.Context) { }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{"comments": list})
"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 {

View File

@@ -1,6 +1,7 @@
package handler package handler
import ( import (
"encoding/base64"
"fmt" "fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -24,8 +25,12 @@ type Handlers struct {
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
View 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
}

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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
View 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"
}

View File

@@ -15,15 +15,20 @@ 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"`
Email string `gorm:"index;size:128;default:''" json:"-"`
Password string `gorm:"size:128;not null" json:"-"` Password string `gorm:"size:128;not null" json:"-"`
Nickname string `gorm:"size:64" json:"nickname"` Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:256" json:"avatar"` Avatar string `gorm:"size:256" json:"avatar"`
Role Role `gorm:"size:16;default:user" json:"role"` Role Role `gorm:"size:16;default:user" json:"role"`
Banned bool `gorm:"default:false" json:"banned"` Banned bool `gorm:"default:false" json:"banned"`
BannedAt *time.Time `json:"banned_at,omitempty"` BannedAt *time.Time `json:"banned_at,omitempty"`
LastLoginAt *time.Time `json:"-"`
LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
@@ -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
View 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
View 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
}

View File

@@ -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 ProviderGitea 等外部站点 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)
} }
// 管理员 APIReact SPA 后台统一使用 JSON // 管理员 APIReact 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)

View File

@@ -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 tokenclientIP 写入上次登录记录
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
View 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)
}

View File

@@ -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 {

View File

@@ -3,9 +3,10 @@ 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"
@@ -13,10 +14,12 @@ import (
var ( var (
ErrUserExists = errors.New("用户名已存在") ErrUserExists = errors.New("用户名已存在")
ErrEmailExists = errors.New("邮箱已被注册")
ErrInvalidCred = errors.New("用户名或密码错误") ErrInvalidCred = errors.New("用户名或密码错误")
ErrUserBanned = errors.New("账号已被禁言") ErrUserBanned = errors.New("账号已被禁言")
ErrWeakPassword = errors.New("密码至少 6 位") ErrWeakPassword = errors.New("密码至少 6 位")
ErrInvalidUsername = errors.New("用户名 3-32 位字母数字下划线") ErrInvalidUsername = errors.New("用户名 2-32 位,支持中文、字母数字下划线")
ErrInvalidEmail = errors.New("邮箱格式不正确")
ErrPostNotFound = errors.New("帖子不存在") ErrPostNotFound = errors.New("帖子不存在")
ErrCommentNotFound = errors.New("评论不存在") ErrCommentNotFound = errors.New("评论不存在")
ErrPermissionDenied = errors.New("无权操作") ErrPermissionDenied = errors.New("无权操作")
@@ -31,10 +34,13 @@ var (
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
View 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
View 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
View 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
View 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
View 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
}

View File

@@ -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()
}
}

View File

@@ -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)

View File

@@ -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,13 +90,12 @@ 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 前台可见的限制(不含限流等内部配置)
@@ -74,10 +107,11 @@ type ForumLimitsPublic struct {
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"`
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"`
} }
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
} }