新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
31
Makefile
31
Makefile
@@ -17,32 +17,37 @@ all: build
|
||||
frontend-build:
|
||||
cd frontend && npm install && npm run build
|
||||
|
||||
## 编译当前平台二进制
|
||||
## 编译当前平台二进制(纯 Go SQLite,无需 CGO)
|
||||
build: frontend-build
|
||||
@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)"
|
||||
|
||||
## Windows amd64
|
||||
build-windows:
|
||||
## Windows amd64(先打包前端再 embed)
|
||||
build-windows: frontend-build
|
||||
@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"
|
||||
|
||||
## Linux amd64
|
||||
build-linux:
|
||||
## Linux amd64(先打包前端再 embed)
|
||||
build-linux: frontend-build
|
||||
@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"
|
||||
|
||||
## macOS arm64 (Apple Silicon)
|
||||
build-darwin:
|
||||
## macOS arm64 (Apple Silicon)(先打包前端再 embed)
|
||||
build-darwin: frontend-build
|
||||
@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"
|
||||
|
||||
## 跨平台全量编译
|
||||
build-all: build-windows build-linux build-darwin build
|
||||
## 跨平台全量编译(frontend-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 "✓ 全平台编译完成"
|
||||
|
||||
## 整理依赖
|
||||
|
||||
16
README.md
16
README.md
@@ -43,7 +43,7 @@
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-light.png" alt="浅色主题首页" width="100%">
|
||||
<br><b>浅色主题</b><br>
|
||||
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/动态/在线</sub>
|
||||
<sub>左栏板块导航 · Feed 排序切换 · 右栏热门/评论</sub>
|
||||
</td>
|
||||
<td width="50%" align="center">
|
||||
<img src="docs/screenshots/home-dark.png" alt="暗色主题首页" width="100%">
|
||||
@@ -78,7 +78,7 @@
|
||||
|
||||
| 特性 | 说明 |
|
||||
|------|------|
|
||||
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/通知/在线 |
|
||||
| **三栏布局** | 左栏板块菜单(可折叠)+ 中间虚拟滚动帖列表 + 右栏热门/最新评论 |
|
||||
| **虚拟滚动** | `@tanstack/react-virtual` 驱动帖列表与楼层回复,长列表依然流畅 |
|
||||
| **帖子排序** | 最新发帖 / 最新回复 / 热门讨论,一键切换 Feed 排序 |
|
||||
| **主题切换** | 浅色 / 暗色一键切换,跟随 `prefers-color-scheme` 与本地记忆 |
|
||||
@@ -93,7 +93,7 @@
|
||||
- 帖子修订历史:编辑后保留版本记录,支持 diff 对比查看
|
||||
- 可配置编辑时限:管理员设定普通用户修改帖子的有效窗口
|
||||
- 楼层式评论,支持回复指定楼层、@ 高亮、引用回复
|
||||
- 点赞、收藏、热门帖、最新动态
|
||||
- 点赞、收藏、热门帖、最新评论
|
||||
- 管理员后台:删帖、删评论、禁言、论坛参数配置、敏感词管理、SQLite 一键备份
|
||||
- 内置敏感词过滤、发帖 / 评论 / 注册 / 登录限流(后台可配)
|
||||
|
||||
@@ -174,15 +174,21 @@ cp app.ini.example /opt/jiang13/app.ini
|
||||
```ini
|
||||
[server]
|
||||
HTTP_PORT = 3000
|
||||
ROOT_URL = https://bbs.iioio.com
|
||||
|
||||
[paths]
|
||||
DATA = data
|
||||
|
||||
[security]
|
||||
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` > 内置默认值。
|
||||
|
||||
@@ -291,7 +297,7 @@ jiang13-forum/
|
||||
├── model/ # GORM 模型与数据库迁移
|
||||
├── service/ # 业务逻辑(认证、帖子、评论…)
|
||||
├── handler/ # HTTP 处理器(前台 + 后台)
|
||||
├── middleware/ # JWT 鉴权、在线状态
|
||||
├── middleware/ # JWT 鉴权
|
||||
├── router/ # 路由注册
|
||||
├── embed_static/ # go:embed 内嵌的 SPA 与模板
|
||||
├── frontend/ # React 源码(Vite 构建)
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| 前台 SPA(React) | ✅ | 浏览、发帖、回复、管理操作已统一在 SPA 内 |
|
||||
| 管理后台 | ✅ | React 后台 `/admin/*`,与前台风格一致 |
|
||||
| 评论系统 | ✅ | 换行显示已修复 |
|
||||
| OIDC Provider | ✅ | 可供 Gitea 等站点 SSO(`ROOT_URL` + `[oauth]`) |
|
||||
|
||||
---
|
||||
|
||||
@@ -27,7 +28,7 @@ _当前无已记录缺陷。发现新问题请提交 [Issue](https://git.iioio.c
|
||||
|
||||
| 优先级 | 功能 | 说明 |
|
||||
|--------|------|------|
|
||||
| 中 | 通知动态优化 | 右栏最新动态的展示与交互 |
|
||||
| 中 | 通知动态优化 | 右栏最新评论的展示与交互 |
|
||||
| 低 | 帖子搜索增强 | 标题/正文/作者组合筛选 |
|
||||
| 低 | 邮件通知 | 回复提醒(需 SMTP 配置) |
|
||||
|
||||
@@ -48,6 +49,8 @@ _当前无公开认领任务。_
|
||||
- [x] 浅色 / 暗色主题切换
|
||||
- [x] 移动端响应式适配
|
||||
- [x] 用户注册登录、JWT 鉴权
|
||||
- [x] OIDC Provider(对接 Gitea SSO:Discovery / Authorize / Token / UserInfo)
|
||||
- [x] OAuth 应用管理(密钥哈希、多客户端、登出端点、groups 映射)
|
||||
- [x] 板块管理、发帖、TipTap 富文本编辑
|
||||
- [x] 帖子正文图片本地上传
|
||||
- [x] 帖子修订历史与 diff 对比
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
[server]
|
||||
HTTP_PORT = 3000
|
||||
; 对外公网根地址(无尾斜杠);也可在管理后台 OIDC 设置中填写
|
||||
; 例:https://bbs.iioio.com
|
||||
ROOT_URL = https://bbs.iioio.com
|
||||
|
||||
[paths]
|
||||
; 相对路径相对于工作目录(默认可执行文件所在目录)
|
||||
@@ -12,3 +15,22 @@ DATA = data
|
||||
[security]
|
||||
; 留空则自动生成并持久化到 data/.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
|
||||
|
||||
17
build.ps1
17
build.ps1
@@ -39,16 +39,30 @@ function Build-Go([string]$OutFile, [string]$GoOS = '', [string]$GoArch = '') {
|
||||
if ($GoOS) { $env:GOOS = $GoOS } else { Remove-Item Env:GOOS -ErrorAction SilentlyContinue }
|
||||
if ($GoArch) { $env:GOARCH = $GoArch } else { Remove-Item Env:GOARCH -ErrorAction SilentlyContinue }
|
||||
|
||||
# 纯 Go SQLite(glebarez),交叉编译无需 C 工具链
|
||||
$prevCgo = $env:CGO_ENABLED
|
||||
$env:CGO_ENABLED = '0'
|
||||
|
||||
$isWindows = ($GoOS -eq 'windows') -or (($GoOS -eq '') -and ($env:OS -match 'Windows'))
|
||||
if ($isWindows -and ($OutFile -notmatch '\.exe$')) {
|
||||
$OutFile = "$OutFile.exe"
|
||||
}
|
||||
|
||||
$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
|
||||
if ($LASTEXITCODE -ne 0) { throw 'go build failed' }
|
||||
Write-Host "[ok] $outPath" -ForegroundColor Green
|
||||
} finally {
|
||||
if ($null -eq $prevCgo) {
|
||||
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
$env:CGO_ENABLED = $prevCgo
|
||||
}
|
||||
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
switch ($Target) {
|
||||
@@ -103,6 +117,7 @@ switch ($Target) {
|
||||
Build-Go -OutFile $AppName -GoOS 'windows' -GoArch 'amd64'
|
||||
}
|
||||
'build-linux' {
|
||||
Write-Host '[build-linux] will npm run build then go:embed SPA' -ForegroundColor Yellow
|
||||
Build-Frontend
|
||||
Build-Go -OutFile "$AppName-linux-amd64" -GoOS 'linux' -GoArch 'amd64'
|
||||
}
|
||||
|
||||
@@ -16,10 +16,20 @@ type Config struct {
|
||||
ConfigFile string
|
||||
// 监听端口
|
||||
Port int
|
||||
// 对外公网根地址(无尾斜杠),OIDC Issuer 使用
|
||||
RootURL string
|
||||
// 数据目录:SQLite、上传、日志(绝对路径)
|
||||
DataDir string
|
||||
// JWT 签名密钥
|
||||
JWTSecret string
|
||||
// OIDC 客户端(P0:写死在 app.ini,供 Gitea 对接)
|
||||
OAuthClientID string
|
||||
OAuthClientSecret string
|
||||
OAuthRedirectURIs []string
|
||||
// Gitea API 同步种子(可选,运行时以管理后台为准)
|
||||
GiteaBaseURL string
|
||||
GiteaToken string
|
||||
GiteaSyncEnabled bool
|
||||
// 日志文件路径
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
@@ -86,8 +96,15 @@ func Parse() (*Config, error) {
|
||||
WorkPath: workPath,
|
||||
ConfigFile: configFile,
|
||||
Port: port,
|
||||
RootURL: normalizeRootURL(fileCfg.RootURL),
|
||||
DataDir: absData,
|
||||
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"),
|
||||
ServiceAction: action,
|
||||
}
|
||||
@@ -97,18 +114,29 @@ func Parse() (*Config, error) {
|
||||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||
if !configExists {
|
||||
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)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "已生成默认配置: %s\n", configFile)
|
||||
} else if action == "install" {
|
||||
// 安装服务前把当前生效配置写回,避免服务只读旧 app.ini
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
iniJWT := ""
|
||||
iniJWT := fileCfg.JWTSecret
|
||||
if strings.TrimSpace(*jwtFlag) != "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -149,6 +177,7 @@ func ensureDataDirs(dataDir string) error {
|
||||
for _, sub := range []string{
|
||||
filepath.Join(dataDir, "uploads", "avatars"),
|
||||
filepath.Join(dataDir, "uploads", "posts"),
|
||||
filepath.Join(dataDir, "uploads", "site"),
|
||||
} {
|
||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||
@@ -198,6 +227,11 @@ func (c *Config) PostImageUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "posts")
|
||||
}
|
||||
|
||||
// SiteUploadDir 返回站点品牌资源(Logo / Favicon)目录
|
||||
func (c *Config) SiteUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "site")
|
||||
}
|
||||
|
||||
// UploadDir 返回头像上传目录(兼容旧调用)
|
||||
func (c *Config) UploadDir() string {
|
||||
return c.AvatarUploadDir()
|
||||
@@ -208,6 +242,29 @@ func (c *Config) FilterWordsPath() string {
|
||||
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 {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
|
||||
@@ -21,6 +21,13 @@ type fileSettings struct {
|
||||
Port int
|
||||
DataRel string
|
||||
JWTSecret string
|
||||
RootURL string
|
||||
OAuthClientID string
|
||||
OAuthClientSecret string
|
||||
OAuthRedirectURIs string
|
||||
GiteaBaseURL string
|
||||
GiteaToken string
|
||||
GiteaSyncEnabled bool
|
||||
}
|
||||
|
||||
func defaultFileSettings() fileSettings {
|
||||
@@ -50,6 +57,7 @@ func loadAppINI(path string) (fileSettings, error) {
|
||||
}
|
||||
out.Port = p
|
||||
}
|
||||
out.RootURL = strings.TrimSpace(sec.Key("ROOT_URL").String())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
@@ -80,17 +100,36 @@ func writeAppINI(path string, port int, dataRel, jwtSecret string) error {
|
||||
b.WriteString("\n")
|
||||
b.WriteString("[server]\n")
|
||||
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("[paths]\n")
|
||||
b.WriteString("; 相对路径相对于工作目录(默认可执行文件所在目录)\n")
|
||||
b.WriteString("DATA = ")
|
||||
b.WriteString(dataRel)
|
||||
b.WriteString(s.DataRel)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("[security]\n")
|
||||
b.WriteString("; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)\n")
|
||||
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")
|
||||
|
||||
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||
|
||||
@@ -53,7 +53,9 @@ func IsSPARoute(path string) bool {
|
||||
strings.HasPrefix(path, "/admin") ||
|
||||
strings.HasPrefix(path, "/uploads") ||
|
||||
strings.HasPrefix(path, "/legacy") ||
|
||||
strings.HasPrefix(path, "/assets") {
|
||||
strings.HasPrefix(path, "/assets") ||
|
||||
strings.HasPrefix(path, "/oauth") ||
|
||||
strings.HasPrefix(path, "/.well-known") {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -36,13 +36,6 @@
|
||||
<div class="label">评论总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-stat-online">
|
||||
<div class="admin-stat-icon">线</div>
|
||||
<div class="admin-stat-info">
|
||||
<div class="value">{{.OnlineCount}}</div>
|
||||
<div class="label">当前浏览</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="table-responsive">
|
||||
<table class="table admin-table mb-0">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>角色</th><th>状态</th><th>注册时间</th><th width="120">操作</th></tr>
|
||||
<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>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
@@ -19,6 +19,7 @@
|
||||
<td>{{.ID}}</td>
|
||||
<td>{{.Username}}</td>
|
||||
<td>{{.Nickname}}</td>
|
||||
<td>{{if .Email}}{{.Email}}{{else}}—{{end}}</td>
|
||||
<td>
|
||||
{{if eq .Role "admin"}}
|
||||
<span class="badge badge-admin">管理员</span>
|
||||
@@ -27,6 +28,8 @@
|
||||
<td>
|
||||
{{if .Banned}}<span class="badge badge-banned">已禁言</span>{{else}}<span class="text-success">正常</span>{{end}}
|
||||
</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>
|
||||
{{if eq .Role "admin"}}
|
||||
@@ -39,7 +42,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="7" class="admin-empty">暂无用户</td></tr>
|
||||
<tr><td colspan="10" class="admin-empty">暂无用户</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
|
||||
<h4>{{.ProfileUser.Nickname}}</h4>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
@@ -5,20 +5,31 @@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-center mb-4">注册账号</h3>
|
||||
<p class="text-center text-muted small mb-3">本站首个注册用户将自动成为管理员</p>
|
||||
<p id="regTip" class="text-center text-muted small mb-3"></p>
|
||||
<form id="regForm">
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">昵称</label>
|
||||
<input type="text" name="nickname" class="form-control">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">邮箱</label>
|
||||
<input type="email" name="email" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">密码(至少6位)</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</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>
|
||||
</form>
|
||||
<p class="text-center mt-3 mb-0">已有账号?<a href="/login">去登录</a></p>
|
||||
@@ -29,6 +40,26 @@
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<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){
|
||||
e.preventDefault();
|
||||
apiPost('/api/register', this, true).then(d=>{
|
||||
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
@@ -3043,6 +3044,15 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"diff": "^9.0.0",
|
||||
"dompurify": "^3.4.10",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^1.18.0",
|
||||
"marked": "^18.0.5",
|
||||
"postcss": "^8.5.15",
|
||||
|
||||
@@ -23,6 +23,7 @@ const ComposePage = lazy(() => import('./pages/ComposePage'));
|
||||
const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||
const ProjectsPage = lazy(() => import('./pages/ProjectsPage'));
|
||||
const AdminDashboardPage = lazy(() => import('./pages/admin/AdminDashboardPage'));
|
||||
const AdminPostsPage = lazy(() => import('./pages/admin/AdminPostsPage'));
|
||||
const AdminCommentsPage = lazy(() => import('./pages/admin/AdminCommentsPage'));
|
||||
@@ -32,8 +33,8 @@ const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader fullScreen />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader fullScreen />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
@@ -51,6 +52,7 @@ const router = createBrowserRouter(
|
||||
<Route path="/compose" element={<ComposePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
</Route>
|
||||
</>,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, Board, PostItem, Comment, Notification, OnlineUser, OnlineStats, ForumStats, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision } from './types';
|
||||
import type { User, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, SiteBranding, RegisterConfig } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -25,12 +25,23 @@ export const api = {
|
||||
me: () => request<{ user: User | null }>('/api/me'),
|
||||
stats: () => request<ForumStats>('/api/stats'),
|
||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
||||
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>) => {
|
||||
const q = new URLSearchParams(params as Record<string, string>).toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; has_more: boolean }>(`/api/posts?${q}`);
|
||||
},
|
||||
hotPosts: () => request<{ posts: PostItem[] }>('/api/posts/hot'),
|
||||
tags: (limit = 40) => request<{ tags: TagCount[] }>(`/api/tags?limit=${limit}`),
|
||||
post: (id: number, opts?: { skipView?: boolean }) => {
|
||||
const q = opts?.skipView ? '?skip_view=1' : '';
|
||||
return request<PostDetailResponse>(`/api/posts/${id}${q}`);
|
||||
@@ -39,9 +50,7 @@ export const api = {
|
||||
const q = myIds?.length ? `?my_ids=${myIds.join(',')}` : '';
|
||||
return request<{ comments: Comment[]; total: number }>(`/api/posts/${id}/comments${q}`);
|
||||
},
|
||||
notifications: () => request<{ notifications: Notification[] }>('/api/notifications'),
|
||||
online: () => request<OnlineStats>('/api/online'),
|
||||
presence: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/presence', { method: 'POST' }),
|
||||
recentComments: () => request<{ comments: RecentComment[] }>('/api/comments/recent'),
|
||||
favorites: () => request<{ favorites: unknown[]; total: number }>('/api/favorites'),
|
||||
createBoard: (body: { name: string; description: string; sort_order: number; icon?: string; color_index?: number }) =>
|
||||
request<{ board: Board }>('/api/admin/boards', { method: 'POST', body: JSON.stringify(body) }),
|
||||
@@ -72,6 +81,57 @@ export const api = {
|
||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||
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) =>
|
||||
request<{ message: string; word_count: number }>('/api/admin/settings/filter-words', {
|
||||
method: 'PUT', body: JSON.stringify({ content }),
|
||||
@@ -132,19 +192,35 @@ export const api = {
|
||||
fd.append('tags', data.tags || '');
|
||||
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) => {
|
||||
const fd = new FormData();
|
||||
fd.append('username', username);
|
||||
fd.append('password', password);
|
||||
return request('/api/login', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
register: (username: string, password: string, nickname: string) => {
|
||||
register: (data: {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
emailCode?: string;
|
||||
}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('username', username);
|
||||
fd.append('password', password);
|
||||
fd.append('nickname', nickname);
|
||||
fd.append('username', data.username);
|
||||
fd.append('password', data.password);
|
||||
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: {} });
|
||||
},
|
||||
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' }),
|
||||
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' }),
|
||||
@@ -165,5 +241,10 @@ export const api = {
|
||||
if (data.isPrivate) fd.append('is_private', '1');
|
||||
return request<{ message: string; floor: number; id: number }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
ping: () => request<Pick<OnlineStats, 'count' | 'members' | 'guests'>>('/api/ping', { method: 'POST' }),
|
||||
updateComment: (id: number, content: string) => {
|
||||
const fd = new FormData();
|
||||
fd.append('content', content);
|
||||
return request<{ message: string; content: string }>(`/api/comments/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||
},
|
||||
deleteComment: (id: number) => request<{ message: string }>(`/api/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
role: 'user' | 'admin';
|
||||
banned?: boolean;
|
||||
banned_at?: string;
|
||||
last_login_at?: string;
|
||||
last_login_ip?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Board {
|
||||
@@ -24,6 +29,12 @@ export interface ForumStats {
|
||||
boards: number;
|
||||
}
|
||||
|
||||
/** 标签云单项 */
|
||||
export interface TagCount {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PostItem {
|
||||
id: number;
|
||||
board_id: number;
|
||||
@@ -78,6 +89,7 @@ export interface Comment {
|
||||
is_private?: boolean;
|
||||
content_hidden?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
user?: User;
|
||||
post?: PostItem;
|
||||
reply_target?: Comment;
|
||||
@@ -88,7 +100,6 @@ export interface AdminDashboard {
|
||||
posts: number;
|
||||
boards: number;
|
||||
comments: number;
|
||||
online: number;
|
||||
recent_posts: PostItem[];
|
||||
}
|
||||
|
||||
@@ -106,11 +117,10 @@ export interface ForumLimits {
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
page_size_max: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
}
|
||||
|
||||
export interface ForumLimitsPublic {
|
||||
@@ -121,10 +131,19 @@ export interface ForumLimitsPublic {
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
feed_max_pages: number;
|
||||
feed_max_items: number;
|
||||
password_min_len: number;
|
||||
avatar_max_mb: number;
|
||||
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 {
|
||||
@@ -133,10 +152,91 @@ export interface AdminSettings {
|
||||
db_path: string;
|
||||
port: number;
|
||||
limits: ForumLimits;
|
||||
mail: MailConfig;
|
||||
oidc: OIDCConfig;
|
||||
oauth_clients: OAuthClient[];
|
||||
gitea?: GiteaSyncConfig;
|
||||
branding?: SiteBranding;
|
||||
filter_words: string;
|
||||
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> {
|
||||
total: number;
|
||||
page: number;
|
||||
@@ -144,22 +244,12 @@ export interface Paginated<T> {
|
||||
items: T;
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
export interface RecentComment {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
post_id: number;
|
||||
author: string;
|
||||
avatar: string;
|
||||
excerpt: string;
|
||||
post_title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface OnlineUser {
|
||||
id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
export interface OnlineStats {
|
||||
count: number;
|
||||
members: number;
|
||||
guests: number;
|
||||
users: OnlineUser[];
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
FileCode, PenLine, Maximize2, Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
|
||||
import { renderPostContentHtml } from '../utils/postContent';
|
||||
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
|
||||
import PostContent from './PostContent';
|
||||
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
|
||||
import {
|
||||
wrapMarkdownSelection,
|
||||
@@ -362,7 +362,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
}, [markdownSource, handleMarkdownChange]);
|
||||
|
||||
const markdownPreviewHtml = useMemo(
|
||||
() => renderPostContentHtml(sanitizeHtml(markdownToHtml(markdownSource)), true),
|
||||
() => sanitizeHtml(markdownToHtml(markdownSource)),
|
||||
[markdownSource],
|
||||
);
|
||||
|
||||
@@ -384,7 +384,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
{
|
||||
icon: <LockKeyhole size={15} />,
|
||||
title: '登录可见',
|
||||
hint: '独立输入区;Ctrl+Enter 退出',
|
||||
hint: '插入或包裹;区块内 Ctrl+Enter 退出',
|
||||
active: editor.isActive('membersOnly'),
|
||||
className: 'article-tool-btn--members',
|
||||
action: wrapMembersOnly,
|
||||
@@ -451,9 +451,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
|
||||
</div>
|
||||
<div className="article-editor-markdown-preview">
|
||||
<div className="article-editor-markdown-preview-label">预览</div>
|
||||
<div
|
||||
<PostContent
|
||||
html={markdownPreviewHtml}
|
||||
isLoggedIn
|
||||
className="article-editor-markdown-preview-body post-detail-content"
|
||||
dangerouslySetInnerHTML={{ __html: markdownPreviewHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
149
frontend/src/components/ArticleOutline.tsx
Normal file
149
frontend/src/components/ArticleOutline.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ListTree } from 'lucide-react';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
headings: PostHeading[];
|
||||
/** 滚动容器;不传则用 viewport */
|
||||
scrollRoot?: HTMLElement | null;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** 根据滚动位置取当前应高亮的标题 id */
|
||||
function resolveActiveHeadingId(
|
||||
headings: PostHeading[],
|
||||
root: HTMLElement | null,
|
||||
offsetPx = 28,
|
||||
): string {
|
||||
if (headings.length === 0) return '';
|
||||
|
||||
const rootTop = root ? root.getBoundingClientRect().top : 0;
|
||||
const marker = rootTop + offsetPx;
|
||||
|
||||
let current = headings[0].id;
|
||||
for (const h of headings) {
|
||||
const el = document.getElementById(h.id);
|
||||
if (!el) continue;
|
||||
if (el.getBoundingClientRect().top <= marker) {
|
||||
current = h.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/** 文章目录树:点击跳转,滚动时高亮当前标题 */
|
||||
export default function ArticleOutline({
|
||||
headings,
|
||||
scrollRoot,
|
||||
title = '文章目录',
|
||||
className,
|
||||
}: Props) {
|
||||
const [activeId, setActiveId] = useState(headings[0]?.id ?? '');
|
||||
/** 点击跳转期间锁定高亮,避免 Intersection/滚动回调来回抢 */
|
||||
const lockUntilRef = useRef(0);
|
||||
const lockIdRef = useRef('');
|
||||
const rafRef = useRef(0);
|
||||
|
||||
const minLevel = useMemo(
|
||||
() => (headings.length ? Math.min(...headings.map(h => h.level)) : 2),
|
||||
[headings],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(headings[0]?.id ?? '');
|
||||
lockUntilRef.current = 0;
|
||||
lockIdRef.current = '';
|
||||
}, [headings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (headings.length === 0) return undefined;
|
||||
|
||||
const root: HTMLElement | Window = scrollRoot ?? window;
|
||||
|
||||
const syncActive = () => {
|
||||
if (Date.now() < lockUntilRef.current) {
|
||||
if (lockIdRef.current) setActiveId(lockIdRef.current);
|
||||
return;
|
||||
}
|
||||
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||
if (next) setActiveId(next);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = requestAnimationFrame(syncActive);
|
||||
};
|
||||
|
||||
syncActive();
|
||||
root.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
root.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onScroll);
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [headings, scrollRoot]);
|
||||
|
||||
const jumpTo = (id: string) => {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
|
||||
// 立即高亮并锁定一段时间,覆盖 smooth 滚动过程中的中间态
|
||||
setActiveId(id);
|
||||
lockIdRef.current = id;
|
||||
lockUntilRef.current = Date.now() + 900;
|
||||
|
||||
const root = scrollRoot;
|
||||
if (root) {
|
||||
const rootRect = root.getBoundingClientRect();
|
||||
const elRect = el.getBoundingClientRect();
|
||||
const top = root.scrollTop + (elRect.top - rootRect.top) - 12;
|
||||
root.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
} else {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
// 滚动结束后再按位置校正一次(若用户中途手动滑会自然解锁)
|
||||
window.setTimeout(() => {
|
||||
if (lockIdRef.current !== id) return;
|
||||
lockUntilRef.current = 0;
|
||||
const next = resolveActiveHeadingId(headings, scrollRoot ?? null);
|
||||
if (next) setActiveId(next);
|
||||
}, 920);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('article-outline', className)}>
|
||||
<div className="sidebar-section article-outline-head">
|
||||
<ListTree size={12} aria-hidden />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
{headings.length === 0 ? (
|
||||
<p className="article-outline-empty">本文暂无标题结构</p>
|
||||
) : (
|
||||
<nav className="article-outline-nav" aria-label="文章目录">
|
||||
{headings.map(h => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'article-outline-item',
|
||||
`article-outline-item--l${Math.min(6, Math.max(1, h.level - minLevel + 1))}`,
|
||||
activeId === h.id && 'active',
|
||||
)}
|
||||
onClick={() => jumpTo(h.id)}
|
||||
title={h.text}
|
||||
>
|
||||
{h.text}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Clock, MessageSquare, X } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment } from '../api/types';
|
||||
import type { Comment, User } from '../api/types';
|
||||
import CommentContent from './CommentContent';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
commentNick,
|
||||
commentInitial,
|
||||
@@ -10,33 +22,75 @@ import {
|
||||
buildCommentTree,
|
||||
type CommentNode,
|
||||
} 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 {
|
||||
node: CommentNode;
|
||||
nested?: boolean;
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
editingId?: number | null;
|
||||
currentUser?: User | null;
|
||||
onReply: (comment: Comment) => 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;
|
||||
}
|
||||
|
||||
/** 单条评论(支持嵌套子回复 + 内联回复框) */
|
||||
/** 单条评论(支持嵌套子回复 + 内联回复框 + 编辑/删除) */
|
||||
function CommentItem({
|
||||
node,
|
||||
nested,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
editingId,
|
||||
currentUser,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const { limits } = useForumLimits();
|
||||
const c = node.comment;
|
||||
const nick = commentNick(c);
|
||||
const guest = isGuestComment(c);
|
||||
const isHighlighted = highlightFloor === c.floor;
|
||||
const hidden = !!c.content_hidden;
|
||||
const isReplying = replyToId === c.id;
|
||||
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 (
|
||||
<div
|
||||
@@ -66,6 +120,29 @@ function CommentItem({
|
||||
<div className="waline-comment-private-mask">
|
||||
该评论为私密评论,仅文章作者与评论发起者可见!
|
||||
</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">
|
||||
{c.reply_target && (
|
||||
@@ -79,8 +156,10 @@ function CommentItem({
|
||||
<span className="waline-comment-date">
|
||||
<Clock size={14} />
|
||||
{formatCommentDate(c.created_at)}
|
||||
{showEdited && <span className="waline-comment-edited"> · 已编辑</span>}
|
||||
</span>
|
||||
{isReplying ? (
|
||||
{!hidden && !isEditing && (
|
||||
isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
<X size={14} />
|
||||
取消
|
||||
@@ -90,6 +169,44 @@ function CommentItem({
|
||||
<MessageSquare size={14} />
|
||||
回复
|
||||
</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>
|
||||
|
||||
@@ -108,8 +225,14 @@ function CommentItem({
|
||||
nested
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
editingId={editingId}
|
||||
currentUser={currentUser}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
@@ -124,8 +247,14 @@ interface Props {
|
||||
comments: Comment[];
|
||||
highlightFloor?: number | null;
|
||||
replyToId?: number | null;
|
||||
editingId?: number | null;
|
||||
currentUser?: User | null;
|
||||
onReply: (comment: Comment) => 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;
|
||||
}
|
||||
|
||||
@@ -134,8 +263,14 @@ export default function CommentThreadList({
|
||||
comments,
|
||||
highlightFloor,
|
||||
replyToId,
|
||||
editingId,
|
||||
currentUser,
|
||||
onReply,
|
||||
onCancelReply,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
@@ -148,8 +283,14 @@ export default function CommentThreadList({
|
||||
node={node}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyToId}
|
||||
editingId={editingId}
|
||||
currentUser={currentUser}
|
||||
onReply={onReply}
|
||||
onCancelReply={onCancelReply}
|
||||
onStartEdit={onStartEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
|
||||
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
34
frontend/src/components/FeedPageSkeleton.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
|
||||
/** 首页 Feed 初始骨架(标题区 + 排序栏 + 列表) */
|
||||
export default function FeedPageSkeleton() {
|
||||
return (
|
||||
<div className="page-wrap page-wrap--feed" aria-busy="true" aria-label="内容加载中">
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<div className="feed-head">
|
||||
<div className="feed-head__title">
|
||||
<Skeleton className="skeleton--feed-title" />
|
||||
<div className="feed-head__stats">
|
||||
<Skeleton className="skeleton--stat-chip" />
|
||||
<Skeleton className="skeleton--stat-chip" />
|
||||
<Skeleton className="skeleton--stat-chip" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="feed-toolbar feed-toolbar--skeleton" aria-hidden>
|
||||
<Skeleton className="skeleton--sort-tab" />
|
||||
<Skeleton className="skeleton--sort-tab" />
|
||||
<Skeleton className="skeleton--sort-tab" />
|
||||
<span className="feed-toolbar__spacer" />
|
||||
<Skeleton className="skeleton--count" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-list-scroll">
|
||||
<PostListSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/FeedPagination.tsx
Normal file
149
frontend/src/components/FeedPagination.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
postTotal: number;
|
||||
loading?: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/** 生成页码窗口:两端 + 当前邻页,中间用省略号 */
|
||||
function buildPageItems(current: number, total: number): Array<number | 'gap'> {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const set = new Set<number>();
|
||||
set.add(1);
|
||||
set.add(total);
|
||||
for (let i = current - 1; i <= current + 1; i++) {
|
||||
if (i >= 1 && i <= total) set.add(i);
|
||||
}
|
||||
// 靠近端点时多露出几页,避免 1 … 2 3 这种浪费
|
||||
if (current <= 3) {
|
||||
set.add(2);
|
||||
set.add(3);
|
||||
set.add(4);
|
||||
}
|
||||
if (current >= total - 2) {
|
||||
set.add(total - 1);
|
||||
set.add(total - 2);
|
||||
set.add(total - 3);
|
||||
}
|
||||
|
||||
const sorted = [...set].sort((a, b) => a - b);
|
||||
const items: Array<number | 'gap'> = [];
|
||||
for (let i = 0; i < sorted.length; i++) {
|
||||
if (i > 0 && sorted[i] - sorted[i - 1] > 1) items.push('gap');
|
||||
items.push(sorted[i]);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function FeedPagination({
|
||||
page,
|
||||
totalPages,
|
||||
postTotal,
|
||||
loading = false,
|
||||
onPageChange,
|
||||
}: Props) {
|
||||
const [jumpInput, setJumpInput] = useState(String(page));
|
||||
const pageItems = buildPageItems(page, totalPages);
|
||||
const showJump = totalPages > 5;
|
||||
|
||||
useEffect(() => {
|
||||
setJumpInput(String(page));
|
||||
}, [page]);
|
||||
|
||||
const commitJump = () => {
|
||||
if (loading) return;
|
||||
const n = Number.parseInt(jumpInput, 10);
|
||||
if (!Number.isFinite(n)) {
|
||||
setJumpInput(String(page));
|
||||
return;
|
||||
}
|
||||
const target = Math.min(totalPages, Math.max(1, n));
|
||||
setJumpInput(String(target));
|
||||
if (target !== page) onPageChange(target);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="feed-pagination" aria-label="帖子分页">
|
||||
<p className="feed-pagination__meta" aria-live="polite">
|
||||
共 <strong>{postTotal}</strong> 条
|
||||
</p>
|
||||
|
||||
<div className="feed-pagination__pages">
|
||||
<button
|
||||
type="button"
|
||||
className="feed-pagination__nav"
|
||||
disabled={loading || page <= 1}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ChevronLeft aria-hidden size={16} strokeWidth={2} />
|
||||
</button>
|
||||
|
||||
{pageItems.map((item, idx) =>
|
||||
item === 'gap' ? (
|
||||
<span key={`gap-${idx}`} className="feed-pagination__gap" aria-hidden>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={cn('feed-pagination__page', item === page && 'is-active')}
|
||||
disabled={loading || item === page}
|
||||
aria-label={`第 ${item} 页`}
|
||||
aria-current={item === page ? 'page' : undefined}
|
||||
onClick={() => onPageChange(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="feed-pagination__nav"
|
||||
disabled={loading || page >= totalPages}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
aria-label="下一页"
|
||||
>
|
||||
<ChevronRight aria-hidden size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showJump && (
|
||||
<form
|
||||
className="feed-pagination__jump"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
commitJump();
|
||||
}}
|
||||
>
|
||||
<label htmlFor="feed-page-jump" className="feed-pagination__jump-label">
|
||||
跳至
|
||||
</label>
|
||||
<input
|
||||
id="feed-page-jump"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={jumpInput}
|
||||
disabled={loading}
|
||||
onChange={(e) => setJumpInput(e.target.value.replace(/\D/g, ''))}
|
||||
onBlur={commitJump}
|
||||
className="feed-pagination__jump-input"
|
||||
aria-label={`跳转到指定页,共 ${totalPages} 页`}
|
||||
/>
|
||||
<span className="feed-pagination__jump-suffix">/ {totalPages}</span>
|
||||
</form>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,20 @@
|
||||
/** 路由懒加载时的轻量占位,避免引入 Arco Spin 增大首屏 */
|
||||
export default function PageLoader() {
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type PageLoaderProps = {
|
||||
/** 独立全屏路由(登录/注册)占满视口居中 */
|
||||
fullScreen?: boolean;
|
||||
};
|
||||
|
||||
/** 通用路由懒加载占位;首页请用 FeedPageSkeleton,避免非 Feed 页闪出鱼骨骨架 */
|
||||
export default function PageLoader({ fullScreen = false }: PageLoaderProps) {
|
||||
return (
|
||||
<div className="page-loader" role="status" aria-live="polite">
|
||||
<span className="page-loader__dot" />
|
||||
加载中…
|
||||
<div
|
||||
className={cn('page-loader', fullScreen && 'page-loader--viewport')}
|
||||
aria-busy="true"
|
||||
aria-label="加载中"
|
||||
>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,72 @@
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useMemo, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
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 {
|
||||
html: string;
|
||||
isLoggedIn: boolean;
|
||||
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 { limits } = useForumLimits();
|
||||
|
||||
const rendered = useMemo(
|
||||
() => renderPostContentHtml(html, isLoggedIn),
|
||||
[html, isLoggedIn],
|
||||
);
|
||||
const prepared = useMemo(() => {
|
||||
const rendered = renderPostContentHtml(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;
|
||||
if (target.closest('[data-members-login]')) {
|
||||
e.preventDefault();
|
||||
nav('/login');
|
||||
nav(loginPath());
|
||||
return;
|
||||
}
|
||||
if (target.closest('[data-members-register]')) {
|
||||
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]);
|
||||
|
||||
@@ -34,7 +74,7 @@ export default function PostContent({ html, isLoggedIn, className = 'post-detail
|
||||
<div
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
dangerouslySetInnerHTML={{ __html: rendered }}
|
||||
dangerouslySetInnerHTML={{ __html: prepared.html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
@@ -8,10 +9,10 @@ import { formatTime } from '../utils/content';
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
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 timeLabel = sort === 'reply'
|
||||
? (post.last_reply_at
|
||||
@@ -22,7 +23,7 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
const likeCount = post.like_count ?? 0;
|
||||
|
||||
return (
|
||||
<button type="button" className="post-row" onClick={onClick}>
|
||||
<button type="button" className="post-row" onClick={() => onSelect(post.id)}>
|
||||
<div className="post-avatar">
|
||||
{post.user?.avatar
|
||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
@@ -52,3 +53,5 @@ export default function PostListItem({ post, sort = 'latest', onClick }: Props)
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(PostListItem);
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Flame, Megaphone, Users } from 'lucide-react';
|
||||
import type { PostItem, Notification, OnlineStats } from '../api/types';
|
||||
import { Flame, MessageCircle, Tags } from 'lucide-react';
|
||||
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 {
|
||||
hot: PostItem[];
|
||||
notifications: Notification[];
|
||||
online: OnlineStats | null;
|
||||
recentComments: RecentComment[];
|
||||
tags?: TagCount[];
|
||||
tagsLoading?: boolean;
|
||||
onPostClick: (id: number) => void;
|
||||
/** 首次拉取中,避免空态闪烁 */
|
||||
/** 首次拉取中,显示骨架避免空态闪烁 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -17,16 +22,46 @@ function hotRankClass(index: number): string {
|
||||
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({
|
||||
hot,
|
||||
notifications,
|
||||
online,
|
||||
recentComments,
|
||||
tags = [],
|
||||
tagsLoading = false,
|
||||
onPostClick,
|
||||
loading = false,
|
||||
}: Props) {
|
||||
const { branding } = useSiteBranding();
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get('keyword') || '';
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const noticeList = notifications?.slice(0, 6) ?? [];
|
||||
const members = online?.users ?? [];
|
||||
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||
|
||||
return (
|
||||
<div className="aside-panel-inner">
|
||||
@@ -37,7 +72,7 @@ export default function RightPanel({
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && hotList.length === 0 ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
<HotSkeleton />
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
@@ -54,65 +89,53 @@ export default function RightPanel({
|
||||
</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-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 className="widget-card-body">
|
||||
{loading && noticeList.length === 0 ? (
|
||||
<div className="widget-empty">加载中…</div>
|
||||
) : noticeList.length === 0 ? (
|
||||
<div className="widget-empty">暂无动态</div>
|
||||
) : noticeList.map(item => (
|
||||
{loading && commentList.length === 0 ? (
|
||||
<CommentSkeleton />
|
||||
) : commentList.length === 0 ? (
|
||||
<div className="widget-empty">暂无评论</div>
|
||||
) : commentList.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item widget-item--notice"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
className="widget-item widget-item--comment"
|
||||
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>
|
||||
</button>
|
||||
))}
|
||||
</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-body">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {
|
||||
Home, Star, LayoutDashboard,
|
||||
Home, Star, LayoutDashboard, FolderGit2, ArrowLeft,
|
||||
} from 'lucide-react';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import type { Board } from '../api/types';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { buildHomeUrl, parseFeedSort } from './FeedSortBar';
|
||||
import { navigateFeed } from '../utils/feedCache';
|
||||
import BoardIconDisplay from './BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
|
||||
// 内容页不参与左侧栏高亮(非 feed 浏览上下文)
|
||||
const NEUTRAL_SIDEBAR_PREFIXES = ['/post/', '/profile'];
|
||||
@@ -20,6 +23,7 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/projects')) return 'projects';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||
}
|
||||
@@ -28,9 +32,25 @@ interface Props {
|
||||
boards: Board[];
|
||||
activeBoard: number;
|
||||
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 loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
@@ -52,15 +72,48 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
</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 (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-section">浏览</div>
|
||||
<nav className="sidebar-nav">
|
||||
{navItem('all', '全部帖子', <Home aria-hidden />, () => { onSelectBoard(0); navigateFeed(nav, buildHomeUrl(0, sort)); })}
|
||||
{user && navItem('favorites', '我的收藏', <Star aria-hidden />, () => nav('/favorites'))}
|
||||
{navItem('projects', '开源码桶', <FolderGit2 aria-hidden />, () => nav('/projects'))}
|
||||
</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>
|
||||
<nav className="sidebar-nav">
|
||||
@@ -92,7 +145,7 @@ export default function Sidebar({ boards, activeBoard, onSelectBoard }: Props) {
|
||||
})}
|
||||
</nav>
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
|
||||
26
frontend/src/components/SiteBrandMark.tsx
Normal file
26
frontend/src/components/SiteBrandMark.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SiteBranding } from '../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
branding: SiteBranding;
|
||||
/** CSS 类:header-logo-mark / logo-mark / admin-topbar-mark */
|
||||
className?: string;
|
||||
/** 有 Logo 图时用的额外类名 */
|
||||
imgClassName?: string;
|
||||
}
|
||||
|
||||
/** 站点字标或 Logo 图 */
|
||||
export default function SiteBrandMark({ branding, className, imgClassName }: Props) {
|
||||
if (branding.logo) {
|
||||
return (
|
||||
<img
|
||||
src={branding.logo}
|
||||
alt={branding.name}
|
||||
className={cn(className, 'site-brand-logo-img', imgClassName)}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span className={className}>{branding.logo_mark || branding.name.charAt(0) || '?'}</span>;
|
||||
}
|
||||
114
frontend/src/components/TagCloud.tsx
Normal file
114
frontend/src/components/TagCloud.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { TagCount } from '../api/types';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
tags: TagCount[];
|
||||
loading?: boolean;
|
||||
activeTag?: string;
|
||||
}
|
||||
|
||||
type TagTone = 0 | 1 | 2 | 3 | 4 | 5;
|
||||
|
||||
/** 稳定哈希,让同一标签颜色固定 */
|
||||
function hashTone(name: string): TagTone {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return (h % 6) as TagTone;
|
||||
}
|
||||
|
||||
/** 权重档位 0–4,驱动字号与透明度 */
|
||||
function weightTier(count: number, min: number, max: number): number {
|
||||
if (max <= min) return 2;
|
||||
const t = (count - min) / (max - min);
|
||||
return Math.min(4, Math.max(0, Math.round(t * 4)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 打散排序:热门标签穿插分布,避免「大标签全挤在顶上」。
|
||||
* 用名称哈希做次级键,视觉更像云而非排行榜。
|
||||
*/
|
||||
function layoutTags(tags: TagCount[]): TagCount[] {
|
||||
const ranked = [...tags].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'zh'));
|
||||
const top = ranked.slice(0, Math.min(6, ranked.length));
|
||||
const rest = ranked.slice(top.length);
|
||||
const out: TagCount[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < top.length || j < rest.length) {
|
||||
if (j < rest.length) out.push(rest[j++]);
|
||||
if (i < top.length) out.push(top[i++]);
|
||||
if (j < rest.length) out.push(rest[j++]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 右侧栏标签云:按热度缩放,色调错落 */
|
||||
export default function TagCloud({ tags, loading = false, activeTag = '' }: Props) {
|
||||
const nav = useNavigate();
|
||||
|
||||
const { items, min, max } = useMemo(() => {
|
||||
if (tags.length === 0) return { items: [] as TagCount[], min: 1, max: 1 };
|
||||
let lo = tags[0].count;
|
||||
let hi = tags[0].count;
|
||||
for (const t of tags) {
|
||||
if (t.count < lo) lo = t.count;
|
||||
if (t.count > hi) hi = t.count;
|
||||
}
|
||||
return { items: layoutTags(tags), min: lo, max: hi };
|
||||
}, [tags]);
|
||||
|
||||
if (loading && tags.length === 0) {
|
||||
return (
|
||||
<div className="tag-cloud tag-cloud--skeleton" aria-busy="true" aria-label="标签加载中">
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="skeleton--tag-cloud"
|
||||
style={{
|
||||
width: `${42 + (i % 5) * 16}px`,
|
||||
height: `${20 + (i % 3) * 4}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <div className="tag-cloud-empty">暂无标签</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tag-cloud" role="list" aria-label="标签云">
|
||||
{items.map((tag, index) => {
|
||||
const active = activeTag.trim().toLowerCase() === tag.name.toLowerCase();
|
||||
const tier = weightTier(tag.count, min, max);
|
||||
const tone = hashTone(tag.name);
|
||||
return (
|
||||
<button
|
||||
key={tag.name}
|
||||
type="button"
|
||||
role="listitem"
|
||||
className={cn(
|
||||
'tag-cloud-item',
|
||||
`tag-cloud-item--w${tier}`,
|
||||
`tag-cloud-item--t${tone}`,
|
||||
`tag-cloud-item--r${index % 5}`,
|
||||
active && 'active',
|
||||
)}
|
||||
title={`${tag.name} · ${tag.count} 篇`}
|
||||
onClick={() => nav(`/?keyword=${encodeURIComponent(tag.name)}`)}
|
||||
>
|
||||
<span className="tag-cloud-item__name">{tag.name}</span>
|
||||
{tier >= 3 && <span className="tag-cloud-item__count">{tag.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
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 { FeedSort } from './FeedSortBar';
|
||||
|
||||
@@ -11,15 +15,16 @@ interface Props {
|
||||
posts: PostItem[];
|
||||
sort?: FeedSort;
|
||||
loading: boolean;
|
||||
/** 当前页之后是否还有更多 */
|
||||
hasMore: boolean;
|
||||
/** 是否允许滚动触底自动加载(达到上限后为 false) */
|
||||
canAutoLoad: boolean;
|
||||
/** 是否显示底部分页控件 */
|
||||
showPagination: boolean;
|
||||
page: number;
|
||||
totalPages: number;
|
||||
postTotal: number;
|
||||
onLoadMore: () => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onSelect: (id: number) => void;
|
||||
/** 返回列表时恢复的滚动位置 */
|
||||
restoreScrollTop?: number | null;
|
||||
/** 递增时强制回到列表顶部(主动刷新导航) */
|
||||
resetScrollKey?: number;
|
||||
onScrollTopChange?: (top: number) => void;
|
||||
onScrollRestored?: () => void;
|
||||
@@ -30,17 +35,25 @@ export default function VirtualPostList({
|
||||
sort = 'latest',
|
||||
loading,
|
||||
hasMore,
|
||||
canAutoLoad,
|
||||
showPagination,
|
||||
page,
|
||||
totalPages,
|
||||
postTotal,
|
||||
onLoadMore,
|
||||
onPageChange,
|
||||
onSelect,
|
||||
restoreScrollTop,
|
||||
resetScrollKey = 0,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
const onScrollTopChangeRef = useRef(onScrollTopChange);
|
||||
const onScrollRestoredRef = useRef(onScrollRestored);
|
||||
onScrollTopChangeRef.current = onScrollTopChange;
|
||||
onScrollRestoredRef.current = onScrollRestored;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: posts.length,
|
||||
@@ -53,10 +66,8 @@ export default function VirtualPostList({
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const showHistoryPrompt = hasMore && !canAutoLoad && !loading;
|
||||
const showEnd = !hasMore && posts.length > 0 && !loading;
|
||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||
const isInitialLoad = loading && posts.length === 0;
|
||||
const isLoadingMore = loading && posts.length > 0;
|
||||
const isEmpty = !loading && posts.length === 0;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -67,15 +78,15 @@ export default function VirtualPostList({
|
||||
virtualizer.scrollToOffset(0);
|
||||
}
|
||||
restoredRef.current = true;
|
||||
onScrollTopChange?.(0);
|
||||
}, [resetScrollKey, virtualizer, onScrollTopChange]);
|
||||
onScrollTopChangeRef.current?.(0);
|
||||
}, [resetScrollKey, virtualizer]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (restoreScrollTop == null || restoredRef.current || posts.length === 0) return;
|
||||
virtualizer.scrollToOffset(restoreScrollTop);
|
||||
restoredRef.current = true;
|
||||
onScrollRestored?.();
|
||||
}, [restoreScrollTop, posts.length, virtualizer, onScrollRestored]);
|
||||
onScrollRestoredRef.current?.();
|
||||
}, [restoreScrollTop, posts.length, virtualizer]);
|
||||
|
||||
useEffect(() => {
|
||||
restoredRef.current = false;
|
||||
@@ -85,33 +96,42 @@ export default function VirtualPostList({
|
||||
const el = parentRef.current;
|
||||
if (!el) return;
|
||||
const onScroll = () => {
|
||||
onScrollTopChange?.(el.scrollTop);
|
||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 120 && canAutoLoad && hasMore && !loading) {
|
||||
onLoadMore();
|
||||
}
|
||||
onScrollTopChangeRef.current?.(el.scrollTop);
|
||||
};
|
||||
el.addEventListener('scroll', onScroll);
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, [canAutoLoad, hasMore, loading, onLoadMore, onScrollTopChange]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
{isInitialLoad ? (
|
||||
<PostListSkeleton />
|
||||
) : isEmpty ? (
|
||||
<div className="empty-feed">
|
||||
<div className="empty-feed" role="status">
|
||||
<Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无帖子</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 className="content-surface" style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
||||
{virtualizer.getVirtualItems().map(vi => {
|
||||
const post = posts[vi.index];
|
||||
if (!post) return null;
|
||||
return (
|
||||
<div
|
||||
key={post.id}
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
@@ -122,20 +142,20 @@ export default function VirtualPostList({
|
||||
transform: `translateY(${vi.start}px)`,
|
||||
}}
|
||||
>
|
||||
<PostListItem post={post} sort={sort} onClick={() => onSelect(post.id)} />
|
||||
<PostListItem post={post} sort={sort} onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{isLoadingMore && <PostListSkeleton count={2} />}
|
||||
{showHistoryPrompt && (
|
||||
<div className="feed-list-footer feed-list-footer--history">
|
||||
<p className="feed-list-footer__hint">
|
||||
已显示 {posts.length} / {postTotal} 条
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onLoadMore}>
|
||||
加载更多历史
|
||||
</Button>
|
||||
{showPagination && (
|
||||
<div className="feed-list-footer feed-list-footer--pagination">
|
||||
<FeedPagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
loading={loading}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showEnd && (
|
||||
|
||||
@@ -1,51 +1,83 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import {
|
||||
ReactNodeViewRenderer,
|
||||
NodeViewWrapper,
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
} 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) {
|
||||
if ($pos.node(d).type.name === 'membersOnly') return d;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** 登录可见区块是否无实质文字 */
|
||||
function isMembersOnlyEmpty(node: ProseMirrorNode): boolean {
|
||||
return node.textContent.trim().length === 0;
|
||||
}
|
||||
|
||||
/** 编辑态「登录可见」区块视图 */
|
||||
function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
||||
const handleExit = () => {
|
||||
editor.chain().focus().exitMembersOnly().run();
|
||||
function MembersOnlyView({ selected, editor, node, getPos }: NodeViewProps) {
|
||||
const empty = isMembersOnlyEmpty(node);
|
||||
|
||||
/** 按 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 pos = getPos();
|
||||
if (typeof pos !== 'number') {
|
||||
editor.chain().focus().unwrapMembersOnly().run();
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.command(({ tr, dispatch }) => {
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
if (dispatch) tr.delete(pos, pos + node.nodeSize);
|
||||
} else if (dispatch) {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="members-only"
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}`}
|
||||
className={`post-members-only post-members-only--visible editor-members-only${selected ? ' editor-members-only--selected' : ''}${empty ? ' editor-members-only--empty' : ''}`}
|
||||
>
|
||||
<div className="post-members-only__badge" contentEditable={false}>
|
||||
<span className="post-members-only__badge-icon" aria-hidden="true">
|
||||
<LockKeyhole size={12} />
|
||||
</span>
|
||||
<span>登录可见</span>
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__exit-btn"
|
||||
title="Ctrl+Enter 退出到公开区域"
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={handleExit}
|
||||
>
|
||||
<LogOut size={11} />
|
||||
退出
|
||||
</button>
|
||||
<span className="post-members-only__shortcut-hint">Ctrl+Enter 退出</span>
|
||||
<div className="post-members-only__badge-actions">
|
||||
{!empty && (
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__unwrap-btn"
|
||||
@@ -55,8 +87,20 @@ function MembersOnlyView({ selected, editor }: NodeViewProps) {
|
||||
>
|
||||
取消包裹
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="post-members-only__remove-btn"
|
||||
title={empty ? '删除空的登录可见区块' : '删除整个登录可见区块'}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={deleteThisBlock}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" />
|
||||
</div>
|
||||
<NodeViewContent className="post-members-only__body" data-placeholder="此处内容游客不可见…" />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -68,6 +112,7 @@ declare module '@tiptap/core' {
|
||||
wrapMembersOnly: () => ReturnType;
|
||||
exitMembersOnly: () => ReturnType;
|
||||
unwrapMembersOnly: () => ReturnType;
|
||||
removeMembersOnly: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -94,6 +139,37 @@ export const MembersOnly = Node.create({
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
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: ({ editor }) => {
|
||||
const { $from, empty } = editor.state.selection;
|
||||
@@ -107,6 +183,12 @@ export const MembersOnly = Node.create({
|
||||
const isEmptyBlock = parent.textContent.trim().length === 0;
|
||||
if (!atBlockEnd || !isEmptyBlock) return false;
|
||||
|
||||
// 整块为空时直接删除,避免退出后仍残留空登录可见壳
|
||||
const membersNode = $from.node(depth);
|
||||
if (isMembersOnlyEmpty(membersNode) && membersNode.childCount <= 1) {
|
||||
return editor.commands.removeMembersOnly();
|
||||
}
|
||||
|
||||
return editor.commands.exitMembersOnly();
|
||||
},
|
||||
// Ctrl+Enter / Cmd+Enter 退出到公开区域
|
||||
@@ -162,7 +244,25 @@ export const MembersOnly = Node.create({
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
|
||||
// 空区块:直接删除,避免留下空段落套壳
|
||||
if (isMembersOnlyEmpty(node)) {
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
} else {
|
||||
tr.replaceWith(pos, pos + node.nodeSize, node.content);
|
||||
}
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
removeMembersOnly: () => ({ tr, state, dispatch }) => {
|
||||
const { $from } = state.selection;
|
||||
const depth = findMembersOnlyDepth($from);
|
||||
if (depth < 0) return false;
|
||||
|
||||
const pos = $from.before(depth);
|
||||
const node = $from.node(depth);
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
if (dispatch) dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -13,7 +13,8 @@ const AlertDialogOverlay = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
/* 需高于全屏编辑器 (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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -31,7 +32,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -15,7 +15,8 @@ const DialogOverlay = React.forwardRef<
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-[110] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
/* 需高于全屏编辑器 (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,
|
||||
)}
|
||||
{...props}
|
||||
@@ -32,7 +33,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-[110] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
'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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,14 +10,16 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
search_keyword_min: 1,
|
||||
search_keyword_max: 50,
|
||||
page_size_default: 30,
|
||||
feed_max_pages: 10,
|
||||
feed_max_items: 300,
|
||||
password_min_len: 6,
|
||||
avatar_max_mb: 2,
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
};
|
||||
|
||||
let cached: ForumLimitsPublic | null = null;
|
||||
let inflight: Promise<ForumLimitsPublic> | null = null;
|
||||
let cacheEpoch = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
@@ -27,7 +29,7 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
cached = limits;
|
||||
return limits;
|
||||
})
|
||||
.catch(() => DEFAULT_LIMITS)
|
||||
.catch(() => cached ?? DEFAULT_LIMITS)
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
@@ -36,14 +38,34 @@ function fetchLimits(): Promise<ForumLimitsPublic> {
|
||||
export function useForumLimits() {
|
||||
const [limits, setLimits] = useState<ForumLimitsPublic>(cached ?? DEFAULT_LIMITS);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||
export function invalidateForumLimitsCache() {
|
||||
cached = null;
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
92
frontend/src/hooks/useSiteBranding.ts
Normal file
92
frontend/src/hooks/useSiteBranding.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { SiteBranding } from '../api/types';
|
||||
|
||||
export const DEFAULT_BRANDING: SiteBranding = {
|
||||
name: '姜十三论坛',
|
||||
name_en: 'Jiang13 Forum',
|
||||
slogan: '拾三一隅,自在交流',
|
||||
logo_mark: '姜',
|
||||
logo: '',
|
||||
favicon: '',
|
||||
};
|
||||
|
||||
let cached: SiteBranding | null = null;
|
||||
let inflight: Promise<SiteBranding> | null = null;
|
||||
let cacheEpoch = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function fetchBranding(): Promise<SiteBranding> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (inflight) return inflight;
|
||||
inflight = api.siteBranding()
|
||||
.then(b => {
|
||||
cached = { ...DEFAULT_BRANDING, ...b };
|
||||
return cached;
|
||||
})
|
||||
.catch(() => cached ?? DEFAULT_BRANDING)
|
||||
.finally(() => { inflight = null; });
|
||||
return inflight;
|
||||
}
|
||||
|
||||
function applyDocumentBrand(brand: SiteBranding) {
|
||||
const title = brand.name_en ? `${brand.name} ${brand.name_en}` : brand.name;
|
||||
if (document.title !== title) document.title = title;
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (brand.favicon) {
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
if (link.href !== new URL(brand.favicon, window.location.origin).href) {
|
||||
link.href = brand.favicon;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取站点品牌配置(名称、Logo 等) */
|
||||
export function useSiteBranding() {
|
||||
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
||||
const [loading, setLoading] = useState(!cached);
|
||||
const [epoch, setEpoch] = useState(cacheEpoch);
|
||||
|
||||
useEffect(() => {
|
||||
const onInvalidate = () => setEpoch(cacheEpoch);
|
||||
listeners.add(onInvalidate);
|
||||
return () => { listeners.delete(onInvalidate); };
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!cached) setLoading(true);
|
||||
fetchBranding()
|
||||
.then(next => {
|
||||
if (cancelled) return;
|
||||
setBranding(next);
|
||||
applyDocumentBrand(next);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [epoch]);
|
||||
|
||||
return { branding, loading };
|
||||
}
|
||||
|
||||
/** 清除缓存并通知已挂载的 hook 重新拉取 */
|
||||
export function invalidateSiteBrandingCache() {
|
||||
cached = null;
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/** 用管理端刚保存的值立即更新缓存与文档标题 */
|
||||
export function seedSiteBrandingCache(brand: SiteBranding) {
|
||||
cached = { ...DEFAULT_BRANDING, ...brand };
|
||||
applyDocumentBrand(cached);
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import { useOverlayA11y } from '../hooks/useOverlayA11y';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const NAV = [
|
||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
@@ -24,6 +27,7 @@ const NAV = [
|
||||
export default function AdminLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const nav = useNavigate();
|
||||
@@ -38,7 +42,7 @@ export default function AdminLayout() {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) {
|
||||
nav('/login');
|
||||
nav(loginPath('/admin/dashboard'));
|
||||
return;
|
||||
}
|
||||
if (user.role !== 'admin') {
|
||||
@@ -91,9 +95,9 @@ export default function AdminLayout() {
|
||||
{navOpen ? <X size={18} aria-hidden /> : <Menu size={18} aria-hidden />}
|
||||
</button>
|
||||
)}
|
||||
<div className="admin-topbar-mark">姜</div>
|
||||
<SiteBrandMark branding={branding} className="admin-topbar-mark" />
|
||||
<div>
|
||||
<div className="admin-topbar-title">姜十三论坛</div>
|
||||
<div className="admin-topbar-title">{branding.name}</div>
|
||||
<div className="admin-topbar-sub">管理后台</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'react';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
|
||||
import {
|
||||
@@ -13,8 +14,9 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
|
||||
import { api } from '../api/client';
|
||||
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import type { Board, PostItem, RecentComment, ForumStats, TagCount } from '../api/types';
|
||||
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 RightPanel from '../components/RightPanel';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
@@ -24,10 +26,15 @@ import { navigateFeed } from '../utils/feedCache';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
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() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||
const nav = useNavigate();
|
||||
@@ -37,11 +44,18 @@ export default function MainLayout() {
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||
const [hot, setHot] = useState<PostItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
||||
const [hot, setHot] = useState<PostItem[]>(() => getCachedHot());
|
||||
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
|
||||
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 [asideLoading, setAsideLoading] = useState(false);
|
||||
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||
const asideEverLoaded = useRef(false);
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
@@ -60,6 +74,9 @@ export default function MainLayout() {
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
useEffect(() => { setAsideOpen(false); }, [loc.pathname, loc.search]);
|
||||
useEffect(() => {
|
||||
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
||||
}, [loc.pathname]);
|
||||
useEffect(() => {
|
||||
if (!hideAside) setAsideOpen(false);
|
||||
}, [hideAside]);
|
||||
@@ -71,7 +88,7 @@ export default function MainLayout() {
|
||||
}, [asideOpen]);
|
||||
|
||||
const refreshBoards = useCallback(() => {
|
||||
Promise.all([
|
||||
return Promise.all([
|
||||
api.boards().then(d => {
|
||||
const next = d.boards ?? [];
|
||||
setBoards(next);
|
||||
@@ -83,18 +100,9 @@ export default function MainLayout() {
|
||||
setCachedStats(next);
|
||||
return next;
|
||||
}).catch(() => null),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
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 : [],
|
||||
]).finally(() => {
|
||||
setBoardsLoading(false);
|
||||
});
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -104,25 +112,51 @@ export default function MainLayout() {
|
||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||
}, [refreshBoards]);
|
||||
|
||||
// 标签云:非编辑页拉取(左侧栏常显)
|
||||
useEffect(() => {
|
||||
if (isCompose) return;
|
||||
api.presence().catch(() => {});
|
||||
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
||||
return () => clearInterval(presenceTimer);
|
||||
let cancelled = false;
|
||||
const loadTags = () => {
|
||||
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]);
|
||||
|
||||
const needAsideData = !isCompose && (!hideAside || asideOpen);
|
||||
useEffect(() => {
|
||||
if (!needAsideData) return;
|
||||
let cancelled = false;
|
||||
if (!asideEverLoaded.current) setAsideLoading(true);
|
||||
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
|
||||
if (!asideEverLoaded.current && !hasCachedAside()) {
|
||||
setAsideLoading(true);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
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(() => {}),
|
||||
api.notifications().then(d => {
|
||||
if (!cancelled) setNotifications(Array.isArray(d.notifications) ? d.notifications : []);
|
||||
api.recentComments().then(d => {
|
||||
if (cancelled) return;
|
||||
const next = Array.isArray(d.comments) ? d.comments : [];
|
||||
setRecentComments(next);
|
||||
setCachedRecentComments(next);
|
||||
}).catch(() => {}),
|
||||
]).finally(() => {
|
||||
if (!cancelled) {
|
||||
@@ -131,13 +165,10 @@ export default function MainLayout() {
|
||||
}
|
||||
});
|
||||
|
||||
refreshOnline();
|
||||
const onlineTimer = setInterval(refreshOnline, 30000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(onlineTimer);
|
||||
};
|
||||
}, [needAsideData, refreshOnline]);
|
||||
}, [needAsideData]);
|
||||
|
||||
const doSearch = () => {
|
||||
const kw = keyword.trim();
|
||||
@@ -157,10 +188,10 @@ export default function MainLayout() {
|
||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||
};
|
||||
|
||||
const openPost = (id: number) => {
|
||||
const openPost = useCallback((id: number) => {
|
||||
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 isFeedHome = loc.pathname === '/';
|
||||
@@ -169,6 +200,22 @@ export default function MainLayout() {
|
||||
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
|
||||
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) => {
|
||||
setBoardId(id);
|
||||
navigateFeed(nav, buildHomeUrl(id, feedSort));
|
||||
@@ -191,8 +238,8 @@ export default function MainLayout() {
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<span className="header-logo-mark">姜</span>
|
||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||
</button>
|
||||
|
||||
{!isCompose && (
|
||||
@@ -224,7 +271,7 @@ export default function MainLayout() {
|
||||
<button
|
||||
type="button"
|
||||
className="header-compose-btn"
|
||||
onClick={() => user ? nav('/compose') : nav('/login')}
|
||||
onClick={() => user ? nav('/compose') : nav(loginPath('/compose'))}
|
||||
aria-label="发帖"
|
||||
>
|
||||
<Plus size={16} aria-hidden />
|
||||
@@ -288,7 +335,7 @@ export default function MainLayout() {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
|
||||
<button type="button" className="header-login-btn" onClick={() => nav(loginPath())}>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
@@ -303,6 +350,11 @@ export default function MainLayout() {
|
||||
boards={boards}
|
||||
activeBoard={boardId}
|
||||
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>
|
||||
)}
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet context={{
|
||||
boardId,
|
||||
keyword: params.get('keyword') || '',
|
||||
setBoardId,
|
||||
boards,
|
||||
stats,
|
||||
refreshBoards,
|
||||
isMobile,
|
||||
} satisfies LayoutCtx} />
|
||||
<Suspense fallback={isFeedHome ? <FeedPageSkeleton /> : <PageLoader />}>
|
||||
<Outlet context={layoutCtx} />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
@@ -363,8 +407,9 @@ export default function MainLayout() {
|
||||
<aside className="aside-panel">
|
||||
<RightPanel
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
recentComments={recentComments}
|
||||
tags={tags}
|
||||
tagsLoading={tagsLoading}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
/>
|
||||
@@ -406,8 +451,9 @@ export default function MainLayout() {
|
||||
<div className="aside-drawer-body">
|
||||
<RightPanel
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
recentComments={recentComments}
|
||||
tags={tags}
|
||||
tagsLoading={tagsLoading}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
/>
|
||||
@@ -429,4 +475,10 @@ export type LayoutCtx = {
|
||||
stats: ForumStats | null;
|
||||
refreshBoards: () => void;
|
||||
isMobile: boolean;
|
||||
/** 详情页上报文章目录,供左侧栏展示 */
|
||||
setPostOutline: (outline: {
|
||||
headings: PostHeading[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
title?: string;
|
||||
} | null) => void;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, Send, Pencil } from 'lucide-react';
|
||||
import { notify } from '@/lib/notify';
|
||||
@@ -14,6 +14,13 @@ import TagInput, { serializeTags, parseTags } from '../components/TagInput';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getCachedBoards } from '../utils/layoutCache';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import {
|
||||
loadComposeDraft,
|
||||
saveComposeDraft,
|
||||
clearComposeDraft,
|
||||
draftHasContent,
|
||||
} from '../utils/composeDraft';
|
||||
|
||||
interface ComposeBaseline {
|
||||
title: string;
|
||||
@@ -27,6 +34,22 @@ function resolveBoards(ctxBoards?: Board[]): Board[] {
|
||||
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() {
|
||||
const nav = useNavigate();
|
||||
const { id: editIdParam } = useParams();
|
||||
@@ -50,13 +73,21 @@ export default function ComposePage() {
|
||||
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
|
||||
);
|
||||
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(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) {
|
||||
nav(loginPath(isEdit ? `/post/${editId}/edit` : '/compose'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEdit) {
|
||||
setLoading(true);
|
||||
draftReadyRef.current = false;
|
||||
const cached = resolveBoards(layoutCtx?.boards);
|
||||
const boardsPromise = cached.length > 0
|
||||
? Promise.resolve({ boards: cached })
|
||||
@@ -78,16 +109,43 @@ export default function ComposePage() {
|
||||
return;
|
||||
}
|
||||
const loadedBoardId = String(post.board_id);
|
||||
setBoardId(loadedBoardId);
|
||||
setTitle(post.title);
|
||||
setTags(post.tags ?? '');
|
||||
setContent(post.content ?? '');
|
||||
setBaseline({
|
||||
const serverBaseline: ComposeBaseline = {
|
||||
title: post.title,
|
||||
tags: post.tags ?? '',
|
||||
content: post.content ?? '',
|
||||
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) => {
|
||||
notify.error(e instanceof Error ? e.message : '加载帖子失败');
|
||||
@@ -97,40 +155,77 @@ export default function ComposePage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const list = resolveBoards(layoutCtx?.boards);
|
||||
if (list.length > 0) {
|
||||
draftReadyRef.current = false;
|
||||
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
|
||||
setBoards(list);
|
||||
setBoardsReady(true);
|
||||
const initialBoardId = defaultBoard || String(list[0].id);
|
||||
if (!defaultBoard) setBoardId(initialBoardId);
|
||||
const boardForBaseline = defaultBoard || initialBoardId;
|
||||
setBoardId(prev => prev || boardForBaseline);
|
||||
|
||||
const draft = loadComposeDraft(null);
|
||||
if (draft && draftHasContent(draft)) {
|
||||
setTitle(draft.title);
|
||||
setTags(draft.tags);
|
||||
setContent(draft.content);
|
||||
if (draft.boardId && list.some(b => String(b.id) === draft.boardId)) {
|
||||
setBoardId(draft.boardId);
|
||||
}
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: 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;
|
||||
}
|
||||
|
||||
setBoardsReady(false);
|
||||
api.boards().then(d => {
|
||||
const next = d.boards ?? [];
|
||||
setBoards(next);
|
||||
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
|
||||
if (!defaultBoard && next.length > 0) {
|
||||
setBoardId(initialBoardId);
|
||||
}
|
||||
setBaseline({
|
||||
title: '',
|
||||
tags: '',
|
||||
content: '',
|
||||
boardId: initialBoardId,
|
||||
});
|
||||
applyNewBaseline(next, initialBoardId);
|
||||
}).catch(() => {
|
||||
setBoards([]);
|
||||
}).finally(() => setBoardsReady(true));
|
||||
}, [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(() => {
|
||||
if (!baseline) return false;
|
||||
return (
|
||||
@@ -206,11 +301,13 @@ export default function ComposePage() {
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
notify.success('帖子已更新');
|
||||
clearComposeDraft(editId);
|
||||
markSaved();
|
||||
nav(`/post/${editId}`);
|
||||
} else {
|
||||
const res = await api.createPost({ board_id: boardId, ...payload });
|
||||
notify.success('发帖成功');
|
||||
clearComposeDraft(null);
|
||||
markSaved();
|
||||
nav(`/post/${res.post_id}`);
|
||||
}
|
||||
@@ -230,12 +327,20 @@ export default function ComposePage() {
|
||||
<button
|
||||
type="button"
|
||||
className="compose-back"
|
||||
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
|
||||
onClick={() => requestLeave(() => {
|
||||
if (isEdit) nav(`/post/${editId}`);
|
||||
else nav(-1);
|
||||
})}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>返回</span>
|
||||
</button>
|
||||
<div className="compose-header-actions">
|
||||
{(draftHint || editWindowHint) && (
|
||||
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
|
||||
{editWindowHint || draftHint}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="compose-publish-btn"
|
||||
@@ -287,6 +392,9 @@ export default function ComposePage() {
|
||||
{currentBoard && (
|
||||
<div className="compose-subtitle">
|
||||
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
|
||||
{editWindowHint && (
|
||||
<span className="compose-edit-window"> · {editWindowHint}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<ArticleEditor
|
||||
|
||||
@@ -8,6 +8,9 @@ import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
|
||||
interface FavItem {
|
||||
id: number;
|
||||
@@ -19,12 +22,13 @@ interface FavItem {
|
||||
export default function FavoritesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const [list, setList] = useState<FavItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) { nav(loginPath('/favorites')); return; }
|
||||
api.favorites()
|
||||
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
|
||||
.catch(e => notify.error(e.message))
|
||||
@@ -58,14 +62,14 @@ export default function FavoritesPage() {
|
||||
<PostListItem
|
||||
key={fav.id}
|
||||
post={fav.post}
|
||||
onClick={() => nav(`/post/${fav.post_id}`)}
|
||||
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
key={fav.id}
|
||||
type="button"
|
||||
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-title">帖子已删除</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
@@ -6,6 +6,7 @@ import type { PostItem } from '../api/types';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import VirtualPostList from '../components/VirtualPostList';
|
||||
import FeedHeader from '../components/FeedHeader';
|
||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import {
|
||||
@@ -16,44 +17,41 @@ import {
|
||||
FEED_RESET_EVENT,
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default;
|
||||
const feedMaxPages = limits.feed_max_pages;
|
||||
const feedMaxItems = limits.feed_max_items;
|
||||
const { limits, loading: limitsLoading } = useForumLimits();
|
||||
const pageSize = Math.max(1, limits.page_size_default);
|
||||
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const keyword = params.get('keyword') || '';
|
||||
const sort = parseFeedSort(params.get('sort'));
|
||||
const initialCache = getFeedCache(boardId, keyword, sort);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
|
||||
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
|
||||
const [page, setPage] = useState(() => initialCache?.page ?? 1);
|
||||
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
|
||||
const [loading, setLoading] = useState(() => !initialCache);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
|
||||
const [listResetKey, setListResetKey] = useState(0);
|
||||
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
|
||||
const pageWrapRef = useRef<HTMLDivElement>(null);
|
||||
/** 主动刷新时不把旧列表/滚动位置写回 cache */
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
|
||||
const canAutoLoad = useMemo(
|
||||
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
|
||||
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
|
||||
);
|
||||
const scrollTopRef = useRef(0);
|
||||
const skipCacheSaveRef = useRef(false);
|
||||
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(() => {
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
setListResetKey(k => k + 1);
|
||||
pageWrapRef.current?.scrollTo(0);
|
||||
}, []);
|
||||
|
||||
const beginFeedRefresh = useCallback(() => {
|
||||
@@ -62,7 +60,9 @@ export default function HomePage() {
|
||||
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);
|
||||
try {
|
||||
const data = await api.posts({
|
||||
@@ -73,67 +73,77 @@ export default function HomePage() {
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const batch = Array.isArray(data.posts) ? data.posts : [];
|
||||
setPosts(prev => (reset ? batch : [...prev, ...batch]));
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
const total = data.total ?? 0;
|
||||
setPosts(batch);
|
||||
setPostTotal(total);
|
||||
setPage(p);
|
||||
pageRef.current = p;
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
if (reset) setPosts([]);
|
||||
setPosts([]);
|
||||
setPostTotal(0);
|
||||
setPage(1);
|
||||
pageRef.current = 1;
|
||||
} finally {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
|
||||
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
|
||||
const revalidate = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.posts({
|
||||
page: 1,
|
||||
size: pageSize,
|
||||
board_id: boardId || '',
|
||||
keyword,
|
||||
sort: sort === 'latest' ? '' : sort,
|
||||
});
|
||||
const fresh = Array.isArray(data.posts) ? data.posts : [];
|
||||
const freshIds = new Set(fresh.map(p => p.id));
|
||||
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
|
||||
setPostTotal(data.total ?? 0);
|
||||
setHasMore(!!data.has_more);
|
||||
} catch {
|
||||
// 静默失败,保留缓存数据
|
||||
}
|
||||
}, [boardId, keyword, sort, pageSize]);
|
||||
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
|
||||
|
||||
const loadNextPage = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
load(page + 1);
|
||||
}, [loading, hasMore, page, load]);
|
||||
const goToPage = useCallback((p: number) => {
|
||||
if (loadingRef.current) return;
|
||||
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
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(() => {
|
||||
if (limitsLoading) return;
|
||||
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = getFeedCache(boardId, keyword, sort);
|
||||
if (cached) {
|
||||
if (cached && cached.posts.length > 0) {
|
||||
setPosts(cached.posts);
|
||||
setPostTotal(cached.postTotal);
|
||||
setPage(cached.page);
|
||||
setHasMore(cached.hasMore);
|
||||
pageRef.current = cached.page;
|
||||
setRestoreScrollTop(cached.scrollTop);
|
||||
scrollTopRef.current = cached.scrollTop;
|
||||
setLoading(false);
|
||||
revalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
setRestoreScrollTop(null);
|
||||
scrollTopRef.current = 0;
|
||||
load(1, true);
|
||||
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
|
||||
loadFirst();
|
||||
}, [
|
||||
limitsLoading,
|
||||
pageSize,
|
||||
boardId,
|
||||
keyword,
|
||||
sort,
|
||||
location.key,
|
||||
location.state,
|
||||
loadFirst,
|
||||
beginFeedRefresh,
|
||||
]);
|
||||
|
||||
// 离开当前筛选条件时写入内存缓存
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
@@ -141,22 +151,17 @@ export default function HomePage() {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
hasMore,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
|
||||
}, [boardId, keyword, sort, posts, postTotal, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) {
|
||||
skipCacheSaveRef.current = false;
|
||||
}
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
}, [loading, posts.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onFeedReset = () => {
|
||||
beginFeedRefresh();
|
||||
};
|
||||
const onFeedReset = () => beginFeedRefresh();
|
||||
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
|
||||
}, [beginFeedRefresh]);
|
||||
@@ -164,16 +169,16 @@ export default function HomePage() {
|
||||
useEffect(() => {
|
||||
const fn = () => {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
loadFirst();
|
||||
};
|
||||
window.addEventListener('posts-refresh', fn);
|
||||
return () => window.removeEventListener('posts-refresh', fn);
|
||||
}, [beginFeedRefresh, load]);
|
||||
}, [beginFeedRefresh, loadFirst]);
|
||||
|
||||
const handleSortChange = (next: FeedSort) => {
|
||||
if (next === sort) {
|
||||
beginFeedRefresh();
|
||||
load(1, true);
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next));
|
||||
@@ -181,8 +186,13 @@ export default function HomePage() {
|
||||
|
||||
const showSortBar = !keyword;
|
||||
|
||||
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
||||
if ((loading || limitsLoading) && posts.length === 0) {
|
||||
return <FeedPageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrap" ref={pageWrapRef}>
|
||||
<div className="page-wrap page-wrap--feed">
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
@@ -199,12 +209,14 @@ export default function HomePage() {
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
sort={sort}
|
||||
loading={loading}
|
||||
loading={loading || limitsLoading}
|
||||
hasMore={hasMore}
|
||||
canAutoLoad={canAutoLoad}
|
||||
showPagination={showPagination}
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
postTotal={postTotal}
|
||||
onLoadMore={loadNextPage}
|
||||
onSelect={(id) => nav(`/post/${id}`)}
|
||||
onPageChange={goToPage}
|
||||
onSelect={handleSelectPost}
|
||||
restoreScrollTop={restoreScrollTop}
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
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({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
@@ -19,8 +22,11 @@ type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const { branding } = useSiteBranding();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { username: '', password: '' },
|
||||
@@ -32,7 +38,7 @@ export default function LoginPage() {
|
||||
await api.login(values.username, values.password);
|
||||
await refresh();
|
||||
notify.success('登录成功');
|
||||
nav('/', { replace: true });
|
||||
navigateAfterAuth(nav, redirectTo);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
@@ -43,9 +49,9 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="logo-mark">姜</div>
|
||||
<h1>登录姜十三论坛</h1>
|
||||
<p className="subtitle">拾三一隅,自在交流</p>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<h1>登录{branding.name}</h1>
|
||||
<p className="subtitle">{branding.slogan || '欢迎回来'}</p>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
@@ -80,7 +86,7 @@ export default function LoginPage() {
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
没有账号?<Link to="/register">注册</Link>
|
||||
没有账号?<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}>注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion, Trash2 } from 'lucide-react';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, Comment } from '../api/types';
|
||||
@@ -13,23 +24,42 @@ import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||
import ArticleOutline from '../components/ArticleOutline';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const postId = Number(id);
|
||||
const nav = useNavigate();
|
||||
const { user, refresh } = useAuth();
|
||||
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||
|
||||
const [post, setPost] = useState<PostItem | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [replyTo, setReplyTo] = useState<Comment | null>(null);
|
||||
const [editingCommentId, setEditingCommentId] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
|
||||
@@ -37,7 +67,10 @@ export default function PostDetailPage() {
|
||||
const [canEdit, setCanEdit] = useState(false);
|
||||
const [isEdited, setIsEdited] = useState(false);
|
||||
const [editBlockReason, setEditBlockReason] = useState('');
|
||||
const [editWindowHours, setEditWindowHours] = useState(0);
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [deletingPost, setDeletingPost] = useState(false);
|
||||
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -46,18 +79,37 @@ export default function PostDetailPage() {
|
||||
|
||||
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 postPath = `/post/${postId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(null);
|
||||
setHeadings([]);
|
||||
const seq = ++loadSeq.current;
|
||||
setLoading(true);
|
||||
setPost(null);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const [detail, comm] = await Promise.all([
|
||||
api.post(postId),
|
||||
@@ -70,8 +122,8 @@ export default function PostDetailPage() {
|
||||
setCanEdit(detail.can_edit ?? false);
|
||||
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
|
||||
setEditBlockReason(detail.edit_block_reason ?? '');
|
||||
setEditWindowHours(detail.post_edit_window_hours ?? 0);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
// 会话刷新与正文展示解耦;勿作为 effect 依赖
|
||||
void refresh();
|
||||
} catch (e: unknown) {
|
||||
if (seq !== loadSeq.current) return;
|
||||
@@ -81,16 +133,15 @@ export default function PostDetailPage() {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
})();
|
||||
// 仅 postId 变化时加载;user/refresh 变化不得重跑
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
|
||||
}, [postId]);
|
||||
|
||||
// 发评后局部刷新评论列表(不整页重载)
|
||||
const reloadComments = useCallback(async () => {
|
||||
const myIds = user ? [] : loadMyCommentIds();
|
||||
const comm = await api.comments(postId, myIds);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
}, [postId, user]);
|
||||
|
||||
const jumpToFloor = useCallback((floor: number) => {
|
||||
const el = document.getElementById(`floor-${floor}`);
|
||||
if (!el) return;
|
||||
@@ -100,7 +151,13 @@ export default function PostDetailPage() {
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
}, []);
|
||||
|
||||
const requireLogin = (actionLabel: string) => {
|
||||
notify.warning(`登录后即可${actionLabel}`);
|
||||
nav(loginPath(postPath));
|
||||
};
|
||||
|
||||
const handleReplyTo = (comment: Comment) => {
|
||||
setEditingCommentId(null);
|
||||
if (replyTo?.id === comment.id) {
|
||||
setReplyTo(null);
|
||||
return;
|
||||
@@ -108,7 +165,6 @@ export default function PostDetailPage() {
|
||||
setReplyTo(comment);
|
||||
};
|
||||
|
||||
// DOM 提交后再滚动,避免 setTimeout 与 focus 抢滚动导致概率性错位
|
||||
useLayoutEffect(() => {
|
||||
if (!replyTo) return;
|
||||
const el = document.getElementById(`reply-box-${replyTo.id}`);
|
||||
@@ -120,7 +176,7 @@ export default function PostDetailPage() {
|
||||
}, []);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) { requireLogin('点赞'); return; }
|
||||
try {
|
||||
const r = await api.like(postId);
|
||||
setLiked(r.liked);
|
||||
@@ -131,7 +187,7 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!user) { nav('/login'); return; }
|
||||
if (!user) { requireLogin('收藏'); return; }
|
||||
try {
|
||||
const r = await api.favorite(postId);
|
||||
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 = {
|
||||
user,
|
||||
submitting,
|
||||
@@ -184,9 +284,12 @@ export default function PostDetailPage() {
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
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 showEdited = isEdited && post.updated_at;
|
||||
const editRemaining = canEdit && user?.role !== 'admin'
|
||||
? formatEditRemaining(post.created_at, editWindowHours)
|
||||
: '';
|
||||
|
||||
const handlePin = async () => {
|
||||
if (!post) return;
|
||||
@@ -264,14 +367,41 @@ export default function PostDetailPage() {
|
||||
</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">
|
||||
<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 />
|
||||
点赞 {post.like_count}
|
||||
</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 />
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
</Button>
|
||||
@@ -287,6 +417,29 @@ export default function PostDetailPage() {
|
||||
编辑历史
|
||||
</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 && (
|
||||
<span className="post-detail-edit-hint" title={editBlockReason}>
|
||||
{editBlockReason}
|
||||
@@ -338,8 +491,17 @@ export default function PostDetailPage() {
|
||||
comments={comments}
|
||||
highlightFloor={highlightFloor}
|
||||
replyToId={replyTo?.id ?? null}
|
||||
editingId={editingCommentId}
|
||||
currentUser={user}
|
||||
onReply={handleReplyTo}
|
||||
onCancelReply={() => setReplyTo(null)}
|
||||
onStartEdit={(c) => {
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(c.id);
|
||||
}}
|
||||
onCancelEdit={() => setEditingCommentId(null)}
|
||||
onSaveEdit={handleSaveComment}
|
||||
onDelete={handleDeleteComment}
|
||||
renderReplyBox={(c) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import AvatarCropDialog from '../components/AvatarCropDialog';
|
||||
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
|
||||
const nickSchema = z.object({
|
||||
nickname: z.string().min(1, '请输入昵称').max(64),
|
||||
@@ -61,7 +62,7 @@ export default function ProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
nav('/login');
|
||||
nav(loginPath('/profile'));
|
||||
}
|
||||
}, [authLoading, user, nav]);
|
||||
|
||||
@@ -317,6 +318,12 @@ export default function ProfilePage() {
|
||||
<Input value={user.username} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input value={user.email || '未设置'} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
<FormField
|
||||
control={nickForm.control}
|
||||
name="nickname"
|
||||
|
||||
119
frontend/src/pages/ProjectsPage.tsx
Normal file
119
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, FolderGit2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { GiteaProject } from '../api/types';
|
||||
|
||||
function formatRemoteTime(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
const d = new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const nav = useNavigate();
|
||||
const [list, setList] = useState<GiteaProject[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api.projects({ page, limit: 30 })
|
||||
.then(d => {
|
||||
setList(Array.isArray(d.projects) ? d.projects : []);
|
||||
setTotal(d.total ?? 0);
|
||||
setTotalPages(d.total_pages ?? 0);
|
||||
})
|
||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
<h1 className="page-title">开源码桶</h1>
|
||||
<p className="page-desc">
|
||||
论坛会员在 Gitea 上的公开仓库
|
||||
{total > 0 ? ` · 共 ${total} 个` : ''}
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无同步到的公开项目</p>
|
||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||
管理员可在「系统设置 → Gitea 同步」配置后执行同步
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="content-surface projects-list">
|
||||
{list.map(p => (
|
||||
<article key={p.id} className="project-row">
|
||||
<div className="project-row-body">
|
||||
<h2 className="project-row-title">{p.full_name || p.name}</h2>
|
||||
{p.description ? (
|
||||
<p className="project-row-desc">{p.description}</p>
|
||||
) : null}
|
||||
<div className="project-row-meta">
|
||||
<span>{p.owner_login}</span>
|
||||
{p.updated_at_remote && (
|
||||
<span>更新于 {formatRemoteTime(p.updated_at_remote)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
className="project-row-link"
|
||||
href={p.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
在 Gitea 打开
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
</a>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="projects-pager">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="projects-pager-info">{page} / {totalPages}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -10,32 +10,96 @@ import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
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({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
username: z.string().min(2, '用户名至少 2 位').max(32, '用户名最多 32 位'),
|
||||
nickname: z.string().optional(),
|
||||
email: z.string().min(1, '请输入邮箱').email('请输入有效邮箱'),
|
||||
password: z.string().min(minLen, `密码至少 ${minLen} 位`),
|
||||
email_code: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
|
||||
export default function RegisterPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const { branding } = useSiteBranding();
|
||||
const nav = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
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>({
|
||||
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) => {
|
||||
if (regConfig && !regConfig.register_open) {
|
||||
notify.error('论坛暂未开放注册,请联系管理员配置邮件服务');
|
||||
return;
|
||||
}
|
||||
if (requireCode && !values.email_code?.trim()) {
|
||||
form.setError('email_code', { message: '请输入邮箱验证码' });
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
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();
|
||||
notify.success('注册成功');
|
||||
nav('/', { replace: true });
|
||||
navigateAfterAuth(nav, redirectTo);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '注册失败');
|
||||
} 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 (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<div className="logo-mark">姜</div>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<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 onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
@@ -58,7 +135,7 @@ export default function RegisterPage() {
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="3-32 位字母数字下划线" autoComplete="username" {...field} />
|
||||
<Input placeholder="2-32 位,支持中文" autoComplete="username" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -77,6 +154,19 @@ export default function RegisterPage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="用于接收验证码" autoComplete="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
@@ -90,14 +180,51 @@ export default function RegisterPage() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{requireCode && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email_code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱验证码</FormLabel>
|
||||
<div className="auth-captcha-row">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6 位数字验证码"
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="auth-code-btn"
|
||||
loading={sendingCode}
|
||||
disabled={countdown > 0}
|
||||
onClick={() => void sendCode()}
|
||||
>
|
||||
{countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{regConfig?.is_first_user && !regConfig.mail_ready && (
|
||||
<p className="auth-hint">首次注册无需邮箱验证码,请注册后到后台配置 SMTP。</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" loading={loading}>
|
||||
注册
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<p className="auth-footer">
|
||||
已有账号?<Link to="/login">登录</Link>
|
||||
已有账号?<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}>登录</Link>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,6 @@ export default function AdminDashboardPage() {
|
||||
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
|
||||
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
|
||||
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
|
||||
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,14 +58,18 @@ export default function AdminUsersPage() {
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-table-scroll">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>用户名</th>
|
||||
<th>昵称</th>
|
||||
<th>邮箱</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>上次登录</th>
|
||||
<th>登录 IP</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -76,12 +80,15 @@ export default function AdminUsersPage() {
|
||||
<td>{u.id}</td>
|
||||
<td>{u.username}</td>
|
||||
<td>{u.nickname}</td>
|
||||
<td className="admin-table-email">{u.email || '—'}</td>
|
||||
<td>
|
||||
{u.role === 'admin'
|
||||
? <Badge variant="orange">管理员</Badge>
|
||||
: <Badge variant="secondary">用户</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.role !== 'admin' && (
|
||||
@@ -94,6 +101,7 @@ export default function AdminUsersPage() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{users.length === 0 && <div className="admin-empty">暂无用户</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
49
frontend/src/utils/authRedirect.ts
Normal file
49
frontend/src/utils/authRedirect.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** 构造带回跳的登录路径 */
|
||||
export function loginPath(from?: string): string {
|
||||
const path = sanitizeReturnPath(from ?? currentPath());
|
||||
if (!path) return '/login';
|
||||
return `/login?from=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** 构造带回跳的注册路径 */
|
||||
export function registerPath(from?: string): string {
|
||||
const path = sanitizeReturnPath(from ?? currentPath());
|
||||
if (!path) return '/register';
|
||||
return `/register?from=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
/** 从查询参数解析登录/注册成功后的回跳地址 */
|
||||
export function resolveAuthRedirect(search: string | URLSearchParams, fallback = '/'): string {
|
||||
const params = typeof search === 'string' ? new URLSearchParams(search) : search;
|
||||
return sanitizeReturnPath(params.get('from') ?? '') || fallback;
|
||||
}
|
||||
|
||||
/** OAuth/OIDC 协议路径需整页跳转,不能走 React Router */
|
||||
export function isProtocolReturnPath(path: string): boolean {
|
||||
return path.startsWith('/oauth/') || path.startsWith('/.well-known/');
|
||||
}
|
||||
|
||||
/** 登录/注册成功后回跳(协议路径用 location 整页导航) */
|
||||
export function navigateAfterAuth(
|
||||
nav: (to: string, opts?: { replace?: boolean }) => void,
|
||||
redirectTo: string,
|
||||
): void {
|
||||
if (isProtocolReturnPath(redirectTo)) {
|
||||
window.location.assign(redirectTo);
|
||||
return;
|
||||
}
|
||||
nav(redirectTo, { replace: true });
|
||||
}
|
||||
|
||||
function currentPath(): string {
|
||||
if (typeof window === 'undefined') return '/';
|
||||
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
}
|
||||
|
||||
/** 仅允许站内相对路径,避免开放重定向 */
|
||||
function sanitizeReturnPath(raw: string): string {
|
||||
const path = raw.trim();
|
||||
if (!path.startsWith('/') || path.startsWith('//')) return '';
|
||||
if (path.startsWith('/login') || path.startsWith('/register')) return '';
|
||||
return path;
|
||||
}
|
||||
61
frontend/src/utils/composeDraft.ts
Normal file
61
frontend/src/utils/composeDraft.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export interface ComposeDraft {
|
||||
title: string;
|
||||
tags: string;
|
||||
content: string;
|
||||
boardId: string;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
const PREFIX = 'j13-compose-draft:';
|
||||
|
||||
function draftKey(editId: number | null): string {
|
||||
return editId == null ? `${PREFIX}new` : `${PREFIX}edit:${editId}`;
|
||||
}
|
||||
|
||||
/** 读取发帖/编辑草稿 */
|
||||
export function loadComposeDraft(editId: number | null): ComposeDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(draftKey(editId));
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as ComposeDraft;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
return {
|
||||
title: typeof data.title === 'string' ? data.title : '',
|
||||
tags: typeof data.tags === 'string' ? data.tags : '',
|
||||
content: typeof data.content === 'string' ? data.content : '',
|
||||
boardId: typeof data.boardId === 'string' ? data.boardId : '',
|
||||
savedAt: typeof data.savedAt === 'number' ? data.savedAt : 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入发帖/编辑草稿 */
|
||||
export function saveComposeDraft(editId: number | null, draft: Omit<ComposeDraft, 'savedAt'>): void {
|
||||
try {
|
||||
const payload: ComposeDraft = { ...draft, savedAt: Date.now() };
|
||||
localStorage.setItem(draftKey(editId), JSON.stringify(payload));
|
||||
} catch {
|
||||
// 配额不足等场景静默忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除发帖/编辑草稿 */
|
||||
export function clearComposeDraft(editId: number | null): void {
|
||||
try {
|
||||
localStorage.removeItem(draftKey(editId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** 草稿是否相对 baseline 有实质内容 */
|
||||
export function draftHasContent(draft: ComposeDraft): boolean {
|
||||
return Boolean(
|
||||
draft.title.trim()
|
||||
|| draft.tags.trim()
|
||||
|| draft.content.trim()
|
||||
|| draft.boardId.trim(),
|
||||
);
|
||||
}
|
||||
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
74
frontend/src/utils/enhanceCodeBlocks.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
|
||||
/** 从 class / data-lang 中解析作者标注的语言标识 */
|
||||
function detectLang(...els: Element[]): string {
|
||||
for (const el of els) {
|
||||
const data = el.getAttribute('data-lang') || el.getAttribute('data-language');
|
||||
if (data?.trim()) return data.trim().toLowerCase();
|
||||
const cls = el.getAttribute('class') || '';
|
||||
const m = cls.match(/(?:language|lang)-([a-z0-9_+-]+)/i);
|
||||
if (m?.[1]) return m[1].toLowerCase();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
|
||||
export function enhanceCodeBlocks(root: ParentNode): void {
|
||||
root.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.closest('.md-codeblock')) return;
|
||||
const code = pre.querySelector('code') || pre;
|
||||
const raw = code.textContent || '';
|
||||
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
||||
const declaredLang = detectLang(code, pre);
|
||||
let label = declaredLang || 'code';
|
||||
|
||||
try {
|
||||
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||
code.innerHTML = result.value;
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (declaredLang) {
|
||||
// 未收录语言(如 aardio):保留原文与标签,不做自动猜测
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (raw.length >= 24) {
|
||||
const result = hljs.highlightAuto(raw);
|
||||
code.innerHTML = result.value;
|
||||
code.classList.add('hljs');
|
||||
if (result.language) {
|
||||
label = result.language;
|
||||
code.classList.add(`language-${result.language}`);
|
||||
}
|
||||
} else {
|
||||
code.classList.add('hljs');
|
||||
}
|
||||
} catch {
|
||||
code.textContent = raw;
|
||||
code.classList.add('hljs');
|
||||
if (declaredLang) code.classList.add(`language-${declaredLang}`);
|
||||
}
|
||||
|
||||
const wrap = pre.ownerDocument.createElement('div');
|
||||
wrap.className = 'md-codeblock';
|
||||
wrap.setAttribute('data-lang', label);
|
||||
|
||||
const head = pre.ownerDocument.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
head.innerHTML = `
|
||||
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
||||
<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>
|
||||
`;
|
||||
|
||||
pre.parentNode?.insertBefore(wrap, pre);
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
pre.classList.add('md-codeblock__pre');
|
||||
});
|
||||
}
|
||||
@@ -5,111 +5,33 @@ import type { FeedSort } from '../components/FeedSortBar';
|
||||
/** 导航到帖子列表时附带的状态,用于同 URL 重复点击时强制刷新 */
|
||||
export type FeedNavState = { refreshFeed?: boolean };
|
||||
|
||||
|
||||
|
||||
export type FeedCache = {
|
||||
|
||||
posts: PostItem[];
|
||||
|
||||
postTotal: number;
|
||||
|
||||
page: number;
|
||||
|
||||
hasMore: boolean;
|
||||
|
||||
scrollTop: number;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
const PREFIX = 'j13-feed-cache:';
|
||||
|
||||
|
||||
/** 仅存内存:SPA 内返回可恢复,浏览器刷新自动清空 */
|
||||
const store = new Map<string, FeedCache>();
|
||||
|
||||
function cacheKey(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
return `${PREFIX}${boardId}:${keyword}:${sort}`;
|
||||
|
||||
return `${boardId}:${keyword}:${sort}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 读取帖子列表缓存,用于从详情页返回时恢复浏览位置 */
|
||||
|
||||
/** 读取帖子列表缓存(从详情页返回时恢复浏览位置) */
|
||||
export function getFeedCache(boardId: number, keyword: string, sort: FeedSort): FeedCache | null {
|
||||
|
||||
try {
|
||||
|
||||
const raw = sessionStorage.getItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
return raw ? (JSON.parse(raw) as FeedCache) : null;
|
||||
|
||||
} catch {
|
||||
|
||||
return null;
|
||||
|
||||
return store.get(cacheKey(boardId, keyword, sort)) ?? null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 保存帖子列表缓存 */
|
||||
|
||||
export function setFeedCache(boardId: number, keyword: string, sort: FeedSort, data: FeedCache) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.setItem(cacheKey(boardId, keyword, sort), JSON.stringify(data));
|
||||
|
||||
} catch {
|
||||
|
||||
// sessionStorage 不可用时忽略
|
||||
|
||||
store.set(cacheKey(boardId, keyword, sort), data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除指定筛选条件下的列表缓存 */
|
||||
|
||||
export function clearFeedCache(boardId: number, keyword: string, sort: FeedSort) {
|
||||
|
||||
try {
|
||||
|
||||
sessionStorage.removeItem(cacheKey(boardId, keyword, sort));
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** 清除所有帖子列表缓存(置顶等操作后列表需全量刷新) */
|
||||
/** 清除所有帖子列表缓存 */
|
||||
export function clearAllFeedCache() {
|
||||
|
||||
try {
|
||||
|
||||
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
||||
|
||||
const key = sessionStorage.key(i);
|
||||
|
||||
if (key?.startsWith(PREFIX)) sessionStorage.removeItem(key);
|
||||
|
||||
}
|
||||
|
||||
} catch {
|
||||
|
||||
// ignore
|
||||
|
||||
}
|
||||
|
||||
store.clear();
|
||||
}
|
||||
|
||||
/** 主动刷新帖子列表时派发,用于同页内立即回到顶部 */
|
||||
|
||||
@@ -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 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 {
|
||||
try {
|
||||
@@ -30,6 +33,33 @@ export function getCachedStats(): ForumStats | null {
|
||||
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[]) {
|
||||
writeJson(BOARDS_KEY, boards);
|
||||
}
|
||||
@@ -37,3 +67,15 @@ export function setCachedBoards(boards: Board[]) {
|
||||
export function setCachedStats(stats: ForumStats) {
|
||||
writeJson(STATS_KEY, stats);
|
||||
}
|
||||
|
||||
export function setCachedHot(posts: PostItem[]) {
|
||||
writeJson(HOT_KEY, posts);
|
||||
}
|
||||
|
||||
export function setCachedRecentComments(list: RecentComment[]) {
|
||||
writeJson(RECENT_COMMENTS_KEY, list);
|
||||
}
|
||||
|
||||
export function setCachedTags(tags: TagCount[]) {
|
||||
writeJson(TAGS_KEY, tags);
|
||||
}
|
||||
|
||||
15
frontend/src/utils/openPost.ts
Normal file
15
frontend/src/utils/openPost.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
|
||||
export function openForumPost(
|
||||
nav: NavigateFunction,
|
||||
postId: number,
|
||||
openInNewTab: boolean,
|
||||
) {
|
||||
const path = `/post/${postId}`;
|
||||
if (openInNewTab) {
|
||||
window.open(path, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
}
|
||||
nav(path);
|
||||
}
|
||||
@@ -1,52 +1,34 @@
|
||||
import DOMPurify from 'dompurify';
|
||||
import type { Config } from 'dompurify';
|
||||
import { enhanceCodeBlocks } from './enhanceCodeBlocks';
|
||||
import { enhanceHeadingAnchors } from './postHeadings';
|
||||
|
||||
/** DOMPurify 配置:允许会员专属自定义标签 */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: DOMPurify.Config = {
|
||||
/** DOMPurify 配置:允许会员专属自定义标签与链接 target */
|
||||
export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
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 = `
|
||||
<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="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>`;
|
||||
|
||||
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 {
|
||||
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
|
||||
? `约 ${charLength} 字的`
|
||||
: '一段';
|
||||
? `约 ${charLength} 字`
|
||||
: '专属内容';
|
||||
|
||||
return `
|
||||
<div class="post-members-only__locked-wrap">
|
||||
<div class="post-members-only__badge post-members-only__badge--locked">
|
||||
<span class="post-members-only__badge-icon" aria-hidden="true">${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-icon" aria-hidden="true">${LOCK_ICON_SVG}</div>
|
||||
<p class="post-members-only__gate-title">此处有${lengthHint}专属内容</p>
|
||||
<p class="post-members-only__gate-desc">作者已将这部分内容设为仅登录用户可见,登录后即可阅读全文。</p>
|
||||
<span class="post-members-only__gate-icon" aria-hidden="true">${LOCK_ICON_SVG}</span>
|
||||
<div class="post-members-only__gate-text">
|
||||
<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>
|
||||
<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>`;
|
||||
}
|
||||
@@ -55,18 +37,22 @@ function buildLockedGateHtml(charLength: number): string {
|
||||
export function isHtmlEmpty(html: string): boolean {
|
||||
if (!html.trim()) return true;
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||
'text/html',
|
||||
);
|
||||
return (doc.body.textContent ?? '').trim().length === 0;
|
||||
}
|
||||
|
||||
/** 根据登录状态渲染帖子正文 HTML */
|
||||
export function renderPostContentHtml(html: string, isLoggedIn: boolean): string {
|
||||
export function renderPostContentHtml(
|
||||
html: string,
|
||||
isLoggedIn: boolean,
|
||||
opts?: { openLinksInNewTab?: boolean },
|
||||
): string {
|
||||
if (!html.trim()) return '';
|
||||
|
||||
const doc = new DOMParser().parseFromString(
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG),
|
||||
DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG) as string,
|
||||
'text/html',
|
||||
);
|
||||
|
||||
@@ -87,8 +73,9 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
.map(n => (n instanceof Element ? n.outerHTML : n.textContent ?? ''))
|
||||
.join('');
|
||||
|
||||
// 已登录:降噪,不展示醒目 badge,仅保留结构容器
|
||||
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 => {
|
||||
@@ -96,5 +83,20 @@ export function renderPostContentHtml(html: string, isLoggedIn: boolean): string
|
||||
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;
|
||||
}
|
||||
|
||||
50
frontend/src/utils/postHeadings.ts
Normal file
50
frontend/src/utils/postHeadings.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** 正文标题节点(用于文章目录树) */
|
||||
export interface PostHeading {
|
||||
id: string;
|
||||
level: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** 为标题补全锚点 id,并返回目录树数据 */
|
||||
export function enhanceHeadingAnchors(root: ParentNode): PostHeading[] {
|
||||
const headings: PostHeading[] = [];
|
||||
const used = new Map<string, number>();
|
||||
|
||||
root.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach((el, index) => {
|
||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!text) return;
|
||||
|
||||
const level = Number(el.tagName.slice(1)) || 2;
|
||||
let id = el.getAttribute('id')?.trim() || '';
|
||||
if (!id) {
|
||||
id = `heading-${index + 1}`;
|
||||
}
|
||||
const n = (used.get(id) || 0) + 1;
|
||||
used.set(id, n);
|
||||
if (n > 1) id = `${id}-${n}`;
|
||||
el.setAttribute('id', id);
|
||||
el.classList.add('post-heading-anchor');
|
||||
|
||||
headings.push({ id, level, text });
|
||||
});
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
/** 从已渲染 HTML 中读取目录(假定 id 已由 enhanceHeadingAnchors 写入) */
|
||||
export function extractHeadingsFromHtml(html: string): PostHeading[] {
|
||||
if (!html.trim()) return [];
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const headings: PostHeading[] = [];
|
||||
doc.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(el => {
|
||||
const id = el.getAttribute('id')?.trim();
|
||||
const text = (el.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (!id || !text) return;
|
||||
headings.push({
|
||||
id,
|
||||
level: Number(el.tagName.slice(1)) || 2,
|
||||
text,
|
||||
});
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
11
go.mod
11
go.mod
@@ -4,11 +4,11 @@ go 1.26
|
||||
|
||||
require (
|
||||
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/kardianos/service v1.2.2
|
||||
golang.org/x/crypto v0.31.0
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
@@ -17,22 +17,25 @@ require (
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // 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/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/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // 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/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // 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/reflect2 v1.0.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/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
@@ -41,4 +44,8 @@ require (
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
29
go.sum
29
go.sum
@@ -9,12 +9,18 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/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/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/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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
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/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-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-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
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/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
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/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=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -40,7 +40,6 @@ func (h *Handlers) AdminDashboard(c *gin.Context) {
|
||||
"PostCount": postCount,
|
||||
"BoardCount": boardCount,
|
||||
"CommentCount": commentCount,
|
||||
"OnlineCount": h.Online.Count(),
|
||||
"RecentPosts": recentPosts,
|
||||
}))
|
||||
}
|
||||
@@ -223,7 +222,7 @@ func (h *Handlers) AdminAPILogin(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
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 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
368
handler/api.go
368
handler/api.go
@@ -2,13 +2,16 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/service"
|
||||
)
|
||||
@@ -22,11 +25,13 @@ func (h *Handlers) APIMe(c *gin.Context) {
|
||||
}
|
||||
user, err := h.User.GetByID(uid)
|
||||
if err != nil {
|
||||
// 账号已删或不存在:清掉失效 cookie,与未登录态一致
|
||||
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||
c.JSON(http.StatusOK, gin.H{"user": nil})
|
||||
return
|
||||
}
|
||||
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{
|
||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||
"comments": commentCount, "online": h.Online.Count(),
|
||||
"comments": commentCount,
|
||||
"recent_posts": recentPosts,
|
||||
})
|
||||
}
|
||||
@@ -242,7 +247,7 @@ func (h *Handlers) APIAdminUsers(c *gin.Context) {
|
||||
users = []model.User{}
|
||||
}
|
||||
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),
|
||||
})
|
||||
}
|
||||
@@ -298,17 +303,110 @@ func (h *Handlers) APIAdminDownloadBackup(c *gin.Context) {
|
||||
func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
||||
limits := h.Settings.Limits()
|
||||
filterContent, _ := service.ReadFilterWordsFile(h.Cfg.FilterWordsPath())
|
||||
clients, _ := h.Settings.ListOAuthClients()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"filter_path": h.Cfg.FilterWordsPath(),
|
||||
"data_dir": h.Cfg.DataDir,
|
||||
"db_path": h.Cfg.DBPath(),
|
||||
"port": h.Cfg.Port,
|
||||
"limits": limits,
|
||||
"mail": h.Settings.MailConfigPublic(),
|
||||
"oidc": h.Settings.OIDCConfigPublic(),
|
||||
"oauth_clients": clients,
|
||||
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||
"branding": h.Settings.SiteBranding(),
|
||||
"filter_words": filterContent,
|
||||
"filter_word_count": service.CountFilterWords(filterContent),
|
||||
})
|
||||
}
|
||||
|
||||
// APISiteBranding 前台公开的站点品牌配置
|
||||
func (h *Handlers) APISiteBranding(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, h.Settings.SiteBranding())
|
||||
}
|
||||
|
||||
// APIAdminUpdateBranding 更新站点品牌文案
|
||||
func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
|
||||
var req service.SiteBranding
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Settings.UpdateSiteBranding(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "站点品牌已保存",
|
||||
"branding": h.Settings.SiteBranding(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUploadBrandingAsset 上传 Logo 或 Favicon(form: file + kind=logo|favicon)
|
||||
func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
|
||||
kind := strings.TrimSpace(c.PostForm("kind"))
|
||||
if kind != "logo" && kind != "favicon" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||
return
|
||||
}
|
||||
const maxBytes = 2 * 1024 * 1024
|
||||
if file.Size > maxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
|
||||
return
|
||||
}
|
||||
url, err := service.SaveUploadedImage(file, h.Cfg.SiteUploadDir(), "/uploads/site", kind)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
prev := h.Settings.SiteBranding()
|
||||
if kind == "logo" {
|
||||
_ = h.Settings.SetSiteLogo(url)
|
||||
h.removeSiteUploadIfLocal(prev.Logo)
|
||||
} else {
|
||||
_ = h.Settings.SetSiteFavicon(url)
|
||||
h.removeSiteUploadIfLocal(prev.Favicon)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "上传成功",
|
||||
"url": url,
|
||||
"branding": h.Settings.SiteBranding(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminClearBrandingAsset 清除 Logo 或 Favicon
|
||||
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(req.Kind)
|
||||
brand := h.Settings.SiteBranding()
|
||||
switch kind {
|
||||
case "logo":
|
||||
_ = h.Settings.SetSiteLogo("")
|
||||
h.removeSiteUploadIfLocal(brand.Logo)
|
||||
case "favicon":
|
||||
_ = h.Settings.SetSiteFavicon("")
|
||||
h.removeSiteUploadIfLocal(brand.Favicon)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已清除",
|
||||
"branding": h.Settings.SiteBranding(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUpdateForumSettings 更新论坛设置
|
||||
func (h *Handlers) APIAdminUpdateForumSettings(c *gin.Context) {
|
||||
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 读取敏感词配置
|
||||
func (h *Handlers) APIAdminFilterWords(c *gin.Context) {
|
||||
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})
|
||||
}
|
||||
|
||||
// APINotifications 最新动态通知
|
||||
func (h *Handlers) APINotifications(c *gin.Context) {
|
||||
posts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
|
||||
type notice struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
// APITags 标签云(按使用次数聚合)
|
||||
func (h *Handlers) APITags(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "40"))
|
||||
tags, err := h.Post.PopularTags(limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
list := make([]notice, 0, len(posts))
|
||||
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})
|
||||
c.JSON(http.StatusOK, gin.H{"tags": tags})
|
||||
}
|
||||
|
||||
// APIOnline 当前浏览统计
|
||||
func (h *Handlers) APIOnline(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"count": h.Online.Count(),
|
||||
"members": h.Online.CountMembers(),
|
||||
"guests": h.Online.CountGuests(),
|
||||
"users": h.Online.List(20),
|
||||
})
|
||||
// APIRecentComments 最新公开评论
|
||||
func (h *Handlers) APIRecentComments(c *gin.Context) {
|
||||
list, err := h.Comment.ListRecentPublic(8)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// APIPresence 上报浏览心跳(会员与游客均可)
|
||||
func (h *Handlers) APIPresence(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"count": h.Online.Count(),
|
||||
"members": h.Online.CountMembers(),
|
||||
"guests": h.Online.CountGuests(),
|
||||
})
|
||||
if list == nil {
|
||||
list = []service.RecentCommentItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"comments": list})
|
||||
}
|
||||
|
||||
// APIFavorites 我的收藏
|
||||
@@ -556,8 +840,18 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIPing(c *gin.Context) {
|
||||
h.APIPresence(c)
|
||||
// removeSiteUploadIfLocal 删除本站 uploads/site 下的旧资源文件
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -24,8 +25,12 @@ type Handlers struct {
|
||||
Backup *service.BackupService
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
Online *service.OnlineService
|
||||
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) {
|
||||
@@ -70,8 +75,9 @@ func (h *Handlers) pageData(c *gin.Context, title string, data gin.H) gin.H {
|
||||
data = gin.H{}
|
||||
}
|
||||
data["Title"] = title
|
||||
data["SiteName"] = "姜十三论坛"
|
||||
data["SiteEN"] = "Jiang13 Forum"
|
||||
brand := h.Settings.SiteBranding()
|
||||
data["SiteName"] = brand.Name
|
||||
data["SiteEN"] = brand.NameEN
|
||||
if uid := h.currentUserID(c); uid > 0 {
|
||||
data["CurrentUserID"] = uid
|
||||
if u, err := h.User.GetByID(uid); err == nil {
|
||||
@@ -170,22 +176,82 @@ func (h *Handlers) FavoritesPage(c *gin.Context) {
|
||||
|
||||
// --- 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 {
|
||||
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"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password)
|
||||
token, _, _ := h.Auth.Login(req.Username, req.Password, c.ClientIP())
|
||||
h.setAuthCookie(c, token)
|
||||
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": "参数错误"})
|
||||
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 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -220,7 +286,11 @@ func (h *Handlers) APIUpdateProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
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) {
|
||||
@@ -368,3 +438,14 @@ func (h *Handlers) APIDeleteComment(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
content := c.PostForm("content")
|
||||
saved, err := h.Comment.Update(h.currentUserID(c), uint(id), h.isAdmin(c), content)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已更新", "content": saved})
|
||||
}
|
||||
|
||||
224
handler/oidc.go
Normal file
224
handler/oidc.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// OIDCDiscovery OpenID Provider 元数据
|
||||
func (h *Handlers) OIDCDiscovery(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.Discovery()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCJWKS JSON Web Key Set
|
||||
func (h *Handlers) OIDCJWKS(c *gin.Context) {
|
||||
if h.OIDC == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未启用"})
|
||||
return
|
||||
}
|
||||
doc, err := h.OIDC.JWKS()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, doc)
|
||||
}
|
||||
|
||||
// OIDCAuthorize 授权端点:已登录则静默发码;未登录跳转论坛登录
|
||||
func (h *Handlers) OIDCAuthorize(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "OIDC 未配置,请在管理后台「系统设置 → OIDC / SSO」启用并创建 OAuth 应用")
|
||||
return
|
||||
}
|
||||
|
||||
req := service.AuthorizeRequest{
|
||||
ClientID: c.Query("client_id"),
|
||||
RedirectURI: c.Query("redirect_uri"),
|
||||
ResponseType: c.Query("response_type"),
|
||||
Scope: c.Query("scope"),
|
||||
State: c.Query("state"),
|
||||
Nonce: c.Query("nonce"),
|
||||
CodeChallenge: c.Query("code_challenge"),
|
||||
CodeChallengeMethod: c.Query("code_challenge_method"),
|
||||
}
|
||||
|
||||
if err := h.OIDC.ValidateAuthorize(req); err != nil {
|
||||
// redirect_uri 未通过校验时不能重定向,避免开放重定向
|
||||
if errors.Is(err, service.ErrOIDCInvalidRedirect) || errors.Is(err, service.ErrOIDCInvalidClient) {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
from := c.Request.URL.RequestURI()
|
||||
c.Redirect(http.StatusFound, "/login?from="+url.QueryEscape(from))
|
||||
return
|
||||
}
|
||||
|
||||
callback, err := h.OIDC.IssueAuthCode(uid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrOIDCUserBanned) {
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "access_denied", "账号已被禁言")
|
||||
return
|
||||
}
|
||||
h.oidcErrorRedirect(c, req.RedirectURI, req.State, "server_error", err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, callback)
|
||||
}
|
||||
|
||||
func (h *Handlers) oidcErrorRedirect(c *gin.Context, redirectURI, state, code, desc string) {
|
||||
if redirectURI == "" {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
u, err := url.Parse(redirectURI)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, desc)
|
||||
return
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("error", code)
|
||||
q.Set("error_description", desc)
|
||||
if state != "" {
|
||||
q.Set("state", state)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
c.Redirect(http.StatusFound, u.String())
|
||||
}
|
||||
|
||||
// OIDCToken 令牌端点
|
||||
func (h *Handlers) OIDCToken(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "temporarily_unavailable", "error_description": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
|
||||
clientID, clientSecret := c.PostForm("client_id"), c.PostForm("client_secret")
|
||||
if clientID == "" && clientSecret == "" {
|
||||
if id, secret, ok := parseBasicAuth(c.GetHeader("Authorization")); ok {
|
||||
clientID, clientSecret = id, secret
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.OIDC.ExchangeCode(service.TokenRequest{
|
||||
GrantType: c.PostForm("grant_type"),
|
||||
Code: c.PostForm("code"),
|
||||
RedirectURI: c.PostForm("redirect_uri"),
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
CodeVerifier: c.PostForm("code_verifier"),
|
||||
})
|
||||
if err != nil {
|
||||
status := http.StatusBadRequest
|
||||
code := "invalid_grant"
|
||||
switch {
|
||||
case errors.Is(err, service.ErrOIDCInvalidClient):
|
||||
status = http.StatusUnauthorized
|
||||
code = "invalid_client"
|
||||
case errors.Is(err, service.ErrOIDCInvalidRequest):
|
||||
code = "invalid_request"
|
||||
case errors.Is(err, service.ErrOIDCPKCEFailed):
|
||||
code = "invalid_grant"
|
||||
}
|
||||
c.JSON(status, gin.H{"error": code, "error_description": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// OIDCUserInfo 用户信息端点
|
||||
func (h *Handlers) OIDCUserInfo(c *gin.Context) {
|
||||
if h.OIDC == nil || !h.OIDC.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC 未配置"})
|
||||
return
|
||||
}
|
||||
token := extractBearer(c)
|
||||
if token == "" {
|
||||
c.Header("WWW-Authenticate", `Bearer`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
info, err := h.OIDC.UserInfo(token)
|
||||
if err != nil {
|
||||
c.Header("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
// OIDCLogout RP-Initiated Logout:清除论坛会话并可选跳回客户端
|
||||
func (h *Handlers) OIDCLogout(c *gin.Context) {
|
||||
postLogout := c.Query("post_logout_redirect_uri")
|
||||
if postLogout == "" {
|
||||
postLogout = c.PostForm("post_logout_redirect_uri")
|
||||
}
|
||||
state := c.Query("state")
|
||||
if state == "" {
|
||||
state = c.PostForm("state")
|
||||
}
|
||||
|
||||
c.SetCookie(middleware.CookieName, "", -1, "/", "", false, true)
|
||||
|
||||
if h.OIDC == nil {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
target, err := h.OIDC.ResolveLogoutRedirect(postLogout, state)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, target)
|
||||
}
|
||||
|
||||
func extractBearer(c *gin.Context) string {
|
||||
auth := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
|
||||
}
|
||||
return c.Query("access_token")
|
||||
}
|
||||
|
||||
func parseBasicAuth(header string) (user, pass string, ok bool) {
|
||||
const prefix = "Basic "
|
||||
if !strings.HasPrefix(header, prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(string(raw), ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", "", false
|
||||
}
|
||||
// client_id / client_secret 可能被 URL 编码
|
||||
uid, err1 := url.QueryUnescape(parts[0])
|
||||
sec, err2 := url.QueryUnescape(parts[1])
|
||||
if err1 != nil || err2 != nil {
|
||||
return parts[0], parts[1], true
|
||||
}
|
||||
return uid, sec, true
|
||||
}
|
||||
@@ -25,15 +25,21 @@ func NewAuthMiddleware(auth *service.AuthService) *AuthMiddleware {
|
||||
return &AuthMiddleware{auth: auth}
|
||||
}
|
||||
|
||||
// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截
|
||||
// OptionalAuth 可选鉴权:有 token 则解析,无 token 不拦截。
|
||||
// 用户已删除或不存在时清除失效 cookie,避免前端误显示为已登录。
|
||||
func (m *AuthMiddleware) OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := extractToken(c)
|
||||
if token != "" {
|
||||
if claims, err := m.auth.ParseToken(token); err == nil {
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUsername, claims.Username)
|
||||
c.Set(CtxRole, claims.Role)
|
||||
var user model.User
|
||||
if err := model.DB.Select("id", "username", "role").First(&user, claims.UserID).Error; err != nil {
|
||||
c.SetCookie(CookieName, "", -1, "/", "", false, true)
|
||||
} else {
|
||||
c.Set(CtxUserID, user.ID)
|
||||
c.Set(CtxUsername, user.Username)
|
||||
c.Set(CtxRole, user.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
const VisitorCookieName = "j13_vid"
|
||||
|
||||
// PresenceMiddleware 记录当前请求的浏览活跃(会员或游客)
|
||||
func PresenceMiddleware(online *service.OnlineService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if uid, ok := c.Get(CtxUserID); ok {
|
||||
if id, ok := uid.(uint); ok && id > 0 {
|
||||
online.Ping(id)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
}
|
||||
vid, err := c.Cookie(VisitorCookieName)
|
||||
if err != nil || vid == "" {
|
||||
vid = newVisitorID()
|
||||
c.SetCookie(VisitorCookieName, vid, 86400, "/", "", false, true)
|
||||
}
|
||||
online.PingGuest(vid)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func newVisitorID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return hex.EncodeToString([]byte("fallback-visitor-id"))
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"github.com/glebarez/sqlite" // 纯 Go,支持 CGO_ENABLED=0 交叉编译
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
@@ -36,6 +36,8 @@ func InitDB(dbPath string) error {
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{},
|
||||
&PostLike{}, &PostFavorite{}, &PostRevision{}, &ForumSetting{},
|
||||
&OAuthClient{}, &OAuthAuthCode{},
|
||||
&GiteaRepo{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
24
model/gitea.go
Normal file
24
model/gitea.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// GiteaRepo 从 Gitea 同步的公开仓库缓存(侧栏 /projects 读取)
|
||||
type GiteaRepo struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
GiteaID int64 `gorm:"uniqueIndex;not null" json:"gitea_id"`
|
||||
OwnerLogin string `gorm:"size:128;not null;index" json:"owner_login"`
|
||||
Name string `gorm:"size:255;not null" json:"name"`
|
||||
FullName string `gorm:"size:512;not null" json:"full_name"`
|
||||
Description string `gorm:"size:2048;default:''" json:"description"`
|
||||
HTMLURL string `gorm:"size:1024;not null" json:"html_url"`
|
||||
Private bool `gorm:"default:false" json:"private"`
|
||||
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||
ForumUserID *uint `gorm:"index" json:"forum_user_id"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (GiteaRepo) TableName() string {
|
||||
return "gitea_repos"
|
||||
}
|
||||
@@ -15,15 +15,20 @@ const (
|
||||
)
|
||||
|
||||
// User 用户表
|
||||
// Email / Password / LastLogin* 默认不随帖子等嵌套 User 序列化;
|
||||
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
||||
type User struct {
|
||||
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:"-"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Avatar string `gorm:"size:256" json:"avatar"`
|
||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||
Banned bool `gorm:"default:false" json:"banned"`
|
||||
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"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
@@ -80,7 +85,7 @@ type PostRevision struct {
|
||||
// ForumSetting 论坛全局设置(键值对)
|
||||
type ForumSetting struct {
|
||||
Key string `gorm:"primaryKey;size:64" json:"key"`
|
||||
Value string `gorm:"size:256" json:"value"`
|
||||
Value string `gorm:"size:2048" json:"value"`
|
||||
}
|
||||
|
||||
// Comment 楼层评论
|
||||
|
||||
31
model/oauth.go
Normal file
31
model/oauth.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// OAuthClient OIDC/OAuth2 客户端应用(密钥仅存 bcrypt 哈希)
|
||||
type OAuthClient struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ClientID string `gorm:"uniqueIndex;size:128;not null" json:"client_id"`
|
||||
ClientSecretHash string `gorm:"size:128;not null" json:"-"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
RedirectURIs string `gorm:"size:2048;not null" json:"redirect_uris"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// OAuthAuthCode 一次性授权码(OIDC Authorization Code Flow)
|
||||
type OAuthAuthCode struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Code string `gorm:"uniqueIndex;size:64;not null"`
|
||||
ClientID string `gorm:"size:128;not null;index"`
|
||||
UserID uint `gorm:"not null;index"`
|
||||
RedirectURI string `gorm:"size:512;not null"`
|
||||
Scope string `gorm:"size:256;default:''"`
|
||||
Nonce string `gorm:"size:128;default:''"`
|
||||
CodeChallenge string `gorm:"size:128;default:''"`
|
||||
CodeChallengeMethod string `gorm:"size:16;default:''"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
Used bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
}
|
||||
76
model/user_view.go
Normal file
76
model/user_view.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// UserSelf 当前登录用户视图(含邮箱,不含登录 IP)
|
||||
type UserSelf struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role Role `json:"role"`
|
||||
Banned bool `json:"banned"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UserAdmin 后台用户管理视图(含邮箱与上次登录信息)
|
||||
type UserAdmin struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Role Role `json:"role"`
|
||||
Banned bool `json:"banned"`
|
||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
LastLoginIP string `json:"last_login_ip,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ToSelf 转为个人中心 /api/me 视图
|
||||
func (u *User) ToSelf() UserSelf {
|
||||
return UserSelf{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
Role: u.Role,
|
||||
Banned: u.Banned,
|
||||
BannedAt: u.BannedAt,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// ToAdmin 转为后台用户列表视图
|
||||
func (u *User) ToAdmin() UserAdmin {
|
||||
return UserAdmin{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Nickname: u.Nickname,
|
||||
Avatar: u.Avatar,
|
||||
Role: u.Role,
|
||||
Banned: u.Banned,
|
||||
BannedAt: u.BannedAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
LastLoginIP: u.LastLoginIP,
|
||||
CreatedAt: u.CreatedAt,
|
||||
UpdatedAt: u.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// UsersToAdmin 批量转换后台用户列表
|
||||
func UsersToAdmin(users []User) []UserAdmin {
|
||||
out := make([]UserAdmin, 0, len(users))
|
||||
for i := range users {
|
||||
out = append(out, users[i].ToAdmin())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
@@ -27,49 +28,79 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
filter.LoadFromFile(cfg.FilterWordsPath())
|
||||
|
||||
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)
|
||||
userSvc := service.NewUserService(filter, settingsSvc)
|
||||
boardSvc := service.NewBoardService()
|
||||
postSvc := service.NewPostService(filter, settingsSvc)
|
||||
commentSvc := service.NewCommentService(filter, settingsSvc)
|
||||
backupSvc := service.NewBackupService(cfg.DBPath(), cfg.DataDir)
|
||||
onlineSvc := service.NewOnlineService()
|
||||
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{
|
||||
Cfg: cfg, Auth: authSvc, User: userSvc, Board: boardSvc,
|
||||
Post: postSvc, Comment: commentSvc, Backup: backupSvc,
|
||||
Filter: filter, Limiter: limiter, Online: onlineSvc,
|
||||
Settings: settingsSvc,
|
||||
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
||||
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
||||
OIDC: oidcSvc, Gitea: giteaSvc,
|
||||
}
|
||||
authMW := middleware.NewAuthMiddleware(authSvc)
|
||||
|
||||
r.Static("/uploads", filepath.Join(cfg.DataDir, "uploads"))
|
||||
|
||||
// OIDC Provider(Gitea 等外部站点 SSO)
|
||||
r.GET("/.well-known/openid-configuration", h.OIDCDiscovery)
|
||||
r.GET("/oauth/jwks", h.OIDCJWKS)
|
||||
r.GET("/oauth/authorize", authMW.OptionalAuth(), h.OIDCAuthorize)
|
||||
r.POST("/oauth/token", h.OIDCToken)
|
||||
r.GET("/oauth/userinfo", h.OIDCUserInfo)
|
||||
r.POST("/oauth/userinfo", h.OIDCUserInfo)
|
||||
r.GET("/oauth/logout", h.OIDCLogout)
|
||||
r.POST("/oauth/logout", h.OIDCLogout)
|
||||
|
||||
// 公开 JSON API(可选登录)
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth(), middleware.PresenceMiddleware(onlineSvc))
|
||||
pubAPI := r.Group("/api", authMW.OptionalAuth())
|
||||
{
|
||||
pubAPI.GET("/me", h.APIMe)
|
||||
pubAPI.GET("/boards", h.APIBoards)
|
||||
pubAPI.GET("/stats", h.APIStats)
|
||||
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/hot", h.APIHotPosts)
|
||||
pubAPI.GET("/notifications", h.APINotifications)
|
||||
pubAPI.GET("/online", h.APIOnline)
|
||||
pubAPI.POST("/presence", h.APIPresence)
|
||||
pubAPI.GET("/tags", h.APITags)
|
||||
pubAPI.GET("/comments/recent", h.APIRecentComments)
|
||||
pubAPI.GET("/posts/:id", h.APIPostDetail)
|
||||
pubAPI.GET("/posts/:id/comments", h.APIPostComments)
|
||||
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("/login", middleware.RateLimitMiddleware(limiter, "login"), h.APILogin)
|
||||
}
|
||||
|
||||
// 需登录 API
|
||||
api := r.Group("/api", authMW.RequireAuth(), middleware.PresenceMiddleware(onlineSvc))
|
||||
api := r.Group("/api", authMW.RequireAuth())
|
||||
{
|
||||
api.POST("/logout", h.APILogout)
|
||||
api.POST("/ping", h.APIPing)
|
||||
api.GET("/favorites", h.APIFavorites)
|
||||
api.POST("/profile/nickname", h.APIUpdateProfile)
|
||||
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/favorite", h.APIToggleFavorite)
|
||||
api.DELETE("/comments/:id", h.APIDeleteComment)
|
||||
api.PUT("/comments/:id", h.APIUpdateComment)
|
||||
}
|
||||
|
||||
// 管理员 API(React SPA 后台统一使用 JSON)
|
||||
@@ -91,6 +123,18 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
||||
adminAPI.GET("/settings", h.APIAdminSettings)
|
||||
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.PUT("/settings/filter-words", h.APIAdminUpdateFilterWords)
|
||||
adminAPI.POST("/boards", h.APIAdminCreateBoard)
|
||||
|
||||
@@ -27,18 +27,34 @@ func NewAuthService(jwtSecret string, filter *SensitiveFilter, settings *ForumSe
|
||||
return &AuthService{jwtSecret: jwtSecret, filter: filter, settings: settings}
|
||||
}
|
||||
|
||||
// UserCount 当前用户数
|
||||
func (s *AuthService) UserCount() int64 {
|
||||
var n int64
|
||||
model.DB.Model(&model.User{}).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidatePassword(password, s.settings.PasswordMinLen()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var exist model.User
|
||||
if err := model.DB.Where("username = ?", username).First(&exist).Error; err == nil {
|
||||
return nil, ErrUserExists
|
||||
}
|
||||
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,14 +66,13 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
||||
|
||||
// 首个注册用户自动成为管理员
|
||||
role := model.RoleUser
|
||||
var userCount int64
|
||||
model.DB.Model(&model.User{}).Count(&userCount)
|
||||
if userCount == 0 {
|
||||
if s.UserCount() == 0 {
|
||||
role = model.RoleAdmin
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
Email: email,
|
||||
Password: hash,
|
||||
Nickname: nickname,
|
||||
Role: role,
|
||||
@@ -68,8 +83,8 @@ func (s *AuthService) Register(username, password, nickname string) (*model.User
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// Login 用户登录,返回 JWT token
|
||||
func (s *AuthService) Login(username, password string) (string, *model.User, error) {
|
||||
// Login 用户登录,返回 JWT token;clientIP 写入上次登录记录
|
||||
func (s *AuthService) Login(username, password, clientIP string) (string, *model.User, error) {
|
||||
var user model.User
|
||||
if err := model.DB.Where("username = ?", username).First(&user).Error; err != nil {
|
||||
return "", nil, ErrInvalidCred
|
||||
@@ -80,10 +95,26 @@ func (s *AuthService) Login(username, password string) (string, *model.User, err
|
||||
if !CheckPassword(user.Password, password) {
|
||||
return "", nil, ErrInvalidCred
|
||||
}
|
||||
s.recordLogin(&user, clientIP)
|
||||
token, err := s.GenerateToken(&user)
|
||||
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
|
||||
func (s *AuthService) GenerateToken(user *model.User) (string, error) {
|
||||
claims := Claims{
|
||||
|
||||
158
service/captcha.go
Normal file
158
service/captcha.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
captchaLen = 4
|
||||
captchaTTL = 5 * time.Minute
|
||||
captchaChars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
captchaWidth = 120
|
||||
captchaHeight = 40
|
||||
)
|
||||
|
||||
type captchaEntry struct {
|
||||
answer string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// CaptchaService 内存图形验证码
|
||||
type CaptchaService struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]captchaEntry
|
||||
}
|
||||
|
||||
func NewCaptchaService() *CaptchaService {
|
||||
s := &CaptchaService{entries: make(map[string]captchaEntry)}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
// Generate 生成验证码,返回 id 与 SVG 图片
|
||||
func (s *CaptchaService) Generate() (id, svg string, err error) {
|
||||
answer, err := randomCaptchaText(captchaLen)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
rawID := make([]byte, 16)
|
||||
if _, err := rand.Read(rawID); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
id = hex.EncodeToString(rawID)
|
||||
|
||||
s.mu.Lock()
|
||||
s.entries[id] = captchaEntry{
|
||||
answer: strings.ToUpper(answer),
|
||||
expiresAt: time.Now().Add(captchaTTL),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return id, renderCaptchaSVG(answer), nil
|
||||
}
|
||||
|
||||
// Verify 校验验证码(一次性,大小写不敏感)
|
||||
func (s *CaptchaService) Verify(id, answer string) bool {
|
||||
if id == "" || answer == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.entries[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.entries, id)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(entry.answer, strings.TrimSpace(answer))
|
||||
}
|
||||
|
||||
func (s *CaptchaService) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for id, entry := range s.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.entries, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func randomCaptchaText(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
max := big.NewInt(int64(len(captchaChars)))
|
||||
for i := 0; i < n; i++ {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(captchaChars[idx.Int64()])
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func renderCaptchaSVG(text string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d">`,
|
||||
captchaWidth, captchaHeight, captchaWidth, captchaHeight,
|
||||
))
|
||||
b.WriteString(`<rect width="100%" height="100%" fill="#f4f6f5"/>`)
|
||||
|
||||
// 干扰线
|
||||
for i := 0; i < 4; i++ {
|
||||
x1, _ := randInt(0, captchaWidth)
|
||||
y1, _ := randInt(0, captchaHeight)
|
||||
x2, _ := randInt(0, captchaWidth)
|
||||
y2, _ := randInt(0, captchaHeight)
|
||||
color := noiseColor()
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`<line x1="%d" y1="%d" x2="%d" y2="%d" stroke="%s" stroke-width="1"/>`,
|
||||
x1, y1, x2, y2, color,
|
||||
))
|
||||
}
|
||||
|
||||
step := captchaWidth / (len(text) + 1)
|
||||
for i, ch := range text {
|
||||
x := step*(i+1) - 6
|
||||
y, _ := randInt(26, 34)
|
||||
rot, _ := randInt(-18, 18)
|
||||
b.WriteString(fmt.Sprintf(
|
||||
`<text x="%d" y="%d" fill="#2d5a45" font-size="22" font-family="monospace" font-weight="700" transform="rotate(%d %d %d)">%c</text>`,
|
||||
x, y, rot, x+6, y-6, ch,
|
||||
))
|
||||
}
|
||||
|
||||
b.WriteString(`</svg>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func randInt(min, max int) (int, error) {
|
||||
if max <= min {
|
||||
return min, nil
|
||||
}
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(max-min+1)))
|
||||
if err != nil {
|
||||
return min, err
|
||||
}
|
||||
return min + int(n.Int64()), nil
|
||||
}
|
||||
|
||||
func noiseColor() string {
|
||||
r, _ := randInt(160, 210)
|
||||
g, _ := randInt(170, 220)
|
||||
b, _ := randInt(160, 210)
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
if !isAdmin && comment.UserID != userID {
|
||||
if !isAdmin && (comment.UserID == 0 || comment.UserID != userID) {
|
||||
return ErrPermissionDenied
|
||||
}
|
||||
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 {
|
||||
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 管理员查看最近评论
|
||||
func (s *CommentService) ListRecent(page, size int) ([]model.Comment, int64, error) {
|
||||
if page < 1 {
|
||||
|
||||
@@ -3,9 +3,10 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"net/mail"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -13,10 +14,12 @@ import (
|
||||
|
||||
var (
|
||||
ErrUserExists = errors.New("用户名已存在")
|
||||
ErrEmailExists = errors.New("邮箱已被注册")
|
||||
ErrInvalidCred = errors.New("用户名或密码错误")
|
||||
ErrUserBanned = errors.New("账号已被禁言")
|
||||
ErrWeakPassword = errors.New("密码至少 6 位")
|
||||
ErrInvalidUsername = errors.New("用户名 3-32 位字母数字下划线")
|
||||
ErrInvalidUsername = errors.New("用户名 2-32 位,支持中文、字母、数字与下划线")
|
||||
ErrInvalidEmail = errors.New("邮箱格式不正确")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrPermissionDenied = errors.New("无权操作")
|
||||
@@ -31,10 +34,13 @@ var (
|
||||
ErrPostTagsTooLong = errors.New("标签过长")
|
||||
ErrPostContentTooLong = 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 加密密码
|
||||
func HashPassword(password string) (string, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// ValidateUsername 校验用户名格式
|
||||
// ValidateUsername 校验用户名:中文/字母/数字/下划线,2-32 个字符
|
||||
func ValidateUsername(username string) error {
|
||||
if !usernameRe.MatchString(username) {
|
||||
n := utf8.RuneCountInString(username)
|
||||
if n < 2 || n > 32 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
128
service/email_code.go
Normal file
128
service/email_code.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const (
|
||||
emailCodeLen = 6
|
||||
emailCodeTTL = 10 * time.Minute
|
||||
emailCodeCooldown = 60 * time.Second
|
||||
)
|
||||
|
||||
type emailCodeEntry struct {
|
||||
code string
|
||||
expiresAt time.Time
|
||||
sentAt time.Time
|
||||
}
|
||||
|
||||
// EmailCodeService 注册邮箱验证码
|
||||
type EmailCodeService struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]emailCodeEntry
|
||||
mail *MailService
|
||||
}
|
||||
|
||||
func NewEmailCodeService(mail *MailService) *EmailCodeService {
|
||||
s := &EmailCodeService{
|
||||
entries: make(map[string]emailCodeEntry),
|
||||
mail: mail,
|
||||
}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
// SendRegisterCode 向邮箱发送注册验证码
|
||||
func (s *EmailCodeService) SendRegisterCode(email string) error {
|
||||
email = NormalizeEmail(email)
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var exist model.User
|
||||
if err := model.DB.Where("email = ?", email).First(&exist).Error; err == nil {
|
||||
return ErrEmailExists
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if prev, ok := s.entries[email]; ok && time.Since(prev.sentAt) < emailCodeCooldown {
|
||||
s.mu.Unlock()
|
||||
return ErrEmailCodeCooldown
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
code, err := randomDigits(emailCodeLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
subject := "注册验证码"
|
||||
body := fmt.Sprintf("您的注册验证码是:%s\n\n%d 分钟内有效,如非本人操作请忽略。", code, int(emailCodeTTL.Minutes()))
|
||||
if err := s.mail.Send(email, subject, body); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.entries[email] = emailCodeEntry{
|
||||
code: code,
|
||||
expiresAt: time.Now().Add(emailCodeTTL),
|
||||
sentAt: time.Now(),
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Verify 校验邮箱验证码(一次性)
|
||||
func (s *EmailCodeService) Verify(email, code string) bool {
|
||||
email = NormalizeEmail(email)
|
||||
code = strings.TrimSpace(code)
|
||||
if email == "" || code == "" {
|
||||
return false
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
entry, ok := s.entries[email]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
delete(s.entries, email)
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
return false
|
||||
}
|
||||
return entry.code == code
|
||||
}
|
||||
|
||||
func (s *EmailCodeService) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
for email, entry := range s.entries {
|
||||
if now.After(entry.expiresAt) {
|
||||
delete(s.entries, email)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func randomDigits(n int) (string, error) {
|
||||
var b strings.Builder
|
||||
b.Grow(n)
|
||||
max := big.NewInt(10)
|
||||
for i := 0; i < n; i++ {
|
||||
v, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteByte(byte('0' + v.Int64()))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
342
service/gitea.go
Normal file
342
service/gitea.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGiteaNotConfigured = errors.New("Gitea 同步未配置或未启用")
|
||||
ErrGiteaSyncBusy = errors.New("同步正在进行中,请稍后再试")
|
||||
)
|
||||
|
||||
// GiteaRepoView 前台展示
|
||||
type GiteaRepoView struct {
|
||||
ID uint `json:"id"`
|
||||
GiteaID int64 `json:"gitea_id"`
|
||||
OwnerLogin string `json:"owner_login"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
UpdatedAtRemote *time.Time `json:"updated_at_remote"`
|
||||
ForumUserID *uint `json:"forum_user_id,omitempty"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
}
|
||||
|
||||
// GiteaService 从 Gitea API 同步会员公开仓库
|
||||
type GiteaService struct {
|
||||
settings *ForumSettingsService
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
syncing bool
|
||||
stopCh chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewGiteaService(settings *ForumSettingsService) *GiteaService {
|
||||
return &GiteaService{
|
||||
settings: settings,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackgroundSync 按配置间隔后台同步;失败只记日志
|
||||
func (g *GiteaService) StartBackgroundSync() {
|
||||
g.wg.Add(1)
|
||||
go func() {
|
||||
defer g.wg.Done()
|
||||
// 启动后稍等再首次尝试,避免拖慢启动
|
||||
timer := time.NewTimer(15 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-g.stopCh:
|
||||
return
|
||||
case <-timer.C:
|
||||
if _, err := g.SyncRepos(); err != nil && !errors.Is(err, ErrGiteaNotConfigured) && !errors.Is(err, ErrGiteaSyncBusy) {
|
||||
log.Printf("[gitea] 后台同步失败: %v", err)
|
||||
}
|
||||
cfg := g.settings.GiteaSyncConfig()
|
||||
interval := time.Duration(cfg.SyncIntervalMin) * time.Minute
|
||||
if interval < 5*time.Minute {
|
||||
interval = 5 * time.Minute
|
||||
}
|
||||
timer.Reset(interval)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Stop 停止后台同步
|
||||
func (g *GiteaService) Stop() {
|
||||
select {
|
||||
case <-g.stopCh:
|
||||
default:
|
||||
close(g.stopCh)
|
||||
}
|
||||
g.wg.Wait()
|
||||
}
|
||||
|
||||
// ListPublic 列出已同步的公开仓库
|
||||
func (g *GiteaService) ListPublic(page, size int) ([]GiteaRepoView, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 {
|
||||
size = 30
|
||||
}
|
||||
if size > 100 {
|
||||
size = 100
|
||||
}
|
||||
var total int64
|
||||
q := model.DB.Model(&model.GiteaRepo{}).Where("private = ?", false)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.GiteaRepo
|
||||
err := model.DB.Where("private = ?", false).
|
||||
Order("updated_at_remote desc, id desc").
|
||||
Offset((page - 1) * size).
|
||||
Limit(size).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]GiteaRepoView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toGiteaRepoView(r))
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// SyncRepos 按论坛用户名拉取 Gitea 公开仓并 upsert
|
||||
func (g *GiteaService) SyncRepos() (int, error) {
|
||||
cfg := g.settings.GiteaSyncConfig()
|
||||
if !cfg.Ready {
|
||||
return 0, ErrGiteaNotConfigured
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
if g.syncing {
|
||||
g.mu.Unlock()
|
||||
return 0, ErrGiteaSyncBusy
|
||||
}
|
||||
g.syncing = true
|
||||
g.mu.Unlock()
|
||||
defer func() {
|
||||
g.mu.Lock()
|
||||
g.syncing = false
|
||||
g.mu.Unlock()
|
||||
}()
|
||||
|
||||
var users []model.User
|
||||
if err := model.DB.Where("banned = ?", false).Select("id", "username").Find(&users).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{})
|
||||
syncedOwners := make(map[string]struct{})
|
||||
now := time.Now()
|
||||
upserted := 0
|
||||
|
||||
for _, u := range users {
|
||||
username := strings.TrimSpace(u.Username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
repos, err := g.fetchUserPublicRepos(cfg.BaseURL, cfg.Token, username)
|
||||
if err != nil {
|
||||
// 用户在 Gitea 不存在等:跳过,不中断整次同步
|
||||
log.Printf("[gitea] 跳过用户 %s: %v", username, err)
|
||||
continue
|
||||
}
|
||||
syncedOwners[strings.ToLower(username)] = struct{}{}
|
||||
uid := u.ID
|
||||
for _, gr := range repos {
|
||||
if gr.Private {
|
||||
continue
|
||||
}
|
||||
seen[gr.ID] = struct{}{}
|
||||
owner := gr.Owner.Login
|
||||
if owner == "" {
|
||||
owner = username
|
||||
}
|
||||
row := model.GiteaRepo{
|
||||
GiteaID: gr.ID,
|
||||
OwnerLogin: owner,
|
||||
Name: gr.Name,
|
||||
FullName: gr.FullName,
|
||||
Description: truncStr(gr.Description, 2048),
|
||||
HTMLURL: gr.HTMLURL,
|
||||
Private: false,
|
||||
UpdatedAtRemote: parseGiteaTime(gr.UpdatedAt),
|
||||
ForumUserID: &uid,
|
||||
SyncedAt: now,
|
||||
}
|
||||
var existing model.GiteaRepo
|
||||
err := model.DB.Where("gitea_id = ?", gr.ID).First(&existing).Error
|
||||
if err != nil {
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
log.Printf("[gitea] 创建仓库失败 %s: %v", gr.FullName, err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
row.ID = existing.ID
|
||||
if err := model.DB.Model(&existing).Updates(map[string]any{
|
||||
"owner_login": row.OwnerLogin,
|
||||
"name": row.Name,
|
||||
"full_name": row.FullName,
|
||||
"description": row.Description,
|
||||
"html_url": row.HTMLURL,
|
||||
"private": false,
|
||||
"updated_at_remote": row.UpdatedAtRemote,
|
||||
"forum_user_id": row.ForumUserID,
|
||||
"synced_at": row.SyncedAt,
|
||||
}).Error; err != nil {
|
||||
log.Printf("[gitea] 更新仓库失败 %s: %v", gr.FullName, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
upserted++
|
||||
}
|
||||
}
|
||||
|
||||
// 仅清理本次成功同步到的 owner 下、却未再出现的旧记录
|
||||
if len(syncedOwners) > 0 {
|
||||
var all []model.GiteaRepo
|
||||
if err := model.DB.Where("private = ?", false).Find(&all).Error; err == nil {
|
||||
for _, r := range all {
|
||||
if _, ok := syncedOwners[strings.ToLower(r.OwnerLogin)]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[r.GiteaID]; !ok {
|
||||
_ = model.DB.Delete(&r).Error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[gitea] 同步完成:upsert %d 个公开仓库", upserted)
|
||||
return upserted, nil
|
||||
}
|
||||
|
||||
type giteaAPIRepo struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FullName string `json:"full_name"`
|
||||
Description string `json:"description"`
|
||||
HTMLURL string `json:"html_url"`
|
||||
Private bool `json:"private"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Owner struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
}
|
||||
|
||||
func (g *GiteaService) fetchUserPublicRepos(baseURL, token, username string) ([]giteaAPIRepo, error) {
|
||||
var all []giteaAPIRepo
|
||||
page := 1
|
||||
for {
|
||||
u, err := url.Parse(strings.TrimRight(baseURL, "/") + "/api/v1/users/" + url.PathEscape(username) + "/repos")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", "50")
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := g.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200))
|
||||
}
|
||||
|
||||
var pageRepos []giteaAPIRepo
|
||||
if err := json.Unmarshal(body, &pageRepos); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pageRepos) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, pageRepos...)
|
||||
if len(pageRepos) < 50 {
|
||||
break
|
||||
}
|
||||
page++
|
||||
if page > 20 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func toGiteaRepoView(r model.GiteaRepo) GiteaRepoView {
|
||||
return GiteaRepoView{
|
||||
ID: r.ID,
|
||||
GiteaID: r.GiteaID,
|
||||
OwnerLogin: r.OwnerLogin,
|
||||
Name: r.Name,
|
||||
FullName: r.FullName,
|
||||
Description: r.Description,
|
||||
HTMLURL: r.HTMLURL,
|
||||
UpdatedAtRemote: r.UpdatedAtRemote,
|
||||
ForumUserID: r.ForumUserID,
|
||||
SyncedAt: r.SyncedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func parseGiteaTime(raw string) *time.Time {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, err := time.Parse(layout, raw); err == nil {
|
||||
return &t
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func truncStr(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
180
service/mail.go
Normal file
180
service/mail.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MailConfig 邮件 SMTP 配置
|
||||
type MailConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"` // 更新时传入;回显时为空
|
||||
From string `json:"from"`
|
||||
FromName string `json:"from_name"`
|
||||
Encryption string `json:"encryption"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
|
||||
// MailService 基于 SMTP 发信
|
||||
type MailService struct {
|
||||
settings *ForumSettingsService
|
||||
}
|
||||
|
||||
func NewMailService(settings *ForumSettingsService) *MailService {
|
||||
return &MailService{settings: settings}
|
||||
}
|
||||
|
||||
// Send 发送纯文本邮件
|
||||
func (m *MailService) Send(to, subject, body string) error {
|
||||
cfg := m.settings.MailConfig()
|
||||
if !m.settings.MailReady() {
|
||||
return ErrMailNotConfigured
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(cfg.From)
|
||||
fromHeader := from
|
||||
if name := strings.TrimSpace(cfg.FromName); name != "" {
|
||||
fromHeader = fmt.Sprintf("%s <%s>", encodeMailHeader(name), from)
|
||||
}
|
||||
|
||||
msg := strings.Join([]string{
|
||||
"From: " + fromHeader,
|
||||
"To: " + to,
|
||||
"Subject: " + encodeMailHeader(subject),
|
||||
"MIME-Version: 1.0",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"Content-Transfer-Encoding: 8bit",
|
||||
"",
|
||||
body,
|
||||
}, "\r\n")
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
|
||||
auth := smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host)
|
||||
|
||||
switch normalizeEncryption(cfg.Encryption) {
|
||||
case "ssl":
|
||||
return sendSMTPWithTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg), true)
|
||||
case "starttls":
|
||||
return sendSMTPStartTLS(addr, cfg.Host, auth, from, []string{to}, []byte(msg))
|
||||
default:
|
||||
return smtp.SendMail(addr, auth, from, []string{to}, []byte(msg))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeEncryption(v string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "ssl", "tls":
|
||||
return "ssl"
|
||||
case "starttls":
|
||||
return "starttls"
|
||||
default:
|
||||
return "none"
|
||||
}
|
||||
}
|
||||
|
||||
func sendSMTPWithTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte, implicitTLS bool) error {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接邮件服务器失败: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("邮件认证失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = implicitTLS
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func sendSMTPStartTLS(addr, host string, auth smtp.Auth, from string, to []string, msg []byte) error {
|
||||
conn, err := net.DialTimeout("tcp", addr, 15*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("连接邮件服务器失败: %w", err)
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
tlsCfg := &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}
|
||||
if err := client.StartTLS(tlsCfg); err != nil {
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
if auth != nil {
|
||||
if ok, _ := client.Extension("AUTH"); ok {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("邮件认证失败: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// encodeMailHeader 简单编码含非 ASCII 的邮件头
|
||||
func encodeMailHeader(s string) string {
|
||||
for _, r := range s {
|
||||
if r > 127 {
|
||||
return "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(s)) + "?="
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
253
service/oauth_clients.go
Normal file
253
service/oauth_clients.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOAuthClientNotFound = errors.New("OAuth 应用不存在")
|
||||
ErrOAuthClientExists = errors.New("client_id 已存在")
|
||||
ErrOAuthClientInvalid = errors.New("OAuth 应用参数无效")
|
||||
)
|
||||
|
||||
// OAuthClientView 管理端展示(不含密钥哈希)
|
||||
type OAuthClientView struct {
|
||||
ID uint `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
RedirectURIs string `json:"redirect_uris"`
|
||||
Enabled bool `json:"enabled"`
|
||||
HasSecret bool `json:"has_secret"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// ClientSecret 仅在创建或轮换时返回一次明文
|
||||
ClientSecret string `json:"client_secret,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthClientInput 创建/更新请求
|
||||
type OAuthClientInput struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
RedirectURIs string `json:"redirect_uris"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
// ClientSecret 留空:创建时自动生成;更新时表示不改
|
||||
ClientSecret string `json:"client_secret"`
|
||||
// RotateSecret 更新时为 true 则重新生成密钥
|
||||
RotateSecret bool `json:"rotate_secret"`
|
||||
}
|
||||
|
||||
// ListOAuthClients 列出全部 OAuth 应用
|
||||
func (s *ForumSettingsService) ListOAuthClients() ([]OAuthClientView, error) {
|
||||
var rows []model.OAuthClient
|
||||
if err := model.DB.Order("id asc").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]OAuthClientView, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, toOAuthClientView(r, ""))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CreateOAuthClient 创建应用;返回含明文密钥的视图
|
||||
func (s *ForumSettingsService) CreateOAuthClient(in OAuthClientInput) (*OAuthClientView, error) {
|
||||
clientID := strings.TrimSpace(in.ClientID)
|
||||
name := strings.TrimSpace(in.Name)
|
||||
uris := normalizeRedirectURIs(in.RedirectURIs)
|
||||
if clientID == "" || name == "" || uris == "" {
|
||||
return nil, ErrOAuthClientInvalid
|
||||
}
|
||||
var n int64
|
||||
model.DB.Model(&model.OAuthClient{}).Where("client_id = ?", clientID).Count(&n)
|
||||
if n > 0 {
|
||||
return nil, ErrOAuthClientExists
|
||||
}
|
||||
|
||||
plain := strings.TrimSpace(in.ClientSecret)
|
||||
if plain == "" {
|
||||
var err error
|
||||
plain, err = generateClientSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enabled := true
|
||||
if in.Enabled != nil {
|
||||
enabled = *in.Enabled
|
||||
}
|
||||
row := model.OAuthClient{
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: hash,
|
||||
Name: name,
|
||||
RedirectURIs: uris,
|
||||
Enabled: enabled,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toOAuthClientView(row, plain)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// UpdateOAuthClient 更新应用
|
||||
func (s *ForumSettingsService) UpdateOAuthClient(id uint, in OAuthClientInput) (*OAuthClientView, error) {
|
||||
var row model.OAuthClient
|
||||
if err := model.DB.First(&row, id).Error; err != nil {
|
||||
return nil, ErrOAuthClientNotFound
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
uris := normalizeRedirectURIs(in.RedirectURIs)
|
||||
if name == "" || uris == "" {
|
||||
return nil, ErrOAuthClientInvalid
|
||||
}
|
||||
row.Name = name
|
||||
row.RedirectURIs = uris
|
||||
if in.Enabled != nil {
|
||||
row.Enabled = *in.Enabled
|
||||
}
|
||||
|
||||
plain := ""
|
||||
if in.RotateSecret {
|
||||
var err error
|
||||
plain, err = generateClientSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ClientSecretHash = hash
|
||||
} else if strings.TrimSpace(in.ClientSecret) != "" {
|
||||
plain = strings.TrimSpace(in.ClientSecret)
|
||||
hash, err := HashPassword(plain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ClientSecretHash = hash
|
||||
}
|
||||
|
||||
if err := model.DB.Save(&row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v := toOAuthClientView(row, plain)
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// DeleteOAuthClient 删除应用
|
||||
func (s *ForumSettingsService) DeleteOAuthClient(id uint) error {
|
||||
res := model.DB.Delete(&model.OAuthClient{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrOAuthClientNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindEnabledOAuthClient 按 client_id 查找已启用应用
|
||||
func FindEnabledOAuthClient(clientID string) (*model.OAuthClient, error) {
|
||||
var row model.OAuthClient
|
||||
if err := model.DB.Where("client_id = ? AND enabled = ?", clientID, true).First(&row).Error; err != nil {
|
||||
return nil, ErrOIDCInvalidClient
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// VerifyOAuthClientSecret 校验客户端密钥(支持 bcrypt;兼容尚未哈希的历史明文)
|
||||
func VerifyOAuthClientSecret(row *model.OAuthClient, secret string) bool {
|
||||
if row == nil || secret == "" || row.ClientSecretHash == "" {
|
||||
return false
|
||||
}
|
||||
hash := row.ClientSecretHash
|
||||
if strings.HasPrefix(hash, "$2a$") || strings.HasPrefix(hash, "$2b$") || strings.HasPrefix(hash, "$2y$") {
|
||||
return CheckPassword(hash, secret)
|
||||
}
|
||||
// 遗留明文:校验通过后就地升级为哈希
|
||||
if hash == secret {
|
||||
if newHash, err := HashPassword(secret); err == nil {
|
||||
_ = model.DB.Model(row).Update("client_secret_hash", newHash).Error
|
||||
row.ClientSecretHash = newHash
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func toOAuthClientView(row model.OAuthClient, plainSecret string) OAuthClientView {
|
||||
return OAuthClientView{
|
||||
ID: row.ID,
|
||||
ClientID: row.ClientID,
|
||||
Name: row.Name,
|
||||
RedirectURIs: row.RedirectURIs,
|
||||
Enabled: row.Enabled,
|
||||
HasSecret: row.ClientSecretHash != "",
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ClientSecret: plainSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func generateClientSecret() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// CountEnabledOAuthClients 已启用客户端数量
|
||||
func CountEnabledOAuthClients() int64 {
|
||||
var n int64
|
||||
model.DB.Model(&model.OAuthClient{}).Where("enabled = ?", true).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// MigrateLegacyOIDCClient 将旧版 ForumSetting 单客户端迁入 oauth_clients(仅一次)
|
||||
func (s *ForumSettingsService) MigrateLegacyOIDCClient() {
|
||||
if CountEnabledOAuthClients() > 0 {
|
||||
// 仍清理遗留明文密钥字段
|
||||
s.clearLegacyOAuthSecrets()
|
||||
return
|
||||
}
|
||||
clientID := strings.TrimSpace(s.getString(SettingOAuthClientID, ""))
|
||||
secret := s.getString(SettingOAuthClientSecret, "")
|
||||
uris := normalizeRedirectURIs(s.getString(SettingOAuthRedirectURIs, ""))
|
||||
if clientID == "" || secret == "" || uris == "" {
|
||||
return
|
||||
}
|
||||
hash := secret
|
||||
if !(strings.HasPrefix(secret, "$2a$") || strings.HasPrefix(secret, "$2b$") || strings.HasPrefix(secret, "$2y$")) {
|
||||
h, err := HashPassword(secret)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
hash = h
|
||||
}
|
||||
_ = model.DB.Create(&model.OAuthClient{
|
||||
ClientID: clientID,
|
||||
ClientSecretHash: hash,
|
||||
Name: "Gitea",
|
||||
RedirectURIs: uris,
|
||||
Enabled: true,
|
||||
}).Error
|
||||
s.clearLegacyOAuthSecrets()
|
||||
}
|
||||
|
||||
func (s *ForumSettingsService) clearLegacyOAuthSecrets() {
|
||||
// 清空遗留明文,避免双源配置
|
||||
if s.getString(SettingOAuthClientSecret, "") != "" {
|
||||
_ = s.setString(SettingOAuthClientSecret, "")
|
||||
}
|
||||
}
|
||||
634
service/oidc.go
Normal file
634
service/oidc.go
Normal file
@@ -0,0 +1,634 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"git.iioio.com/freefire/jiang13-forum/config"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const (
|
||||
oidcAuthCodeTTL = 5 * time.Minute
|
||||
oidcAccessTokenTTL = time.Hour
|
||||
oidcIDTokenTTL = time.Hour
|
||||
oidcRSABits = 2048
|
||||
oidcKeyID = "jiang13-oidc-1"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOIDCNotConfigured = errors.New("OIDC 未配置(请在管理后台启用并至少创建一个 OAuth 应用)")
|
||||
ErrOIDCInvalidClient = errors.New("无效的 client_id 或 client_secret")
|
||||
ErrOIDCInvalidRedirect = errors.New("redirect_uri 未登记")
|
||||
ErrOIDCInvalidRequest = errors.New("授权请求参数无效")
|
||||
ErrOIDCInvalidGrant = errors.New("授权码无效或已过期")
|
||||
ErrOIDCInvalidToken = errors.New("access_token 无效")
|
||||
ErrOIDCUserBanned = errors.New("账号已被禁言,无法授权")
|
||||
ErrOIDCPKCEFailed = errors.New("PKCE 校验失败")
|
||||
ErrOIDCInvalidLogout = errors.New("post_logout_redirect_uri 未登记")
|
||||
)
|
||||
|
||||
// OIDCService 论坛作为 OpenID Connect Provider
|
||||
type OIDCService struct {
|
||||
cfg *config.Config
|
||||
settings *ForumSettingsService
|
||||
|
||||
mu sync.RWMutex
|
||||
privateKey *rsa.PrivateKey
|
||||
}
|
||||
|
||||
// NewOIDCService 创建并加载/生成 RSA 密钥
|
||||
func NewOIDCService(cfg *config.Config, settings *ForumSettingsService) (*OIDCService, error) {
|
||||
s := &OIDCService{cfg: cfg, settings: settings}
|
||||
if err := s.loadOrCreateKey(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *OIDCService) runtime() OIDCConfig {
|
||||
if s.settings != nil {
|
||||
return s.settings.OIDCConfig()
|
||||
}
|
||||
return OIDCConfig{}
|
||||
}
|
||||
|
||||
func (s *OIDCService) loadOrCreateKey() error {
|
||||
keyPath := filepath.Join(s.cfg.DataDir, ".oidc_rsa.pem")
|
||||
if data, err := os.ReadFile(keyPath); err == nil && len(data) > 0 {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return fmt.Errorf("解析 OIDC RSA 密钥失败")
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
parsed, err2 := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("解析 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
var ok bool
|
||||
key, ok = parsed.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("OIDC 密钥不是 RSA")
|
||||
}
|
||||
}
|
||||
s.privateKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
key, err := rsa.GenerateKey(rand.Reader, oidcRSABits)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0600); err != nil {
|
||||
return fmt.Errorf("写入 OIDC RSA 密钥失败: %w", err)
|
||||
}
|
||||
s.privateKey = key
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enabled 是否可对外提供 OIDC
|
||||
func (s *OIDCService) Enabled() bool {
|
||||
return s.runtime().Ready
|
||||
}
|
||||
|
||||
// Issuer 返回 OIDC issuer
|
||||
func (s *OIDCService) Issuer() string {
|
||||
return s.runtime().RootURL
|
||||
}
|
||||
|
||||
// Discovery 返回 OpenID Provider Metadata
|
||||
func (s *OIDCService) Discovery() (map[string]any, error) {
|
||||
rt := s.runtime()
|
||||
if !rt.Ready {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
base := rt.RootURL
|
||||
return map[string]any{
|
||||
"issuer": base,
|
||||
"authorization_endpoint": base + "/oauth/authorize",
|
||||
"token_endpoint": base + "/oauth/token",
|
||||
"userinfo_endpoint": base + "/oauth/userinfo",
|
||||
"jwks_uri": base + "/oauth/jwks",
|
||||
"end_session_endpoint": base + "/oauth/logout",
|
||||
"response_types_supported": []string{"code"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post"},
|
||||
"claims_supported": []string{
|
||||
"sub", "name", "preferred_username", "email", "email_verified", "picture", "groups",
|
||||
},
|
||||
"code_challenge_methods_supported": []string{"S256", "plain"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JWKS 返回 JSON Web Key Set
|
||||
func (s *OIDCService) JWKS() (map[string]any, error) {
|
||||
s.mu.RLock()
|
||||
key := s.privateKey
|
||||
s.mu.RUnlock()
|
||||
if key == nil {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
pub := key.PublicKey
|
||||
return map[string]any{
|
||||
"keys": []map[string]string{
|
||||
{
|
||||
"kty": "RSA",
|
||||
"use": "sig",
|
||||
"alg": "RS256",
|
||||
"kid": oidcKeyID,
|
||||
"n": base64.RawURLEncoding.EncodeToString(pub.N.Bytes()),
|
||||
"e": base64.RawURLEncoding.EncodeToString(bigIntBytes(pub.E)),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func bigIntBytes(e int) []byte {
|
||||
if e == 0 {
|
||||
return []byte{0}
|
||||
}
|
||||
var b []byte
|
||||
for v := e; v > 0; v >>= 8 {
|
||||
b = append([]byte{byte(v & 0xff)}, b...)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// AuthorizeRequest 授权端点查询参数
|
||||
type AuthorizeRequest struct {
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
ResponseType string
|
||||
Scope string
|
||||
State string
|
||||
Nonce string
|
||||
CodeChallenge string
|
||||
CodeChallengeMethod string
|
||||
}
|
||||
|
||||
// ValidateAuthorize 校验授权请求(不要求已登录)
|
||||
func (s *OIDCService) ValidateAuthorize(req AuthorizeRequest) error {
|
||||
rt := s.runtime()
|
||||
if !rt.Ready {
|
||||
return ErrOIDCNotConfigured
|
||||
}
|
||||
client, err := FindEnabledOAuthClient(req.ClientID)
|
||||
if err != nil {
|
||||
return ErrOIDCInvalidClient
|
||||
}
|
||||
if req.ResponseType != "code" {
|
||||
return ErrOIDCInvalidRequest
|
||||
}
|
||||
if !redirectAllowed(client.RedirectURIs, req.RedirectURI) {
|
||||
return ErrOIDCInvalidRedirect
|
||||
}
|
||||
if !hasScope(req.Scope, "openid") {
|
||||
return ErrOIDCInvalidRequest
|
||||
}
|
||||
if req.CodeChallenge != "" {
|
||||
m := strings.ToUpper(req.CodeChallengeMethod)
|
||||
if m == "" {
|
||||
m = "PLAIN"
|
||||
}
|
||||
if m != "S256" && m != "PLAIN" {
|
||||
return ErrOIDCInvalidRequest
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasScope(scope, want string) bool {
|
||||
for _, p := range strings.Fields(scope) {
|
||||
if p == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redirectAllowed(redirectURIsCSV, uri string) bool {
|
||||
for _, allowed := range splitRedirectURIs(redirectURIsCSV) {
|
||||
if allowed == uri {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IssueAuthCode 已登录用户签发授权码,返回带 code/state 的回调 URL
|
||||
func (s *OIDCService) IssueAuthCode(userID uint, req AuthorizeRequest) (string, error) {
|
||||
if err := s.ValidateAuthorize(req); err != nil {
|
||||
return "", err
|
||||
}
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, userID).Error; err != nil {
|
||||
return "", ErrOIDCInvalidRequest
|
||||
}
|
||||
if user.Banned {
|
||||
return "", ErrOIDCUserBanned
|
||||
}
|
||||
|
||||
code, err := randomToken(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
method := strings.ToUpper(req.CodeChallengeMethod)
|
||||
if req.CodeChallenge != "" && method == "" {
|
||||
method = "PLAIN"
|
||||
}
|
||||
rec := &model.OAuthAuthCode{
|
||||
Code: code,
|
||||
ClientID: req.ClientID,
|
||||
UserID: user.ID,
|
||||
RedirectURI: req.RedirectURI,
|
||||
Scope: req.Scope,
|
||||
Nonce: req.Nonce,
|
||||
CodeChallenge: req.CodeChallenge,
|
||||
CodeChallengeMethod: method,
|
||||
ExpiresAt: time.Now().Add(oidcAuthCodeTTL),
|
||||
}
|
||||
if err := model.DB.Create(rec).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
u, err := url.Parse(req.RedirectURI)
|
||||
if err != nil {
|
||||
return "", ErrOIDCInvalidRedirect
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("code", code)
|
||||
if req.State != "" {
|
||||
q.Set("state", req.State)
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// TokenRequest 换票请求
|
||||
type TokenRequest struct {
|
||||
GrantType string
|
||||
Code string
|
||||
RedirectURI string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
CodeVerifier string
|
||||
}
|
||||
|
||||
// TokenResponse OAuth token 响应
|
||||
type TokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
}
|
||||
|
||||
// ExchangeCode 授权码换 token
|
||||
func (s *OIDCService) ExchangeCode(req TokenRequest) (*TokenResponse, error) {
|
||||
rt := s.runtime()
|
||||
if !rt.Ready {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
if req.GrantType != "authorization_code" {
|
||||
return nil, ErrOIDCInvalidRequest
|
||||
}
|
||||
client, err := FindEnabledOAuthClient(req.ClientID)
|
||||
if err != nil || !VerifyOAuthClientSecret(client, req.ClientSecret) {
|
||||
return nil, ErrOIDCInvalidClient
|
||||
}
|
||||
|
||||
var rec model.OAuthAuthCode
|
||||
if err := model.DB.Where("code = ?", req.Code).First(&rec).Error; err != nil {
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
if rec.Used || time.Now().After(rec.ExpiresAt) {
|
||||
// 重放:作废同用户同客户端未过期码
|
||||
if rec.Used {
|
||||
_ = model.DB.Model(&model.OAuthAuthCode{}).
|
||||
Where("client_id = ? AND user_id = ? AND used = ? AND expires_at > ?",
|
||||
rec.ClientID, rec.UserID, false, time.Now()).
|
||||
Update("used", true).Error
|
||||
}
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
if rec.ClientID != req.ClientID || rec.RedirectURI != req.RedirectURI {
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
if err := verifyPKCE(rec.CodeChallenge, rec.CodeChallengeMethod, req.CodeVerifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.Used = true
|
||||
_ = model.DB.Save(&rec).Error
|
||||
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, rec.UserID).Error; err != nil || user.Banned {
|
||||
return nil, ErrOIDCInvalidGrant
|
||||
}
|
||||
|
||||
access, err := s.signAccessToken(&user, rec.Scope, req.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idToken, err := s.signIDToken(&user, rec.Scope, req.ClientID, rec.Nonce)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &TokenResponse{
|
||||
AccessToken: access,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(oidcAccessTokenTTL.Seconds()),
|
||||
IDToken: idToken,
|
||||
Scope: rec.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func verifyPKCE(challenge, method, verifier string) error {
|
||||
if challenge == "" {
|
||||
return nil
|
||||
}
|
||||
if verifier == "" {
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
switch strings.ToUpper(method) {
|
||||
case "S256":
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
calc := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
if calc != challenge {
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
case "PLAIN", "":
|
||||
if verifier != challenge {
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
default:
|
||||
return ErrOIDCPKCEFailed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type oidcAccessClaims struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type oidcIDClaims struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
PreferredUsername string `json:"preferred_username,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
EmailVerified bool `json:"email_verified,omitempty"`
|
||||
Picture string `json:"picture,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
Nonce string `json:"nonce,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func (s *OIDCService) signAccessToken(user *model.User, scope, clientID string) (string, error) {
|
||||
now := time.Now()
|
||||
issuer := s.Issuer()
|
||||
claims := oidcAccessClaims{
|
||||
Scope: scope,
|
||||
ClientID: clientID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||
Audience: []string{clientID},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(oidcAccessTokenTTL)),
|
||||
},
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t.Header["kid"] = oidcKeyID
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return t.SignedString(s.privateKey)
|
||||
}
|
||||
|
||||
func (s *OIDCService) signIDToken(user *model.User, scope, clientID, nonce string) (string, error) {
|
||||
now := time.Now()
|
||||
issuer := s.Issuer()
|
||||
claims := oidcIDClaims{
|
||||
Nonce: nonce,
|
||||
Groups: s.userGroups(user),
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: issuer,
|
||||
Subject: strconv.FormatUint(uint64(user.ID), 10),
|
||||
Audience: []string{clientID},
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(oidcIDTokenTTL)),
|
||||
},
|
||||
}
|
||||
if hasScope(scope, "profile") || scope == "" || hasScope(scope, "openid") {
|
||||
claims.Name = user.Nickname
|
||||
if claims.Name == "" {
|
||||
claims.Name = user.Username
|
||||
}
|
||||
claims.PreferredUsername = user.Username
|
||||
claims.Picture = s.absoluteURL(user.Avatar)
|
||||
}
|
||||
if hasScope(scope, "email") || hasScope(scope, "openid") {
|
||||
claims.Email = user.Email
|
||||
claims.EmailVerified = user.Email != ""
|
||||
}
|
||||
t := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||
t.Header["kid"] = oidcKeyID
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return t.SignedString(s.privateKey)
|
||||
}
|
||||
|
||||
func (s *OIDCService) userGroups(user *model.User) []string {
|
||||
rt := s.runtime()
|
||||
groups := make([]string, 0, 2)
|
||||
if rt.UserGroup != "" {
|
||||
groups = append(groups, rt.UserGroup)
|
||||
}
|
||||
if user.Role == model.RoleAdmin && rt.AdminGroup != "" {
|
||||
groups = append(groups, rt.AdminGroup)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
// UserInfo 根据 access_token 返回用户声明
|
||||
func (s *OIDCService) UserInfo(accessToken string) (map[string]any, error) {
|
||||
claims, err := s.parseAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uid, err := strconv.ParseUint(claims.Subject, 10, 64)
|
||||
if err != nil {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
var user model.User
|
||||
if err := model.DB.First(&user, uint(uid)).Error; err != nil || user.Banned {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
rt := s.runtime()
|
||||
out := map[string]any{
|
||||
"sub": strconv.FormatUint(uint64(user.ID), 10),
|
||||
}
|
||||
if hasScope(claims.Scope, "profile") || claims.Scope == "" {
|
||||
name := user.Nickname
|
||||
if name == "" {
|
||||
name = user.Username
|
||||
}
|
||||
out["name"] = name
|
||||
out["preferred_username"] = user.Username
|
||||
if pic := s.absoluteURL(user.Avatar); pic != "" {
|
||||
out["picture"] = pic
|
||||
}
|
||||
}
|
||||
if hasScope(claims.Scope, "email") || hasScope(claims.Scope, "openid") {
|
||||
if user.Email != "" {
|
||||
out["email"] = user.Email
|
||||
out["email_verified"] = true
|
||||
}
|
||||
}
|
||||
if _, ok := out["preferred_username"]; !ok {
|
||||
out["preferred_username"] = user.Username
|
||||
out["name"] = user.Nickname
|
||||
if out["name"] == "" {
|
||||
out["name"] = user.Username
|
||||
}
|
||||
}
|
||||
groups := s.userGroups(&user)
|
||||
if len(groups) > 0 {
|
||||
claim := rt.GroupClaim
|
||||
if claim == "" {
|
||||
claim = "groups"
|
||||
}
|
||||
out[claim] = groups
|
||||
if claim != "groups" {
|
||||
out["groups"] = groups
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ResolveLogoutRedirect 校验并返回登出后跳转地址(空表示回首页)
|
||||
func (s *OIDCService) ResolveLogoutRedirect(postLogoutRedirectURI, state string) (string, error) {
|
||||
uri := strings.TrimSpace(postLogoutRedirectURI)
|
||||
if uri == "" {
|
||||
return "/", nil
|
||||
}
|
||||
var clients []model.OAuthClient
|
||||
if err := model.DB.Where("enabled = ?", true).Find(&clients).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
allowed := false
|
||||
for _, c := range clients {
|
||||
if redirectAllowed(c.RedirectURIs, uri) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
// 允许同 host 下任意已登记前缀的登出回调(Gitea 常用 / 根路径)
|
||||
for _, reg := range splitRedirectURIs(c.RedirectURIs) {
|
||||
if sameOrigin(reg, uri) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return "", ErrOIDCInvalidLogout
|
||||
}
|
||||
u, err := url.Parse(uri)
|
||||
if err != nil {
|
||||
return "", ErrOIDCInvalidLogout
|
||||
}
|
||||
if state != "" {
|
||||
q := u.Query()
|
||||
q.Set("state", state)
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func sameOrigin(a, b string) bool {
|
||||
ua, err1 := url.Parse(a)
|
||||
ub, err2 := url.Parse(b)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(ua.Scheme, ub.Scheme) && strings.EqualFold(ua.Host, ub.Host)
|
||||
}
|
||||
|
||||
func (s *OIDCService) parseAccessToken(tokenStr string) (*oidcAccessClaims, error) {
|
||||
s.mu.RLock()
|
||||
key := s.privateKey
|
||||
s.mu.RUnlock()
|
||||
if key == nil {
|
||||
return nil, ErrOIDCNotConfigured
|
||||
}
|
||||
tok, err := jwt.ParseWithClaims(tokenStr, &oidcAccessClaims{}, func(t *jwt.Token) (any, error) {
|
||||
if t.Method != jwt.SigningMethodRS256 {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
return &key.PublicKey, nil
|
||||
})
|
||||
if err != nil || !tok.Valid {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
claims, ok := tok.Claims.(*oidcAccessClaims)
|
||||
if !ok {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
if claims.Issuer != s.Issuer() {
|
||||
return nil, ErrOIDCInvalidToken
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OIDCService) absoluteURL(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") {
|
||||
return path
|
||||
}
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
base := s.Issuer()
|
||||
if base == "" {
|
||||
return path
|
||||
}
|
||||
return base + path
|
||||
}
|
||||
|
||||
func randomToken(nBytes int) (string, error) {
|
||||
b := make([]byte, nBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
)
|
||||
|
||||
const onlineTTL = 5 * time.Minute
|
||||
|
||||
// OnlineService 在线浏览追踪(内存):登录会员 + 游客
|
||||
type OnlineService struct {
|
||||
mu sync.RWMutex
|
||||
seen map[uint]time.Time // 登录用户
|
||||
guests map[string]time.Time // 游客访客标识
|
||||
}
|
||||
|
||||
func NewOnlineService() *OnlineService {
|
||||
s := &OnlineService{
|
||||
seen: make(map[uint]time.Time),
|
||||
guests: make(map[string]time.Time),
|
||||
}
|
||||
go s.cleanup()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *OnlineService) Ping(userID uint) {
|
||||
if userID == 0 {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.seen[userID] = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *OnlineService) PingGuest(visitorID string) {
|
||||
if visitorID == "" {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.guests[visitorID] = time.Now()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
type OnlineUser struct {
|
||||
ID uint `json:"id"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (s *OnlineService) List(limit int) []OnlineUser {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
cutoff := time.Now().Add(-onlineTTL)
|
||||
s.mu.RLock()
|
||||
var ids []uint
|
||||
for id, t := range s.seen {
|
||||
if t.After(cutoff) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var users []model.User
|
||||
model.DB.Where("id IN ?", ids).Limit(limit).Find(&users)
|
||||
out := make([]OnlineUser, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, OnlineUser{ID: u.ID, Nickname: u.Nickname, Avatar: u.Avatar})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *OnlineService) CountMembers() int {
|
||||
return s.countSeen(s.seen)
|
||||
}
|
||||
|
||||
func (s *OnlineService) CountGuests() int {
|
||||
return s.countSeenString(s.guests)
|
||||
}
|
||||
|
||||
// Count 当前浏览总人数(会员 + 游客)
|
||||
func (s *OnlineService) Count() int {
|
||||
return s.CountMembers() + s.CountGuests()
|
||||
}
|
||||
|
||||
func (s *OnlineService) countSeen(m map[uint]time.Time) int {
|
||||
cutoff := time.Now().Add(-onlineTTL)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := 0
|
||||
for _, t := range m {
|
||||
if t.After(cutoff) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *OnlineService) countSeenString(m map[string]time.Time) int {
|
||||
cutoff := time.Now().Add(-onlineTTL)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
n := 0
|
||||
for _, t := range m {
|
||||
if t.After(cutoff) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *OnlineService) cleanup() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
for range ticker.C {
|
||||
cutoff := time.Now().Add(-onlineTTL * 2)
|
||||
s.mu.Lock()
|
||||
for id, t := range s.seen {
|
||||
if t.Before(cutoff) {
|
||||
delete(s.seen, id)
|
||||
}
|
||||
}
|
||||
for id, t := range s.guests {
|
||||
if t.Before(cutoff) {
|
||||
delete(s.guests, id)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -133,6 +134,60 @@ func (s *PostService) HotPosts(limit int) ([]PostListItem, error) {
|
||||
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 {
|
||||
var count int64
|
||||
model.DB.Model(&model.Comment{}).Where("post_id = ?", postID).Count(&count)
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
@@ -27,13 +28,46 @@ const (
|
||||
SettingSearchKeywordMax = "search_keyword_max"
|
||||
|
||||
SettingPageSizeDefault = "page_size_default"
|
||||
SettingPageSizeMax = "page_size_max"
|
||||
|
||||
SettingFeedMaxPages = "feed_max_pages"
|
||||
SettingFeedMaxItems = "feed_max_items"
|
||||
|
||||
SettingPasswordMinLen = "password_min_len"
|
||||
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 传输结构)
|
||||
@@ -56,13 +90,12 @@ type ForumLimits struct {
|
||||
SearchKeywordMax int `json:"search_keyword_max"`
|
||||
|
||||
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"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
}
|
||||
|
||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||
@@ -74,10 +107,11 @@ type ForumLimitsPublic struct {
|
||||
SearchKeywordMin int `json:"search_keyword_min"`
|
||||
SearchKeywordMax int `json:"search_keyword_max"`
|
||||
PageSizeDefault int `json:"page_size_default"`
|
||||
FeedMaxPages int `json:"feed_max_pages"`
|
||||
FeedMaxItems int `json:"feed_max_items"`
|
||||
PasswordMinLen int `json:"password_min_len"`
|
||||
AvatarMaxMB int `json:"avatar_max_mb"`
|
||||
|
||||
OpenPostsInNewTab bool `json:"open_posts_in_new_tab"`
|
||||
OpenContentLinksInNewTab bool `json:"open_content_links_in_new_tab"`
|
||||
}
|
||||
|
||||
type settingDef struct {
|
||||
@@ -105,14 +139,86 @@ var forumSettingDefs = []settingDef{
|
||||
{SettingSearchKeywordMin, "1", 0, 100},
|
||||
{SettingSearchKeywordMax, "50", 1, 200},
|
||||
|
||||
{SettingPageSizeDefault, "30", 1, 200},
|
||||
{SettingPageSizeMax, "50", 1, 200},
|
||||
|
||||
{SettingFeedMaxPages, "10", 1, 100},
|
||||
{SettingFeedMaxItems, "300", 1, 5000},
|
||||
{SettingPageSizeDefault, "30", 1, pageSizeAPIMax},
|
||||
|
||||
{SettingPasswordMinLen, "6", 4, 128},
|
||||
{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 论坛全局设置
|
||||
@@ -134,6 +240,48 @@ func (s *ForumSettingsService) ensureDefaults() {
|
||||
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 {
|
||||
@@ -186,13 +334,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
||||
SearchKeywordMax: s.SearchKeywordMax(),
|
||||
|
||||
PageSizeDefault: s.PageSizeDefault(),
|
||||
PageSizeMax: s.PageSizeMax(),
|
||||
|
||||
FeedMaxPages: s.FeedMaxPages(),
|
||||
FeedMaxItems: s.FeedMaxItems(),
|
||||
|
||||
PasswordMinLen: s.PasswordMinLen(),
|
||||
AvatarMaxMB: s.AvatarMaxMB(),
|
||||
|
||||
OpenPostsInNewTab: s.OpenPostsInNewTab(),
|
||||
OpenContentLinksInNewTab: s.OpenContentLinksInNewTab(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,10 +353,11 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
||||
SearchKeywordMin: limits.SearchKeywordMin,
|
||||
SearchKeywordMax: limits.SearchKeywordMax,
|
||||
PageSizeDefault: limits.PageSizeDefault,
|
||||
FeedMaxPages: limits.FeedMaxPages,
|
||||
FeedMaxItems: limits.FeedMaxItems,
|
||||
PasswordMinLen: limits.PasswordMinLen,
|
||||
AvatarMaxMB: limits.AvatarMaxMB,
|
||||
|
||||
OpenPostsInNewTab: limits.OpenPostsInNewTab,
|
||||
OpenContentLinksInNewTab: limits.OpenContentLinksInNewTab,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,23 +376,30 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
||||
SettingSearchKeywordMin: in.SearchKeywordMin,
|
||||
SettingSearchKeywordMax: in.SearchKeywordMax,
|
||||
SettingPageSizeDefault: in.PageSizeDefault,
|
||||
SettingPageSizeMax: in.PageSizeMax,
|
||||
SettingFeedMaxPages: in.FeedMaxPages,
|
||||
SettingFeedMaxItems: in.FeedMaxItems,
|
||||
SettingPasswordMinLen: in.PasswordMinLen,
|
||||
SettingAvatarMaxMB: in.AvatarMaxMB,
|
||||
}
|
||||
if in.SearchKeywordMax > 0 && in.SearchKeywordMin > in.SearchKeywordMax {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
if in.PageSizeDefault > in.PageSizeMax {
|
||||
return ErrInvalidSetting
|
||||
}
|
||||
for key, val := range updates {
|
||||
if err := s.setInt(key, val); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) 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) 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 校验并规范化搜索关键词,空字符串表示无搜索
|
||||
func (s *ForumSettingsService) NormalizeSearchKeyword(keyword string) (string, error) {
|
||||
kw := trimRunes(keyword)
|
||||
@@ -311,9 +822,8 @@ func (s *ForumSettingsService) NormalizePageSize(size int) int {
|
||||
if size < 1 {
|
||||
return s.PageSizeDefault()
|
||||
}
|
||||
maxSize := s.PageSizeMax()
|
||||
if maxSize > 0 && size > maxSize {
|
||||
return maxSize
|
||||
if size > pageSizeAPIMax {
|
||||
return pageSizeAPIMax
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user