移除旧版 HTML 模板与兼容层,并完善私信、举报、媒体存储与 SEO。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,7 +13,7 @@ body:
|
||||
attributes:
|
||||
label: 要解决什么问题?
|
||||
description: 从用户场景出发描述痛点
|
||||
placeholder: 管理员在 React 前台无法置顶帖子,必须切到旧版后台…
|
||||
placeholder: 管理员在帖子详情页缺少某某操作入口…
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
||||
@@ -279,7 +279,7 @@ make dev # Linux / macOS
|
||||
|
||||
**何时需要完整构建:**
|
||||
|
||||
- 修改 Go 代码、HTML 模板、embed 静态资源 → `go build` / `make build`
|
||||
- 修改 Go 代码、前端或 embed 静态资源 → `go build` / `make build`(或 `build.bat`)
|
||||
- 发布单二进制前 → `npm run build` + `make build`
|
||||
- 更新 README 界面截图 → 启动服务后执行 `node scripts/capture-screenshots.mjs`
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ DATA = data
|
||||
JWT_SECRET =
|
||||
|
||||
[oauth]
|
||||
; 可选:启动时若管理后台尚未配置,会用此处种子写入数据库一次
|
||||
; 可选:启动时若管理后台尚未配置,会用此处种子写入 oauth_clients 一次
|
||||
; 日常请优先在管理后台「系统设置 → OIDC / SSO」修改(保存即生效)
|
||||
; Gitea 认证源名称须与回调路径一致,例如名称 jiang13 对应:
|
||||
; https://git.iioio.com/user/oauth2/jiang13/callback
|
||||
@@ -34,3 +34,17 @@ BASE_URL =
|
||||
TOKEN =
|
||||
; 首次种子时若 BASE_URL+TOKEN 齐全且此项为 true,则写入并启用同步
|
||||
SYNC_ENABLED = false
|
||||
|
||||
[storage]
|
||||
; 可选种子:仅当管理后台尚未配置时写入一次;日常请在「系统设置 → 对象存储」修改(保存即生效)
|
||||
; TYPE = local | s3
|
||||
TYPE = local
|
||||
; 以下仅 TYPE = s3 时作为种子(MinIO / 七牛 / 又拍 / 阿里云 OSS 等)
|
||||
ENDPOINT =
|
||||
REGION = us-east-1
|
||||
BUCKET =
|
||||
ACCESS_KEY =
|
||||
SECRET_KEY =
|
||||
PUBLIC_BASE_URL =
|
||||
PREFIX =
|
||||
FORCE_PATH_STYLE = true
|
||||
|
||||
101
config/config.go
101
config/config.go
@@ -8,6 +8,24 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StorageTypeLocal / StorageTypeS3 上传存储后端
|
||||
const (
|
||||
StorageTypeLocal = "local"
|
||||
StorageTypeS3 = "s3"
|
||||
)
|
||||
|
||||
// S3Config S3 兼容对象存储(MinIO / 七牛 / 又拍 / 阿里云 OSS 等)
|
||||
type S3Config struct {
|
||||
Endpoint string // 例:https://s3.example.com 或 s3.example.com:9000
|
||||
Region string
|
||||
Bucket string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
PublicBaseURL string // 公开访问根 URL(无尾斜杠),上传后返回此前缀下的绝对地址
|
||||
Prefix string // 对象 key 前缀(可选,如 forum/)
|
||||
ForcePathStyle bool // path-style(MinIO 等通常为 true;AWS 官方多为 false)
|
||||
}
|
||||
|
||||
// Config 应用全局配置:默认读工作目录下 app.ini,命令行可覆盖
|
||||
type Config struct {
|
||||
// 工作目录(默认可执行文件所在目录)
|
||||
@@ -30,6 +48,9 @@ type Config struct {
|
||||
GiteaBaseURL string
|
||||
GiteaToken string
|
||||
GiteaSyncEnabled bool
|
||||
// 上传存储:local(默认)或 s3
|
||||
StorageType string
|
||||
S3 S3Config
|
||||
// 日志文件路径
|
||||
LogFile string
|
||||
// 系统服务控制动作:install|uninstall|start|stop|restart|status,空表示正常运行
|
||||
@@ -92,6 +113,14 @@ func Parse() (*Config, error) {
|
||||
jwtSecret = fileCfg.JWTSecret
|
||||
}
|
||||
|
||||
storageType := strings.ToLower(strings.TrimSpace(fileCfg.StorageType))
|
||||
if storageType == "" {
|
||||
storageType = StorageTypeLocal
|
||||
}
|
||||
if storageType != StorageTypeLocal && storageType != StorageTypeS3 {
|
||||
return nil, fmt.Errorf("storage.TYPE 无效: %q(可选 local / s3)", fileCfg.StorageType)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
WorkPath: workPath,
|
||||
ConfigFile: configFile,
|
||||
@@ -105,18 +134,34 @@ func Parse() (*Config, error) {
|
||||
GiteaBaseURL: normalizeRootURL(fileCfg.GiteaBaseURL),
|
||||
GiteaToken: fileCfg.GiteaToken,
|
||||
GiteaSyncEnabled: fileCfg.GiteaSyncEnabled,
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
StorageType: storageType,
|
||||
S3: S3Config{
|
||||
Endpoint: strings.TrimSpace(fileCfg.S3Endpoint),
|
||||
Region: strings.TrimSpace(fileCfg.S3Region),
|
||||
Bucket: strings.TrimSpace(fileCfg.S3Bucket),
|
||||
AccessKey: strings.TrimSpace(fileCfg.S3AccessKey),
|
||||
SecretKey: strings.TrimSpace(fileCfg.S3SecretKey),
|
||||
PublicBaseURL: normalizeRootURL(fileCfg.S3PublicBaseURL),
|
||||
Prefix: normalizeStoragePrefix(fileCfg.S3Prefix),
|
||||
ForcePathStyle: fileCfg.S3ForcePathStyle,
|
||||
},
|
||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||
ServiceAction: action,
|
||||
}
|
||||
|
||||
// [storage] 仅作首次种子;运行时以管理后台为准,此处不强制校验 S3 完整性
|
||||
|
||||
needDirs := action == "" || action == "install"
|
||||
if needDirs {
|
||||
// 首次启动自动生成 app.ini,便于像 Gitea 一样改文件而不记一长串参数
|
||||
if !configExists {
|
||||
dataRel := resolveDataRelForINI(workPath, absData)
|
||||
if err := writeAppINI(configFile, fileSettings{
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
Port: port,
|
||||
DataRel: dataRel,
|
||||
StorageType: StorageTypeLocal,
|
||||
S3ForcePathStyle: true,
|
||||
S3Region: "us-east-1",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("生成默认配置文件失败: %w", err)
|
||||
}
|
||||
@@ -136,6 +181,18 @@ func Parse() (*Config, error) {
|
||||
OAuthClientID: fileCfg.OAuthClientID,
|
||||
OAuthClientSecret: fileCfg.OAuthClientSecret,
|
||||
OAuthRedirectURIs: fileCfg.OAuthRedirectURIs,
|
||||
GiteaBaseURL: fileCfg.GiteaBaseURL,
|
||||
GiteaToken: fileCfg.GiteaToken,
|
||||
GiteaSyncEnabled: fileCfg.GiteaSyncEnabled,
|
||||
StorageType: fileCfg.StorageType,
|
||||
S3Endpoint: fileCfg.S3Endpoint,
|
||||
S3Region: fileCfg.S3Region,
|
||||
S3Bucket: fileCfg.S3Bucket,
|
||||
S3AccessKey: fileCfg.S3AccessKey,
|
||||
S3SecretKey: fileCfg.S3SecretKey,
|
||||
S3PublicBaseURL: fileCfg.S3PublicBaseURL,
|
||||
S3Prefix: fileCfg.S3Prefix,
|
||||
S3ForcePathStyle: fileCfg.S3ForcePathStyle,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("更新配置文件失败: %w", err)
|
||||
}
|
||||
@@ -232,27 +289,43 @@ func (c *Config) SiteUploadDir() string {
|
||||
return filepath.Join(c.DataDir, "uploads", "site")
|
||||
}
|
||||
|
||||
// UploadDir 返回头像上传目录(兼容旧调用)
|
||||
func (c *Config) UploadDir() string {
|
||||
return c.AvatarUploadDir()
|
||||
}
|
||||
|
||||
// FilterWordsPath 返回敏感词配置文件路径
|
||||
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
|
||||
}
|
||||
|
||||
// normalizeStoragePrefix 规范化对象 key 前缀:去首尾空白与首斜杠,非空时保证尾斜杠
|
||||
func normalizeStoragePrefix(raw string) string {
|
||||
p := strings.TrimSpace(raw)
|
||||
p = strings.TrimPrefix(p, "/")
|
||||
if p == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(p, "/") + "/"
|
||||
}
|
||||
|
||||
func (s S3Config) validate() error {
|
||||
if s.Endpoint == "" {
|
||||
return fmt.Errorf("storage.TYPE=s3 时必须配置 ENDPOINT")
|
||||
}
|
||||
if s.Bucket == "" {
|
||||
return fmt.Errorf("storage.TYPE=s3 时必须配置 BUCKET")
|
||||
}
|
||||
if s.AccessKey == "" || s.SecretKey == "" {
|
||||
return fmt.Errorf("storage.TYPE=s3 时必须配置 ACCESS_KEY 与 SECRET_KEY")
|
||||
}
|
||||
if s.PublicBaseURL == "" {
|
||||
return fmt.Errorf("storage.TYPE=s3 时必须配置 PUBLIC_BASE_URL(公开访问根地址)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
|
||||
@@ -28,12 +28,24 @@ type fileSettings struct {
|
||||
GiteaBaseURL string
|
||||
GiteaToken string
|
||||
GiteaSyncEnabled bool
|
||||
StorageType string
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3PublicBaseURL string
|
||||
S3Prefix string
|
||||
S3ForcePathStyle bool
|
||||
}
|
||||
|
||||
func defaultFileSettings() fileSettings {
|
||||
return fileSettings{
|
||||
Port: defaultPort,
|
||||
DataRel: defaultDataRel,
|
||||
Port: defaultPort,
|
||||
DataRel: defaultDataRel,
|
||||
StorageType: StorageTypeLocal,
|
||||
S3Region: "us-east-1",
|
||||
S3ForcePathStyle: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +94,24 @@ func loadAppINI(path string) (fileSettings, error) {
|
||||
out.GiteaSyncEnabled = sec.Key("SYNC_ENABLED").MustBool(false)
|
||||
}
|
||||
|
||||
if sec, err := cfg.GetSection("storage"); err == nil {
|
||||
if v := strings.TrimSpace(sec.Key("TYPE").String()); v != "" {
|
||||
out.StorageType = v
|
||||
}
|
||||
out.S3Endpoint = strings.TrimSpace(sec.Key("ENDPOINT").String())
|
||||
if v := strings.TrimSpace(sec.Key("REGION").String()); v != "" {
|
||||
out.S3Region = v
|
||||
}
|
||||
out.S3Bucket = strings.TrimSpace(sec.Key("BUCKET").String())
|
||||
out.S3AccessKey = strings.TrimSpace(sec.Key("ACCESS_KEY").String())
|
||||
out.S3SecretKey = strings.TrimSpace(sec.Key("SECRET_KEY").String())
|
||||
out.S3PublicBaseURL = strings.TrimSpace(sec.Key("PUBLIC_BASE_URL").String())
|
||||
out.S3Prefix = strings.TrimSpace(sec.Key("PREFIX").String())
|
||||
if sec.HasKey("FORCE_PATH_STYLE") {
|
||||
out.S3ForcePathStyle = sec.Key("FORCE_PATH_STYLE").MustBool(true)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -91,6 +121,15 @@ func writeAppINI(path string, s fileSettings) error {
|
||||
return err
|
||||
}
|
||||
|
||||
storageType := strings.TrimSpace(s.StorageType)
|
||||
if storageType == "" {
|
||||
storageType = StorageTypeLocal
|
||||
}
|
||||
region := strings.TrimSpace(s.S3Region)
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("; 姜十三论坛 Jiang13 Forum — 配置文件(风格类似 Gitea app.ini)\n")
|
||||
b.WriteString("; 修改后重启进程/服务生效。命令行参数优先级高于本文件。\n")
|
||||
@@ -130,6 +169,52 @@ func writeAppINI(path string, s fileSettings) error {
|
||||
b.WriteString("; 多个回调用逗号分隔\n")
|
||||
b.WriteString("REDIRECT_URIS = ")
|
||||
b.WriteString(s.OAuthRedirectURIs)
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("[gitea]\n")
|
||||
b.WriteString("; 可选:同步会员公开仓库到侧栏 /projects\n")
|
||||
b.WriteString("BASE_URL = ")
|
||||
b.WriteString(s.GiteaBaseURL)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("TOKEN = ")
|
||||
b.WriteString(s.GiteaToken)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("SYNC_ENABLED = ")
|
||||
b.WriteString(strconv.FormatBool(s.GiteaSyncEnabled))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString("[storage]\n")
|
||||
b.WriteString("; 可选种子:日常请在管理后台「系统设置 → 对象存储」配置(保存即生效)\n")
|
||||
b.WriteString("TYPE = ")
|
||||
b.WriteString(storageType)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("; 以下仅作首次种子(MinIO / 七牛 / 又拍 / 阿里云 OSS 等)\n")
|
||||
b.WriteString("; ENDPOINT = https://s3.example.com\n")
|
||||
b.WriteString("ENDPOINT = ")
|
||||
b.WriteString(s.S3Endpoint)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("REGION = ")
|
||||
b.WriteString(region)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("BUCKET = ")
|
||||
b.WriteString(s.S3Bucket)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("ACCESS_KEY = ")
|
||||
b.WriteString(s.S3AccessKey)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("SECRET_KEY = ")
|
||||
b.WriteString(s.S3SecretKey)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("; 公开访问根 URL(无尾斜杠);上传后返回 PUBLIC_BASE_URL/avatars/xxx.jpg\n")
|
||||
b.WriteString("; PUBLIC_BASE_URL = https://cdn.example.com/forum\n")
|
||||
b.WriteString("PUBLIC_BASE_URL = ")
|
||||
b.WriteString(s.S3PublicBaseURL)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("; 对象 key 前缀(可选),如 forum/\n")
|
||||
b.WriteString("PREFIX = ")
|
||||
b.WriteString(s.S3Prefix)
|
||||
b.WriteString("\n")
|
||||
b.WriteString("; MinIO 等多为 true;AWS S3 官方多为 false\n")
|
||||
b.WriteString("FORCE_PATH_STYLE = ")
|
||||
b.WriteString(strconv.FormatBool(s.S3ForcePathStyle))
|
||||
b.WriteString("\n")
|
||||
|
||||
return os.WriteFile(path, []byte(b.String()), 0644)
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
|
||||
---
|
||||
|
||||
## Issue #2 · React 前台支持帖子置顶
|
||||
## Issue #2 · 示例:管理能力扩展(模板文案)
|
||||
|
||||
**标题:** `[Feature] React 前台增加帖子置顶操作`
|
||||
**标题:** `[Feature] 管理后台增加某某能力`
|
||||
|
||||
**标签:** `enhancement` `ui/ux` `good first issue`
|
||||
|
||||
@@ -65,35 +65,28 @@
|
||||
|
||||
### 要解决的问题
|
||||
|
||||
管理员无法在 React SPA 前台对帖子执行置顶/取消置顶,必须跳转到旧版 HTML 管理后台(`/admin/posts`),体验割裂。
|
||||
描述管理员在 React SPA 管理后台 / 前台中缺少的操作入口或能力。
|
||||
|
||||
### 现状
|
||||
|
||||
|
||||
| 能力 | 状态 |
|
||||
| ----------------------------------- | ---- |
|
||||
| 数据模型 `pinned` 字段 | ✅ 已有 |
|
||||
| 列表按置顶排序 | ✅ 已有 |
|
||||
| API `POST /admin/api/posts/:id/pin` | ✅ 已有 |
|
||||
| 旧版后台置顶按钮 | ✅ 已有 |
|
||||
| React 列表/详情显示置顶徽章 | ✅ 已有 |
|
||||
| **React 前台置顶操作入口** | ❌ 缺失 |
|
||||
|
||||
| 能力 | 状态 |
|
||||
| --- | --- |
|
||||
| 数据模型与业务逻辑 | ✅ / ❌ |
|
||||
| JSON API(如 `POST /api/admin/...`) | ✅ / ❌ |
|
||||
| React 管理后台入口 | ✅ / ❌ |
|
||||
| React 前台操作入口(如适用) | ✅ / ❌ |
|
||||
|
||||
### 期望方案
|
||||
|
||||
在 React SPA 中为管理员提供置顶操作,例如:
|
||||
|
||||
1. **帖子详情页**:标题旁增加「置顶 / 取消置顶」按钮(仅 `role === 'admin'` 可见)
|
||||
2. **帖列表项**:管理员 hover 时显示置顶快捷操作(可选)
|
||||
3. 调用已有 API,成功后刷新列表/详情,无需跳转旧后台
|
||||
1. 在对应页面为管理员增加操作入口(仅 `role === 'admin'` 可见)
|
||||
2. 调用已有或新增的 `/api/admin/*` JSON API
|
||||
3. 成功后刷新列表/详情,无需离开当前页面
|
||||
|
||||
### 相关代码
|
||||
|
||||
- 后端:`service/post.go` → `SetPinned`,`handler/admin.go` → `AdminAPIPinPost`
|
||||
- 前端:`frontend/src/pages/PostDetailPage.tsx`、`frontend/src/components/PostListItem.tsx`
|
||||
- 参考旧版:`embed_static/templates/admin/posts.html` 中的 `togglePin`
|
||||
- 后端:`service/`、`handler/api.go`、`router/router.go`
|
||||
- 前端:`frontend/src/pages/admin/`、`frontend/src/api/client.ts`
|
||||
|
||||
### 备注
|
||||
|
||||
适合作为 `good first issue`,改动范围小、API 已就绪。
|
||||
适合作为 `good first issue` 时,优先选择 API 已就绪、只需补 UI 的小改动。
|
||||
|
||||
@@ -2,8 +2,6 @@ package embed_static
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -15,12 +13,10 @@ import (
|
||||
//go:embed static/*
|
||||
var staticFS embed.FS
|
||||
|
||||
//go:embed templates/*
|
||||
var templatesFS embed.FS
|
||||
|
||||
var (
|
||||
spaTitleRe = regexp.MustCompile(`(?s)<title>.*?</title>`)
|
||||
spaBrandTitleFn func() string
|
||||
spaBrandJSONFn func() []byte // 站点品牌 JSON,注入 window.__J13_BRANDING__
|
||||
)
|
||||
|
||||
// SetSPADocumentTitle 注册站点标题提供者,ServeSPA 会注入到入口 HTML,避免刷新闪烁
|
||||
@@ -28,50 +24,45 @@ func SetSPADocumentTitle(fn func() string) {
|
||||
spaBrandTitleFn = fn
|
||||
}
|
||||
|
||||
// SetupEmbed 配置内嵌资源:SPA 前端 + 后台 HTML 模板
|
||||
func SetupEmbed(r *gin.Engine) error {
|
||||
tmpl, err := LoadTemplates()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
// SetSPABrandingJSON 注册品牌 JSON 提供者(须为合法 JSON 对象),供前端首屏同步读入
|
||||
func SetSPABrandingJSON(fn func() []byte) {
|
||||
spaBrandJSONFn = fn
|
||||
}
|
||||
|
||||
// React SPA 构建产物(Vite)
|
||||
// SetupEmbed 配置内嵌资源:React SPA 静态资源
|
||||
func SetupEmbed(r *gin.Engine) error {
|
||||
if sub, err := fs.Sub(staticFS, "static/spa/assets"); err == nil {
|
||||
r.GET("/assets/*filepath", gin.WrapH(http.StripPrefix("/assets", http.FileServer(http.FS(sub)))))
|
||||
}
|
||||
|
||||
// 后台管理遗留静态资源
|
||||
if sub, err := fs.Sub(staticFS, "static/legacy"); err == nil {
|
||||
r.GET("/legacy/*filepath", gin.WrapH(http.StripPrefix("/legacy", http.FileServer(http.FS(sub)))))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServeSPA 返回 React SPA 入口
|
||||
// ServeSPA 返回 React SPA 入口(仅注入站点默认标题)
|
||||
func ServeSPA(c *gin.Context) {
|
||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||
return
|
||||
}
|
||||
ServeSPAWithMeta(c, nil)
|
||||
}
|
||||
|
||||
// ServeSPANoIndex 返回带 noindex 的 SPA(登录/后台等私密页)
|
||||
func ServeSPANoIndex(c *gin.Context) {
|
||||
title := ""
|
||||
if spaBrandTitleFn != nil {
|
||||
if title := strings.TrimSpace(spaBrandTitleFn()); title != "" {
|
||||
escaped := html.EscapeString(title)
|
||||
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||
}
|
||||
title = strings.TrimSpace(spaBrandTitleFn())
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
ServeSPAWithMeta(c, &SPAPageMeta{
|
||||
Title: title,
|
||||
Robots: "noindex,nofollow",
|
||||
})
|
||||
}
|
||||
|
||||
// IsSPARoute 判断是否应由 SPA 处理
|
||||
func IsSPARoute(path string) bool {
|
||||
if path == "/robots.txt" || path == "/sitemap.xml" {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(path, "/api") ||
|
||||
strings.HasPrefix(path, "/admin") ||
|
||||
strings.HasPrefix(path, "/uploads") ||
|
||||
strings.HasPrefix(path, "/media") ||
|
||||
strings.HasPrefix(path, "/legacy") ||
|
||||
strings.HasPrefix(path, "/assets") ||
|
||||
strings.HasPrefix(path, "/oauth") ||
|
||||
strings.HasPrefix(path, "/.well-known") {
|
||||
@@ -79,16 +70,3 @@ func IsSPARoute(path string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func LoadTemplates() (*template.Template, error) {
|
||||
sub, err := fs.Sub(templatesFS, "templates")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tmpl := template.New("").Funcs(template.FuncMap{
|
||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
})
|
||||
return tmpl.ParseFS(sub, "*.html", "admin/*.html")
|
||||
}
|
||||
|
||||
154
embed_static/spa_meta.go
Normal file
154
embed_static/spa_meta.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package embed_static
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SPAPageMeta 注入到 SPA 入口 HTML 的 SEO / 社交预览元数据(仅 <head>,不写 #root,避免刷新闪屏)
|
||||
type SPAPageMeta struct {
|
||||
Title string // 完整 <title>
|
||||
Description string
|
||||
Keywords string // meta keywords
|
||||
Canonical string
|
||||
OGType string // 默认 website
|
||||
OGImage string
|
||||
SiteName string // og:site_name
|
||||
Locale string // og:locale,默认 zh_CN
|
||||
Robots string // 如 noindex,nofollow
|
||||
JSONLD string // 已序列化的 JSON-LD 对象(不含 script 标签)
|
||||
Status int // HTTP 状态码,0 视为 200
|
||||
}
|
||||
|
||||
// ServeSPAWithMeta 返回带页面级 meta / JSON-LD 的干净 SPA 入口
|
||||
func ServeSPAWithMeta(c *gin.Context, meta *SPAPageMeta) {
|
||||
status := http.StatusOK
|
||||
if meta != nil && meta.Status != 0 {
|
||||
status = meta.Status
|
||||
}
|
||||
data, err := staticFS.ReadFile("static/spa/index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "前端未构建,请运行: cd frontend && npm run build")
|
||||
return
|
||||
}
|
||||
data = applySPAPageMeta(data, meta)
|
||||
c.Data(status, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
func applySPAPageMeta(data []byte, meta *SPAPageMeta) []byte {
|
||||
if meta == nil {
|
||||
meta = &SPAPageMeta{}
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(meta.Title)
|
||||
if title == "" && spaBrandTitleFn != nil {
|
||||
title = strings.TrimSpace(spaBrandTitleFn())
|
||||
}
|
||||
if title != "" {
|
||||
escaped := html.EscapeString(title)
|
||||
data = spaTitleRe.ReplaceAll(data, []byte("<title>"+escaped+"</title>"))
|
||||
}
|
||||
|
||||
var head strings.Builder
|
||||
writeMeta(&head, "description", meta.Description)
|
||||
writeMeta(&head, "keywords", meta.Keywords)
|
||||
if canonical := strings.TrimSpace(meta.Canonical); canonical != "" {
|
||||
head.WriteString(`<link rel="canonical" href="` + html.EscapeString(canonical) + `"/>`)
|
||||
}
|
||||
robots := strings.TrimSpace(meta.Robots)
|
||||
if robots != "" {
|
||||
writeMeta(&head, "robots", robots)
|
||||
}
|
||||
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
if ogType == "" {
|
||||
ogType = "website"
|
||||
}
|
||||
locale := strings.TrimSpace(meta.Locale)
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
writeProp(&head, "og:type", ogType)
|
||||
writeProp(&head, "og:site_name", meta.SiteName)
|
||||
writeProp(&head, "og:locale", locale)
|
||||
writeProp(&head, "og:title", firstNonEmpty(meta.Title, title))
|
||||
writeProp(&head, "og:description", meta.Description)
|
||||
writeProp(&head, "og:url", meta.Canonical)
|
||||
writeProp(&head, "og:image", meta.OGImage)
|
||||
writeMetaName(&head, "twitter:card", twitterCard(meta.OGImage))
|
||||
writeMetaName(&head, "twitter:title", firstNonEmpty(meta.Title, title))
|
||||
writeMetaName(&head, "twitter:description", meta.Description)
|
||||
writeMetaName(&head, "twitter:image", meta.OGImage)
|
||||
|
||||
if jsonld := strings.TrimSpace(meta.JSONLD); jsonld != "" {
|
||||
head.WriteString(`<script type="application/ld+json">`)
|
||||
head.WriteString(jsonld)
|
||||
head.WriteString(`</script>`)
|
||||
}
|
||||
|
||||
// 同步注入品牌配置,避免 React 首屏用默认名闪一下
|
||||
if boot := spaBrandingBootScript(); boot != "" {
|
||||
head.WriteString(boot)
|
||||
}
|
||||
|
||||
if head.Len() > 0 {
|
||||
data = bytes.Replace(data, []byte("</head>"), []byte(head.String()+"</head>"), 1)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// spaBrandingBootScript 生成 window.__J13_BRANDING__=...; 内联脚本
|
||||
func spaBrandingBootScript() string {
|
||||
if spaBrandJSONFn == nil {
|
||||
return ""
|
||||
}
|
||||
raw := bytes.TrimSpace(spaBrandJSONFn())
|
||||
if len(raw) == 0 || !json.Valid(raw) {
|
||||
return ""
|
||||
}
|
||||
// 防止 JSON 字符串中的 </script> 提前闭合标签
|
||||
safe := bytes.ReplaceAll(raw, []byte("<"), []byte(`\u003c`))
|
||||
return "<script>window.__J13_BRANDING__=" + string(safe) + ";</script>"
|
||||
}
|
||||
|
||||
func writeMeta(b *strings.Builder, name, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString(`<meta name="` + html.EscapeString(name) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||
}
|
||||
|
||||
func writeMetaName(b *strings.Builder, name, content string) {
|
||||
writeMeta(b, name, content)
|
||||
}
|
||||
|
||||
func writeProp(b *strings.Builder, prop, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString(`<meta property="` + html.EscapeString(prop) + `" content="` + html.EscapeString(content) + `"/>`)
|
||||
}
|
||||
|
||||
func twitterCard(ogImage string) string {
|
||||
if strings.TrimSpace(ogImage) != "" {
|
||||
return "summary_large_image"
|
||||
}
|
||||
return "summary"
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if s := strings.TrimSpace(v); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
/* 姜十三论坛全局样式 */
|
||||
:root {
|
||||
--primary: #2c5530;
|
||||
--primary-light: #3d7a44;
|
||||
--accent: #c9a227;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
background: #212529;
|
||||
color: #adb5bd;
|
||||
padding: 1.5rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.post-content {
|
||||
line-height: 1.8;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.post-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.comment-floor {
|
||||
border-left: 3px solid var(--primary);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.avatar-sm {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-md {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.badge-pin {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link {
|
||||
color: #333;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.login-slogan {
|
||||
font-style: italic;
|
||||
color: #6c757d;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.navbar-brand span.en { display: none; }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// 姜十三论坛前端通用脚本
|
||||
function apiPost(url, data, isForm) {
|
||||
const opts = { method: 'POST', credentials: 'same-origin' };
|
||||
if (isForm) {
|
||||
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
||||
if (!(data instanceof HTMLFormElement)) {
|
||||
const fd = new FormData();
|
||||
for (const k in data) fd.append(k, data[k]);
|
||||
opts.body = fd;
|
||||
}
|
||||
} else {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(data);
|
||||
}
|
||||
return fetch(url, opts).then(r => r.json());
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
const el = document.getElementById('toast');
|
||||
if (!el) { alert(msg); return; }
|
||||
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
||||
el.querySelector('.toast-body').textContent = msg;
|
||||
new bootstrap.Toast(el).show();
|
||||
}
|
||||
|
||||
function confirmAction(msg, callback) {
|
||||
if (confirm(msg)) callback();
|
||||
}
|
||||
@@ -1,499 +0,0 @@
|
||||
/* 姜十三论坛 - 前台 + 管理后台样式 */
|
||||
:root {
|
||||
--primary: #1a7f4b;
|
||||
--primary-dark: #156b3f;
|
||||
--primary-light: #e8f5ee;
|
||||
--accent: #c9a227;
|
||||
--admin-sidebar-w: 220px;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: #f4f6f8;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
/* ===== 管理后台 ===== */
|
||||
.admin-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: #f2f3f5;
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
height: 56px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,.1);
|
||||
}
|
||||
|
||||
.admin-topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-topbar-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-topbar-title {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.admin-topbar-sub {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: .75;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.admin-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-topbar-user {
|
||||
font-size: 13px;
|
||||
opacity: .9;
|
||||
padding-right: 4px;
|
||||
border-right: 1px solid rgba(255,255,255,.25);
|
||||
margin-right: 4px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: var(--admin-sidebar-w);
|
||||
flex-shrink: 0;
|
||||
background: #fff;
|
||||
border-right: 1px solid #e5e6eb;
|
||||
padding: 20px 14px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-sidebar-section {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #86909c;
|
||||
padding: 12px 12px 6px;
|
||||
letter-spacing: .06em;
|
||||
}
|
||||
|
||||
.admin-sidebar-section:first-child {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link {
|
||||
display: block;
|
||||
color: #4e5969;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 2px;
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: background .15s, color .15s;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link:hover {
|
||||
background: #f7f8fa;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-sidebar .nav-link.active {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
border-left-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 28px 32px;
|
||||
overflow-y: auto;
|
||||
max-width: 1280px;
|
||||
}
|
||||
|
||||
.admin-page-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-page-head h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 6px;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-page-head p {
|
||||
margin: 0;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-search-bar .form-control {
|
||||
width: 240px;
|
||||
border-color: #e5e6eb;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 12px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
|
||||
.admin-stat-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.06);
|
||||
}
|
||||
|
||||
.admin-stat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-stat-users .admin-stat-icon { background: #e8f3ff; color: #3491fa; }
|
||||
.admin-stat-posts .admin-stat-icon { background: #e8f5ee; color: var(--primary); }
|
||||
.admin-stat-boards .admin-stat-icon { background: #fff7e8; color: #ff7d00; }
|
||||
.admin-stat-comments .admin-stat-icon { background: #f5e8ff; color: #722ed1; }
|
||||
.admin-stat-online .admin-stat-icon { background: #e8ffea; color: #00b42a; }
|
||||
|
||||
.admin-stat-users .value { color: #3491fa; }
|
||||
.admin-stat-posts .value { color: var(--primary); }
|
||||
.admin-stat-boards .value { color: #ff7d00; }
|
||||
.admin-stat-comments .value { color: #722ed1; }
|
||||
.admin-stat-online .value { color: #00b42a; }
|
||||
|
||||
.admin-stat-card .value {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-stat-card .label {
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.04);
|
||||
}
|
||||
|
||||
.admin-card-head {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-card-link {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: opacity .15s;
|
||||
}
|
||||
|
||||
.admin-card-link:hover {
|
||||
opacity: .75;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.admin-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
--bs-table-hover-bg: #f7f8fa;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
background: #fafbfc;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
color: #4e5969;
|
||||
font-size: 12px;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
vertical-align: middle;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover td {
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.admin-table .btn-link {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-table .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.text-truncate-cell {
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-pin {
|
||||
background: #ff7d00;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: var(--primary-light);
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-banned {
|
||||
background: #ffece8;
|
||||
color: #f53f3f;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-empty {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: #86909c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #f2f3f5;
|
||||
}
|
||||
|
||||
.admin-pagination-info {
|
||||
font-size: 13px;
|
||||
color: #86909c;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.admin-body .btn-success {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-body .btn-success:hover {
|
||||
background: var(--primary-dark);
|
||||
border-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.admin-body .btn-outline-success {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-body .btn-outline-success:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.admin-body .form-control:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(26,127,75,.12);
|
||||
}
|
||||
|
||||
.admin-body .form-label {
|
||||
color: #4e5969;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(160deg, #f2f3f5 0%, #e8f5ee 60%, #d4edda 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e6eb;
|
||||
border-radius: 16px;
|
||||
padding: 36px 32px;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.admin-login-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 14px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 18px;
|
||||
box-shadow: 0 4px 12px rgba(26,127,75,.25);
|
||||
}
|
||||
|
||||
.login-slogan {
|
||||
color: #86909c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f2f3f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-row .info-label { width: 100px; color: #86909c; flex-shrink: 0; }
|
||||
.info-row .info-value { flex: 1; word-break: break-all; color: #1d2129; }
|
||||
|
||||
#adminToast.toast {
|
||||
background: #1d2129;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.15);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-stat-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-shell { flex-direction: column; }
|
||||
.admin-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e6eb;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.admin-sidebar-section { display: none; }
|
||||
.admin-sidebar .nav-link {
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
border-left: none;
|
||||
}
|
||||
.admin-sidebar .nav-link.active {
|
||||
border-left: none;
|
||||
}
|
||||
.admin-main { padding: 16px; max-width: none; }
|
||||
.admin-stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-stat-card { padding: 14px 16px; }
|
||||
.admin-stat-card .value { font-size: 22px; }
|
||||
.admin-topbar-user { display: none; }
|
||||
.admin-search-bar .form-control { width: 160px; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.admin-stat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 旧版前台兼容 ===== */
|
||||
.navbar-brand { font-weight: 700; letter-spacing: 0.05em; }
|
||||
.post-content { line-height: 1.8; word-break: break-word; }
|
||||
.post-content img { max-width: 100%; height: auto; }
|
||||
.avatar-sm { width: 36px; height: 36px; object-fit: cover; border-radius: 50%; }
|
||||
@@ -1,79 +0,0 @@
|
||||
// 姜十三论坛 - 管理后台通用脚本
|
||||
|
||||
async function adminFetch(url, opts) {
|
||||
opts = opts || {};
|
||||
var res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
||||
var data;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (e) {
|
||||
throw new Error('服务器响应异常,请重新登录后再试');
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '请求失败');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
var el = document.getElementById('adminToast');
|
||||
if (!el) {
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
el.className = 'toast align-items-center text-bg-' + (type || 'success') + ' border-0 show';
|
||||
el.querySelector('.toast-body').textContent = msg;
|
||||
bootstrap.Toast.getOrCreateInstance(el, { delay: 2800 }).show();
|
||||
}
|
||||
|
||||
function adminConfirm(msg, fn) {
|
||||
if (confirm(msg)) fn();
|
||||
}
|
||||
|
||||
function setBtnLoading(btn, loading) {
|
||||
if (!btn) return;
|
||||
btn.disabled = loading;
|
||||
if (loading) {
|
||||
btn.dataset.originalHtml = btn.innerHTML;
|
||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||
} else if (btn.dataset.originalHtml) {
|
||||
btn.innerHTML = btn.dataset.originalHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function adminLogout() {
|
||||
adminFetch('/admin/api/logout', { method: 'POST' })
|
||||
.then(function () { location.href = '/admin/login'; })
|
||||
.catch(function (e) { showToast(e.message, 'danger'); });
|
||||
}
|
||||
|
||||
function postForm(url, method, fields) {
|
||||
var fd = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
|
||||
return adminFetch(url, { method: method, body: fd });
|
||||
}
|
||||
|
||||
function reloadSoon() {
|
||||
setTimeout(function () { location.reload(); }, 600);
|
||||
}
|
||||
|
||||
// 兼容旧模板
|
||||
function apiPost(url, data, isForm) {
|
||||
var opts = { method: 'POST', credentials: 'same-origin' };
|
||||
if (isForm) {
|
||||
opts.body = new FormData(data instanceof HTMLFormElement ? data : undefined);
|
||||
if (!(data instanceof HTMLFormElement)) {
|
||||
var fd = new FormData();
|
||||
for (var k in data) fd.append(k, data[k]);
|
||||
opts.body = fd;
|
||||
}
|
||||
} else {
|
||||
opts.headers = { 'Content-Type': 'application/json' };
|
||||
opts.body = JSON.stringify(data);
|
||||
}
|
||||
return fetch(url, opts).then(function (r) { return r.json(); });
|
||||
}
|
||||
|
||||
function confirmAction(msg, callback) {
|
||||
adminConfirm(msg, callback);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
{{define "admin/boards.html"}}{{template "admin/layout" .}}{{end}}
|
||||
{{define "admin_content_boards"}}
|
||||
<div class="admin-page-head">
|
||||
<div>
|
||||
<h1>板块管理</h1>
|
||||
<p>创建和维护论坛板块,有帖子的板块无法删除</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-head">新建板块</div>
|
||||
<div class="admin-card-body">
|
||||
<form id="createBoardForm" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">板块名称</label>
|
||||
<input name="name" class="form-control" placeholder="如:技术交流" required maxlength="64">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">简介</label>
|
||||
<input name="description" class="form-control" placeholder="板块说明(可选)" maxlength="500">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">排序</label>
|
||||
<input name="sort_order" type="number" class="form-control" value="0">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-success w-100" id="createBoardBtn">创建板块</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-head">板块列表 <span class="text-muted fw-normal">共 {{len .Boards}} 个</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table admin-table mb-0">
|
||||
<thead><tr><th>ID</th><th>名称</th><th>描述</th><th>排序</th><th>帖子数</th><th width="160">操作</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Boards}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td><input class="form-control form-control-sm" id="name-{{.ID}}" value="{{.Name}}"></td>
|
||||
<td><input class="form-control form-control-sm" id="desc-{{.ID}}" value="{{.Description}}"></td>
|
||||
<td><input class="form-control form-control-sm" id="sort-{{.ID}}" type="number" value="{{.SortOrder}}" style="width:72px"></td>
|
||||
<td><span class="badge bg-secondary">{{.PostCount}}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" onclick="updateBoard({{.ID}}, this)">保存</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteBoard({{.ID}}, {{.PostCount}})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="6" class="admin-empty">还没有板块,请先创建</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_scripts_boards"}}
|
||||
<script>
|
||||
document.getElementById('createBoardForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('createBoardBtn');
|
||||
setBtnLoading(btn, true);
|
||||
adminFetch('/admin/api/boards', { method: 'POST', body: new FormData(this) })
|
||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); })
|
||||
.finally(function() { setBtnLoading(btn, false); });
|
||||
});
|
||||
|
||||
function updateBoard(id, btn) {
|
||||
setBtnLoading(btn, true);
|
||||
postForm('/admin/api/boards/' + id, 'PUT', {
|
||||
name: document.getElementById('name-' + id).value,
|
||||
description: document.getElementById('desc-' + id).value,
|
||||
sort_order: document.getElementById('sort-' + id).value
|
||||
}).then(function(d) { showToast(d.message); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); })
|
||||
.finally(function() { setBtnLoading(btn, false); });
|
||||
}
|
||||
|
||||
function deleteBoard(id, postCount) {
|
||||
if (postCount > 0) {
|
||||
showToast('该板块下还有 ' + postCount + ' 篇帖子,无法删除', 'warning');
|
||||
return;
|
||||
}
|
||||
adminConfirm('确定删除该板块?此操作不可恢复。', function() {
|
||||
adminFetch('/admin/api/boards/' + id, { method: 'DELETE' })
|
||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,58 +0,0 @@
|
||||
{{define "admin/comments.html"}}{{template "admin/layout" .}}{{end}}
|
||||
{{define "admin_content_comments"}}
|
||||
<div class="admin-page-head">
|
||||
<div>
|
||||
<h1>评论管理</h1>
|
||||
<p>查看和删除评论,共 {{.Total}} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table admin-table mb-0">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>楼层</th><th>帖子</th><th>作者</th><th>回复</th><th>内容</th><th>时间</th><th width="100">操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Comments}}
|
||||
<tr>
|
||||
<td>{{.ID}}</td>
|
||||
<td>#{{.Floor}}</td>
|
||||
<td class="text-truncate-cell">
|
||||
{{if .Post}}
|
||||
<a href="/post/{{.PostID}}" target="_blank">{{.Post.Title}}</a>
|
||||
{{else}}帖子 #{{.PostID}}{{end}}
|
||||
</td>
|
||||
<td>{{if .UserID}}{{if .User}}{{.User.Nickname}}{{else}}-{{end}}{{else}}{{.GuestNick}}{{end}}</td>
|
||||
<td>
|
||||
{{if .ReplyTarget}}
|
||||
@{{if .ReplyTarget.UserID}}{{if .ReplyTarget.User}}{{.ReplyTarget.User.Nickname}}{{else}}-{{end}}{{else}}{{.ReplyTarget.GuestNick}}{{end}}
|
||||
{{else}}-{{end}}
|
||||
</td>
|
||||
<td class="text-truncate-cell">{{.Content}}</td>
|
||||
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteComment({{.ID}})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="8" class="admin-empty">暂无评论</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{template "admin_pagination" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_scripts_comments"}}
|
||||
<script>
|
||||
function deleteComment(id) {
|
||||
adminConfirm('确定删除该评论?', function() {
|
||||
adminFetch('/admin/api/comments/' + id, { method: 'DELETE' })
|
||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,69 +0,0 @@
|
||||
{{define "admin/dashboard.html"}}{{template "admin/layout" .}}{{end}}
|
||||
{{define "admin_content_dashboard"}}
|
||||
<div class="admin-page-head">
|
||||
<div>
|
||||
<h1>仪表盘</h1>
|
||||
<p>欢迎回来{{if .CurrentUser}},{{.CurrentUser.Nickname}}{{end}} · 论坛运行概况</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-stat-grid">
|
||||
<div class="admin-stat-card admin-stat-users">
|
||||
<div class="admin-stat-icon">用</div>
|
||||
<div class="admin-stat-info">
|
||||
<div class="value">{{.UserCount}}</div>
|
||||
<div class="label">注册用户</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-stat-posts">
|
||||
<div class="admin-stat-icon">帖</div>
|
||||
<div class="admin-stat-info">
|
||||
<div class="value">{{.PostCount}}</div>
|
||||
<div class="label">帖子总数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-stat-boards">
|
||||
<div class="admin-stat-icon">板</div>
|
||||
<div class="admin-stat-info">
|
||||
<div class="value">{{.BoardCount}}</div>
|
||||
<div class="label">板块数量</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-stat-comments">
|
||||
<div class="admin-stat-icon">评</div>
|
||||
<div class="admin-stat-info">
|
||||
<div class="value">{{.CommentCount}}</div>
|
||||
<div class="label">评论总数</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-head">
|
||||
<span>最近帖子</span>
|
||||
<a href="/admin/posts" class="admin-card-link">查看全部 →</a>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table admin-table mb-0">
|
||||
<thead><tr><th>标题</th><th>板块</th><th>作者</th><th>时间</th><th width="80"></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .RecentPosts}}
|
||||
<tr>
|
||||
<td class="text-truncate-cell">
|
||||
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Title}}
|
||||
</td>
|
||||
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
|
||||
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
|
||||
<td class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</td>
|
||||
<td><a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link px-0">查看</a></td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="5" class="admin-empty">暂无帖子</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_scripts_dashboard"}}{{end}}
|
||||
@@ -1,88 +0,0 @@
|
||||
{{define "admin/layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - 姜十三论坛管理后台</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/legacy/css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="admin-body">
|
||||
<div class="toast-container position-fixed top-0 end-0 p-3" style="z-index:9999">
|
||||
<div id="adminToast" class="toast" role="alert">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body"></div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header class="admin-topbar">
|
||||
<div class="admin-topbar-brand">
|
||||
<div class="admin-topbar-mark">姜</div>
|
||||
<div>
|
||||
<span class="admin-topbar-title">姜十三论坛</span>
|
||||
<span class="admin-topbar-sub">管理后台</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-topbar-actions">
|
||||
{{if .CurrentUser}}
|
||||
<span class="admin-topbar-user">{{.CurrentUser.Nickname}}</span>
|
||||
{{end}}
|
||||
<a href="/" class="btn btn-outline-light btn-sm">返回前台</a>
|
||||
<button class="btn btn-light btn-sm" onclick="adminLogout()">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-shell">
|
||||
<aside class="admin-sidebar">
|
||||
<div class="admin-sidebar-section">概览</div>
|
||||
<a href="/admin/dashboard" class="nav-link {{if eq .ActiveNav "dashboard"}}active{{end}}">仪表盘</a>
|
||||
|
||||
<div class="admin-sidebar-section">内容</div>
|
||||
<a href="/admin/boards" class="nav-link {{if eq .ActiveNav "boards"}}active{{end}}">板块管理</a>
|
||||
<a href="/admin/posts" class="nav-link {{if eq .ActiveNav "posts"}}active{{end}}">帖子管理</a>
|
||||
<a href="/admin/comments" class="nav-link {{if eq .ActiveNav "comments"}}active{{end}}">评论管理</a>
|
||||
|
||||
<div class="admin-sidebar-section">系统</div>
|
||||
<a href="/admin/users" class="nav-link {{if eq .ActiveNav "users"}}active{{end}}">用户管理</a>
|
||||
<a href="/admin/settings" class="nav-link {{if eq .ActiveNav "settings"}}active{{end}}">系统设置</a>
|
||||
</aside>
|
||||
|
||||
<main class="admin-main">
|
||||
{{if eq .ActiveNav "dashboard"}}{{template "admin_content_dashboard" .}}
|
||||
{{else if eq .ActiveNav "boards"}}{{template "admin_content_boards" .}}
|
||||
{{else if eq .ActiveNav "posts"}}{{template "admin_content_posts" .}}
|
||||
{{else if eq .ActiveNav "comments"}}{{template "admin_content_comments" .}}
|
||||
{{else if eq .ActiveNav "users"}}{{template "admin_content_users" .}}
|
||||
{{else if eq .ActiveNav "settings"}}{{template "admin_content_settings" .}}
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/legacy/js/app.js"></script>
|
||||
{{if eq .ActiveNav "boards"}}{{template "admin_scripts_boards" .}}
|
||||
{{else if eq .ActiveNav "posts"}}{{template "admin_scripts_posts" .}}
|
||||
{{else if eq .ActiveNav "comments"}}{{template "admin_scripts_comments" .}}
|
||||
{{else if eq .ActiveNav "users"}}{{template "admin_scripts_users" .}}
|
||||
{{else if eq .ActiveNav "settings"}}{{template "admin_scripts_settings" .}}
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_pagination"}}
|
||||
{{if gt .TotalPages 1}}
|
||||
<nav class="admin-pagination">
|
||||
{{if gt .Page 1}}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="?page={{sub .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">上一页</a>
|
||||
{{end}}
|
||||
<span class="admin-pagination-info">第 {{.Page}} / {{.TotalPages}} 页</span>
|
||||
{{if lt .Page .TotalPages}}
|
||||
<a class="btn btn-sm btn-outline-secondary" href="?page={{add .Page 1}}{{if .Keyword}}&keyword={{.Keyword}}{{end}}">下一页</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
{{end}}
|
||||
{{end}}
|
||||
@@ -1,49 +0,0 @@
|
||||
{{define "admin/login.html"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>后台登录 - 姜十三论坛</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/legacy/css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body class="admin-login-page">
|
||||
<div class="admin-login-card">
|
||||
<div class="admin-login-mark">姜</div>
|
||||
<h4 class="text-center mb-1">管理后台登录</h4>
|
||||
<p class="login-slogan text-center mb-4">姜十三论坛 · 仅管理员可访问</p>
|
||||
{{if eq .QueryBanned "1"}}
|
||||
<div class="alert alert-warning small py-2">账号已被禁言,无法登录后台</div>
|
||||
{{end}}
|
||||
<form id="adminLoginForm">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">管理员账号</label>
|
||||
<input type="text" name="username" class="form-control" placeholder="用户名" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label small">密码</label>
|
||||
<input type="password" name="password" class="form-control" placeholder="密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100" id="loginBtn">登录</button>
|
||||
</form>
|
||||
<p class="text-center mt-3 mb-0 small">
|
||||
<a href="/" class="text-muted">← 返回论坛前台</a>
|
||||
</p>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/legacy/js/app.js"></script>
|
||||
<script>
|
||||
document.getElementById('adminLoginForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
var btn = document.getElementById('loginBtn');
|
||||
setBtnLoading(btn, true);
|
||||
adminFetch('/admin/api/login', { method: 'POST', body: new FormData(this) })
|
||||
.then(function() { location.href = '/admin/dashboard'; })
|
||||
.catch(function(err) { alert(err.message); })
|
||||
.finally(function() { setBtnLoading(btn, false); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,67 +0,0 @@
|
||||
{{define "admin/posts.html"}}{{template "admin/layout" .}}{{end}}
|
||||
{{define "admin_content_posts"}}
|
||||
<div class="admin-page-head">
|
||||
<div>
|
||||
<h1>帖子管理</h1>
|
||||
<p>置顶、删除帖子,共 {{.Total}} 篇</p>
|
||||
</div>
|
||||
<form class="admin-search-bar" method="get">
|
||||
<input name="keyword" class="form-control form-control-sm" placeholder="搜索标题/内容..." value="{{.Keyword}}">
|
||||
<button class="btn btn-sm btn-success">搜索</button>
|
||||
{{if .Keyword}}<a href="/admin/posts" class="btn btn-sm btn-outline-secondary">清除</a>{{end}}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table admin-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th><th>标题</th><th>板块</th><th>作者</th>
|
||||
<th>置顶</th><th>点赞</th><th>浏览</th><th>时间</th><th width="180">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Posts}}
|
||||
<tr id="post-row-{{.ID}}">
|
||||
<td>{{.ID}}</td>
|
||||
<td class="text-truncate-cell">{{.Title}}</td>
|
||||
<td>{{if .Board}}{{.Board.Name}}{{else}}-{{end}}</td>
|
||||
<td>{{if .User}}{{.User.Nickname}}{{else}}-{{end}}</td>
|
||||
<td>{{if .Pinned}}<span class="badge badge-pin">是</span>{{else}}<span class="text-muted">否</span>{{end}}</td>
|
||||
<td>{{.LikeCount}}</td>
|
||||
<td>{{.ViewCount}}</td>
|
||||
<td>{{.CreatedAt.Format "01-02 15:04"}}</td>
|
||||
<td>
|
||||
<a href="/post/{{.ID}}" target="_blank" class="btn btn-sm btn-link">查看</a>
|
||||
<button class="btn btn-sm btn-outline-warning" onclick="togglePin({{.ID}}, {{.Pinned}})">{{if .Pinned}}取消置顶{{else}}置顶{{end}}</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deletePost({{.ID}})">删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="9" class="admin-empty">没有找到帖子</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{template "admin_pagination" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_scripts_posts"}}
|
||||
<script>
|
||||
function togglePin(id, pinned) {
|
||||
postForm('/admin/api/posts/' + id + '/pin', 'POST', { pinned: pinned ? 'false' : 'true' })
|
||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||
}
|
||||
|
||||
function deletePost(id) {
|
||||
adminConfirm('确定删除该帖子?相关评论也将一并删除。', function() {
|
||||
adminFetch('/admin/api/posts/' + id, { method: 'DELETE' })
|
||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,56 +0,0 @@
|
||||
{{define "admin/settings.html"}}{{template "admin/layout" .}}{{end}}
|
||||
{{define "admin_content_settings"}}
|
||||
<div class="admin-page-head">
|
||||
<div>
|
||||
<h1>系统设置</h1>
|
||||
<p>数据目录、敏感词配置与数据库备份</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-head">运行信息</div>
|
||||
<div class="admin-card-body">
|
||||
<div class="info-row"><div class="info-label">数据目录</div><div class="info-value"><code>{{.DataDir}}</code></div></div>
|
||||
<div class="info-row"><div class="info-label">数据库文件</div><div class="info-value"><code>{{.DBPath}}</code></div></div>
|
||||
<div class="info-row"><div class="info-label">监听端口</div><div class="info-value">{{.Port}}</div></div>
|
||||
<div class="info-row"><div class="info-label">敏感词配置</div><div class="info-value"><code>{{.FilterPath}}</code></div></div>
|
||||
<p class="text-muted small mt-3 mb-0">敏感词文件每行一个词,<code>#</code> 开头为注释,修改后需重启服务生效。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="admin-card">
|
||||
<div class="admin-card-head">数据库备份</div>
|
||||
<div class="admin-card-body">
|
||||
<p class="text-muted small">将 SQLite 数据库复制到数据目录,生成带时间戳的备份文件。</p>
|
||||
<button class="btn btn-success" id="backupBtn" onclick="doBackup()">一键导出备份</button>
|
||||
<div id="backupResult" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_scripts_settings"}}
|
||||
<script>
|
||||
function doBackup() {
|
||||
var btn = document.getElementById('backupBtn');
|
||||
setBtnLoading(btn, true);
|
||||
adminFetch('/admin/api/backup', { method: 'POST' })
|
||||
.then(function(d) {
|
||||
showToast(d.message);
|
||||
var html = '<div class="alert alert-success small mb-0">';
|
||||
html += '<div>备份文件:<code>' + d.filename + '</code></div>';
|
||||
if (d.download) {
|
||||
html += '<a href="' + d.download + '" class="btn btn-sm btn-outline-success mt-2">下载备份文件</a>';
|
||||
}
|
||||
html += '<div class="text-muted mt-2">存储路径:' + d.path + '</div></div>';
|
||||
document.getElementById('backupResult').innerHTML = html;
|
||||
})
|
||||
.catch(function(e) { showToast(e.message, 'danger'); })
|
||||
.finally(function() { setBtnLoading(btn, false); });
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,65 +0,0 @@
|
||||
{{define "admin/users.html"}}{{template "admin/layout" .}}{{end}}
|
||||
{{define "admin_content_users"}}
|
||||
<div class="admin-page-head">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>禁言违规用户,共 {{.Total}} 位注册用户</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-card">
|
||||
<div class="table-responsive">
|
||||
<table class="table admin-table mb-0">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>用户名</th><th>昵称</th><th>邮箱</th><th>角色</th><th>状态</th><th>上次登录</th><th>登录 IP</th><th>注册时间</th><th width="120">操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<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>
|
||||
{{else}}普通用户{{end}}
|
||||
</td>
|
||||
<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"}}
|
||||
<span class="text-muted small">—</span>
|
||||
{{else if .Banned}}
|
||||
<button class="btn btn-sm btn-success" onclick="banUser({{.ID}}, false)">解除禁言</button>
|
||||
{{else}}
|
||||
<button class="btn btn-sm btn-outline-warning" onclick="banUser({{.ID}}, true)">禁言</button>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="10" class="admin-empty">暂无用户</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{template "admin_pagination" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_scripts_users"}}
|
||||
<script>
|
||||
function banUser(id, banned) {
|
||||
var msg = banned ? '确定禁言该用户?禁言后无法登录和发帖。' : '确定解除该用户的禁言?';
|
||||
adminConfirm(msg, function() {
|
||||
postForm('/admin/api/users/' + id + '/ban', 'POST', { banned: banned ? 'true' : 'false' })
|
||||
.then(function(d) { showToast(d.message); reloadSoon(); })
|
||||
.catch(function(e) { showToast(e.message, 'danger'); });
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,16 +0,0 @@
|
||||
{{define "board.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item active">{{.Board.Name}}</li></ol></nav>
|
||||
<h3>{{.Board.Name}}</h3>
|
||||
<p class="text-muted">{{.Board.Description}}</p>
|
||||
{{if .CurrentUser}}<a href="/post/new?board={{.Board.ID}}" class="btn btn-success btn-sm mb-3">在此板块发帖</a>{{end}}
|
||||
{{range .Posts}}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
|
||||
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
|
||||
<div class="small text-muted mt-1">{{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}}</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}<p class="text-muted">该板块暂无帖子</p>{{end}}
|
||||
{{end}}
|
||||
@@ -1,7 +0,0 @@
|
||||
{{define "error.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="text-center py-5">
|
||||
<h3>{{.Message}}</h3>
|
||||
<a href="/" class="btn btn-primary mt-3">返回首页</a>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,10 +0,0 @@
|
||||
{{define "favorites.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<h3>我的收藏</h3>
|
||||
{{range .Favorites}}
|
||||
<div class="card mb-2"><div class="card-body py-2">
|
||||
<a href="/post/{{.Post.ID}}" class="fw-semibold text-decoration-none">{{.Post.Title}}</a>
|
||||
<div class="small text-muted">{{.Post.Board.Name}} · 收藏于 {{.CreatedAt.Format "2006-01-02"}}</div>
|
||||
</div></div>
|
||||
{{else}}<p class="text-muted">暂无收藏</p>{{end}}
|
||||
{{end}}
|
||||
@@ -1,34 +0,0 @@
|
||||
{{define "index.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<h4 class="mb-3">最新帖子</h4>
|
||||
{{range .Posts}}
|
||||
<div class="card mb-2">
|
||||
<div class="card-body py-2">
|
||||
{{if .Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}
|
||||
<a href="/post/{{.ID}}" class="text-decoration-none fw-semibold">{{.Title}}</a>
|
||||
<div class="small text-muted mt-1">
|
||||
{{.Board.Name}} · {{.User.Nickname}} · {{.CreatedAt.Format "2006-01-02 15:04"}} · 👍 {{.LikeCount}} · 👁 {{.ViewCount}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">暂无帖子,快来发帖吧!</p>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<h5>板块列表</h5>
|
||||
<div class="list-group">
|
||||
{{range .Boards}}
|
||||
<a href="/board/{{.ID}}" class="list-group-item list-group-item-action">
|
||||
<strong>{{.Name}}</strong>
|
||||
<small class="d-block text-muted">{{.Description}}</small>
|
||||
</a>
|
||||
{{else}}
|
||||
<p class="text-muted small">管理员尚未创建板块</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -1,56 +0,0 @@
|
||||
{{define "layout"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} - 姜十三论坛</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/assets/css/style.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark" style="background:var(--primary)">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">姜十三论坛 <span class="en fs-6 opacity-75">Jiang13 Forum</span></a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#nav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="nav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<li class="nav-item"><a class="nav-link" href="/">首页</a></li>
|
||||
{{if .CurrentUser}}
|
||||
<li class="nav-item"><a class="nav-link" href="/post/new">发帖</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/favorites">我的收藏</a></li>
|
||||
{{end}}
|
||||
{{if .IsAdmin}}<li class="nav-item"><a class="nav-link" href="/admin/dashboard">管理后台</a></li>{{end}}
|
||||
</ul>
|
||||
<ul class="navbar-nav">
|
||||
{{if .CurrentUser}}
|
||||
<li class="nav-item"><a class="nav-link" href="/profile">{{.CurrentUser.Nickname}}</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="#" onclick="logout()">退出</a></li>
|
||||
{{else}}
|
||||
<li class="nav-item"><a class="nav-link" href="/login">登录</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/register">注册</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container py-4 flex-grow-1">
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
<footer class="site-footer text-center">
|
||||
<div class="container">© 2026 姜十三论坛 Jiang13 Forum · 拾三一隅,自在交流</div>
|
||||
</footer>
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
<div id="toast" class="toast" role="alert"><div class="toast-body"></div></div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/app.js"></script>
|
||||
<script>
|
||||
function logout(){ fetch('/api/logout',{method:'POST',credentials:'same-origin'}).then(()=>location.href='/'); }
|
||||
</script>
|
||||
{{block "scripts" .}}{{end}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,36 +0,0 @@
|
||||
{{define "login.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-center mb-1">登录</h3>
|
||||
<p class="text-center login-slogan mb-4">拾三一隅,自在交流</p>
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">用户名</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">密码</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success w-100">登录</button>
|
||||
</form>
|
||||
<p class="text-center mt-3 mb-0">还没有账号?<a href="/register">立即注册</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', function(e){
|
||||
e.preventDefault();
|
||||
apiPost('/api/login', this, true).then(d=>{
|
||||
if(d.error){ showToast(d.error,'danger'); return; }
|
||||
location.href='/';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,72 +0,0 @@
|
||||
{{define "post.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb"><li class="breadcrumb-item"><a href="/">首页</a></li><li class="breadcrumb-item"><a href="/board/{{.Post.BoardID}}">{{.Post.Board.Name}}</a></li><li class="breadcrumb-item active">帖子</li></ol></nav>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h3>{{if .Post.Pinned}}<span class="badge badge-pin me-1">置顶</span>{{end}}{{.Post.Title}}</h3>
|
||||
<div class="small text-muted mb-3">
|
||||
<a href="/user/{{.Post.UserID}}">{{.Post.User.Nickname}}</a> · {{.Post.CreatedAt.Format "2006-01-02 15:04"}} · 👁 {{.Post.ViewCount}}
|
||||
{{if .Post.Tags}}· 标签:{{.Post.Tags}}{{end}}
|
||||
</div>
|
||||
<div class="post-content">{{safeHTML .Post.Content}}</div>
|
||||
<div class="mt-3 d-flex gap-2 flex-wrap">
|
||||
{{if .CurrentUser}}
|
||||
<button class="btn btn-outline-primary btn-sm" id="likeBtn" onclick="toggleLike()">{{if .Liked}}已点赞{{else}}点赞{{end}}</button>
|
||||
<button class="btn btn-outline-warning btn-sm" id="favBtn" onclick="toggleFav()">{{if .Favorited}}已收藏{{else}}收藏{{end}}</button>
|
||||
{{if or (eq .CurrentUserID .Post.UserID) .IsAdmin}}
|
||||
<a href="/post/{{.Post.ID}}/edit" class="btn btn-outline-secondary btn-sm">编辑</a>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deletePost()">删除</button>
|
||||
{{end}}
|
||||
{{if .IsAdmin}}
|
||||
<button class="btn btn-outline-dark btn-sm" onclick="pinPost()">{{if .Post.Pinned}}取消置顶{{else}}置顶{{end}}</button>
|
||||
{{end}}
|
||||
{{else}}<a href="/login" class="btn btn-outline-primary btn-sm">登录后互动</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h5>评论 ({{len .Comments}})</h5>
|
||||
{{range .Comments}}
|
||||
<div class="card mb-2 comment-floor">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between">
|
||||
<strong>#{{.Floor}} {{.User.Nickname}}</strong>
|
||||
<small class="text-muted">{{.CreatedAt.Format "01-02 15:04"}}</small>
|
||||
</div>
|
||||
{{if .ReplyUser}}<div class="small text-muted">回复 #{{.ReplyUser.Floor}} {{.ReplyUser.User.Nickname}}</div>{{end}}
|
||||
<div class="mt-1">{{.Content}}</div>
|
||||
{{if $.CurrentUser}}
|
||||
<button class="btn btn-link btn-sm p-0" onclick="replyTo({{.ID}},{{.Floor}},'{{.User.Nickname}}')">回复</button>
|
||||
{{if or (eq $.CurrentUserID .UserID) $.IsAdmin}}
|
||||
<button class="btn btn-link btn-sm p-0 text-danger" onclick="deleteComment({{.ID}})">删除</button>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .CurrentUser}}
|
||||
<div class="card mt-3">
|
||||
<div class="card-body">
|
||||
<form id="commentForm">
|
||||
<div id="replyHint" class="small text-muted mb-2" style="display:none"></div>
|
||||
<input type="hidden" name="reply_to" id="replyTo">
|
||||
<textarea name="content" class="form-control mb-2" rows="3" placeholder="写下你的评论..." required></textarea>
|
||||
<button type="submit" class="btn btn-success btn-sm">发表评论</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="cancelReply()" id="cancelReplyBtn" style="display:none">取消回复</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
const postId={{.Post.ID}};
|
||||
function toggleLike(){ fetch('/api/posts/'+postId+'/like',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('likeBtn').textContent=d.liked?'已点赞':'点赞'; }); }
|
||||
function toggleFav(){ fetch('/api/posts/'+postId+'/favorite',{method:'POST',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} document.getElementById('favBtn').textContent=d.favorited?'已收藏':'收藏'; }); }
|
||||
function deletePost(){ confirmAction('确定删除此帖?',()=>{ fetch('/api/posts/'+postId,{method:'DELETE',credentials:'same-origin'}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.href='/board/{{.Post.BoardID}}'; }); }); }
|
||||
function pinPost(){ fetch('/admin/api/posts/'+postId+'/pin',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:'pinned={{if .Post.Pinned}}false{{else}}true{{end}}'}).then(r=>r.json()).then(()=>location.reload()); }
|
||||
function replyTo(id,floor,name){ document.getElementById('replyTo').value=id; document.getElementById('replyHint').style.display='block'; document.getElementById('replyHint').textContent='回复 #'+floor+' '+name; document.getElementById('cancelReplyBtn').style.display='inline-block'; }
|
||||
function cancelReply(){ document.getElementById('replyTo').value=''; document.getElementById('replyHint').style.display='none'; document.getElementById('cancelReplyBtn').style.display='none'; }
|
||||
function deleteComment(id){ confirmAction('确定删除?',()=>{ fetch('/api/comments/'+id,{method:'DELETE',credentials:'same-origin'}).then(()=>location.reload()); }); }
|
||||
document.getElementById('commentForm')?.addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/posts/'+postId+'/comments',this,true).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,31 +0,0 @@
|
||||
{{define "post_edit.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<h3>编辑帖子</h3>
|
||||
<form id="editForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">标题</label>
|
||||
<input type="text" name="title" class="form-control" value="{{.Post.Title}}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">标签</label>
|
||||
<input type="text" name="tags" class="form-control" value="{{.Post.Tags}}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">内容</label>
|
||||
<textarea name="content" class="form-control" rows="12" required>{{.Post.Content}}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">保存</button>
|
||||
<a href="/post/{{.Post.ID}}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
document.getElementById('editForm').addEventListener('submit',function(e){
|
||||
e.preventDefault();
|
||||
fetch('/api/posts/{{.Post.ID}}',{method:'PUT',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{
|
||||
if(d.error){showToast(d.error,'danger');return;}
|
||||
location.href='/post/{{.Post.ID}}';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,37 +0,0 @@
|
||||
{{define "post_new.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<h3>发布新帖</h3>
|
||||
<form id="postForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">选择板块</label>
|
||||
<select name="board_id" class="form-select" required>
|
||||
{{range .Boards}}<option value="{{.ID}}">{{.Name}}</option>{{end}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">标题</label>
|
||||
<input type="text" name="title" class="form-control" required maxlength="256">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">标签(逗号分隔)</label>
|
||||
<input type="text" name="tags" class="form-control" placeholder="Go,技术,交流">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">内容(支持 HTML 富文本)</label>
|
||||
<textarea name="content" class="form-control" rows="12" required></textarea>
|
||||
<div class="form-text">可使用 <b>、<i>、<a>、<img> 等 HTML 标签</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">发布</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
document.getElementById('postForm').addEventListener('submit',function(e){
|
||||
e.preventDefault();
|
||||
apiPost('/api/posts',this,true).then(d=>{
|
||||
if(d.error){showToast(d.error,'danger');return;}
|
||||
location.href='/post/'+d.post_id;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,35 +0,0 @@
|
||||
{{define "profile.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="row">
|
||||
<div class="col-md-4 text-center mb-4">
|
||||
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2" alt="avatar">
|
||||
<h4>{{.ProfileUser.Nickname}}</h4>
|
||||
<p class="text-muted">@{{.ProfileUser.Username}}</p>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-3"><div class="card-header">修改昵称</div><div class="card-body">
|
||||
<form id="nickForm"><input type="text" name="nickname" class="form-control mb-2" value="{{.ProfileUser.Nickname}}">
|
||||
<button class="btn btn-success btn-sm">保存昵称</button></form>
|
||||
</div></div>
|
||||
<div class="card"><div class="card-header">修改密码</div><div class="card-body">
|
||||
<form id="pwdForm">
|
||||
<input type="password" name="old_password" class="form-control mb-2" placeholder="原密码" required>
|
||||
<input type="password" name="new_password" class="form-control mb-2" placeholder="新密码(至少6位)" required>
|
||||
<button class="btn btn-success btn-sm">修改密码</button>
|
||||
</form>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
document.getElementById('nickForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/nickname',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
|
||||
document.getElementById('pwdForm').addEventListener('submit',function(e){ e.preventDefault(); apiPost('/api/profile/password',this,true).then(d=>{ showToast(d.error||d.message,d.error?'danger':'success'); }); });
|
||||
document.getElementById('avatarForm').addEventListener('submit',function(e){ e.preventDefault(); fetch('/api/profile/avatar',{method:'POST',credentials:'same-origin',body:new FormData(this)}).then(r=>r.json()).then(d=>{ if(d.error){showToast(d.error,'danger');return;} location.reload(); }); });
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,71 +0,0 @@
|
||||
{{define "register.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<h3 class="text-center mb-4">注册账号</h3>
|
||||
<p id="regTip" class="text-center text-muted small mb-3"></p>
|
||||
<form id="regForm">
|
||||
<div class="mb-3">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{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=>{
|
||||
if(d.error){ showToast(d.error,'danger'); return; }
|
||||
showToast('注册成功'); location.href='/';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -1,8 +0,0 @@
|
||||
{{define "user_profile.html"}}{{template "layout" .}}{{end}}
|
||||
{{define "content"}}
|
||||
<div class="text-center py-4">
|
||||
<img src="{{if .ProfileUser.Avatar}}{{.ProfileUser.Avatar}}{{else}}https://via.placeholder.com/128{{end}}" class="avatar-md mb-2">
|
||||
<h3>{{.ProfileUser.Nickname}}</h3>
|
||||
<p class="text-muted">@{{.ProfileUser.Username}} · 注册于 {{.ProfileUser.CreatedAt.Format "2006-01-02"}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="拾三一隅,自在交流" />
|
||||
<title>姜十三论坛 - 拾三一隅,自在交流</title>
|
||||
<style>
|
||||
/* 关键布局样式:在 JS/CSS 包加载前即固定三栏结构,避免刷新时组件错位 */
|
||||
@@ -12,6 +13,7 @@
|
||||
.app-shell { height: 100%; max-height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-frame { flex: 1; min-height: 0; height: 100%; max-width: 1400px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.app-header { height: 56px; flex-shrink: 0; }
|
||||
.site-footer { flex-shrink: 0; }
|
||||
.app-body { flex: 1; display: flex; min-height: 0; width: 100%; overflow: hidden; }
|
||||
.content-workspace { flex: 1; display: flex; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.sidebar { width: 210px; flex-shrink: 0; }
|
||||
|
||||
@@ -13,6 +13,7 @@ import MainLayout from './layouts/MainLayout';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import PageLoader from './components/PageLoader';
|
||||
import AuthPageFallback from './components/AuthPageFallback';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
@@ -24,18 +25,22 @@ const BoardsManagePage = lazy(() => import('./pages/BoardsManagePage'));
|
||||
const ProfilePage = lazy(() => import('./pages/ProfilePage'));
|
||||
const UserProfilePage = lazy(() => import('./pages/UserProfilePage'));
|
||||
const FavoritesPage = lazy(() => import('./pages/FavoritesPage'));
|
||||
const MessagesPage = lazy(() => import('./pages/MessagesPage'));
|
||||
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'));
|
||||
const AdminReportsPage = lazy(() => import('./pages/admin/AdminReportsPage'));
|
||||
const AdminUsersPage = lazy(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminMediaPage = lazy(() => import('./pages/admin/AdminMediaPage'));
|
||||
const AdminSettingsPage = lazy(() => import('./pages/admin/AdminSettingsPage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
|
||||
const router = createBrowserRouter(
|
||||
createRoutesFromElements(
|
||||
<>
|
||||
<Route path="/login" element={<Suspense fallback={<PageLoader fullScreen />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<PageLoader fullScreen />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/login" element={<Suspense fallback={<AuthPageFallback />}><LoginPage /></Suspense>} />
|
||||
<Route path="/register" element={<Suspense fallback={<AuthPageFallback />}><RegisterPage /></Suspense>} />
|
||||
<Route path="/boards" element={<Navigate to="/admin/boards" replace />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
<Route index element={<Navigate to="/admin/dashboard" replace />} />
|
||||
@@ -43,19 +48,26 @@ const router = createBrowserRouter(
|
||||
<Route path="boards" element={<Suspense fallback={<PageLoader />}><BoardsManagePage /></Suspense>} />
|
||||
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
||||
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
|
||||
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
|
||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
||||
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
|
||||
</Route>
|
||||
<Route element={<MainLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
{/* :id 可为 123 或 123.html(伪静态后缀由后台配置) */}
|
||||
<Route path="/post/:id" element={<PostDetailPage />} />
|
||||
<Route path="/post/:id/edit" element={<ComposePage />} />
|
||||
<Route path="/compose" element={<ComposePage />} />
|
||||
<Route path="/post/:id/edit" element={<ComposePage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/user/:id" element={<UserProfilePage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
<Route path="/messages" element={<MessagesPage />} />
|
||||
<Route path="/projects" element={<ProjectsPage />} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||
</Route>
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader fullScreen />}><NotFoundPage standalone /></Suspense>} />
|
||||
</>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, SiteBranding, RegisterConfig } from './types';
|
||||
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -60,23 +60,60 @@ export const api = {
|
||||
// 管理后台 API
|
||||
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
|
||||
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
|
||||
adminPosts: (params: { page?: number; keyword?: string }) => {
|
||||
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.keyword) q.set('keyword', params.keyword);
|
||||
if (params.status) q.set('status', params.status);
|
||||
const qs = q.toString();
|
||||
return request<{ posts: PostItem[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/posts${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
return request<{
|
||||
posts: PostItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
pending_count?: number;
|
||||
status?: string;
|
||||
}>(`/api/admin/posts${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminApprovePost: (id: number) =>
|
||||
request<{ message: string; status: string }>(`/api/admin/posts/${id}/approve`, { method: 'POST' }),
|
||||
adminPinPost: (id: number, pinned: boolean) =>
|
||||
request<{ message: string; pinned: boolean }>(`/api/admin/posts/${id}/pin`, {
|
||||
method: 'POST', body: JSON.stringify({ pinned }),
|
||||
}),
|
||||
adminFeaturePost: (id: number, featured: boolean) =>
|
||||
request<{ message: string; featured: boolean }>(`/api/admin/posts/${id}/feature`, {
|
||||
method: 'POST', body: JSON.stringify({ featured }),
|
||||
}),
|
||||
adminLockPost: (id: number, locked: boolean) =>
|
||||
request<{ message: string; edit_locked: boolean }>(`/api/admin/posts/${id}/lock`, {
|
||||
method: 'POST', body: JSON.stringify({ locked }),
|
||||
}),
|
||||
adminRejectPost: (id: number, reason: string) =>
|
||||
request<{ message: string; notified: boolean }>(`/api/admin/posts/${id}/reject`, {
|
||||
method: 'POST', body: JSON.stringify({ reason }),
|
||||
}),
|
||||
adminReports: (params?: { page?: number; status?: ReportStatus | 'all' | string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.status) q.set('status', params.status);
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
reports: PostReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
pending_count: number;
|
||||
status: string;
|
||||
}>(`/api/admin/reports${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminHandleReport: (id: number, body: {
|
||||
action: 'dismiss' | 'resolve' | 'reject_post';
|
||||
handle_note?: string;
|
||||
reject_reason?: string;
|
||||
}) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/admin/reports/${id}/handle`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateForumSettings: (body: ForumLimits) =>
|
||||
request<{ message: string; limits: ForumLimits }>('/api/admin/settings/forum', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
@@ -97,11 +134,15 @@ export const api = {
|
||||
request<{ message: string; count: number; gitea: GiteaSyncConfig }>('/api/admin/settings/gitea/sync', {
|
||||
method: 'POST',
|
||||
}),
|
||||
adminUpdateStorageSettings: (body: StorageConfig) =>
|
||||
request<{ message: string; storage: StorageConfig }>('/api/admin/settings/storage', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUpdateBranding: (body: SiteBranding) =>
|
||||
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminUploadBrandingAsset: (kind: 'logo' | 'favicon', file: File) => {
|
||||
adminUploadBrandingAsset: (kind: 'logo' | 'favicon' | 'og_image', file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('kind', kind);
|
||||
fd.append('file', file);
|
||||
@@ -110,7 +151,7 @@ export const api = {
|
||||
{ method: 'POST', body: fd, headers: {} },
|
||||
);
|
||||
},
|
||||
adminClearBrandingAsset: (kind: 'logo' | 'favicon') =>
|
||||
adminClearBrandingAsset: (kind: 'logo' | 'favicon' | 'og_image') =>
|
||||
request<{ message: string; branding: SiteBranding }>('/api/admin/settings/branding/clear', {
|
||||
method: 'POST', body: JSON.stringify({ kind }),
|
||||
}),
|
||||
@@ -141,11 +182,41 @@ export const api = {
|
||||
postRevision: (id: number, revId: number) =>
|
||||
request<{ revision: PostRevision }>(`/api/posts/${id}/revisions/${revId}`),
|
||||
adminDeletePost: (id: number) => request(`/api/admin/posts/${id}`, { method: 'DELETE' }),
|
||||
adminComments: (page = 1) =>
|
||||
request<{ comments: Comment[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/comments?page=${page}`,
|
||||
),
|
||||
adminTrashPosts: (params: { page?: number; keyword?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.keyword) q.set('keyword', params.keyword);
|
||||
const qs = q.toString();
|
||||
return request<{ posts: (PostItem & { deleted_at: string })[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/posts/trash${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
adminRestorePost: (id: number) =>
|
||||
request<{ message: string }>(`/api/admin/posts/${id}/restore`, { method: 'POST' }),
|
||||
adminPurgePost: (id: number) =>
|
||||
request<{ message: string }>(`/api/admin/posts/${id}/purge`, { method: 'DELETE' }),
|
||||
adminComments: (params?: { page?: number; status?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.status) q.set('status', params.status);
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
comments: Comment[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
pending_count?: number;
|
||||
}>(`/api/admin/comments${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminApproveComment: (id: number) =>
|
||||
request<{ message: string; status: string }>(`/api/admin/comments/${id}/approve`, { method: 'POST' }),
|
||||
adminRejectComment: (id: number, reason?: string) =>
|
||||
request<{ message: string; status: string }>(`/api/admin/comments/${id}/reject`, {
|
||||
method: 'POST', body: JSON.stringify({ reason: reason || '' }),
|
||||
}),
|
||||
adminDeleteComment: (id: number) => request(`/api/admin/comments/${id}`, { method: 'DELETE' }),
|
||||
adminCommentRevisions: (id: number) =>
|
||||
request<{ revisions: CommentRevision[] }>(`/api/admin/comments/${id}/revisions`),
|
||||
adminUsers: (page = 1) =>
|
||||
request<{ users: User[]; total: number; page: number; total_pages: number }>(
|
||||
`/api/admin/users?page=${page}`,
|
||||
@@ -154,6 +225,19 @@ export const api = {
|
||||
request<{ message: string; banned: boolean }>(`/api/admin/users/${id}/ban`, {
|
||||
method: 'POST', body: JSON.stringify({ banned }),
|
||||
}),
|
||||
adminMedia: (params?: { category?: string; page?: number; size?: number; q?: string }) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params?.category) sp.set('category', params.category);
|
||||
if (params?.page) sp.set('page', String(params.page));
|
||||
if (params?.size) sp.set('size', String(params.size));
|
||||
if (params?.q) sp.set('q', params.q);
|
||||
const qs = sp.toString();
|
||||
return request<MediaListResult>(`/api/admin/media${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
adminDeleteMedia: (urls: string[]) =>
|
||||
request<{ message: string; deleted: number }>('/api/admin/media/delete', {
|
||||
method: 'POST', body: JSON.stringify({ urls }),
|
||||
}),
|
||||
adminBackup: () =>
|
||||
request<{ message: string; filename: string; download: string }>('/api/admin/backup', { method: 'POST' }),
|
||||
profileStats: () => request<{ stats: UserActivityStats }>('/api/profile/stats'),
|
||||
@@ -193,13 +277,16 @@ export const api = {
|
||||
fd.append('title', data.title);
|
||||
fd.append('content', data.content);
|
||||
fd.append('tags', data.tags || '');
|
||||
return request<{ post_id: number }>('/api/posts', { method: 'POST', body: fd, headers: {} });
|
||||
return request<{ post_id: number; message?: string; status?: string }>('/api/posts', { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
updatePost: (id: number, data: { title: string; content: string; tags?: string }) => {
|
||||
updatePost: (id: number, data: { title: string; content: string; tags?: string; board_id?: string | number }) => {
|
||||
const fd = new FormData();
|
||||
fd.append('title', data.title);
|
||||
fd.append('content', data.content);
|
||||
fd.append('tags', data.tags || '');
|
||||
if (data.board_id != null && data.board_id !== '') {
|
||||
fd.append('board_id', String(data.board_id));
|
||||
}
|
||||
return request<{ message: string }>(`/api/posts/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||
},
|
||||
deletePost: (id: number) => request<{ message: string }>(`/api/posts/${id}`, { method: 'DELETE' }),
|
||||
@@ -234,27 +321,56 @@ export const api = {
|
||||
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' }),
|
||||
reportPost: (id: number, body: { reason: ReportReason; detail?: string }) =>
|
||||
request<{ message: string; report: PostReport }>(`/api/posts/${id}/report`, {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
messageConversations: (params?: { page?: number; size?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', String(params.page));
|
||||
if (params?.size) q.set('size', String(params.size));
|
||||
const qs = q.toString();
|
||||
return request<{ conversations: MessageConversation[]; total: number; page: number }>(
|
||||
`/api/messages/conversations${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
conversationMessages: (peerId: number, params?: { size?: number; before?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.size) q.set('size', String(params.size));
|
||||
if (params?.before) q.set('before', String(params.before));
|
||||
const qs = q.toString();
|
||||
return request<{
|
||||
messages: PrivateMessage[];
|
||||
total: number;
|
||||
peer_user_id: number;
|
||||
peer_user?: User;
|
||||
is_system: boolean;
|
||||
}>(`/api/messages/conversations/${peerId}${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
markConversationRead: (peerId: number) =>
|
||||
request<{ message: string }>(`/api/messages/conversations/${peerId}/read`, { method: 'POST' }),
|
||||
messageUnreadCount: () => request<{ count: number }>('/api/messages/unread-count'),
|
||||
sendMessage: (body: { to_user_id: number; subject?: string; content: string }) =>
|
||||
request<{ message: PrivateMessage }>('/api/messages', {
|
||||
method: 'POST', body: JSON.stringify(body),
|
||||
}),
|
||||
markAllMessagesRead: () =>
|
||||
request<{ message: string }>('/api/messages/read-all', { method: 'POST' }),
|
||||
addComment: (postId: number, data: {
|
||||
content: string;
|
||||
replyTo?: number;
|
||||
guestNick?: string;
|
||||
guestEmail?: string;
|
||||
guestUrl?: string;
|
||||
isPrivate?: boolean;
|
||||
}) => {
|
||||
const fd = new FormData();
|
||||
fd.append('content', data.content);
|
||||
if (data.replyTo) fd.append('reply_to', String(data.replyTo));
|
||||
if (data.guestNick) fd.append('guest_nick', data.guestNick);
|
||||
if (data.guestEmail) fd.append('guest_email', data.guestEmail);
|
||||
if (data.guestUrl) fd.append('guest_url', data.guestUrl);
|
||||
if (data.isPrivate) fd.append('is_private', '1');
|
||||
return request<{ message: string; floor: number; id: number }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
|
||||
return request<{ message: string; floor: number; id: number; status?: string }>(`/api/posts/${postId}/comments`, { method: 'POST', body: fd, headers: {} });
|
||||
},
|
||||
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: {} });
|
||||
return request<{ message: string; content: string; status?: string }>(`/api/comments/${id}`, { method: 'PUT', body: fd, headers: {} });
|
||||
},
|
||||
deleteComment: (id: number) => request<{ message: string }>(`/api/comments/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
@@ -65,7 +65,9 @@ export interface PostItem {
|
||||
content?: string;
|
||||
tags: string;
|
||||
pinned: boolean;
|
||||
featured?: boolean;
|
||||
edit_locked?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
like_count: number;
|
||||
view_count: number;
|
||||
comment_count: number;
|
||||
@@ -87,6 +89,15 @@ export interface PostRevision {
|
||||
editor?: User;
|
||||
}
|
||||
|
||||
export interface CommentRevision {
|
||||
id: number;
|
||||
comment_id: number;
|
||||
editor_id: number;
|
||||
content: string;
|
||||
created_at: string;
|
||||
editor?: User;
|
||||
}
|
||||
|
||||
export interface PostDetailResponse {
|
||||
post: PostItem;
|
||||
comment_count: number;
|
||||
@@ -105,10 +116,13 @@ export interface Comment {
|
||||
floor: number;
|
||||
content: string;
|
||||
reply_to?: number;
|
||||
/** 嵌套展示父评论(父评论不可见时可能回挂到祖先) */
|
||||
thread_parent_id?: number;
|
||||
guest_nick?: string;
|
||||
guest_email?: string;
|
||||
guest_url?: string;
|
||||
is_private?: boolean;
|
||||
status?: 'pending' | 'published' | 'rejected' | string;
|
||||
content_hidden?: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
@@ -127,6 +141,7 @@ export interface AdminDashboard {
|
||||
|
||||
export interface ForumLimits {
|
||||
post_edit_window_hours: number;
|
||||
comment_edit_window_hours: number;
|
||||
rate_limit_post: number;
|
||||
rate_limit_comment: number;
|
||||
rate_limit_register: number;
|
||||
@@ -144,6 +159,10 @@ export interface ForumLimits {
|
||||
signature_max: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
/** 伪静态(固定链接)开关 */
|
||||
permalink_enabled: boolean;
|
||||
/** 伪静态后缀,不含点,如 html / htm */
|
||||
permalink_ext: string;
|
||||
}
|
||||
|
||||
export interface ForumLimitsPublic {
|
||||
@@ -151,6 +170,7 @@ export interface ForumLimitsPublic {
|
||||
post_tags_max: number;
|
||||
post_content_max: number;
|
||||
comment_max: number;
|
||||
comment_edit_window_hours: number;
|
||||
search_keyword_min: number;
|
||||
search_keyword_max: number;
|
||||
page_size_default: number;
|
||||
@@ -159,15 +179,33 @@ export interface ForumLimitsPublic {
|
||||
signature_max: number;
|
||||
open_posts_in_new_tab: boolean;
|
||||
open_content_links_in_new_tab: boolean;
|
||||
permalink_enabled: boolean;
|
||||
permalink_ext: string;
|
||||
}
|
||||
|
||||
export interface FriendLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SiteBranding {
|
||||
name: string;
|
||||
name_en: string;
|
||||
slogan: string;
|
||||
/** 站点简介(首页可见 + SEO description) */
|
||||
description?: string;
|
||||
/** SEO keywords,逗号分隔 */
|
||||
keywords?: string;
|
||||
logo_mark: string;
|
||||
logo: string;
|
||||
favicon: string;
|
||||
/** 默认社交分享图(Open Graph) */
|
||||
og_image?: string;
|
||||
/** ICP 备案号(可选) */
|
||||
icp_beian?: string;
|
||||
/** ICP 备案跳转链接(可选,默认工信部查询页) */
|
||||
icp_beian_url?: string;
|
||||
/** 页脚友情链接 */
|
||||
friend_links?: FriendLink[];
|
||||
}
|
||||
|
||||
export interface AdminSettings {
|
||||
@@ -180,11 +218,48 @@ export interface AdminSettings {
|
||||
oidc: OIDCConfig;
|
||||
oauth_clients: OAuthClient[];
|
||||
gitea?: GiteaSyncConfig;
|
||||
storage?: StorageConfig;
|
||||
branding?: SiteBranding;
|
||||
filter_words: string;
|
||||
filter_word_count: number;
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
type: 'local' | 's3';
|
||||
endpoint: string;
|
||||
region: string;
|
||||
bucket: string;
|
||||
access_key: string;
|
||||
secret_key?: string;
|
||||
public_base_url: string;
|
||||
prefix: string;
|
||||
force_path_style: boolean;
|
||||
has_secret_key: boolean;
|
||||
ready: boolean;
|
||||
/** 展示方案:webp(默认)| original;上传始终保留原图 */
|
||||
image_delivery: 'webp' | 'original';
|
||||
}
|
||||
|
||||
export type MediaCategory = 'avatars' | 'posts' | 'site';
|
||||
|
||||
export interface MediaItem {
|
||||
category: MediaCategory;
|
||||
name: string;
|
||||
url: string;
|
||||
size: number;
|
||||
modified_at: string;
|
||||
content_type: string;
|
||||
}
|
||||
|
||||
export interface MediaListResult {
|
||||
files: MediaItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
total_pages: number;
|
||||
storage_type: 'local' | 's3';
|
||||
category_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface MailConfig {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
@@ -259,6 +334,8 @@ export interface RegisterConfig {
|
||||
mail_ready: boolean;
|
||||
require_email_code: boolean;
|
||||
register_open: boolean;
|
||||
/** 邮箱验证码位数,默认 6 */
|
||||
email_code_len?: number;
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
@@ -271,6 +348,7 @@ export interface Paginated<T> {
|
||||
export interface RecentComment {
|
||||
id: number;
|
||||
post_id: number;
|
||||
floor: number;
|
||||
user_id?: number;
|
||||
author: string;
|
||||
avatar: string;
|
||||
@@ -278,3 +356,49 @@ export interface RecentComment {
|
||||
post_title: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** 站内私信 */
|
||||
export interface PrivateMessage {
|
||||
id: number;
|
||||
from_user_id: number;
|
||||
to_user_id: number;
|
||||
subject: string;
|
||||
content: string;
|
||||
kind: 'user' | 'system' | 'reject' | 'report_result' | string;
|
||||
related_post_id?: number;
|
||||
related_report_id?: number;
|
||||
is_read: boolean;
|
||||
created_at: string;
|
||||
from_user?: User;
|
||||
to_user?: User;
|
||||
}
|
||||
|
||||
/** 按对方聚合的私信会话 */
|
||||
export interface MessageConversation {
|
||||
peer_user_id: number; // 0 = 系统通知
|
||||
peer_user?: User;
|
||||
is_system: boolean;
|
||||
last_message?: PrivateMessage;
|
||||
unread_count: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export type ReportReason = 'spam' | 'abuse' | 'illegal' | 'irrelevant' | 'other';
|
||||
export type ReportStatus = 'pending' | 'resolved' | 'dismissed';
|
||||
|
||||
/** 帖子举报 */
|
||||
export interface PostReport {
|
||||
id: number;
|
||||
post_id: number;
|
||||
reporter_id: number;
|
||||
reason: ReportReason | string;
|
||||
detail: string;
|
||||
status: ReportStatus | string;
|
||||
handler_id?: number;
|
||||
handle_note: string;
|
||||
created_at: string;
|
||||
handled_at?: string;
|
||||
post?: PostItem;
|
||||
reporter?: User;
|
||||
handler?: User;
|
||||
}
|
||||
|
||||
12
frontend/src/components/AuthPageFallback.tsx
Normal file
12
frontend/src/components/AuthPageFallback.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
|
||||
/** 登录/注册懒加载占位:保持 auth 页氛围,避免整屏空白转圈 */
|
||||
export default function AuthPageFallback() {
|
||||
return (
|
||||
<div className="auth-page" aria-busy="true" aria-label="加载中">
|
||||
<div className="auth-box auth-box--loading">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/AuthPasswordInput.tsx
Normal file
30
frontend/src/components/AuthPasswordInput.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState, type ComponentProps } from 'react';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Props = ComponentProps<typeof Input>;
|
||||
|
||||
/** 带显示/隐藏切换的密码输入 */
|
||||
export default function AuthPasswordInput({ className, ...props }: Props) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="auth-password-field">
|
||||
<Input
|
||||
{...props}
|
||||
type={visible ? 'text' : 'password'}
|
||||
className={cn('auth-password-field__input', className)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="auth-password-field__toggle"
|
||||
onClick={() => setVisible(v => !v)}
|
||||
aria-label={visible ? '隐藏密码' : '显示密码'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{visible ? <EyeOff size={16} aria-hidden /> : <Eye size={16} aria-hidden />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { ArrowUp } from 'lucide-react';
|
||||
import { ArrowUp, MessageSquare } from 'lucide-react';
|
||||
|
||||
/** 滚动超过该距离后显示按钮 */
|
||||
const SHOW_THRESHOLD = 320;
|
||||
@@ -32,6 +32,7 @@ export default function BackToTop() {
|
||||
const loc = useLocation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const scrollElRef = useRef<HTMLElement | null>(null);
|
||||
const isPostDetail = /^\/post\/\d+/.test(loc.pathname);
|
||||
|
||||
const syncVisible = useCallback(() => {
|
||||
const el = scrollElRef.current;
|
||||
@@ -110,16 +111,35 @@ export default function BackToTop() {
|
||||
el.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const scrollToComments = () => {
|
||||
const section = document.querySelector<HTMLElement>('.comment-section');
|
||||
section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`back-to-top${visible ? ' back-to-top--visible' : ''}`}
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
<ArrowUp size={20} strokeWidth={2.25} />
|
||||
</button>
|
||||
<div className={`back-to-top-stack${visible ? ' back-to-top-stack--visible' : ''}`}>
|
||||
{isPostDetail && (
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top back-to-top--comment"
|
||||
onClick={scrollToComments}
|
||||
aria-label="前往评论"
|
||||
title="前往评论"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
<MessageSquare size={18} strokeWidth={2.25} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="back-to-top"
|
||||
onClick={scrollToTop}
|
||||
aria-label="回到顶部"
|
||||
title="回到顶部"
|
||||
tabIndex={visible ? 0 : -1}
|
||||
>
|
||||
<ArrowUp size={20} strokeWidth={2.25} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Send } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { notify } from '@/lib/notify';
|
||||
import type { User, Comment } from '../api/types';
|
||||
import EmojiPicker from './EmojiPicker';
|
||||
import { loadGuestInfo, saveGuestInfo } from '../utils/guest';
|
||||
import { commentNick } from '../utils/comment';
|
||||
import { loginPath, registerPath } from '../utils/authRedirect';
|
||||
|
||||
export interface CommentSubmitData {
|
||||
content: string;
|
||||
guestNick?: string;
|
||||
guestEmail?: string;
|
||||
guestUrl?: string;
|
||||
isPrivate: boolean;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,9 @@ interface Props {
|
||||
onCancelReply?: () => void;
|
||||
}
|
||||
|
||||
/** Waline 风格评论输入框:登录用户 / 游客双模式 */
|
||||
/** 评论输入框:需登录后发表 */
|
||||
export default function CommentBox({ user, replyTo, inline, submitting, submitCount = 0, onSubmit, onCancelReply }: Props) {
|
||||
const saved = loadGuestInfo();
|
||||
const [content, setContent] = useState('');
|
||||
const [guestNick, setGuestNick] = useState(saved.nick);
|
||||
const [guestEmail, setGuestEmail] = useState(saved.email);
|
||||
const [guestUrl, setGuestUrl] = useState(saved.url);
|
||||
const [isPrivate, setIsPrivate] = useState(false);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -39,7 +35,6 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
|
||||
useEffect(() => {
|
||||
if (inline && replyTo) {
|
||||
// preventScroll 避免 focus 与页面 scrollIntoView 争抢滚动位置
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}
|
||||
}, [replyTo?.id, inline]);
|
||||
@@ -90,21 +85,14 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!user) return;
|
||||
const text = content.trim();
|
||||
if (!text) return;
|
||||
if (!user && !guestNick.trim()) return;
|
||||
|
||||
if (!user) {
|
||||
saveGuestInfo({ nick: guestNick.trim(), email: guestEmail.trim(), url: guestUrl.trim() });
|
||||
if (!text) {
|
||||
notify.warning('请先写点内容');
|
||||
textareaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit({
|
||||
content: text,
|
||||
guestNick: user ? undefined : guestNick.trim(),
|
||||
guestEmail: user ? undefined : guestEmail.trim(),
|
||||
guestUrl: user ? undefined : guestUrl.trim(),
|
||||
isPrivate,
|
||||
});
|
||||
onSubmit({ content: text, isPrivate });
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -114,20 +102,33 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
}
|
||||
};
|
||||
|
||||
const avatarInitial = user?.nickname?.[0] || guestNick?.[0] || '?';
|
||||
if (!user) {
|
||||
return (
|
||||
<div className={`comment-login-gate${inline ? ' comment-login-gate--inline' : ''}`}>
|
||||
<p className="comment-login-gate__text">登录后即可参与讨论与回复</p>
|
||||
<div className="comment-login-gate__actions">
|
||||
<Button asChild size="sm">
|
||||
<Link to={loginPath()}>登录</Link>
|
||||
</Button>
|
||||
<Link to={registerPath()} className="comment-login-gate__register">
|
||||
注册账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatarInitial = user.nickname?.[0] || '?';
|
||||
const canSend = !!content.trim() && !submitting;
|
||||
|
||||
return (
|
||||
<div className="comment-box" ref={boxRef}>
|
||||
<div className="comment-box-avatar">
|
||||
{user?.avatar ? (
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" className="comment-box-avatar-img" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<div className={`comment-box-avatar-placeholder ${user ? '' : 'guest'}`}>
|
||||
{user ? avatarInitial : (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M12 12c2.7 0 4.8-2.1 4.8-4.8S14.7 2.4 12 2.4 7.2 4.5 7.2 7.2 9.3 12 12 12zm0 2.4c-3.2 0-9.6 1.6-9.6 4.8v2.4h19.2v-2.4c0-3.2-6.4-4.8-9.6-4.8z" />
|
||||
</svg>
|
||||
)}
|
||||
<div className="comment-box-avatar-placeholder">
|
||||
{avatarInitial}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -155,62 +156,15 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
<button
|
||||
type="button"
|
||||
className="comment-box-send"
|
||||
disabled={submitting || !content.trim() || (!user && !guestNick.trim())}
|
||||
disabled={!canSend}
|
||||
onClick={handleSubmit}
|
||||
aria-label="发送评论"
|
||||
title="发送"
|
||||
title="发送(Ctrl/⌘ + Enter)"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!user && (
|
||||
<div className="comment-box-guest-fields">
|
||||
<label className="comment-box-guest-field">
|
||||
<span className="comment-box-guest-label">
|
||||
昵称
|
||||
<em className="comment-box-guest-required">必填</em>
|
||||
</span>
|
||||
<input
|
||||
className="comment-box-guest-input"
|
||||
placeholder="怎么称呼你"
|
||||
autoComplete="nickname"
|
||||
value={guestNick}
|
||||
onChange={(e) => setGuestNick(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="comment-box-guest-field">
|
||||
<span className="comment-box-guest-label">
|
||||
邮箱
|
||||
<em className="comment-box-guest-optional">选填</em>
|
||||
</span>
|
||||
<input
|
||||
className="comment-box-guest-input"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={guestEmail}
|
||||
onChange={(e) => setGuestEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="comment-box-guest-field">
|
||||
<span className="comment-box-guest-label">
|
||||
网址
|
||||
<em className="comment-box-guest-optional">选填</em>
|
||||
</span>
|
||||
<input
|
||||
className="comment-box-guest-input"
|
||||
placeholder="https://example.com"
|
||||
type="url"
|
||||
autoComplete="url"
|
||||
value={guestUrl}
|
||||
onChange={(e) => setGuestUrl(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<p className="comment-box-guest-hint">邮箱不会公开展示,仅用于站内记录。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="comment-box-toolbar">
|
||||
<button
|
||||
ref={owoRef}
|
||||
@@ -223,10 +177,11 @@ export default function CommentBox({ user, replyTo, inline, submitting, submitCo
|
||||
>
|
||||
OwO
|
||||
</button>
|
||||
<label className="comment-box-private">
|
||||
<label className="comment-box-private" title="仅作者与管理员可见">
|
||||
<Switch checked={isPrivate} onCheckedChange={setIsPrivate} />
|
||||
<span>隐私评论</span>
|
||||
</label>
|
||||
<span className="comment-box-private-hint">仅作者与管理员可见</span>
|
||||
</div>
|
||||
|
||||
{showEmoji && <EmojiPicker id="comment-emoji-picker" onSelect={insertEmoji} />}
|
||||
|
||||
142
frontend/src/components/CommentRevisionDialog.tsx
Normal file
142
frontend/src/components/CommentRevisionDialog.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { Comment, CommentRevision } from '../api/types';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { countLineChanges, diffTextLines } from '../utils/revisionDiff';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
comment: Comment | null;
|
||||
}
|
||||
|
||||
function DiffBlock({ before, after }: { before: string; after: string }) {
|
||||
const parts = diffTextLines(before, after);
|
||||
const { added, removed } = countLineChanges(parts);
|
||||
if (before === after) {
|
||||
return <p className="revision-diff-unchanged">内容无变化</p>;
|
||||
}
|
||||
return (
|
||||
<div className="revision-diff-lines">
|
||||
<div className="revision-diff-stats">
|
||||
{removed > 0 && <span className="revision-diff-stat revision-diff-stat--del">删除 {removed} 行</span>}
|
||||
{added > 0 && <span className="revision-diff-stat revision-diff-stat--add">新增 {added} 行</span>}
|
||||
</div>
|
||||
<pre className="revision-diff-pre">
|
||||
{parts.map((part, i) => {
|
||||
const lines = part.value.split('\n');
|
||||
return lines.map((line, j) => {
|
||||
if (j === lines.length - 1 && line === '') return null;
|
||||
const cls = part.added
|
||||
? 'revision-diff-line revision-diff-line--add'
|
||||
: part.removed
|
||||
? 'revision-diff-line revision-diff-line--del'
|
||||
: 'revision-diff-line revision-diff-line--same';
|
||||
const prefix = part.added ? '+' : part.removed ? '−' : ' ';
|
||||
return (
|
||||
<div key={`${i}-${j}`} className={cls}>
|
||||
<span className="revision-diff-gutter" aria-hidden="true">{prefix}</span>
|
||||
<span className="revision-diff-text">{line || ' '}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 管理员查看评论编辑历史 */
|
||||
export default function CommentRevisionDialog({ open, onOpenChange, comment }: Props) {
|
||||
const [revisions, setRevisions] = useState<CommentRevision[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !comment) {
|
||||
setRevisions([]);
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.adminCommentRevisions(comment.id)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
const list = r.revisions ?? [];
|
||||
setRevisions(list);
|
||||
setActiveId(list[0]?.id ?? null);
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [open, comment]);
|
||||
|
||||
const active = revisions.find((r) => r.id === activeId) || null;
|
||||
const activeIndex = active ? revisions.findIndex((r) => r.id === active.id) : -1;
|
||||
const afterContent = activeIndex <= 0
|
||||
? (comment?.content ?? '')
|
||||
: (revisions[activeIndex - 1]?.content ?? '');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>评论编辑记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
{comment ? `#${comment.floor} 楼 · 共 ${revisions.length} 次修改前快照` : '评论编辑历史'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : revisions.length === 0 ? (
|
||||
<div className="admin-empty">暂无编辑记录</div>
|
||||
) : (
|
||||
<div className="comment-rev-layout">
|
||||
<aside className="comment-rev-list" aria-label="历史版本">
|
||||
{revisions.map((rev, i) => (
|
||||
<button
|
||||
key={rev.id}
|
||||
type="button"
|
||||
className={`comment-rev-item${activeId === rev.id ? ' active' : ''}`}
|
||||
onClick={() => setActiveId(rev.id)}
|
||||
>
|
||||
<span className="comment-rev-item__ver">版本 {revisions.length - i}</span>
|
||||
<span className="comment-rev-item__meta">
|
||||
{rev.editor?.nickname || `用户 #${rev.editor_id}`}
|
||||
{' · '}
|
||||
{formatTime(rev.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</aside>
|
||||
<div className="comment-rev-detail">
|
||||
{active ? (
|
||||
<>
|
||||
<p className="comment-rev-detail__hint">
|
||||
与{activeIndex <= 0 ? '当前正文' : '下一版本'}对比
|
||||
</p>
|
||||
<DiffBlock before={active.content} after={afterContent} />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Check, Clock, History, MessageSquare, X, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Comment, User } from '../api/types';
|
||||
import CommentContent from './CommentContent';
|
||||
import CommentRevisionDialog from './CommentRevisionDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -26,10 +27,18 @@ import { isTimeDiffSignificant } from '../utils/content';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import UserLink from './UserLink';
|
||||
|
||||
function canManageComment(c: Comment, user?: User | null): boolean {
|
||||
function isCommentAuthor(c: Comment, user?: User | null): boolean {
|
||||
return !!user && c.user_id > 0 && c.user_id === user.id;
|
||||
}
|
||||
|
||||
function canEditComment(c: Comment, user: User | null | undefined, windowHours: number): boolean {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
return c.user_id > 0 && c.user_id === user.id;
|
||||
if (!isCommentAuthor(c, user)) return false;
|
||||
if (windowHours <= 0) return true;
|
||||
const created = new Date(c.created_at).getTime();
|
||||
if (Number.isNaN(created)) return false;
|
||||
return Date.now() - created <= windowHours * 3600_000;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
@@ -45,6 +54,7 @@ interface ItemProps {
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
onApprove?: (comment: Comment) => Promise<void>;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -62,6 +72,7 @@ function CommentItem({
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
renderReplyBox,
|
||||
}: ItemProps) {
|
||||
const { limits } = useForumLimits();
|
||||
@@ -72,11 +83,18 @@ function CommentItem({
|
||||
const hidden = !!c.content_hidden;
|
||||
const isReplying = replyToId === c.id;
|
||||
const isEditing = editingId === c.id;
|
||||
const manageable = canManageComment(c, currentUser);
|
||||
const isAdmin = currentUser?.role === 'admin';
|
||||
const canEdit = canEditComment(c, currentUser, limits.comment_edit_window_hours ?? 24);
|
||||
const canDelete = isAdmin;
|
||||
const canApprove = isAdmin
|
||||
&& (c.status === 'pending' || c.status === 'rejected')
|
||||
&& !!onApprove;
|
||||
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);
|
||||
const [approving, setApproving] = useState(false);
|
||||
const [revOpen, setRevOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) setEditText(c.content);
|
||||
@@ -178,7 +196,27 @@ function CommentItem({
|
||||
<Clock size={14} />
|
||||
{formatCommentDate(c.created_at)}
|
||||
{showEdited && <span className="waline-comment-edited"> · 已编辑</span>}
|
||||
{c.status === 'pending' && <span className="waline-comment-status waline-comment-status--pending"> · 审核中</span>}
|
||||
{c.status === 'rejected' && <span className="waline-comment-status waline-comment-status--rejected"> · 未通过</span>}
|
||||
</span>
|
||||
{!hidden && !isEditing && canApprove && (
|
||||
<button
|
||||
type="button"
|
||||
className="waline-comment-reply-btn waline-comment-approve-btn"
|
||||
disabled={approving}
|
||||
onClick={async () => {
|
||||
setApproving(true);
|
||||
try {
|
||||
await onApprove?.(c);
|
||||
} finally {
|
||||
setApproving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check size={14} />
|
||||
{approving ? '通过中…' : '通过'}
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && (
|
||||
isReplying ? (
|
||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||
@@ -192,13 +230,19 @@ function CommentItem({
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{!hidden && !isEditing && manageable && (
|
||||
{!hidden && !isEditing && canEdit && (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
|
||||
<Pencil size={14} />
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && manageable && (
|
||||
{!hidden && !isEditing && isAdmin && showEdited && (
|
||||
<button type="button" className="waline-comment-reply-btn" onClick={() => setRevOpen(true)}>
|
||||
<History size={14} />
|
||||
编辑记录
|
||||
</button>
|
||||
)}
|
||||
{!hidden && !isEditing && canDelete && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<button type="button" className="waline-comment-reply-btn cancel" disabled={deleting}>
|
||||
@@ -231,6 +275,10 @@ function CommentItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
|
||||
)}
|
||||
|
||||
{isReplying && renderReplyBox && (
|
||||
<div id={`reply-box-${c.id}`} className="comment-box-wrap inline">
|
||||
{renderReplyBox(c)}
|
||||
@@ -254,6 +302,7 @@ function CommentItem({
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
@@ -276,6 +325,7 @@ interface Props {
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: (comment: Comment, content: string) => Promise<void>;
|
||||
onDelete: (comment: Comment) => Promise<void>;
|
||||
onApprove?: (comment: Comment) => Promise<void>;
|
||||
renderReplyBox?: (comment: Comment) => ReactNode;
|
||||
}
|
||||
|
||||
@@ -292,6 +342,7 @@ export default function CommentThreadList({
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onDelete,
|
||||
onApprove,
|
||||
renderReplyBox,
|
||||
}: Props) {
|
||||
const tree = buildCommentTree(comments);
|
||||
@@ -312,6 +363,7 @@ export default function CommentThreadList({
|
||||
onCancelEdit={onCancelEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onDelete={onDelete}
|
||||
onApprove={onApprove}
|
||||
renderReplyBox={renderReplyBox}
|
||||
/>
|
||||
))}
|
||||
|
||||
86
frontend/src/components/ComposeMessageDialog.tsx
Normal file
86
frontend/src/components/ComposeMessageDialog.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
toUserId: number;
|
||||
toNickname: string;
|
||||
onSent?: () => void;
|
||||
}
|
||||
|
||||
/** 发送私信对话框(对话式,无需标题) */
|
||||
export default function ComposeMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
toUserId,
|
||||
toNickname,
|
||||
onSent,
|
||||
}: Props) {
|
||||
const [content, setContent] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const handleOpenChange = (next: boolean) => {
|
||||
if (!next) setContent('');
|
||||
onOpenChange(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!content.trim()) {
|
||||
notify.warning('请填写内容');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
await api.sendMessage({
|
||||
to_user_id: toUserId,
|
||||
content: content.trim(),
|
||||
});
|
||||
notify.success('私信已发送');
|
||||
handleOpenChange(false);
|
||||
onSent?.();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送私信</DialogTitle>
|
||||
<DialogDescription>发给 {toNickname}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span className="sr-only">内容</span>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={6}
|
||||
maxLength={4000}
|
||||
placeholder="写点什么…"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>取消</Button>
|
||||
<Button loading={sending} onClick={submit}>发送</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -19,12 +19,20 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h3>页面渲染出错</h3>
|
||||
<p className="error-boundary-msg">{this.state.error.message}</p>
|
||||
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||
刷新页面
|
||||
</Button>
|
||||
<div className="error-page-shell">
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>500</div>
|
||||
<h1 className="error-page__title">页面渲染出错</h1>
|
||||
<p className="error-page__desc">{this.state.error.message || '发生了意外错误,请尝试刷新页面。'}</p>
|
||||
<div className="error-page__actions">
|
||||
<Button size="sm" onClick={() => { this.setState({ error: null }); window.location.reload(); }}>
|
||||
刷新页面
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => { window.location.href = '/'; }}>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
19
frontend/src/components/FeaturedIcon.tsx
Normal file
19
frontend/src/components/FeaturedIcon.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 精华帖标识 */
|
||||
export default function FeaturedIcon({ className, size = 16 }: Props) {
|
||||
return (
|
||||
<Sparkles
|
||||
className={cn('post-featured-icon', className)}
|
||||
size={size}
|
||||
aria-label="精华"
|
||||
role="img"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,9 +8,11 @@ interface Props {
|
||||
boards: Board[];
|
||||
stats: ForumStats | null;
|
||||
postTotal: number;
|
||||
/** 首页「全部帖子」用 h2,板块/搜索页用 h1 */
|
||||
titleAs?: 'h1' | 'h2';
|
||||
}
|
||||
|
||||
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal }: Props) {
|
||||
export default function FeedHeader({ boardId, keyword, boards, stats, postTotal, titleAs = 'h1' }: Props) {
|
||||
const nav = useNavigate();
|
||||
const board = boards.find(b => b.id === boardId);
|
||||
|
||||
@@ -19,12 +21,27 @@ export default function FeedHeader({ boardId, keyword, boards, stats, postTotal
|
||||
: (boardId && board ? board.name : '全部帖子');
|
||||
|
||||
const boardHint = boardId && board ? (board.description || '') : '';
|
||||
const TitleTag = titleAs;
|
||||
const inBoard = !keyword && boardId > 0 && !!board;
|
||||
|
||||
return (
|
||||
<div className={`feed-head${keyword ? ' feed-head--solo' : ''}`}>
|
||||
<div className="feed-head__title">
|
||||
<h2 title={boardHint || undefined}>{title}</h2>
|
||||
{!keyword && stats && (
|
||||
<TitleTag title={boardHint || undefined}>{title}</TitleTag>
|
||||
{!keyword && inBoard && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<FileText aria-hidden />
|
||||
本板块 <strong>{postTotal}</strong> 帖
|
||||
</span>
|
||||
{stats && (
|
||||
<span className="feed-stat-chip feed-stat-chip--muted" title="全站统计">
|
||||
全站 {stats.posts} 帖 · {stats.users} 会员
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!keyword && !inBoard && stats && (
|
||||
<div className="feed-head__stats">
|
||||
<span className="feed-stat-chip">
|
||||
<Users aria-hidden />
|
||||
|
||||
@@ -7,22 +7,24 @@ export default function FeedPageSkeleton() {
|
||||
<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 className="feed-top__bar">
|
||||
<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>
|
||||
<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 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>
|
||||
<div className="post-list-scroll">
|
||||
|
||||
176
frontend/src/components/PostAuthorCard.tsx
Normal file
176
frontend/src/components/PostAuthorCard.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Eye, FileText, Heart, Mail, MessageCircle, UserRound } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '../api/client';
|
||||
import type { User, UserActivityStats, UserPublic } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { userPath } from '../utils/userPath';
|
||||
import ComposeMessageDialog from './ComposeMessageDialog';
|
||||
import UserLink from './UserLink';
|
||||
|
||||
interface Props {
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
}
|
||||
|
||||
/** 帖子详情右栏:作者信息卡(私信 / 主页 / 统计) */
|
||||
export default function PostAuthorCard({
|
||||
author,
|
||||
publishedAt,
|
||||
viewCount,
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user: me } = useAuth();
|
||||
const [profile, setProfile] = useState<UserPublic | null>(null);
|
||||
const [stats, setStats] = useState<UserActivityStats | null>(null);
|
||||
const [msgOpen, setMsgOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!author?.id) {
|
||||
setProfile(null);
|
||||
setStats(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
api.userProfile(author.id)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setProfile(r.user);
|
||||
setStats(r.stats);
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
// 详情里已有轻量 user,接口失败时仍可展示基本信息
|
||||
setProfile(null);
|
||||
setStats(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [author?.id]);
|
||||
|
||||
if (!author?.id) {
|
||||
return (
|
||||
<div className="widget-card widget-card--author">
|
||||
<div className="widget-card-head">
|
||||
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
|
||||
作者
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
<div className="widget-empty">作者信息加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const display = profile ?? author;
|
||||
const nick = display.nickname || display.username || `用户 #${author.id}`;
|
||||
const initial = nick.charAt(0) || '?';
|
||||
const signature = (profile?.signature ?? author.signature ?? '').trim();
|
||||
const isAdmin = display.role === 'admin';
|
||||
const isSelf = !!me && me.id === author.id;
|
||||
const profileHref = userPath(author.id);
|
||||
|
||||
const openMessage = () => {
|
||||
if (!me) {
|
||||
nav(loginPath(profileHref));
|
||||
return;
|
||||
}
|
||||
setMsgOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="widget-card widget-card--author">
|
||||
<div className="widget-card-head">
|
||||
<UserRound className="widget-card-icon widget-card-icon--author" aria-hidden />
|
||||
作者
|
||||
</div>
|
||||
<div className="widget-author-panel">
|
||||
<div className="widget-author-body">
|
||||
<UserLink
|
||||
user={display}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
className="widget-author-avatar user-link--avatar-only"
|
||||
>
|
||||
{display.avatar
|
||||
? <img src={display.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</UserLink>
|
||||
<div className="widget-author-meta">
|
||||
<div className="widget-author-name-row">
|
||||
<UserLink user={display} className="widget-author-name" />
|
||||
{isAdmin && <Badge variant="green" className="widget-author-badge">管理员</Badge>}
|
||||
{display.banned && <Badge variant="destructive" className="widget-author-badge">已禁言</Badge>}
|
||||
</div>
|
||||
{signature ? (
|
||||
<p className="widget-author-signature" title={signature}>{signature}</p>
|
||||
) : null}
|
||||
{(publishedAt || typeof viewCount === 'number') && (
|
||||
<p className="widget-author-stats">
|
||||
{publishedAt ? <span>{formatTime(publishedAt)} 发布</span> : null}
|
||||
{publishedAt && typeof viewCount === 'number' ? (
|
||||
<span className="widget-author-stats-dot" aria-hidden>·</span>
|
||||
) : null}
|
||||
{typeof viewCount === 'number' ? (
|
||||
<span className="widget-author-views">
|
||||
<Eye size={12} aria-hidden />
|
||||
{viewCount}
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-author-metrics" aria-label="作者统计">
|
||||
<div className="widget-author-metric">
|
||||
<FileText size={13} aria-hidden />
|
||||
<strong>{stats?.post_count ?? '—'}</strong>
|
||||
<span>帖子</span>
|
||||
</div>
|
||||
<div className="widget-author-metric">
|
||||
<MessageCircle size={13} aria-hidden />
|
||||
<strong>{stats?.comment_count ?? '—'}</strong>
|
||||
<span>评论</span>
|
||||
</div>
|
||||
<div className="widget-author-metric">
|
||||
<Heart size={13} aria-hidden />
|
||||
<strong>{stats?.like_received ?? '—'}</strong>
|
||||
<span>获赞</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="widget-author-actions">
|
||||
{!isSelf && (
|
||||
<Button size="sm" className="widget-author-action" onClick={openMessage}>
|
||||
<Mail size={14} />
|
||||
私信
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="widget-author-action"
|
||||
onClick={() => nav(isSelf ? '/profile' : profileHref)}
|
||||
>
|
||||
{isSelf ? '我的主页' : '查看主页'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
<ComposeMessageDialog
|
||||
open={msgOpen}
|
||||
onOpenChange={setMsgOpen}
|
||||
toUserId={author.id}
|
||||
toNickname={nick}
|
||||
onSent={() => nav(`/messages?peer=${author.id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import { MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import { Eye, Image as ImageIcon, MessageCircle, ThumbsUp } from 'lucide-react';
|
||||
import BoardBadge from '@/components/BoardBadge';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import UserLink from '@/components/UserLink';
|
||||
import type { PostItem } from '../api/types';
|
||||
import type { FeedSort } from './FeedSortBar';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { postPath } from '../utils/permalink';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
|
||||
interface Props {
|
||||
post: PostItem;
|
||||
@@ -22,6 +25,10 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
: formatTime(post.created_at);
|
||||
const commentCount = post.comment_count ?? 0;
|
||||
const likeCount = post.like_count ?? 0;
|
||||
const viewCount = post.view_count ?? 0;
|
||||
const href = postPath(post.id);
|
||||
const excerpt = excerptFromHTML(post.content || '', 72);
|
||||
const hasImage = !!firstImageFromHTML(post.content || '');
|
||||
|
||||
const openPost = () => onSelect(post.id);
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
@@ -30,11 +37,21 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
openPost();
|
||||
}
|
||||
};
|
||||
const onTitleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
// 修饰键 / 非左键:交给浏览器(新标签等)
|
||||
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openPost();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="post-row"
|
||||
role="button"
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={openPost}
|
||||
onKeyDown={onKeyDown}
|
||||
@@ -50,26 +67,68 @@ function PostListItem({ post, sort = 'latest', onSelect }: Props) {
|
||||
? <img src={post.user.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: initial}
|
||||
</UserLink>
|
||||
|
||||
<div className="post-body">
|
||||
<div className="post-title">
|
||||
{post.pinned && <PinnedIcon className="mr-1.5" />}
|
||||
<div className="post-head">
|
||||
<div className="post-head-meta">
|
||||
<UserLink user={post.user} stopPropagation className="post-author" />
|
||||
<span className="post-head-dot" aria-hidden>·</span>
|
||||
<span className="post-time">{timeLabel}</span>
|
||||
</div>
|
||||
{(post.featured || post.pinned || post.status === 'pending' || post.status === 'rejected') && (
|
||||
<div className="post-head-badges">
|
||||
{post.status === 'pending' && (
|
||||
<span className="post-status-badge post-status-badge--pending" title="审核中">审核中</span>
|
||||
)}
|
||||
{post.status === 'rejected' && (
|
||||
<span className="post-status-badge post-status-badge--rejected" title="未通过">未通过</span>
|
||||
)}
|
||||
{post.featured && (
|
||||
<span className="post-feature-badge" title="精华">
|
||||
<FeaturedIcon size={12} />
|
||||
精华
|
||||
</span>
|
||||
)}
|
||||
{post.pinned && (
|
||||
<span className="post-pin-badge" title="置顶">
|
||||
<PinnedIcon size={12} />
|
||||
置顶
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<a href={href} className="post-title" onClick={onTitleClick}>
|
||||
{post.title}
|
||||
</a>
|
||||
|
||||
{excerpt && <p className="post-excerpt">{excerpt}</p>}
|
||||
|
||||
<div className="post-foot">
|
||||
<div className="post-foot-left">
|
||||
{post.board && <BoardBadge board={post.board} />}
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
{hasImage && (
|
||||
<span className="post-stat post-stat--media" title="含图片">
|
||||
<ImageIcon aria-hidden />
|
||||
</span>
|
||||
)}
|
||||
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`} title="评论">
|
||||
<MessageCircle aria-hidden />
|
||||
{commentCount}
|
||||
</span>
|
||||
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`} title="点赞">
|
||||
<ThumbsUp aria-hidden />
|
||||
{likeCount}
|
||||
</span>
|
||||
<span className={`post-stat${viewCount === 0 ? ' post-stat--zero' : ''}`} title="浏览">
|
||||
<Eye aria-hidden />
|
||||
{viewCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-meta">
|
||||
{post.board && <BoardBadge board={post.board} />}
|
||||
<UserLink user={post.user} stopPropagation className="post-meta-user" />
|
||||
<span>{timeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<span className={`post-stat${commentCount === 0 ? ' post-stat--zero' : ''}`}>
|
||||
<MessageCircle aria-hidden />
|
||||
{commentCount}
|
||||
</span>
|
||||
<span className={`post-stat${likeCount === 0 ? ' post-stat--zero' : ''}`}>
|
||||
<ThumbsUp aria-hidden />
|
||||
{likeCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ interface Props {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** 帖子列表加载骨架屏 */
|
||||
/** 帖子列表加载骨架屏(对齐卡片式列表) */
|
||||
export default function PostListSkeleton({ count = 8 }: Props) {
|
||||
return (
|
||||
<div className="post-list-skeleton" aria-busy="true" aria-label="加载中">
|
||||
@@ -12,16 +12,23 @@ export default function PostListSkeleton({ count = 8 }: Props) {
|
||||
<div key={i} className="post-row post-row--skeleton">
|
||||
<Skeleton className="skeleton--avatar" />
|
||||
<div className="post-body">
|
||||
<Skeleton className="skeleton--title" style={{ width: `${55 + (i % 4) * 10}%` }} />
|
||||
<div className="skeleton-meta-row">
|
||||
<Skeleton className="skeleton--badge" />
|
||||
<Skeleton className="skeleton--meta" />
|
||||
<Skeleton className="skeleton--meta skeleton--meta-short" />
|
||||
<div className="post-head">
|
||||
<div className="skeleton-meta-row">
|
||||
<Skeleton className="skeleton--meta" />
|
||||
<Skeleton className="skeleton--meta skeleton--meta-short" />
|
||||
</div>
|
||||
{i % 4 === 0 && <Skeleton className="skeleton--badge" />}
|
||||
</div>
|
||||
<Skeleton className="skeleton--title" style={{ width: `${58 + (i % 4) * 9}%` }} />
|
||||
<Skeleton className="skeleton--excerpt" style={{ width: `${72 + (i % 3) * 8}%` }} />
|
||||
<div className="post-foot">
|
||||
<Skeleton className="skeleton--badge" />
|
||||
<div className="post-stats">
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="post-stats">
|
||||
<Skeleton className="skeleton--stat" />
|
||||
<Skeleton className="skeleton--stat" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { Flame, MessageCircle, Tags } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Flame, ListTree, MessageCircle, Tags, Sparkles } from 'lucide-react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import type { PostItem, RecentComment, TagCount } from '../api/types';
|
||||
import type { PostItem, RecentComment, TagCount, User } from '../api/types';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import TagCloud from './TagCloud';
|
||||
import UserLink from './UserLink';
|
||||
import ArticleOutline from './ArticleOutline';
|
||||
import PostAuthorCard from './PostAuthorCard';
|
||||
|
||||
export type PostDetailAside = {
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
headings: PostHeading[];
|
||||
scrollRoot?: HTMLElement | null;
|
||||
outlineTitle?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
hot: PostItem[];
|
||||
recentComments: RecentComment[];
|
||||
tags?: TagCount[];
|
||||
tagsLoading?: boolean;
|
||||
onPostClick: (id: number) => void;
|
||||
onPostClick: (id: number, opts?: { floor?: number }) => void;
|
||||
/** 首次拉取中,显示骨架避免空态闪烁 */
|
||||
loading?: boolean;
|
||||
/** 帖子详情:右侧顶部展示作者与目录 */
|
||||
postDetail?: PostDetailAside | null;
|
||||
}
|
||||
|
||||
function hotRankClass(index: number): string {
|
||||
@@ -57,107 +71,173 @@ export default function RightPanel({
|
||||
tagsLoading = false,
|
||||
onPostClick,
|
||||
loading = false,
|
||||
postDetail = null,
|
||||
}: Props) {
|
||||
const { branding } = useSiteBranding();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const activeTag = params.get('keyword') || '';
|
||||
const hotList = hot?.slice(0, 8) ?? [];
|
||||
const commentList = recentComments?.slice(0, 6) ?? [];
|
||||
// 站点首页:右侧品牌块承担唯一 h1;板块/搜索等页面由 Feed 标题作 h1
|
||||
const isSiteHome = loc.pathname === '/' && !params.get('board') && !params.get('keyword');
|
||||
const description = branding.description?.trim() || '';
|
||||
const slogan = branding.slogan?.trim() || '';
|
||||
// 有独立简介时展示简介;否则用欢迎语,避免与页脚 slogan 三连重复
|
||||
const aboutText = description || '欢迎参与讨论,发帖、评论,一起把小圈子聊热。';
|
||||
// 帖子很少时热门几乎等于主列表,改显示欢迎引导
|
||||
const showHot = loading || hotList.length >= 4;
|
||||
const showWelcome = !loading && hotList.length > 0 && hotList.length < 4;
|
||||
const isPostDetail = !!postDetail;
|
||||
|
||||
return (
|
||||
<div className="aside-panel-inner">
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
热门帖子
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && hotList.length === 0 ? (
|
||||
<HotSkeleton />
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</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">
|
||||
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新评论
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && commentList.length === 0 ? (
|
||||
<CommentSkeleton />
|
||||
) : commentList.length === 0 ? (
|
||||
<div className="widget-empty">暂无评论</div>
|
||||
) : commentList.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="widget-item widget-item--comment"
|
||||
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
|
||||
>
|
||||
{item.user_id ? (
|
||||
<UserLink
|
||||
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
stopPropagation
|
||||
className="widget-item-avatar user-link--avatar-only"
|
||||
>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</UserLink>
|
||||
) : (
|
||||
<span className="widget-item-avatar" aria-hidden>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="widget-item-comment-main"
|
||||
onClick={() => onPostClick(item.post_id)}
|
||||
>
|
||||
<span className="widget-item-title">{item.excerpt}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</button>
|
||||
<div className={`aside-panel-inner${isPostDetail ? ' aside-panel-inner--post-detail' : ''}`}>
|
||||
{isPostDetail && (
|
||||
<>
|
||||
<PostAuthorCard
|
||||
author={postDetail.author}
|
||||
publishedAt={postDetail.publishedAt}
|
||||
viewCount={postDetail.viewCount}
|
||||
/>
|
||||
<div className="widget-card widget-card--outline">
|
||||
<div className="widget-card-head">
|
||||
<ListTree className="widget-card-icon widget-card-icon--outline" aria-hidden />
|
||||
{postDetail.outlineTitle || '文章目录'}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="widget-card-body widget-outline-body">
|
||||
<ArticleOutline
|
||||
headings={postDetail.headings}
|
||||
scrollRoot={postDetail.scrollRoot}
|
||||
title={postDetail.outlineTitle || '文章目录'}
|
||||
className="article-outline--aside"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="widget-card widget-card--about">
|
||||
<div className="widget-card-body">
|
||||
<p className="widget-about-text">
|
||||
<strong>{branding.name}</strong>
|
||||
{branding.slogan
|
||||
? `${branding.slogan}${branding.name_en ? ` · ${branding.name_en}` : ''}`
|
||||
: (branding.name_en || '轻量社区')}
|
||||
</p>
|
||||
{!isPostDetail && showWelcome && (
|
||||
<div className="widget-card widget-card--welcome">
|
||||
<div className="widget-card-head">
|
||||
<Sparkles className="widget-card-icon widget-card-icon--welcome" aria-hidden />
|
||||
加入讨论
|
||||
</div>
|
||||
<div className="widget-card-body widget-welcome-body">
|
||||
<p>社区还在起步,每条回复都很珍贵。</p>
|
||||
<ul>
|
||||
<li>逛逛板块,找到感兴趣的话题</li>
|
||||
<li>游客也能评论,登录可点赞收藏</li>
|
||||
<li>发一篇帖,留下你的痕迹</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && showHot && (
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<Flame className="widget-card-icon widget-card-icon--hot" aria-hidden />
|
||||
热门帖子
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && hotList.length === 0 ? (
|
||||
<HotSkeleton />
|
||||
) : hotList.length === 0 ? (
|
||||
<div className="widget-empty">暂无数据</div>
|
||||
) : hotList.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className="widget-item"
|
||||
onClick={() => onPostClick(item.id)}
|
||||
>
|
||||
<span className={hotRankClass(i)}>{i + 1}</span>
|
||||
<span className="widget-item-title">{item.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!isPostDetail && (
|
||||
<div className="widget-card">
|
||||
<div className="widget-card-head">
|
||||
<MessageCircle className="widget-card-icon widget-card-icon--notice" aria-hidden />
|
||||
最新评论
|
||||
</div>
|
||||
<div className="widget-card-body">
|
||||
{loading && commentList.length === 0 ? (
|
||||
<CommentSkeleton />
|
||||
) : commentList.length === 0 ? (
|
||||
<div className="widget-empty">暂无评论</div>
|
||||
) : commentList.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="widget-item widget-item--comment"
|
||||
title={item.post_title ? `${item.author} · ${item.post_title}` : item.author}
|
||||
>
|
||||
{item.user_id ? (
|
||||
<UserLink
|
||||
user={{ id: item.user_id, nickname: item.author, avatar: item.avatar }}
|
||||
showAvatar={false}
|
||||
showName={false}
|
||||
stopPropagation
|
||||
className="widget-item-avatar user-link--avatar-only"
|
||||
>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</UserLink>
|
||||
) : (
|
||||
<span className="widget-item-avatar" aria-hidden>
|
||||
{item.avatar
|
||||
? <img src={item.avatar} alt="" loading="lazy" decoding="async" />
|
||||
: (item.author?.[0] || '?')}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="widget-item-comment-main"
|
||||
onClick={() => onPostClick(item.post_id, item.floor > 0 ? { floor: item.floor } : undefined)}
|
||||
>
|
||||
<span className="widget-item-title">{item.excerpt}</span>
|
||||
<span className="widget-item-time">{item.created_at}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPostDetail && (
|
||||
<div className="widget-card widget-card--about">
|
||||
<div className="widget-card-body">
|
||||
<div className="widget-about-text">
|
||||
{isSiteHome ? (
|
||||
<h1 className="widget-about-title">{branding.name}</h1>
|
||||
) : (
|
||||
<p className="widget-about-title">{branding.name}</p>
|
||||
)}
|
||||
<p className="widget-about-desc">{aboutText}</p>
|
||||
{description && slogan && slogan !== description && (
|
||||
<p className="widget-about-slogan">{slogan}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ export function isNeutralSidebarRoute(pathname: string): boolean {
|
||||
return NEUTRAL_SIDEBAR_PREFIXES.some(prefix => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
function resolveMenuKey(pathname: string, activeBoard: number): string | null {
|
||||
function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): string | null {
|
||||
if (isNeutralSidebarRoute(pathname)) return null;
|
||||
// 搜索结果不属于「全部帖子」或某一板块,取消侧栏选中高亮
|
||||
if (keyword.trim()) return null;
|
||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||
if (pathname.startsWith('/projects')) return 'projects';
|
||||
if (pathname.startsWith('/admin')) return 'admin';
|
||||
@@ -58,7 +60,8 @@ export default function Sidebar({
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const menuKey = resolveMenuKey(loc.pathname, activeBoard);
|
||||
const keyword = params.get('keyword') || '';
|
||||
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
|
||||
|
||||
const navItem = (key: string, label: React.ReactNode, icon?: React.ReactNode, onClick?: () => void) => (
|
||||
<button
|
||||
@@ -138,7 +141,9 @@ export default function Sidebar({
|
||||
/>
|
||||
<span className="flex-1 truncate">{b.name}</span>
|
||||
{(b.post_count ?? 0) > 0 && (
|
||||
<span className="sidebar-nav-item__meta">{b.post_count}</span>
|
||||
<span className="sidebar-nav-item__meta" title={`${b.post_count} 篇帖子`}>
|
||||
{b.post_count} 帖
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
70
frontend/src/components/SiteFooter.tsx
Normal file
70
frontend/src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useMediaQuery } from '../hooks/useTheme';
|
||||
import type { FriendLink } from '../api/types';
|
||||
|
||||
function FooterSep() {
|
||||
return <span className="site-footer__sep" aria-hidden>·</span>;
|
||||
}
|
||||
|
||||
/** 站点页脚:版权、Sitemap、友链、备案号 */
|
||||
export default function SiteFooter() {
|
||||
const { branding } = useSiteBranding();
|
||||
const year = new Date().getFullYear();
|
||||
const links = Array.isArray(branding.friend_links) ? branding.friend_links : [];
|
||||
const icp = branding.icp_beian?.trim() || '';
|
||||
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div className="site-footer__inner">
|
||||
<div className="site-footer__meta">
|
||||
<span className="site-footer__copy">
|
||||
© {year} {branding.name}
|
||||
</span>
|
||||
{branding.slogan?.trim() && (
|
||||
<>
|
||||
<FooterSep />
|
||||
<span className="site-footer__slogan">{branding.slogan.trim()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(links.length > 0 || icp) && (
|
||||
<nav className="site-footer__nav" aria-label="站点链接">
|
||||
{links.map((link: FriendLink, i) => (
|
||||
<span key={`${link.name}-${link.url}`} className="site-footer__friend">
|
||||
{i > 0 && <FooterSep />}
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.name}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
{icp && (
|
||||
<>
|
||||
{links.length > 0 && <FooterSep />}
|
||||
<a
|
||||
href={icpURL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="site-footer__icp"
|
||||
>
|
||||
{icp}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机端随内容滚动的页脚(放在 .page-wrap / .post-list-scroll 末尾)。
|
||||
* 桌面端返回 null,由 MainLayout 壳层贴底页脚负责。
|
||||
*/
|
||||
export function InFlowSiteFooter() {
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
if (!isMobile) return null;
|
||||
return <SiteFooter />;
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useRef, useEffect, useLayoutEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Inbox } from 'lucide-react';
|
||||
import { Inbox, SearchX } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PostListItem from './PostListItem';
|
||||
import PostListSkeleton from './PostListSkeleton';
|
||||
import FeedPagination from './FeedPagination';
|
||||
import { InFlowSiteFooter } from './SiteFooter';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import type { PostItem } from '../api/types';
|
||||
@@ -28,6 +29,12 @@ interface Props {
|
||||
resetScrollKey?: number;
|
||||
onScrollTopChange?: (top: number) => void;
|
||||
onScrollRestored?: () => void;
|
||||
/** 搜索关键词(用于空态文案) */
|
||||
keyword?: string;
|
||||
/** 当前板块 id,0 表示全部 */
|
||||
boardId?: number;
|
||||
/** 当前板块名 */
|
||||
boardName?: string;
|
||||
}
|
||||
|
||||
export default function VirtualPostList({
|
||||
@@ -45,6 +52,9 @@ export default function VirtualPostList({
|
||||
resetScrollKey = 0,
|
||||
onScrollTopChange,
|
||||
onScrollRestored,
|
||||
keyword = '',
|
||||
boardId = 0,
|
||||
boardName = '',
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -58,7 +68,7 @@ export default function VirtualPostList({
|
||||
const virtualizer = useVirtualizer({
|
||||
count: posts.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 72,
|
||||
estimateSize: () => 108,
|
||||
overscan: 8,
|
||||
measureElement:
|
||||
typeof window !== 'undefined' && !navigator.userAgent.includes('Firefox')
|
||||
@@ -69,6 +79,8 @@ export default function VirtualPostList({
|
||||
const showEnd = !hasMore && !showPagination && posts.length > 0 && !loading;
|
||||
const isInitialLoad = loading && posts.length === 0;
|
||||
const isEmpty = !loading && posts.length === 0;
|
||||
const isSearchEmpty = isEmpty && !!keyword.trim();
|
||||
const composeTarget = boardId > 0 ? `/compose?board=${boardId}` : '/compose';
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (resetScrollKey <= 0) return;
|
||||
@@ -102,26 +114,56 @@ export default function VirtualPostList({
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const emptyActions = (
|
||||
<div className="empty-feed-actions">
|
||||
{isSearchEmpty ? (
|
||||
<>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
|
||||
返回全部帖子
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => nav(user ? composeTarget : loginPath(composeTarget))}>
|
||||
{user ? '发帖' : '登录后发帖'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{boardId > 0 && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => nav('/')}>
|
||||
看看其他板块
|
||||
</Button>
|
||||
)}
|
||||
{user ? (
|
||||
<Button type="button" size="sm" onClick={() => nav(composeTarget)}>
|
||||
{boardName ? `成为「${boardName}」第一帖` : '发第一帖'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" onClick={() => nav(loginPath(composeTarget))}>
|
||||
登录后发帖
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="post-list-scroll" ref={parentRef}>
|
||||
{isInitialLoad ? (
|
||||
<PostListSkeleton />
|
||||
) : isEmpty ? (
|
||||
<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>
|
||||
{isSearchEmpty
|
||||
? <SearchX className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
: <Inbox className="empty-feed-icon" aria-hidden size={36} strokeWidth={1.5} />}
|
||||
<p>{isSearchEmpty ? '没有匹配的帖子' : '暂无帖子'}</p>
|
||||
<p className="empty-feed-hint">
|
||||
{isSearchEmpty
|
||||
? '试试更短的关键词,或浏览标签云 / 板块'
|
||||
: boardName
|
||||
? `「${boardName}」还没有内容,来发第一篇吧`
|
||||
: '换个板块看看,或发第一篇内容'}
|
||||
</p>
|
||||
{emptyActions}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -163,6 +205,7 @@ export default function VirtualPostList({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,17 +39,29 @@ export interface ButtonProps
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, loading, children, disabled, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
const classes = cn(buttonVariants({ variant, size, className }));
|
||||
// asChild 时 Slot 只能有单一子元素,不能夹 loading 图标
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={classes}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Slot>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
<button
|
||||
className={classes}
|
||||
ref={ref}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <Loader2 className="animate-spin" /> : null}
|
||||
{children}
|
||||
</Comp>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
post_tags_max: 256,
|
||||
post_content_max: 50000,
|
||||
comment_max: 5000,
|
||||
comment_edit_window_hours: 24,
|
||||
search_keyword_min: 1,
|
||||
search_keyword_max: 50,
|
||||
page_size_default: 30,
|
||||
@@ -15,6 +16,8 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
signature_max: 200,
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
};
|
||||
|
||||
let cached: ForumLimitsPublic | null = null;
|
||||
@@ -70,3 +73,8 @@ export function invalidateForumLimitsCache() {
|
||||
cacheEpoch += 1;
|
||||
listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/** 同步读取已缓存的论坛限制(供路径生成等非 hook 场景) */
|
||||
export function getCachedForumLimits(): ForumLimitsPublic {
|
||||
return cached ?? DEFAULT_LIMITS;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ export function useGlobalWheelScroll(scrollRef: RefObject<HTMLElement | null>, e
|
||||
const inner = findScrollable(target, e.deltaY, root);
|
||||
// 主内容区内部嵌套滚动(如 textarea、表情面板)保留原生行为
|
||||
if (inner && inner !== scrollEl && scrollEl.contains(inner)) return;
|
||||
// 主内容区外的独立滚动区(如右侧目录)保留原生行为,避免滚轮被抢走
|
||||
if (inner && !scrollEl.contains(inner)) return;
|
||||
// 鼠标已在主滚动容器上时,交给浏览器原生处理
|
||||
if (inner === scrollEl) return;
|
||||
|
||||
|
||||
154
frontend/src/hooks/usePageSEO.ts
Normal file
154
frontend/src/hooks/usePageSEO.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useEffect } from 'react';
|
||||
import { formatDocumentTitle, getCachedSiteBranding, siteMetaDescription } from './useSiteBranding';
|
||||
|
||||
export interface PageSEO {
|
||||
/** 页面标题(不含站点名);若提供 titleFull 则优先生效 */
|
||||
title?: string;
|
||||
/** 完整 document.title */
|
||||
titleFull?: string;
|
||||
description?: string;
|
||||
/** 覆盖站点默认 keywords;不传则用品牌配置 */
|
||||
keywords?: string;
|
||||
canonicalPath?: string;
|
||||
ogType?: string;
|
||||
ogImage?: string;
|
||||
/** 默认 index;私密页传 noindex,nofollow */
|
||||
robots?: string;
|
||||
jsonLd?: Record<string, unknown> | Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** 合并页面与站点关键词(逗号分隔) */
|
||||
export function joinSEOKeywords(...parts: Array<string | undefined | null>): string {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (!part) continue;
|
||||
for (const raw of part.replace(/[,、;;]/g, ',').split(',')) {
|
||||
const p = raw.trim();
|
||||
if (!p || seen.has(p)) continue;
|
||||
seen.add(p);
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
return out.join(',');
|
||||
}
|
||||
|
||||
const SEO_ATTR = 'data-j13-seo';
|
||||
|
||||
function upsertMeta(selector: string, attr: 'name' | 'property', key: string, content: string) {
|
||||
const head = document.head;
|
||||
let el = head.querySelector<HTMLMetaElement>(selector);
|
||||
if (!content) {
|
||||
el?.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('meta');
|
||||
el.setAttribute(attr, key);
|
||||
head.appendChild(el);
|
||||
}
|
||||
el.content = content;
|
||||
}
|
||||
|
||||
function upsertLink(rel: string, href: string) {
|
||||
const head = document.head;
|
||||
let el = head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`);
|
||||
if (!href) {
|
||||
el?.remove();
|
||||
return;
|
||||
}
|
||||
if (!el) {
|
||||
el = document.createElement('link');
|
||||
el.rel = rel;
|
||||
head.appendChild(el);
|
||||
}
|
||||
el.href = href;
|
||||
}
|
||||
|
||||
function upsertJsonLd(data?: PageSEO['jsonLd']) {
|
||||
const id = 'j13-jsonld';
|
||||
document.getElementById(id)?.remove();
|
||||
if (!data) return;
|
||||
const script = document.createElement('script');
|
||||
script.id = id;
|
||||
script.type = 'application/ld+json';
|
||||
script.textContent = JSON.stringify(data);
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function absoluteURL(pathOrURL: string): string {
|
||||
if (!pathOrURL) return '';
|
||||
if (/^https?:\/\//i.test(pathOrURL)) return pathOrURL;
|
||||
return new URL(pathOrURL, window.location.origin).href;
|
||||
}
|
||||
|
||||
/** 客户端路由切换时同步 title / meta / JSON-LD(与服务端首屏注入互补) */
|
||||
export function usePageSEO(seo: PageSEO | null | undefined) {
|
||||
const jsonLdKey = seo?.jsonLd ? JSON.stringify(seo.jsonLd) : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!seo) return;
|
||||
|
||||
const brand = getCachedSiteBranding();
|
||||
const siteName = brand.name.trim() || '姜十三论坛';
|
||||
const title = seo.titleFull?.trim()
|
||||
|| (seo.title?.trim() ? `${seo.title.trim()} - ${siteName}` : formatDocumentTitle(brand));
|
||||
|
||||
document.documentElement.setAttribute(SEO_ATTR, '1');
|
||||
document.title = title;
|
||||
|
||||
const description = (seo.description ?? siteMetaDescription(brand)).trim();
|
||||
const keywords = (seo.keywords ?? brand.keywords ?? '').trim();
|
||||
const canonical = absoluteURL(seo.canonicalPath || window.location.pathname);
|
||||
const ogImage = absoluteURL(seo.ogImage || brand.og_image || brand.logo || brand.favicon || '');
|
||||
const ogType = seo.ogType || 'website';
|
||||
const robots = seo.robots || '';
|
||||
|
||||
upsertMeta('meta[name="description"]', 'name', 'description', description);
|
||||
upsertMeta('meta[name="keywords"]', 'name', 'keywords', keywords);
|
||||
upsertMeta('meta[name="robots"]', 'name', 'robots', robots);
|
||||
upsertLink('canonical', canonical);
|
||||
|
||||
upsertMeta('meta[property="og:type"]', 'property', 'og:type', ogType);
|
||||
upsertMeta('meta[property="og:site_name"]', 'property', 'og:site_name', siteName);
|
||||
upsertMeta('meta[property="og:locale"]', 'property', 'og:locale', 'zh_CN');
|
||||
upsertMeta('meta[property="og:title"]', 'property', 'og:title', title);
|
||||
upsertMeta('meta[property="og:description"]', 'property', 'og:description', description);
|
||||
upsertMeta('meta[property="og:url"]', 'property', 'og:url', canonical);
|
||||
upsertMeta('meta[property="og:image"]', 'property', 'og:image', ogImage);
|
||||
|
||||
upsertMeta('meta[name="twitter:card"]', 'name', 'twitter:card', ogImage ? 'summary_large_image' : 'summary');
|
||||
upsertMeta('meta[name="twitter:title"]', 'name', 'twitter:title', title);
|
||||
upsertMeta('meta[name="twitter:description"]', 'name', 'twitter:description', description);
|
||||
upsertMeta('meta[name="twitter:image"]', 'name', 'twitter:image', ogImage);
|
||||
|
||||
upsertJsonLd(seo.jsonLd);
|
||||
|
||||
return () => {
|
||||
document.documentElement.removeAttribute(SEO_ATTR);
|
||||
// 离开页面时恢复站点默认标题;具体 meta 由下一页 usePageSEO 覆盖
|
||||
document.title = formatDocumentTitle(getCachedSiteBranding());
|
||||
upsertJsonLd(undefined);
|
||||
};
|
||||
// jsonLd 以序列化字符串作为依赖,避免内联对象导致重复执行
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
seo?.title,
|
||||
seo?.titleFull,
|
||||
seo?.description,
|
||||
seo?.keywords,
|
||||
seo?.canonicalPath,
|
||||
seo?.ogType,
|
||||
seo?.ogImage,
|
||||
seo?.robots,
|
||||
jsonLdKey,
|
||||
]);
|
||||
}
|
||||
|
||||
/** 管理 / 登录等私密页一键 noindex */
|
||||
export function useNoIndexSEO(title: string) {
|
||||
usePageSEO({
|
||||
title,
|
||||
robots: 'noindex,nofollow',
|
||||
});
|
||||
}
|
||||
@@ -4,21 +4,53 @@ import type { SiteBranding } from '../api/types';
|
||||
|
||||
export const DEFAULT_BRANDING: SiteBranding = {
|
||||
name: '姜十三论坛',
|
||||
name_en: 'Jiang13 Forum',
|
||||
slogan: '拾三一隅,自在交流',
|
||||
description: '',
|
||||
keywords: '',
|
||||
logo_mark: '姜',
|
||||
logo: '',
|
||||
favicon: '',
|
||||
og_image: '',
|
||||
icp_beian: '',
|
||||
icp_beian_url: 'https://beian.miit.gov.cn/',
|
||||
friend_links: [],
|
||||
};
|
||||
|
||||
let cached: SiteBranding | null = null;
|
||||
declare global {
|
||||
interface Window {
|
||||
/** 服务端注入的首屏品牌配置(见 embed_static SPA HTML) */
|
||||
__J13_BRANDING__?: Partial<SiteBranding>;
|
||||
}
|
||||
}
|
||||
|
||||
/** SEO / 首页展示用简介:优先 description,其次 slogan */
|
||||
export function siteMetaDescription(brand: SiteBranding): string {
|
||||
const d = brand.description?.trim() ?? '';
|
||||
if (d) return d;
|
||||
return brand.slogan?.trim() ?? '';
|
||||
}
|
||||
|
||||
/** 从服务端注入的 boot 数据同步初始化,避免首屏闪默认站名 */
|
||||
function readBootBranding(): SiteBranding | null {
|
||||
try {
|
||||
const boot = window.__J13_BRANDING__;
|
||||
if (!boot || typeof boot !== 'object') return null;
|
||||
const name = typeof boot.name === 'string' ? boot.name.trim() : '';
|
||||
if (!name) return null;
|
||||
return { ...DEFAULT_BRANDING, ...boot, name };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
let cached: SiteBranding | null = readBootBranding();
|
||||
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;
|
||||
// 有 boot/缓存时首屏已可用;仍请求 API 以同步最新配置
|
||||
inflight = api.siteBranding()
|
||||
.then(b => {
|
||||
cached = { ...DEFAULT_BRANDING, ...b };
|
||||
@@ -37,8 +69,11 @@ export function formatDocumentTitle(brand: SiteBranding): string {
|
||||
}
|
||||
|
||||
function applyDocumentBrand(brand: SiteBranding) {
|
||||
const title = formatDocumentTitle(brand);
|
||||
if (document.title !== title) document.title = title;
|
||||
// 页面级 SEO hook 已接管标题时,勿覆盖
|
||||
if (!document.documentElement.hasAttribute('data-j13-seo')) {
|
||||
const title = formatDocumentTitle(brand);
|
||||
if (document.title !== title) document.title = title;
|
||||
}
|
||||
|
||||
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
|
||||
if (brand.favicon) {
|
||||
@@ -53,6 +88,11 @@ function applyDocumentBrand(brand: SiteBranding) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步读取已缓存的品牌配置(供 SEO 等非 hook 场景) */
|
||||
export function getCachedSiteBranding(): SiteBranding {
|
||||
return cached ?? DEFAULT_BRANDING;
|
||||
}
|
||||
|
||||
/** 获取站点品牌配置(名称、Logo 等) */
|
||||
export function useSiteBranding() {
|
||||
const [branding, setBranding] = useState<SiteBranding>(cached ?? DEFAULT_BRANDING);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Users, Settings, ArrowLeft, Moon, Sun, Menu, X,
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
|
||||
import BackToTop from '../components/BackToTop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const NAV = [
|
||||
@@ -19,7 +20,9 @@ const NAV = [
|
||||
{ to: '/admin/boards', label: '板块管理', icon: FolderKanban },
|
||||
{ to: '/admin/posts', label: '帖子管理', icon: FileText },
|
||||
{ to: '/admin/comments', label: '评论管理', icon: MessageSquare },
|
||||
{ to: '/admin/reports', label: '举报管理', icon: Flag },
|
||||
{ to: '/admin/users', label: '用户管理', icon: Users },
|
||||
{ to: '/admin/media', label: '媒体库', icon: Images },
|
||||
{ to: '/admin/settings', label: '系统设置', icon: Settings },
|
||||
];
|
||||
|
||||
@@ -28,6 +31,7 @@ export default function AdminLayout() {
|
||||
const { user, loading } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('管理后台');
|
||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const nav = useNavigate();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo, Suspense } from 'rea
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import FeedPageSkeleton from '../components/FeedPageSkeleton';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { Menu, Moon, Sun, Search, Plus, PanelRight, X } from 'lucide-react';
|
||||
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -14,7 +14,7 @@ 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, RecentComment, ForumStats, TagCount } from '../api/types';
|
||||
import type { Board, PostItem, RecentComment, ForumStats, TagCount, User } 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';
|
||||
@@ -30,6 +30,8 @@ import { loginPath } from '../utils/authRedirect';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
import SiteFooter from '../components/SiteFooter';
|
||||
import { userPath } from '../utils/userPath';
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
@@ -46,15 +48,21 @@ export default function MainLayout() {
|
||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||
const [hot, setHot] = useState<PostItem[]>(() => getCachedHot());
|
||||
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
|
||||
const [unreadMessages, setUnreadMessages] = useState(0);
|
||||
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
|
||||
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
|
||||
const [postOutline, setPostOutline] = useState<{
|
||||
headings: PostHeading[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
title?: string;
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
} | null>(null);
|
||||
const [asideOpen, setAsideOpen] = useState(false);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
|
||||
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
|
||||
const asideEverLoaded = useRef(false);
|
||||
@@ -92,6 +100,7 @@ export default function MainLayout() {
|
||||
useEffect(() => {
|
||||
setAsideOpen(false);
|
||||
setSidebarOpen(false);
|
||||
setSearchExpanded(false);
|
||||
}, [loc.pathname, loc.search]);
|
||||
useEffect(() => {
|
||||
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
|
||||
@@ -100,8 +109,16 @@ export default function MainLayout() {
|
||||
if (!hideAside) setAsideOpen(false);
|
||||
}, [hideAside]);
|
||||
useEffect(() => {
|
||||
if (!isMobile) setSidebarOpen(false);
|
||||
if (!isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setSearchExpanded(false);
|
||||
}
|
||||
}, [isMobile]);
|
||||
useEffect(() => {
|
||||
if (!searchExpanded) return;
|
||||
const t = window.setTimeout(() => searchInputRef.current?.focus(), 50);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [searchExpanded]);
|
||||
useEffect(() => {
|
||||
if (!asideOpen && !sidebarOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
@@ -134,27 +151,42 @@ export default function MainLayout() {
|
||||
return () => window.removeEventListener('boards-refresh', onRefresh);
|
||||
}, [refreshBoards]);
|
||||
|
||||
// 标签云:非编辑页拉取(左侧栏常显)
|
||||
const refreshUnreadMessages = useCallback(() => {
|
||||
if (!user) {
|
||||
setUnreadMessages(0);
|
||||
return;
|
||||
}
|
||||
api.messageUnreadCount()
|
||||
.then((r) => setUnreadMessages(r.count || 0))
|
||||
.catch(() => setUnreadMessages(0));
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshUnreadMessages();
|
||||
const onRefresh = () => refreshUnreadMessages();
|
||||
window.addEventListener('messages-unread-refresh', onRefresh);
|
||||
const timer = window.setInterval(refreshUnreadMessages, 60_000);
|
||||
return () => {
|
||||
window.removeEventListener('messages-unread-refresh', onRefresh);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [refreshUnreadMessages]);
|
||||
|
||||
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/精华等不改标签)
|
||||
useEffect(() => {
|
||||
if (isCompose) return;
|
||||
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);
|
||||
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);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.removeEventListener('posts-refresh', onRefresh);
|
||||
};
|
||||
}, [isCompose]);
|
||||
|
||||
@@ -194,8 +226,10 @@ export default function MainLayout() {
|
||||
|
||||
const doSearch = () => {
|
||||
const kw = keyword.trim();
|
||||
const active = (params.get('keyword') || '').trim();
|
||||
if (!kw) {
|
||||
nav('/');
|
||||
// 输入已空:仅当 URL 仍带搜索时才回到全部帖子
|
||||
if (active) navigateFeed(nav, '/');
|
||||
return;
|
||||
}
|
||||
const len = [...kw].length;
|
||||
@@ -207,24 +241,41 @@ export default function MainLayout() {
|
||||
notify.warning(`搜索关键词最多 ${forumLimits.search_keyword_max} 个字`);
|
||||
return;
|
||||
}
|
||||
nav(`/?keyword=${encodeURIComponent(kw)}`);
|
||||
const target = `/?keyword=${encodeURIComponent(kw)}`;
|
||||
// 相同关键词再次回车:强制刷新,避免命中错误缓存或被当成空导航
|
||||
if (active === kw && loc.pathname === '/') {
|
||||
navigateFeed(nav, target);
|
||||
return;
|
||||
}
|
||||
nav(target);
|
||||
};
|
||||
|
||||
const openPost = useCallback((id: number) => {
|
||||
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
|
||||
setAsideOpen(false);
|
||||
openForumPost(nav, id, forumLimits.open_posts_in_new_tab);
|
||||
openForumPost(nav, id, forumLimits.open_posts_in_new_tab, opts);
|
||||
}, [nav, forumLimits.open_posts_in_new_tab]);
|
||||
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
const isFeedHome = loc.pathname === '/';
|
||||
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
|
||||
const outletKeyword = params.get('keyword') || '';
|
||||
// 搜索结果页不选中任何板块芯片(避免看起来仍停在「全部」)
|
||||
const mobileActiveBoard =
|
||||
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword
|
||||
? -1
|
||||
: boardId;
|
||||
|
||||
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']) => {
|
||||
const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname);
|
||||
const setPostOutlineSafe = useCallback((outline: {
|
||||
headings: PostHeading[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
title?: string;
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
} | null) => {
|
||||
setPostOutline(outline);
|
||||
}, []);
|
||||
const layoutCtx = useMemo<LayoutCtx>(() => ({
|
||||
@@ -257,14 +308,14 @@ export default function MainLayout() {
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app-frame">
|
||||
<header className="app-header">
|
||||
<header className={`app-header${searchExpanded && isMobile ? ' app-header--search-open' : ''}`}>
|
||||
<div className="header-inner">
|
||||
{isMobile && !isCompose && (
|
||||
{isMobile && !isCompose && !searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={openSidebar}
|
||||
aria-label={isPostDetail ? '打开目录与导航' : '打开导航菜单'}
|
||||
aria-label="打开导航菜单"
|
||||
aria-expanded={sidebarOpen}
|
||||
aria-controls="sidebar-drawer"
|
||||
title="导航"
|
||||
@@ -272,15 +323,38 @@ export default function MainLayout() {
|
||||
<Menu size={18} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||
</button>
|
||||
{!(isMobile && searchExpanded) && (
|
||||
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/')}>
|
||||
<SiteBrandMark branding={branding} className="header-logo-mark" />
|
||||
{!isMobile && <span className="header-logo-text">{branding.name}</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isCompose && (
|
||||
<div className="header-search-wrap">
|
||||
{!isCompose && isMobile && !searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn header-search-toggle"
|
||||
onClick={() => setSearchExpanded(true)}
|
||||
aria-label="搜索帖子"
|
||||
title="搜索"
|
||||
>
|
||||
<Search size={18} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isCompose && (!isMobile || searchExpanded) && (
|
||||
<form
|
||||
className={`header-search-wrap${isMobile && searchExpanded ? ' header-search-wrap--expanded' : ''}`}
|
||||
role="search"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
doSearch();
|
||||
if (isMobile) setSearchExpanded(false);
|
||||
}}
|
||||
>
|
||||
<Search className="header-search-icon" size={16} aria-hidden />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
className="header-search-input"
|
||||
type="search"
|
||||
placeholder="搜索帖子..."
|
||||
@@ -288,19 +362,29 @@ export default function MainLayout() {
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
|
||||
onKeyDown={e => e.key === 'Enter' && doSearch()}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-search-clear"
|
||||
onClick={() => { setKeyword(''); nav('/'); }}
|
||||
onClick={() => { setKeyword(''); navigateFeed(nav, '/'); }}
|
||||
aria-label="清除搜索"
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
{isMobile && searchExpanded && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-search-cancel"
|
||||
onClick={() => setSearchExpanded(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!(isMobile && searchExpanded) && (
|
||||
<div className="header-actions">
|
||||
{!isCompose && (
|
||||
<button
|
||||
@@ -315,33 +399,49 @@ export default function MainLayout() {
|
||||
)}
|
||||
|
||||
<div className="header-action-group">
|
||||
{!isCompose && hideAside && (
|
||||
{/* 平板:侧栏收起时用按钮打开社区动态;手机改由导航抽屉入口 */}
|
||||
{!isCompose && hideAside && !isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={openAside}
|
||||
aria-label="打开社区动态"
|
||||
aria-label={isPostDetail ? '打开作者与目录' : '打开社区动态'}
|
||||
aria-expanded={asideOpen}
|
||||
aria-controls="aside-drawer"
|
||||
title="社区动态"
|
||||
title={isPostDetail ? '作者与目录' : '社区动态'}
|
||||
>
|
||||
<PanelRight size={18} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={toggle}
|
||||
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{authLoading ? (
|
||||
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
|
||||
) : user ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn header-msg-btn"
|
||||
title={unreadMessages > 0 ? `${unreadMessages} 条未读私信` : '站内私信'}
|
||||
aria-label={unreadMessages > 0 ? `站内私信,${unreadMessages} 条未读` : '站内私信'}
|
||||
onClick={() => nav('/messages')}
|
||||
>
|
||||
<Mail size={18} aria-hidden />
|
||||
{unreadMessages > 0 && (
|
||||
<span className="header-msg-badge">{unreadMessages > 99 ? '99+' : unreadMessages}</span>
|
||||
)}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="header-user-btn" title={user.nickname} aria-label={`用户菜单:${user.nickname}`}>
|
||||
@@ -355,9 +455,17 @@ export default function MainLayout() {
|
||||
className="w-40"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem onClick={() => nav(`/user/${user.id}`)}>个人主页</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav(userPath(user.id))}>个人主页</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/profile')}>账号设置</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/messages')}>
|
||||
站内私信{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
||||
{isMobile && (
|
||||
<DropdownMenuItem onClick={toggle}>
|
||||
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -370,6 +478,7 @@ export default function MainLayout() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="header-login-btn" onClick={() => nav(loginPath())}>
|
||||
登录
|
||||
@@ -377,6 +486,7 @@ export default function MainLayout() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -387,14 +497,14 @@ export default function MainLayout() {
|
||||
activeBoard={boardId}
|
||||
onSelectBoard={setBoardId}
|
||||
boardsLoading={boardsLoading}
|
||||
outlineMode={isPostDetail}
|
||||
outlineHeadings={postOutline?.headings ?? []}
|
||||
outlineScrollRoot={postOutline?.scrollRoot ?? null}
|
||||
outlineTitle={postOutline?.title}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
|
||||
<div className={cn(
|
||||
'content-workspace',
|
||||
isCompose && 'content-workspace--compose',
|
||||
hideAside && !isCompose && 'content-workspace--aside-hidden',
|
||||
)}>
|
||||
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
|
||||
{isMobile && !isCompose && isFeedHome && (
|
||||
<div
|
||||
@@ -448,11 +558,22 @@ export default function MainLayout() {
|
||||
tagsLoading={tagsLoading}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
postDetail={isPostDetail ? {
|
||||
author: postOutline?.author ?? null,
|
||||
publishedAt: postOutline?.publishedAt,
|
||||
viewCount: postOutline?.viewCount,
|
||||
headings: postOutline?.headings ?? [],
|
||||
scrollRoot: postOutline?.scrollRoot ?? null,
|
||||
outlineTitle: postOutline?.title,
|
||||
} : null}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 桌面壳层贴底;手机端由各页 InFlowSiteFooter 随内容滚动 */}
|
||||
{!isCompose && !isMobile && <SiteFooter />}
|
||||
</div>
|
||||
|
||||
{sidebarOpen && isMobile && !isCompose && (
|
||||
@@ -470,10 +591,10 @@ export default function MainLayout() {
|
||||
className="sidebar-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={isPostDetail ? '目录与导航' : '导航菜单'}
|
||||
aria-label="导航菜单"
|
||||
>
|
||||
<div className="aside-drawer-head">
|
||||
<span>{isPostDetail ? '目录与导航' : '导航'}</span>
|
||||
<span>导航</span>
|
||||
<button
|
||||
ref={sidebarCloseRef}
|
||||
type="button"
|
||||
@@ -490,11 +611,25 @@ export default function MainLayout() {
|
||||
activeBoard={boardId}
|
||||
onSelectBoard={setBoardId}
|
||||
boardsLoading={boardsLoading}
|
||||
outlineMode={isPostDetail}
|
||||
outlineHeadings={postOutline?.headings ?? []}
|
||||
outlineScrollRoot={postOutline?.scrollRoot ?? null}
|
||||
outlineTitle={postOutline?.title}
|
||||
/>
|
||||
<div className="sidebar-drawer-extras">
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-drawer-extra-btn"
|
||||
onClick={() => { closeSidebar(); openAside(); }}
|
||||
>
|
||||
<PanelRight size={16} aria-hidden />
|
||||
{isPostDetail ? '作者与目录' : '社区动态'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-drawer-extra-btn"
|
||||
onClick={toggle}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={16} aria-hidden /> : <Sun size={16} aria-hidden />}
|
||||
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -515,10 +650,10 @@ export default function MainLayout() {
|
||||
className="aside-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="社区动态"
|
||||
aria-label={isPostDetail ? '作者与目录' : '社区动态'}
|
||||
>
|
||||
<div className="aside-drawer-head">
|
||||
<span>社区动态</span>
|
||||
<span>{isPostDetail ? '作者与目录' : '社区动态'}</span>
|
||||
<button
|
||||
ref={asideCloseRef}
|
||||
type="button"
|
||||
@@ -537,6 +672,14 @@ export default function MainLayout() {
|
||||
tagsLoading={tagsLoading}
|
||||
loading={asideLoading}
|
||||
onPostClick={openPost}
|
||||
postDetail={isPostDetail ? {
|
||||
author: postOutline?.author ?? null,
|
||||
publishedAt: postOutline?.publishedAt,
|
||||
viewCount: postOutline?.viewCount,
|
||||
headings: postOutline?.headings ?? [],
|
||||
scrollRoot: postOutline?.scrollRoot ?? null,
|
||||
outlineTitle: postOutline?.title,
|
||||
} : null}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -556,10 +699,13 @@ export type LayoutCtx = {
|
||||
stats: ForumStats | null;
|
||||
refreshBoards: () => void;
|
||||
isMobile: boolean;
|
||||
/** 详情页上报文章目录,供左侧栏展示 */
|
||||
/** 详情页上报作者与目录,供右侧栏展示 */
|
||||
setPostOutline: (outline: {
|
||||
headings: PostHeading[];
|
||||
scrollRoot: HTMLElement | null;
|
||||
title?: string;
|
||||
author?: User | null;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
} | null) => void;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { getCachedBoards } from '../utils/layoutCache';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { parsePermalinkID, postPath } from '../utils/permalink';
|
||||
import {
|
||||
loadComposeDraft,
|
||||
saveComposeDraft,
|
||||
@@ -53,12 +55,13 @@ function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
export default function ComposePage() {
|
||||
const nav = useNavigate();
|
||||
const { id: editIdParam } = useParams();
|
||||
const editId = editIdParam ? Number(editIdParam) : null;
|
||||
const editId = editIdParam ? parsePermalinkID(editIdParam) : null;
|
||||
const isEdit = editId !== null && !Number.isNaN(editId);
|
||||
const [params] = useSearchParams();
|
||||
const defaultBoard = params.get('board') || '';
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
useNoIndexSEO(isEdit ? '编辑帖子' : '发帖');
|
||||
const layoutCtx = useOutletContext<LayoutCtx | undefined>();
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>(() => resolveBoards(layoutCtx?.boards));
|
||||
@@ -100,12 +103,12 @@ export default function ComposePage() {
|
||||
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
|
||||
if (!isOwnerOrAdmin) {
|
||||
notify.error('无权编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
nav(postPath(editId!, limits));
|
||||
return;
|
||||
}
|
||||
if (!postData.can_edit) {
|
||||
notify.error(postData.edit_block_reason || '当前无法编辑此帖子');
|
||||
nav(`/post/${editId}`);
|
||||
nav(postPath(editId!, limits));
|
||||
return;
|
||||
}
|
||||
const loadedBoardId = String(post.board_id);
|
||||
@@ -232,9 +235,9 @@ export default function ComposePage() {
|
||||
title !== baseline.title
|
||||
|| serializeTags(parseTags(tags)) !== serializeTags(parseTags(baseline.tags))
|
||||
|| content !== baseline.content
|
||||
|| (!isEdit && boardId !== baseline.boardId)
|
||||
|| boardId !== baseline.boardId
|
||||
);
|
||||
}, [baseline, title, tags, content, boardId, isEdit]);
|
||||
}, [baseline, title, tags, content, boardId]);
|
||||
|
||||
const {
|
||||
dialogOpen,
|
||||
@@ -287,7 +290,7 @@ export default function ComposePage() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedTitle = title.trim();
|
||||
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
|
||||
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
|
||||
|
||||
@@ -297,19 +300,20 @@ export default function ComposePage() {
|
||||
title: trimmedTitle,
|
||||
content: content.trim(),
|
||||
tags: serializeTags(parseTags(tags)),
|
||||
board_id: boardId,
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
notify.success('帖子已更新');
|
||||
notify.success(user?.role === 'admin' ? '帖子已更新' : '已更新并重新提交审核');
|
||||
clearComposeDraft(editId);
|
||||
markSaved();
|
||||
nav(`/post/${editId}`);
|
||||
nav(postPath(editId!, limits));
|
||||
} else {
|
||||
const res = await api.createPost({ board_id: boardId, ...payload });
|
||||
notify.success('发帖成功');
|
||||
const res = await api.createPost(payload);
|
||||
notify.success(res.message || (res.status === 'pending' ? '已提交审核' : '发帖成功'));
|
||||
clearComposeDraft(null);
|
||||
markSaved();
|
||||
nav(`/post/${res.post_id}`);
|
||||
nav(postPath(res.post_id, limits));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : isEdit ? '保存失败' : '发帖失败');
|
||||
@@ -318,8 +322,6 @@ export default function ComposePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const currentBoard = boards.find(b => String(b.id) === boardId);
|
||||
|
||||
return (
|
||||
<div className="compose-page">
|
||||
<div className="compose-canvas">
|
||||
@@ -330,7 +332,7 @@ export default function ComposePage() {
|
||||
type="button"
|
||||
className="compose-back"
|
||||
onClick={() => requestLeave(() => {
|
||||
if (isEdit) nav(`/post/${editId}`);
|
||||
if (isEdit) nav(postPath(editId!, limits));
|
||||
else nav(-1);
|
||||
})}
|
||||
>
|
||||
@@ -360,26 +362,20 @@ export default function ComposePage() {
|
||||
<section className="compose-context" aria-label="发布设置">
|
||||
<div className="compose-context-row">
|
||||
<span className="compose-context-label">板块</span>
|
||||
{!isEdit ? (
|
||||
<div className="compose-board-pills" role="listbox" aria-label="选择板块">
|
||||
{boards.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={String(b.id) === boardId}
|
||||
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
|
||||
onClick={() => setBoardId(String(b.id))}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : currentBoard ? (
|
||||
<div className="compose-board-pills">
|
||||
<span className="compose-board-pill active">{currentBoard.name}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="compose-board-pills" role="listbox" aria-label={isEdit ? '修改板块' : '选择板块'}>
|
||||
{boards.map(b => (
|
||||
<button
|
||||
key={b.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={String(b.id) === boardId}
|
||||
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
|
||||
onClick={() => setBoardId(String(b.id))}
|
||||
>
|
||||
{b.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="compose-context-row compose-context-row--tags">
|
||||
<span className="compose-context-label">标签</span>
|
||||
|
||||
@@ -11,6 +11,8 @@ import PostListItem from '../components/PostListItem';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
interface FavItem {
|
||||
id: number;
|
||||
@@ -23,6 +25,7 @@ export default function FavoritesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
useNoIndexSEO('我的收藏');
|
||||
const [list, setList] = useState<FavItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -80,6 +83,7 @@ export default function FavoritesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,18 +18,34 @@ import {
|
||||
type FeedNavState,
|
||||
} from '../utils/feedCache';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { branding } = useSiteBranding();
|
||||
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 board = (ctx?.boards ?? []).find(b => b.id === boardId);
|
||||
const isSiteHome = !boardId && !keyword;
|
||||
const siteIntro = siteMetaDescription(branding);
|
||||
const feedTitle = keyword
|
||||
? `搜索:${keyword}`
|
||||
: (boardId && board ? board.name : '');
|
||||
usePageSEO({
|
||||
title: feedTitle || undefined,
|
||||
description: board?.description?.trim() || siteIntro,
|
||||
keywords: joinSEOKeywords(board?.name, branding.keywords),
|
||||
canonicalPath: boardId ? `/?board=${boardId}` : '/',
|
||||
ogType: 'website',
|
||||
});
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
@@ -43,6 +59,8 @@ export default function HomePage() {
|
||||
const loadingRef = useRef(false);
|
||||
const pageRef = useRef(1);
|
||||
pageRef.current = page;
|
||||
// 与当前筛选一致的列表快照(供卸载/切换筛选时写入缓存)
|
||||
const feedSnapRef = useRef({ boardId, keyword, sort, posts, postTotal, page });
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
|
||||
const showPagination = totalPages > 1 && posts.length > 0;
|
||||
@@ -143,18 +161,31 @@ export default function HomePage() {
|
||||
beginFeedRefresh,
|
||||
]);
|
||||
|
||||
// 离开当前筛选条件时写入内存缓存
|
||||
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
|
||||
if (
|
||||
feedSnapRef.current.boardId === boardId
|
||||
&& feedSnapRef.current.keyword === keyword
|
||||
&& feedSnapRef.current.sort === sort
|
||||
) {
|
||||
feedSnapRef.current = { boardId, keyword, sort, posts, postTotal, page };
|
||||
}
|
||||
|
||||
// 仅在筛选变化 / 卸载时缓存;勿把 posts 放进 deps(否则会用旧列表污染新 keyword)
|
||||
useEffect(() => {
|
||||
// cleanup 先保存上一档;再把快照重置为当前筛选的空占位
|
||||
feedSnapRef.current = { boardId, keyword, sort, posts: [], postTotal: 0, page: 1 };
|
||||
return () => {
|
||||
if (skipCacheSaveRef.current || posts.length === 0) return;
|
||||
setFeedCache(boardId, keyword, sort, {
|
||||
posts,
|
||||
postTotal,
|
||||
page,
|
||||
if (skipCacheSaveRef.current) return;
|
||||
const snap = feedSnapRef.current;
|
||||
if (snap.posts.length === 0) return;
|
||||
setFeedCache(snap.boardId, snap.keyword, snap.sort, {
|
||||
posts: snap.posts,
|
||||
postTotal: snap.postTotal,
|
||||
page: snap.page,
|
||||
scrollTop: scrollTopRef.current,
|
||||
});
|
||||
};
|
||||
}, [boardId, keyword, sort, posts, postTotal, page]);
|
||||
}, [boardId, keyword, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
|
||||
@@ -195,16 +226,19 @@ export default function HomePage() {
|
||||
<div className="page-wrap page-wrap--feed">
|
||||
<div className="feed-panel">
|
||||
<div className="feed-top">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
<div className="feed-top__bar">
|
||||
<FeedHeader
|
||||
boardId={boardId}
|
||||
keyword={keyword}
|
||||
boards={ctx?.boards ?? []}
|
||||
stats={ctx?.stats ?? null}
|
||||
postTotal={postTotal}
|
||||
titleAs={isSiteHome ? 'h2' : 'h1'}
|
||||
/>
|
||||
{showSortBar && (
|
||||
<FeedSortBar value={sort} onChange={handleSortChange} postTotal={postTotal} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<VirtualPostList
|
||||
posts={posts}
|
||||
@@ -221,6 +255,9 @@ export default function HomePage() {
|
||||
resetScrollKey={listResetKey}
|
||||
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
|
||||
onScrollRestored={() => setRestoreScrollTop(null)}
|
||||
keyword={keyword}
|
||||
boardId={boardId}
|
||||
boardName={ctx?.boards?.find(b => b.id === boardId)?.name || ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,17 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import AuthPasswordInput from '@/components/AuthPasswordInput';
|
||||
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 { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = z.object({
|
||||
@@ -25,6 +28,7 @@ export default function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('登录');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const form = useForm<FormValues>({
|
||||
@@ -49,7 +53,9 @@ export default function LoginPage() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<Link to="/" className="auth-brand-link" aria-label={`返回${branding.name}`}>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
</Link>
|
||||
<h1>登录{branding.name}</h1>
|
||||
<p className="subtitle">{branding.slogan || '欢迎回来'}</p>
|
||||
<Form {...form}>
|
||||
@@ -74,7 +80,7 @@ export default function LoginPage() {
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="密码" autoComplete="current-password" {...field} />
|
||||
<AuthPasswordInput placeholder="密码" autoComplete="current-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -88,6 +94,10 @@ export default function LoginPage() {
|
||||
<p className="auth-footer">
|
||||
没有账号?<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}>注册</Link>
|
||||
</p>
|
||||
<Link to="/" className="auth-back">
|
||||
<ArrowLeft size={16} aria-hidden />
|
||||
返回论坛
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
465
frontend/src/pages/MessagesPage.tsx
Normal file
465
frontend/src/pages/MessagesPage.tsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Bell, CheckCheck, Inbox, Send } 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 { MessageConversation, PrivateMessage, User } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { postPath } from '../utils/permalink';
|
||||
import { userPath } from '../utils/userPath';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function kindLabel(kind: string) {
|
||||
switch (kind) {
|
||||
case 'reject': return '拒帖通知';
|
||||
case 'report_result': return '举报结果';
|
||||
case 'system': return '系统通知';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function peerTitle(conv: MessageConversation | null, peerUser: User | null | undefined, peerId: number) {
|
||||
if (peerId === 0 || conv?.is_system) return '系统通知';
|
||||
return peerUser?.nickname || conv?.peer_user?.nickname || `用户 #${peerId}`;
|
||||
}
|
||||
|
||||
function peerInitial(name: string) {
|
||||
return name.trim().charAt(0) || '?';
|
||||
}
|
||||
|
||||
function previewText(msg?: PrivateMessage) {
|
||||
if (!msg) return '暂无消息';
|
||||
const text = (msg.content || msg.subject || '').replace(/\s+/g, ' ').trim();
|
||||
return text || msg.subject || '暂无消息';
|
||||
}
|
||||
|
||||
function AvatarBubble({
|
||||
name,
|
||||
avatar,
|
||||
system,
|
||||
}: {
|
||||
name: string;
|
||||
avatar?: string;
|
||||
system?: boolean;
|
||||
}) {
|
||||
if (system) {
|
||||
return (
|
||||
<span className="pm-avatar pm-avatar--system" aria-hidden>
|
||||
<Bell size={16} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (avatar) {
|
||||
return <img src={avatar} alt="" className="pm-avatar" loading="lazy" decoding="async" />;
|
||||
}
|
||||
return <span className="pm-avatar pm-avatar--fallback">{peerInitial(name)}</span>;
|
||||
}
|
||||
|
||||
export default function MessagesPage() {
|
||||
const nav = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const [params, setParams] = useSearchParams();
|
||||
useNoIndexSEO('站内私信');
|
||||
|
||||
const peerParam = params.get('peer');
|
||||
const selectedPeer = peerParam === null || peerParam === ''
|
||||
? null
|
||||
: Number(peerParam);
|
||||
const peerSelected = selectedPeer !== null && !Number.isNaN(selectedPeer);
|
||||
|
||||
const [conversations, setConversations] = useState<MessageConversation[]>([]);
|
||||
const [convTotal, setConvTotal] = useState(0);
|
||||
const [convPage, setConvPage] = useState(1);
|
||||
const [listLoading, setListLoading] = useState(true);
|
||||
|
||||
const [messages, setMessages] = useState<PrivateMessage[]>([]);
|
||||
const [msgTotal, setMsgTotal] = useState(0);
|
||||
const [threadLoading, setThreadLoading] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [peerUser, setPeerUser] = useState<User | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const threadEndRef = useRef<HTMLDivElement>(null);
|
||||
const threadScrollRef = useRef<HTMLDivElement>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
const loadConversations = useCallback(async (page = 1, append = false) => {
|
||||
setListLoading(true);
|
||||
try {
|
||||
const r = await api.messageConversations({ page, size: 30 });
|
||||
const next = r.conversations || [];
|
||||
setConversations((prev) => (append ? [...prev, ...next] : next));
|
||||
setConvTotal(r.total || 0);
|
||||
setConvPage(r.page || page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setListLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
if (!user) {
|
||||
nav(loginPath('/messages'));
|
||||
return;
|
||||
}
|
||||
loadConversations(1);
|
||||
}, [user, authLoading, nav, loadConversations]);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
requestAnimationFrame(() => {
|
||||
threadEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto', block: 'end' });
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !peerSelected || selectedPeer === null) {
|
||||
setMessages([]);
|
||||
setPeerUser(null);
|
||||
setMsgTotal(0);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setThreadLoading(true);
|
||||
stickToBottomRef.current = true;
|
||||
api.conversationMessages(selectedPeer, { size: 50 })
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setMessages(r.messages || []);
|
||||
setMsgTotal(r.total || 0);
|
||||
setPeerUser(r.peer_user || null);
|
||||
setConversations((prev) => prev.map((c) => (
|
||||
c.peer_user_id === selectedPeer ? { ...c, unread_count: 0 } : c
|
||||
)));
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) notify.error(e instanceof Error ? e.message : '加载会话失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setThreadLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [user, peerSelected, selectedPeer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!threadLoading && stickToBottomRef.current) {
|
||||
scrollToBottom(false);
|
||||
}
|
||||
}, [messages, threadLoading, scrollToBottom]);
|
||||
|
||||
const openPeer = (peerId: number) => {
|
||||
const p = new URLSearchParams();
|
||||
p.set('peer', String(peerId));
|
||||
setParams(p, { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const closeThread = () => {
|
||||
setParams(new URLSearchParams(), { replace: true });
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const markAll = async () => {
|
||||
try {
|
||||
await api.markAllMessagesRead();
|
||||
notify.success('已全部标为已读');
|
||||
setConversations((prev) => prev.map((c) => ({ ...c, unread_count: 0 })));
|
||||
window.dispatchEvent(new Event('messages-unread-refresh'));
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (!peerSelected || selectedPeer === null || messages.length === 0) return;
|
||||
const oldest = messages[0]?.id;
|
||||
if (!oldest) return;
|
||||
setLoadingOlder(true);
|
||||
const el = threadScrollRef.current;
|
||||
const prevHeight = el?.scrollHeight ?? 0;
|
||||
try {
|
||||
const r = await api.conversationMessages(selectedPeer, { size: 40, before: oldest });
|
||||
const older = r.messages || [];
|
||||
if (older.length === 0) return;
|
||||
stickToBottomRef.current = false;
|
||||
setMessages((prev) => [...older, ...prev]);
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight;
|
||||
});
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!peerSelected || selectedPeer === null || selectedPeer === 0) return;
|
||||
const content = draft.trim();
|
||||
if (!content) {
|
||||
notify.warning('请填写内容');
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
try {
|
||||
const r = await api.sendMessage({ to_user_id: selectedPeer, content });
|
||||
stickToBottomRef.current = true;
|
||||
setMessages((prev) => [...prev, r.message]);
|
||||
setMsgTotal((n) => n + 1);
|
||||
setDraft('');
|
||||
setConversations((prev) => {
|
||||
const rest = prev.filter((c) => c.peer_user_id !== selectedPeer);
|
||||
const existing = prev.find((c) => c.peer_user_id === selectedPeer);
|
||||
const next: MessageConversation = {
|
||||
peer_user_id: selectedPeer,
|
||||
peer_user: peerUser || existing?.peer_user,
|
||||
is_system: false,
|
||||
last_message: r.message,
|
||||
unread_count: 0,
|
||||
updated_at: r.message.created_at,
|
||||
};
|
||||
return [next, ...rest];
|
||||
});
|
||||
scrollToBottom(true);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || (listLoading && conversations.length === 0 && !peerSelected)) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!user) return null;
|
||||
|
||||
const activeConv = peerSelected && selectedPeer !== null
|
||||
? conversations.find((c) => c.peer_user_id === selectedPeer) || null
|
||||
: null;
|
||||
const title = peerSelected && selectedPeer !== null
|
||||
? peerTitle(activeConv, peerUser, selectedPeer)
|
||||
: '';
|
||||
const canCompose = peerSelected && selectedPeer !== null && selectedPeer > 0;
|
||||
const unreadTotal = conversations.reduce((n, c) => n + (c.unread_count || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<div className="pm-page-head">
|
||||
<div>
|
||||
<h1 className="page-title">站内私信</h1>
|
||||
<p className="page-desc">按会话查看,与用户即时沟通,并接收系统通知</p>
|
||||
</div>
|
||||
{unreadTotal > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={markAll}>
|
||||
<CheckCheck size={14} />
|
||||
全部已读
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn('pm-layout content-surface', peerSelected && 'pm-layout--thread')}>
|
||||
<aside className="pm-list" aria-label="会话列表">
|
||||
{listLoading && conversations.length === 0 ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : conversations.length === 0 ? (
|
||||
<div className="pm-empty">
|
||||
<Inbox size={28} strokeWidth={1.5} aria-hidden />
|
||||
<p>还没有会话</p>
|
||||
<span>在用户主页点击「发私信」开始对话</span>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((c) => {
|
||||
const name = peerTitle(c, c.peer_user, c.peer_user_id);
|
||||
const active = peerSelected && selectedPeer === c.peer_user_id;
|
||||
return (
|
||||
<button
|
||||
key={c.peer_user_id}
|
||||
type="button"
|
||||
className={cn('pm-conv-item', active && 'active', c.unread_count > 0 && 'unread')}
|
||||
onClick={() => openPeer(c.peer_user_id)}
|
||||
>
|
||||
<AvatarBubble
|
||||
name={name}
|
||||
avatar={c.peer_user?.avatar}
|
||||
system={c.is_system || c.peer_user_id === 0}
|
||||
/>
|
||||
<div className="pm-conv-item__body">
|
||||
<div className="pm-conv-item__top">
|
||||
<span className="pm-conv-item__name">{name}</span>
|
||||
<span className="pm-conv-item__time">
|
||||
{formatTime(c.last_message?.created_at || c.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pm-conv-item__preview">
|
||||
<span>{previewText(c.last_message)}</span>
|
||||
{c.unread_count > 0 && (
|
||||
<span className="pm-conv-item__badge">
|
||||
{c.unread_count > 99 ? '99+' : c.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{convTotal > conversations.length && (
|
||||
<div className="pm-list-more">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={listLoading}
|
||||
onClick={() => loadConversations(convPage + 1, true)}
|
||||
>
|
||||
加载更多会话
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="pm-thread" aria-label="会话内容">
|
||||
{!peerSelected || selectedPeer === null ? (
|
||||
<div className="pm-empty pm-empty--thread">
|
||||
<Send size={32} strokeWidth={1.4} aria-hidden />
|
||||
<p>选择左侧会话开始聊天</p>
|
||||
<span>系统通知也会出现在会话列表中</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<header className="pm-thread-head">
|
||||
<button type="button" className="pm-thread-back" onClick={closeThread} aria-label="返回会话列表">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<AvatarBubble
|
||||
name={title}
|
||||
avatar={peerUser?.avatar || activeConv?.peer_user?.avatar}
|
||||
system={selectedPeer === 0}
|
||||
/>
|
||||
<div className="pm-thread-head__meta">
|
||||
{selectedPeer > 0 ? (
|
||||
<Link to={userPath(selectedPeer)} className="pm-thread-head__name">{title}</Link>
|
||||
) : (
|
||||
<span className="pm-thread-head__name">{title}</span>
|
||||
)}
|
||||
<span className="pm-thread-head__sub">
|
||||
{selectedPeer === 0 ? '审核与系统消息' : '私信对话'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="pm-thread-scroll"
|
||||
ref={threadScrollRef}
|
||||
onScroll={(e) => {
|
||||
const t = e.currentTarget;
|
||||
stickToBottomRef.current = t.scrollHeight - t.scrollTop - t.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
{threadLoading ? (
|
||||
<div className="flex justify-center py-16"><Spinner /></div>
|
||||
) : (
|
||||
<>
|
||||
{msgTotal > messages.length && (
|
||||
<div className="pm-thread-older">
|
||||
<Button variant="ghost" size="sm" loading={loadingOlder} onClick={loadOlder}>
|
||||
查看更早消息
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<div className="pm-empty">还没有消息,打个招呼吧</div>
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
const mine = m.from_user_id === user.id;
|
||||
const system = m.from_user_id === 0 || m.kind !== 'user';
|
||||
const label = kindLabel(m.kind);
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={cn(
|
||||
'pm-bubble-row',
|
||||
mine && 'pm-bubble-row--mine',
|
||||
system && !mine && 'pm-bubble-row--system',
|
||||
)}
|
||||
>
|
||||
<div className={cn('pm-bubble', mine && 'pm-bubble--mine', system && !mine && 'pm-bubble--system')}>
|
||||
{label && !mine && (
|
||||
<span className="pm-bubble__kind">{label}</span>
|
||||
)}
|
||||
{m.subject && m.kind !== 'user' && (
|
||||
<div className="pm-bubble__subject">{m.subject}</div>
|
||||
)}
|
||||
<div className="pm-bubble__text">{m.content}</div>
|
||||
{m.related_post_id ? (
|
||||
<Link className="pm-bubble__link" to={postPath(m.related_post_id)}>
|
||||
查看相关帖子 #{m.related_post_id}
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="pm-bubble__meta">
|
||||
<time>{formatTime(m.created_at)}</time>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={threadEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCompose ? (
|
||||
<footer className="pm-composer">
|
||||
<textarea
|
||||
className="pm-composer__input"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
rows={2}
|
||||
maxLength={4000}
|
||||
placeholder={`发送给 ${title}…`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className="pm-composer__send"
|
||||
loading={sending}
|
||||
disabled={!draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
<Send size={16} />
|
||||
发送
|
||||
</Button>
|
||||
</footer>
|
||||
) : (
|
||||
<footer className="pm-composer pm-composer--readonly">
|
||||
系统通知不可回复;如需联系管理员,请从用户主页发私信。
|
||||
</footer>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/pages/NotFoundPage.tsx
Normal file
59
frontend/src/pages/NotFoundPage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FileQuestion, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
interface Props {
|
||||
/** 独立全屏(无 MainLayout 时) */
|
||||
standalone?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 统一 404 页面 */
|
||||
export default function NotFoundPage({
|
||||
standalone = false,
|
||||
title = '页面不存在',
|
||||
description = '您访问的页面不存在,或内容已被删除。',
|
||||
}: Props) {
|
||||
const nav = useNavigate();
|
||||
usePageSEO({
|
||||
title,
|
||||
description,
|
||||
robots: 'noindex,follow',
|
||||
});
|
||||
|
||||
const body = (
|
||||
<div className="error-page">
|
||||
<div className="error-page__code" aria-hidden>404</div>
|
||||
<FileQuestion className="error-page__icon" aria-hidden size={40} strokeWidth={1.5} />
|
||||
<h1 className="error-page__title">{title}</h1>
|
||||
<p className="error-page__desc">{description}</p>
|
||||
<div className="error-page__actions">
|
||||
<Button onClick={() => nav('/')}>
|
||||
<Home />
|
||||
返回首页
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => nav('/projects')}>
|
||||
浏览项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (standalone) {
|
||||
return (
|
||||
<div className="error-page-shell">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
{body}
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion, Trash2 } from 'lucide-react';
|
||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, Trash2, Sparkles, Flag, Ban } from 'lucide-react';
|
||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||
import PinnedIcon from '@/components/PinnedIcon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -18,22 +19,38 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, Comment } from '../api/types';
|
||||
import type { PostItem, Comment, ReportReason } from '../api/types';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
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 { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
|
||||
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
|
||||
import { loadMyCommentIds } from '../utils/guest';
|
||||
import { clearAllFeedCache } from '../utils/feedCache';
|
||||
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { excerptFromHTML, firstImageFromHTML } from '../utils/seoText';
|
||||
import { canonicalRedirectPath, parsePermalinkID, postPath } from '../utils/permalink';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import type { PostHeading } from '../utils/postHeadings';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
/** 格式化剩余可编辑时间 */
|
||||
function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
@@ -50,9 +67,11 @@ function formatEditRemaining(createdAt: string, windowHours: number): string {
|
||||
|
||||
export default function PostDetailPage() {
|
||||
const { id } = useParams();
|
||||
const postId = Number(id);
|
||||
const postId = parsePermalinkID(id);
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, refresh } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||
|
||||
const [post, setPost] = useState<PostItem | null>(null);
|
||||
@@ -72,6 +91,13 @@ export default function PostDetailPage() {
|
||||
const [showRevisions, setShowRevisions] = useState(false);
|
||||
const [deletingPost, setDeletingPost] = useState(false);
|
||||
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
||||
const [reportOpen, setReportOpen] = useState(false);
|
||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||
const [reportDetail, setReportDetail] = useState('');
|
||||
const [reporting, setReporting] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -80,6 +106,38 @@ export default function PostDetailPage() {
|
||||
|
||||
useGlobalWheelScroll(pageRef, !loading && !!post);
|
||||
|
||||
// SPA 内跳转时纠正非规范伪静态路径
|
||||
useEffect(() => {
|
||||
if (!postId || Number.isNaN(postId)) return;
|
||||
const target = canonicalRedirectPath('post', postId, location.pathname, limits);
|
||||
if (target) nav(target + location.search + location.hash, { replace: true });
|
||||
}, [postId, location.pathname, location.search, location.hash, limits, nav]);
|
||||
|
||||
const brand = getCachedSiteBranding();
|
||||
const postContent = post?.content ?? '';
|
||||
const postSEO = post ? {
|
||||
title: post.title,
|
||||
description: excerptFromHTML(postContent),
|
||||
keywords: joinSEOKeywords(post.board?.name, brand.keywords),
|
||||
canonicalPath: postPath(post.id, limits),
|
||||
ogType: 'article',
|
||||
ogImage: firstImageFromHTML(postContent) || post.user?.avatar || brand.og_image || '',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'DiscussionForumPosting',
|
||||
headline: post.title,
|
||||
description: excerptFromHTML(postContent),
|
||||
datePublished: post.created_at,
|
||||
dateModified: post.updated_at || post.created_at,
|
||||
url: postPath(post.id, limits),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: post.user?.nickname || post.user?.username || '',
|
||||
},
|
||||
},
|
||||
} : null;
|
||||
usePageSEO(postSEO);
|
||||
|
||||
const handleHeadingsChange = useCallback((next: PostHeading[]) => {
|
||||
setHeadings(next);
|
||||
}, []);
|
||||
@@ -93,15 +151,22 @@ export default function PostDetailPage() {
|
||||
headings,
|
||||
scrollRoot: pageRef.current,
|
||||
title: '文章目录',
|
||||
author: post.user ?? null,
|
||||
publishedAt: post.created_at,
|
||||
viewCount: post.view_count,
|
||||
});
|
||||
return () => setPostOutline(null);
|
||||
}, [headings, loading, post, setPostOutline]);
|
||||
|
||||
const loadSeq = useRef(0);
|
||||
const postPath = `/post/${postId}`;
|
||||
const detailPath = postPath(postId, limits);
|
||||
|
||||
useEffect(() => {
|
||||
if (!postId) return;
|
||||
if (!postId || Number.isNaN(postId)) {
|
||||
setPost(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setReplyTo(null);
|
||||
setEditingCommentId(null);
|
||||
setHeadings([]);
|
||||
@@ -126,10 +191,9 @@ export default function PostDetailPage() {
|
||||
setEditWindowHours(detail.post_edit_window_hours ?? 0);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
void refresh();
|
||||
} catch (e: unknown) {
|
||||
} catch {
|
||||
if (seq !== loadSeq.current) return;
|
||||
setPost(null);
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
if (seq === loadSeq.current) setLoading(false);
|
||||
}
|
||||
@@ -152,12 +216,27 @@ export default function PostDetailPage() {
|
||||
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
|
||||
}, []);
|
||||
|
||||
// 从 #floor-N 定位到对应评论(右栏最新评论等入口)
|
||||
useEffect(() => {
|
||||
if (loading || !post) return;
|
||||
const m = location.hash.match(/^#floor-(\d+)$/);
|
||||
if (!m) return;
|
||||
const floor = Number(m[1]);
|
||||
if (!floor) return;
|
||||
const t = window.setTimeout(() => jumpToFloor(floor), 80);
|
||||
return () => clearTimeout(t);
|
||||
}, [loading, post, comments, location.hash, jumpToFloor]);
|
||||
|
||||
const requireLogin = (actionLabel: string) => {
|
||||
notify.warning(`登录后即可${actionLabel}`);
|
||||
nav(loginPath(postPath));
|
||||
nav(loginPath(detailPath));
|
||||
};
|
||||
|
||||
const handleReplyTo = (comment: Comment) => {
|
||||
if (!user) {
|
||||
requireLogin('回复');
|
||||
return;
|
||||
}
|
||||
setEditingCommentId(null);
|
||||
if (replyTo?.id === comment.id) {
|
||||
setReplyTo(null);
|
||||
@@ -199,20 +278,20 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
const handleSubmitComment = async (data: CommentSubmitData) => {
|
||||
if (!user) {
|
||||
requireLogin('评论');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const r = await api.addComment(postId, {
|
||||
content: data.content,
|
||||
replyTo: replyTo?.id,
|
||||
guestNick: data.guestNick,
|
||||
guestEmail: data.guestEmail,
|
||||
guestUrl: data.guestUrl,
|
||||
isPrivate: data.isPrivate,
|
||||
});
|
||||
if (!user) addMyCommentId(r.id);
|
||||
setReplyTo(null);
|
||||
setSubmitCount(c => c + 1);
|
||||
notify.success('评论成功');
|
||||
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
|
||||
await reloadComments();
|
||||
setTimeout(() => jumpToFloor(r.floor), 100);
|
||||
} catch (e: unknown) {
|
||||
@@ -227,11 +306,16 @@ export default function PostDetailPage() {
|
||||
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,
|
||||
content: r.content || content,
|
||||
updated_at: new Date().toISOString(),
|
||||
status: r.status || c.status,
|
||||
}
|
||||
: c
|
||||
)));
|
||||
setEditingCommentId(null);
|
||||
notify.success('评论已更新');
|
||||
notify.success(r.message || '评论已更新');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
throw e;
|
||||
@@ -251,6 +335,19 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveComment = async (comment: Comment) => {
|
||||
try {
|
||||
const r = await api.adminApproveComment(comment.id);
|
||||
setComments(list => list.map(c => (
|
||||
c.id === comment.id ? { ...c, status: r.status } : c
|
||||
)));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '审核失败');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePost = async () => {
|
||||
setDeletingPost(true);
|
||||
try {
|
||||
@@ -275,13 +372,14 @@ export default function PostDetailPage() {
|
||||
};
|
||||
|
||||
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
if (!post) return (
|
||||
<div className="empty-state">
|
||||
<FileQuestion className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>帖子不存在</p>
|
||||
<Button variant="outline" onClick={() => nav('/')}>返回首页</Button>
|
||||
</div>
|
||||
);
|
||||
if (!post) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="帖子不存在"
|
||||
description="该帖子不存在,或已被删除。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const authorInitial = post.user?.nickname?.[0] || '?';
|
||||
const tags = post.tags?.split(/[,,]/).map(t => t.trim()).filter(Boolean) ?? [];
|
||||
@@ -305,6 +403,74 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFeature = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminFeaturePost(postId, !post.featured);
|
||||
setPost(p => p ? { ...p, featured: r.featured } : p);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
const r = await api.adminApprovePost(postId);
|
||||
setPost(p => p ? { ...p, status: r.status } : p);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReport = async () => {
|
||||
if (!user) {
|
||||
requireLogin('举报');
|
||||
return;
|
||||
}
|
||||
setReporting(true);
|
||||
try {
|
||||
const r = await api.reportPost(postId, {
|
||||
reason: reportReason,
|
||||
detail: reportDetail.trim() || undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setReportOpen(false);
|
||||
setReportDetail('');
|
||||
setReportReason('spam');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '举报失败');
|
||||
} finally {
|
||||
setReporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectReason.trim()) {
|
||||
notify.warning('请填写拒绝原因');
|
||||
return;
|
||||
}
|
||||
setRejecting(true);
|
||||
try {
|
||||
const r = await api.adminRejectPost(postId, rejectReason.trim());
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
setRejectOpen(false);
|
||||
nav('/');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
@@ -322,8 +488,12 @@ export default function PostDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const jumpToComments = () => {
|
||||
commentSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<article className="page-wrap post-detail-page" ref={pageRef}>
|
||||
<div className="post-detail-header">
|
||||
<div className="post-detail-nav">
|
||||
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
|
||||
@@ -335,8 +505,22 @@ export default function PostDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.status === 'pending' && (
|
||||
<div className="post-moderation-banner post-moderation-banner--pending">
|
||||
该帖子审核中,仅你与管理员可见;通过后将公开显示。
|
||||
</div>
|
||||
)}
|
||||
{post.status === 'rejected' && (
|
||||
<div className="post-moderation-banner post-moderation-banner--rejected">
|
||||
该帖子未通过审核,仅你与管理员可见。可修改后重新提交,或查看站内私信中的拒绝原因。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="post-detail-head">
|
||||
<h1 className="post-detail-title">
|
||||
{post.status === 'pending' && <Badge variant="orange" className="mr-2 align-middle">审核中</Badge>}
|
||||
{post.status === 'rejected' && <Badge variant="destructive" className="mr-2 align-middle">未通过</Badge>}
|
||||
{post.featured && <FeaturedIcon className="mr-2" size={18} />}
|
||||
{post.pinned && <PinnedIcon className="mr-2" size={18} />}
|
||||
{post.title}
|
||||
</h1>
|
||||
@@ -397,22 +581,38 @@ export default function PostDetailPage() {
|
||||
variant={liked ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleLike}
|
||||
title={!user ? '登录后可点赞' : undefined}
|
||||
title={!user ? '登录后即可点赞' : undefined}
|
||||
className={!user ? 'post-action-guest' : undefined}
|
||||
>
|
||||
<ThumbsUp />
|
||||
点赞 {post.like_count}
|
||||
{!user ? '登录后点赞' : `点赞 ${post.like_count}`}
|
||||
</Button>
|
||||
<Button
|
||||
variant={favorited ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleFavorite}
|
||||
title={!user ? '登录后可收藏' : undefined}
|
||||
title={!user ? '登录后即可收藏' : undefined}
|
||||
className={!user ? 'post-action-guest' : undefined}
|
||||
>
|
||||
<Star />
|
||||
{favorited ? '已收藏' : '收藏'}
|
||||
{!user ? '登录后收藏' : (favorited ? '已收藏' : '收藏')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={jumpToComments}>
|
||||
<MessageSquare />
|
||||
评论 {comments.length}
|
||||
</Button>
|
||||
{user && user.id !== post.user_id && (
|
||||
<Button variant="outline" size="sm" onClick={() => setReportOpen(true)}>
|
||||
<Flag />
|
||||
举报
|
||||
</Button>
|
||||
)}
|
||||
{!user && (
|
||||
<Button variant="outline" size="sm" onClick={() => requireLogin('举报')}>
|
||||
<Flag />
|
||||
举报
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
|
||||
<Pencil />
|
||||
@@ -425,7 +625,7 @@ export default function PostDetailPage() {
|
||||
编辑历史
|
||||
</Button>
|
||||
)}
|
||||
{isOwnerOrAdmin && (
|
||||
{isAdmin && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={deletingPost}>
|
||||
@@ -436,7 +636,9 @@ export default function PostDetailPage() {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
@@ -455,6 +657,15 @@ export default function PostDetailPage() {
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
{(post.status === 'pending' || post.status === 'rejected') && (
|
||||
<Button variant="default" size="sm" onClick={handleApprove}>
|
||||
通过审核
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={handleFeature}>
|
||||
<Sparkles />
|
||||
{post.featured ? '取消精华' : '设为精华'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePin}>
|
||||
<Pin />
|
||||
{post.pinned ? '取消置顶' : '置顶'}
|
||||
@@ -463,11 +674,80 @@ export default function PostDetailPage() {
|
||||
<Lock />
|
||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||
</Button>
|
||||
{post.status !== 'rejected' && (
|
||||
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
|
||||
<Ban />
|
||||
拒绝并通知
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>举报帖子</DialogTitle>
|
||||
<DialogDescription>请选择原因,管理员将尽快处理。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span>举报原因</span>
|
||||
<select
|
||||
value={reportReason}
|
||||
onChange={(e) => setReportReason(e.target.value as ReportReason)}
|
||||
>
|
||||
{REPORT_REASON_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="pm-field">
|
||||
<span>补充说明(可选)</span>
|
||||
<textarea
|
||||
value={reportDetail}
|
||||
onChange={(e) => setReportDetail(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
placeholder="补充更多细节…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReportOpen(false)}>取消</Button>
|
||||
<Button loading={reporting} onClick={handleReport}>提交举报</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>拒绝帖子并通知作者</DialogTitle>
|
||||
<DialogDescription>
|
||||
帖子将移入回收站,拒绝原因会通过站内私信发送给作者。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
<label className="pm-field">
|
||||
<span>拒绝原因</span>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
placeholder="请说明未通过的原因…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRejectOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" loading={rejecting} onClick={handleReject}>确认拒绝</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<PostRevisionPanel
|
||||
postId={postId}
|
||||
currentPost={{ title: post.title, content: post.content ?? '', tags: post.tags ?? '' }}
|
||||
@@ -492,7 +772,7 @@ export default function PostDetailPage() {
|
||||
{comments.length === 0 && !replyTo ? (
|
||||
<div className="comment-empty">
|
||||
<MessageSquare className="comment-empty-icon" aria-hidden size={32} strokeWidth={1.5} />
|
||||
<p>暂无评论,来抢沙发吧</p>
|
||||
<p>{user ? '暂无评论,来抢沙发吧' : '暂无评论,登录后来抢沙发吧'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<CommentThreadList
|
||||
@@ -510,6 +790,7 @@ export default function PostDetailPage() {
|
||||
onCancelEdit={() => setEditingCommentId(null)}
|
||||
onSaveEdit={handleSaveComment}
|
||||
onDelete={handleDeleteComment}
|
||||
onApprove={user?.role === 'admin' ? handleApproveComment : undefined}
|
||||
renderReplyBox={(c) => (
|
||||
<CommentBox
|
||||
key={c.id}
|
||||
@@ -522,6 +803,7 @@ export default function PostDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, UserActivityStats } from '../api/types';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
@@ -37,6 +38,7 @@ import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import { userPath } from '../utils/userPath';
|
||||
|
||||
const nickSchema = z.object({
|
||||
@@ -71,6 +73,7 @@ export default function ProfilePage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = parseTab(params.get('tab'));
|
||||
const { user, loading: authLoading, refresh } = useAuth();
|
||||
useNoIndexSEO('个人中心');
|
||||
const [nickLoading, setNickLoading] = useState(false);
|
||||
const [sigLoading, setSigLoading] = useState(false);
|
||||
const [pwdLoading, setPwdLoading] = useState(false);
|
||||
@@ -596,7 +599,7 @@ export default function ProfilePage() {
|
||||
/>
|
||||
<div className="profile-form-footer">
|
||||
<span className="profile-form-hint">
|
||||
用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
||||
用户名与 ID 不可修改;头像支持 JPG / PNG / GIF / WebP,服务端保留原图并生成 WebP,裁剪后不超过 {limits.avatar_max_mb}MB
|
||||
</span>
|
||||
<Button type="submit" loading={nickLoading}>保存昵称</Button>
|
||||
</div>
|
||||
@@ -690,6 +693,7 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { GiteaProject } from '../api/types';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { getCachedSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
function formatRemoteTime(raw?: string | null): string {
|
||||
if (!raw) return '';
|
||||
@@ -27,6 +30,12 @@ export default function ProjectsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
usePageSEO({
|
||||
title: '项目',
|
||||
description: '公开项目列表',
|
||||
keywords: joinSEOKeywords('项目', getCachedSiteBranding().keywords),
|
||||
canonicalPath: '/projects',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -114,6 +123,7 @@ export default function ProjectsPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { useNavigate, Link, useSearchParams } from 'react-router-dom';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import AuthPasswordInput from '@/components/AuthPasswordInput';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
@@ -13,6 +15,7 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { resolveAuthRedirect, loginPath, navigateAfterAuth } from '../utils/authRedirect';
|
||||
import type { RegisterConfig } from '../api/types';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useNoIndexSEO } from '../hooks/usePageSEO';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
|
||||
const schema = (minLen: number) => z.object({
|
||||
@@ -28,6 +31,7 @@ type FormValues = z.infer<ReturnType<typeof schema>>;
|
||||
export default function RegisterPage() {
|
||||
const { limits } = useForumLimits();
|
||||
const { branding } = useSiteBranding();
|
||||
useNoIndexSEO('注册');
|
||||
const nav = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { refresh } = useAuth();
|
||||
@@ -37,6 +41,9 @@ export default function RegisterPage() {
|
||||
const [regConfig, setRegConfig] = useState<RegisterConfig | null>(null);
|
||||
const redirectTo = resolveAuthRedirect(searchParams);
|
||||
const requireCode = !!regConfig?.require_email_code;
|
||||
const codeLen = regConfig?.email_code_len && regConfig.email_code_len > 0
|
||||
? regConfig.email_code_len
|
||||
: 6;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema(limits.password_min_len)),
|
||||
@@ -84,9 +91,12 @@ export default function RegisterPage() {
|
||||
notify.error('论坛暂未开放注册,请联系管理员配置邮件服务');
|
||||
return;
|
||||
}
|
||||
if (requireCode && !values.email_code?.trim()) {
|
||||
form.setError('email_code', { message: '请输入邮箱验证码' });
|
||||
return;
|
||||
if (requireCode) {
|
||||
const code = (values.email_code || '').trim();
|
||||
if (!new RegExp(`^\\d{${codeLen}}$`).test(code)) {
|
||||
form.setError('email_code', { message: `请输入 ${codeLen} 位数字验证码` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -117,7 +127,9 @@ export default function RegisterPage() {
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-box">
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
<Link to="/" className="auth-brand-link" aria-label={`返回${branding.name}`}>
|
||||
<SiteBrandMark branding={branding} className="logo-mark" />
|
||||
</Link>
|
||||
<h1>注册账号</h1>
|
||||
<p className="subtitle">{subtitle}</p>
|
||||
{regConfig && !regConfig.register_open ? (
|
||||
@@ -174,7 +186,11 @@ export default function RegisterPage() {
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder={`至少 ${limits.password_min_len} 位`} autoComplete="new-password" {...field} />
|
||||
<AuthPasswordInput
|
||||
placeholder={`至少 ${limits.password_min_len} 位`}
|
||||
autoComplete="new-password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -190,10 +206,17 @@ export default function RegisterPage() {
|
||||
<div className="auth-captcha-row">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="6 位数字验证码"
|
||||
placeholder={`${codeLen} 位数字`}
|
||||
autoComplete="one-time-code"
|
||||
inputMode="numeric"
|
||||
pattern={`\\d{${codeLen}}`}
|
||||
maxLength={codeLen}
|
||||
className="auth-email-code-input"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/\D/g, '').slice(0, codeLen);
|
||||
field.onChange(digits);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
@@ -207,6 +230,7 @@ export default function RegisterPage() {
|
||||
{countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="auth-hint">请填写邮件中的 {codeLen} 位数字验证码,有效期 10 分钟</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -225,6 +249,10 @@ export default function RegisterPage() {
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<Link to="/" className="auth-back">
|
||||
<ArrowLeft size={16} aria-hidden />
|
||||
返回论坛
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
Hash,
|
||||
Heart,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
PenLine,
|
||||
Settings,
|
||||
@@ -20,20 +21,29 @@ import { useAuth } from '../hooks/useAuth';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
import PostListItem from '../components/PostListItem';
|
||||
import FeedPagination from '../components/FeedPagination';
|
||||
import ComposeMessageDialog from '../components/ComposeMessageDialog';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { formatDateTime } from '../utils/content';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { canonicalRedirectPath, parsePermalinkID, userPath } from '../utils/permalink';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
|
||||
export default function UserProfilePage() {
|
||||
const { id: idParam } = useParams();
|
||||
const userId = Number(idParam);
|
||||
const userId = parsePermalinkID(idParam);
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user: me } = useAuth();
|
||||
const { limits } = useForumLimits();
|
||||
const pageSize = limits.page_size_default > 0 ? limits.page_size_default : 20;
|
||||
|
||||
const [profile, setProfile] = useState<UserPublic | null>(null);
|
||||
const [stats, setStats] = useState<UserActivityStats | null>(null);
|
||||
const [msgOpen, setMsgOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postsLoading, setPostsLoading] = useState(false);
|
||||
const [postPage, setPostPage] = useState(1);
|
||||
@@ -44,23 +54,25 @@ export default function UserProfilePage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || Number.isNaN(userId)) {
|
||||
notify.error('无效用户');
|
||||
nav('/');
|
||||
setNotFound(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setNotFound(false);
|
||||
setPostPage(1);
|
||||
api.userProfile(userId)
|
||||
.then(d => {
|
||||
setProfile(d.user);
|
||||
setStats(d.stats);
|
||||
})
|
||||
.catch(e => {
|
||||
notify.error(e instanceof Error ? e.message : '用户不存在');
|
||||
nav('/');
|
||||
.catch(() => {
|
||||
setProfile(null);
|
||||
setStats(null);
|
||||
setNotFound(true);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [userId, nav]);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || Number.isNaN(userId) || !profile) return;
|
||||
@@ -81,10 +93,40 @@ export default function UserProfilePage() {
|
||||
return () => { cancelled = true; };
|
||||
}, [userId, profile, postPage, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId || Number.isNaN(userId)) return;
|
||||
const target = canonicalRedirectPath('user', userId, location.pathname, limits);
|
||||
if (target) nav(target + location.search + location.hash, { replace: true });
|
||||
}, [userId, location.pathname, location.search, location.hash, limits, nav]);
|
||||
|
||||
usePageSEO(profile ? {
|
||||
title: `${profile.nickname} 的主页`,
|
||||
description: profile.signature?.trim() || `${profile.nickname} 的主页`,
|
||||
canonicalPath: userPath(profile.id, limits),
|
||||
ogType: 'profile',
|
||||
ogImage: profile.avatar || '',
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ProfilePage',
|
||||
mainEntity: {
|
||||
'@type': 'Person',
|
||||
name: profile.nickname,
|
||||
description: profile.signature?.trim() || undefined,
|
||||
},
|
||||
},
|
||||
} : null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
if (!profile) return null;
|
||||
if (notFound || !profile) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="用户不存在"
|
||||
description="该用户不存在,或账号不可访问。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const joinedAt = profile.created_at ? formatDateTime(profile.created_at) : '';
|
||||
const signature = profile.signature?.trim() || '';
|
||||
@@ -132,14 +174,29 @@ export default function UserProfilePage() {
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{isSelf && (
|
||||
<div className="profile-avatar-actions">
|
||||
<div className="profile-avatar-actions">
|
||||
{isSelf ? (
|
||||
<Button size="sm" variant="outline" onClick={() => nav('/profile?tab=settings')}>
|
||||
<Settings size={14} />
|
||||
编辑资料
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (!me) {
|
||||
nav(loginPath(userPath(profile.id)));
|
||||
return;
|
||||
}
|
||||
setMsgOpen(true);
|
||||
}}
|
||||
>
|
||||
<Mail size={14} />
|
||||
发私信
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-stat-grid" aria-label="活动统计">
|
||||
@@ -206,6 +263,17 @@ export default function UserProfilePage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<InFlowSiteFooter />
|
||||
|
||||
{!isSelf && profile && me && (
|
||||
<ComposeMessageDialog
|
||||
open={msgOpen}
|
||||
onOpenChange={setMsgOpen}
|
||||
toUserId={profile.id}
|
||||
toNickname={profile.nickname}
|
||||
onSent={() => nav(`/messages?peer=${profile.id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,33 +9,74 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { Comment } from '../../api/types';
|
||||
import CommentRevisionDialog from '../../components/CommentRevisionDialog';
|
||||
import { isTimeDiffSignificant } from '../../utils/content';
|
||||
|
||||
type Tab = 'pending' | 'all';
|
||||
|
||||
function statusLabel(status?: string) {
|
||||
switch (status) {
|
||||
case 'pending': return '待审核';
|
||||
case 'rejected': return '未通过';
|
||||
case 'published': return '已公开';
|
||||
default: return status || '—';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminCommentsPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [tab, setTab] = useState<Tab>('pending');
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [revComment, setRevComment] = useState<Comment | null>(null);
|
||||
|
||||
const load = (p = page) => {
|
||||
const load = (p = page, st: Tab = tab) => {
|
||||
setLoading(true);
|
||||
api.adminComments(p)
|
||||
api.adminComments({ page: p, status: st === 'pending' ? 'pending' : 'all' })
|
||||
.then(d => {
|
||||
setComments(d.comments ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
setPendingCount(d.pending_count ?? 0);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1);
|
||||
}, [ready]);
|
||||
if (ready) load(1, tab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ready, tab]);
|
||||
|
||||
const approve = async (id: number) => {
|
||||
try {
|
||||
const r = await api.adminApproveComment(id);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const reject = async (c: Comment) => {
|
||||
const reason = window.prompt('拒绝原因(将私信通知作者):', '不符合社区规范');
|
||||
if (reason == null) return;
|
||||
try {
|
||||
const r = await api.adminRejectComment(c.id, reason.trim() || undefined);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
@@ -53,7 +94,24 @@ export default function AdminCommentsPage() {
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>评论管理</h1>
|
||||
<p>查看与删除楼层评论</p>
|
||||
<p>审核普通用户评论;通过后公开,拒绝后仅作者可见并私信通知。</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-tab', tab === 'pending' && 'active')}
|
||||
onClick={() => setTab('pending')}
|
||||
>
|
||||
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-tab', tab === 'all' && 'active')}
|
||||
onClick={() => setTab('all')}
|
||||
>
|
||||
全部评论
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
@@ -69,6 +127,7 @@ export default function AdminCommentsPage() {
|
||||
<th>帖子</th>
|
||||
<th>作者</th>
|
||||
<th>内容</th>
|
||||
<th>状态</th>
|
||||
<th>私密</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
@@ -92,24 +151,40 @@ export default function AdminCommentsPage() {
|
||||
) : (c.guest_nick || '游客')}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate">{c.content}</td>
|
||||
<td>
|
||||
<Badge variant={c.status === 'pending' ? 'orange' : c.status === 'rejected' ? 'destructive' : 'green'}>
|
||||
{statusLabel(c.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>{c.is_private ? <Badge variant="secondary">是</Badge> : '—'}</td>
|
||||
<td>{new Date(c.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>此操作不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(c.status === 'pending' || c.status === 'rejected') && (
|
||||
<Button size="sm" onClick={() => approve(c.id)}>通过</Button>
|
||||
)}
|
||||
{c.status === 'pending' && (
|
||||
<Button size="sm" variant="outline" onClick={() => reject(c)}>拒绝</Button>
|
||||
)}
|
||||
{c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at) && (
|
||||
<Button size="sm" variant="outline" onClick={() => setRevComment(c)}>编辑记录</Button>
|
||||
)}
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||
<AlertDialogDescription>删除后不可恢复。</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(c.id)}>删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -119,13 +194,19 @@ export default function AdminCommentsPage() {
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<span>{page} / {totalPages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CommentRevisionDialog
|
||||
open={!!revComment}
|
||||
onOpenChange={(open) => { if (!open) setRevComment(null); }}
|
||||
comment={revComment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function AdminDashboardPage() {
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>作者</th>
|
||||
<th>置顶</th>
|
||||
<th>标记</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -80,7 +80,11 @@ export default function AdminDashboardPage() {
|
||||
</button>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td className="space-x-1">
|
||||
{p.featured ? <Badge variant="orange">精华</Badge> : null}
|
||||
{p.pinned ? <Badge variant="green">置顶</Badge> : null}
|
||||
{!p.featured && !p.pinned ? '—' : null}
|
||||
</td>
|
||||
<td>{new Date(p.created_at).toLocaleString('zh-CN')}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
321
frontend/src/pages/admin/AdminMediaPage.tsx
Normal file
321
frontend/src/pages/admin/AdminMediaPage.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Copy, Trash2, ExternalLink } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { MediaItem } from '../../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type CategoryTab = 'all' | 'avatars' | 'posts' | 'site';
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
avatars: '头像',
|
||||
posts: '帖子图',
|
||||
site: '站点资源',
|
||||
};
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return '—';
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function AdminMediaPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [category, setCategory] = useState<CategoryTab>('all');
|
||||
const [q, setQ] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [files, setFiles] = useState<MediaItem[]>([]);
|
||||
const [counts, setCounts] = useState<Record<string, number>>({});
|
||||
const [storageType, setStorageType] = useState<'local' | 's3'>('local');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingUrls, setPendingUrls] = useState<string[]>([]);
|
||||
|
||||
const load = useCallback(async (p = 1, cat: CategoryTab = category, query = keyword) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.adminMedia({
|
||||
category: cat,
|
||||
page: p,
|
||||
size: 24,
|
||||
q: query || undefined,
|
||||
});
|
||||
setFiles(r.files ?? []);
|
||||
setCounts(r.category_counts ?? {});
|
||||
setStorageType(r.storage_type || 'local');
|
||||
setPage(r.page || p);
|
||||
setTotalPages(r.total_pages || 1);
|
||||
setTotal(r.total || 0);
|
||||
setSelected(new Set());
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [category, keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1, category, keyword);
|
||||
}, [ready, category, keyword, load]);
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => files.length > 0 && files.every(f => selected.has(f.url)),
|
||||
[files, selected],
|
||||
);
|
||||
|
||||
const toggleOne = (url: string) => {
|
||||
setSelected(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(url)) next.delete(url);
|
||||
else next.add(url);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
setSelected(new Set(files.map(f => f.url)));
|
||||
};
|
||||
|
||||
const askDelete = (urls: string[]) => {
|
||||
if (urls.length === 0) {
|
||||
notify.warning('请先选择文件');
|
||||
return;
|
||||
}
|
||||
setPendingUrls(urls);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (pendingUrls.length === 0) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const r = await api.adminDeleteMedia(pendingUrls);
|
||||
notify.success(r.message);
|
||||
setConfirmOpen(false);
|
||||
setPendingUrls([]);
|
||||
await load(page, category, keyword);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyURL = async (url: string) => {
|
||||
try {
|
||||
const abs = url.startsWith('http') ? url : `${window.location.origin}${url}`;
|
||||
await navigator.clipboard.writeText(abs);
|
||||
notify.success('已复制链接');
|
||||
} catch {
|
||||
notify.error('复制失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
const tabs: { key: CategoryTab; label: string }[] = [
|
||||
{ key: 'all', label: `全部 (${Object.values(counts).reduce((a, b) => a + (b || 0), 0)})` },
|
||||
{ key: 'avatars', label: `头像 (${counts.avatars || 0})` },
|
||||
{ key: 'posts', label: `帖子图 (${counts.posts || 0})` },
|
||||
{ key: 'site', label: `站点 (${counts.site || 0})` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>媒体库</h1>
|
||||
<p>
|
||||
浏览并管理上传资源(头像 / 帖子图 / 站点品牌图)。当前存储:
|
||||
{storageType === 's3' ? 'S3 兼容' : '本地磁盘'}
|
||||
。列表来自数据库索引;删除会同时清理伴生原图/WebP。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', category === t.key && 'active')}
|
||||
onClick={() => setCategory(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-media-toolbar">
|
||||
<form
|
||||
className="admin-media-search"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
setKeyword(q.trim());
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={q}
|
||||
onChange={e => setQ(e.target.value)}
|
||||
placeholder="按文件名搜索…"
|
||||
aria-label="搜索媒体"
|
||||
/>
|
||||
<Button type="submit" variant="outline">搜索</Button>
|
||||
{keyword && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setQ('');
|
||||
setKeyword('');
|
||||
}}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<div className="admin-media-toolbar-actions">
|
||||
<Button size="sm" variant="outline" onClick={toggleAll} disabled={files.length === 0}>
|
||||
{allSelected ? '取消全选' : '全选本页'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={selected.size === 0 || deleting}
|
||||
onClick={() => askDelete([...selected])}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
删除所选 ({selected.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="admin-empty">暂无媒体文件</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-media-grid">
|
||||
{files.map(f => (
|
||||
<article
|
||||
key={f.url}
|
||||
className={cn('admin-media-card', selected.has(f.url) && 'is-selected')}
|
||||
>
|
||||
<label className="admin-media-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(f.url)}
|
||||
onChange={() => toggleOne(f.url)}
|
||||
aria-label={`选择 ${f.name}`}
|
||||
/>
|
||||
</label>
|
||||
<a
|
||||
className="admin-media-thumb"
|
||||
href={f.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={f.name}
|
||||
>
|
||||
<img src={f.url} alt="" loading="lazy" decoding="async" />
|
||||
</a>
|
||||
<div className="admin-media-meta">
|
||||
<div className="admin-media-name" title={f.name}>{f.name}</div>
|
||||
<div className="admin-media-sub">
|
||||
<Badge variant="secondary">{CATEGORY_LABEL[f.category] || f.category}</Badge>
|
||||
<span>{formatBytes(f.size)}</span>
|
||||
</div>
|
||||
<div className="admin-media-time">
|
||||
{f.modified_at ? new Date(f.modified_at).toLocaleString('zh-CN') : '—'}
|
||||
</div>
|
||||
<div className="admin-media-actions">
|
||||
<Button size="sm" variant="outline" onClick={() => copyURL(f.url)}>
|
||||
<Copy size={13} aria-hidden />
|
||||
复制
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<a href={f.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={13} aria-hidden />
|
||||
打开
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => askDelete([f.url])}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-pagination">
|
||||
<span>共 {total} 个文件</span>
|
||||
{totalPages > 1 && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除媒体?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将删除 {pendingUrls.length} 个文件;若存在同名原图/WebP 伴生文件也会一并清理。此操作不可恢复,且不会自动改写帖子正文中的引用。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
void doDelete();
|
||||
}}
|
||||
>
|
||||
{deleting ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search, Lock, LockOpen } from 'lucide-react';
|
||||
import { Search, Lock, LockOpen, Trash2, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -11,12 +11,16 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { PostItem } from '../../api/types';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import { isTimeDiffSignificant } from '../../utils/content';
|
||||
|
||||
type Tab = 'pending' | 'active' | 'trash';
|
||||
type TrashPost = PostItem & { deleted_at: string };
|
||||
|
||||
function formatAdminTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
@@ -29,28 +33,71 @@ function formatAdminTime(iso: string) {
|
||||
export default function AdminPostsPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const [tab, setTab] = useState<Tab>('pending');
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [trash, setTrash] = useState<TrashPost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const load = (p = page, kw = search) => {
|
||||
const loadActive = (p = page, kw = search, status = tab === 'pending' ? 'pending' : 'all') => {
|
||||
setLoading(true);
|
||||
api.adminPosts({ page: p, keyword: kw })
|
||||
api.adminPosts({ page: p, keyword: kw, status })
|
||||
.then(d => {
|
||||
setPosts(d.posts ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
setPendingCount(d.pending_count ?? 0);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const loadTrash = (p = page, kw = search) => {
|
||||
setLoading(true);
|
||||
api.adminTrashPosts({ page: p, keyword: kw })
|
||||
.then(d => {
|
||||
setTrash(d.posts ?? []);
|
||||
setPage(d.page);
|
||||
setTotalPages(d.total_pages);
|
||||
})
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const load = (p = 1, kw = search) => {
|
||||
if (tab === 'trash') loadTrash(p, kw);
|
||||
else loadActive(p, kw, tab === 'pending' ? 'pending' : 'all');
|
||||
};
|
||||
|
||||
const approvePost = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminApprovePost(post.id);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) load(1, search);
|
||||
}, [ready, search]);
|
||||
if (!ready) return;
|
||||
setPage(1);
|
||||
load(1, search);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅随 tab/search/ready 刷新
|
||||
}, [ready, search, tab]);
|
||||
|
||||
const switchTab = (next: Tab) => {
|
||||
if (next === tab) return;
|
||||
setTab(next);
|
||||
setKeyword('');
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const togglePin = async (post: PostItem) => {
|
||||
try {
|
||||
@@ -58,7 +105,37 @@ export default function AdminPostsPage() {
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load();
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFeature = async (post: PostItem) => {
|
||||
try {
|
||||
const r = await api.adminFeaturePost(post.id, !post.featured);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const rejectPost = async (post: PostItem) => {
|
||||
const reason = window.prompt(`拒绝《${post.title}》并私信通知作者,请填写原因:`);
|
||||
if (reason == null) return;
|
||||
if (!reason.trim()) {
|
||||
notify.warning('请填写拒绝原因');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await api.adminRejectPost(post.id, reason.trim());
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success(r.message);
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
@@ -68,7 +145,7 @@ export default function AdminPostsPage() {
|
||||
try {
|
||||
const r = await api.adminLockPost(post.id, !post.edit_locked);
|
||||
notify.success(r.message);
|
||||
load();
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
}
|
||||
@@ -77,20 +154,81 @@ export default function AdminPostsPage() {
|
||||
const remove = async (id: number) => {
|
||||
try {
|
||||
await api.adminDeletePost(id);
|
||||
notify.success('帖子已删除');
|
||||
load();
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success('帖子已移入回收站');
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (id: number) => {
|
||||
try {
|
||||
await api.adminRestorePost(id);
|
||||
clearAllFeedCache();
|
||||
window.dispatchEvent(new Event('posts-refresh'));
|
||||
notify.success('帖子已恢复');
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '恢复失败');
|
||||
}
|
||||
};
|
||||
|
||||
const purge = async (id: number) => {
|
||||
try {
|
||||
await api.adminPurgePost(id);
|
||||
notify.success('帖子已永久删除');
|
||||
load(page);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '彻底删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>帖子管理</h1>
|
||||
<p>置顶、锁定编辑、删除帖子;支持按标题、标签或正文关键词搜索</p>
|
||||
<p>
|
||||
{tab === 'trash'
|
||||
? '回收站中的帖子可恢复或永久删除;永久删除后不可撤销'
|
||||
: tab === 'pending'
|
||||
? '审核普通用户提交的帖子;通过后公开,拒绝后仅作者可见并私信通知'
|
||||
: '精华、置顶、锁定编辑、删除(移入回收站);支持按标题、标签或正文搜索'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist" aria-label="帖子视图">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'pending'}
|
||||
className={cn('admin-tab', tab === 'pending' && 'active')}
|
||||
onClick={() => switchTab('pending')}
|
||||
>
|
||||
待审核{pendingCount > 0 ? ` (${pendingCount})` : ''}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'active'}
|
||||
className={cn('admin-tab', tab === 'active' && 'active')}
|
||||
onClick={() => switchTab('active')}
|
||||
>
|
||||
全部帖子
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'trash'}
|
||||
className={cn('admin-tab', tab === 'trash' && 'active')}
|
||||
onClick={() => switchTab('trash')}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
回收站
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -113,6 +251,59 @@ export default function AdminPostsPage() {
|
||||
<div className="admin-card">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : tab === 'trash' ? (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>板块</th>
|
||||
<th>作者</th>
|
||||
<th>评论</th>
|
||||
<th>删除时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{trash.map(p => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td className="max-w-[220px] truncate">{p.title}</td>
|
||||
<td>{p.board?.name ?? '—'}</td>
|
||||
<td>{p.user?.nickname ?? '—'}</td>
|
||||
<td>{p.comment_count ?? 0}</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatAdminTime(p.deleted_at)}</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={() => restore(p.id)}>
|
||||
<RotateCcw size={14} /> 恢复
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button size="sm" variant="ghost" className="text-destructive">永久删除</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>永久删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
将彻底清除帖子、评论、点赞、收藏与修订历史,此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => purge(p.id)}>永久删除</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{trash.length === 0 && <div className="admin-empty">回收站为空</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
@@ -124,6 +315,7 @@ export default function AdminPostsPage() {
|
||||
<th>作者</th>
|
||||
<th>标签</th>
|
||||
<th>评论</th>
|
||||
<th>精华</th>
|
||||
<th>置顶</th>
|
||||
<th>锁定</th>
|
||||
<th>点赞</th>
|
||||
@@ -154,7 +346,8 @@ export default function AdminPostsPage() {
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate text-muted-foreground">{p.tags || '—'}</td>
|
||||
<td>{p.comment_count ?? 0}</td>
|
||||
<td>{p.pinned ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.featured ? <Badge variant="orange">是</Badge> : '—'}</td>
|
||||
<td>{p.pinned ? <Badge variant="green">是</Badge> : '—'}</td>
|
||||
<td>{p.edit_locked ? <Badge variant="destructive">是</Badge> : '—'}</td>
|
||||
<td>{p.like_count}</td>
|
||||
<td>{p.view_count}</td>
|
||||
@@ -167,7 +360,18 @@ export default function AdminPostsPage() {
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(p.status === 'pending' || p.status === 'rejected') && (
|
||||
<Button size="sm" onClick={() => approvePost(p)}>通过</Button>
|
||||
)}
|
||||
{p.status !== 'rejected' && (
|
||||
<Button size="sm" variant="outline" onClick={() => rejectPost(p)}>
|
||||
拒绝并通知
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => toggleFeature(p)}>
|
||||
{p.featured ? '取消精华' : '精华'}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => togglePin(p)}>
|
||||
{p.pinned ? '取消置顶' : '置顶'}
|
||||
</Button>
|
||||
@@ -180,12 +384,14 @@ export default function AdminPostsPage() {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||
<AlertDialogDescription>相关评论也将一并删除,不可恢复。</AlertDialogDescription>
|
||||
<AlertDialogTitle>移入回收站?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
帖子与评论将移入回收站,可随时恢复;永久删除请到回收站操作。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => remove(p.id)}>删除</AlertDialogAction>
|
||||
<AlertDialogAction onClick={() => remove(p.id)}>移入回收站</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
@@ -197,15 +403,15 @@ export default function AdminPostsPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
{posts.length === 0 && <div className="admin-empty">没有找到帖子</div>}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{totalPages > 1 && !loading && (
|
||||
<div className="admin-pagination">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= totalPages} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
232
frontend/src/pages/admin/AdminReportsPage.tsx
Normal file
232
frontend/src/pages/admin/AdminReportsPage.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import type { PostReport } from '../../api/types';
|
||||
import { formatTime } from '../../utils/content';
|
||||
import { reportReasonLabel, reportStatusLabel } from '../../utils/report';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type StatusTab = 'pending' | 'resolved' | 'dismissed' | 'all';
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
const nav = useNavigate();
|
||||
const [status, setStatus] = useState<StatusTab>('pending');
|
||||
const [list, setList] = useState<PostReport[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [active, setActive] = useState<PostReport | null>(null);
|
||||
const [action, setAction] = useState<'dismiss' | 'resolve' | 'reject_post' | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async (p = 1, st: StatusTab = status) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await api.adminReports({ page: p, status: st });
|
||||
setList(r.reports || []);
|
||||
setTotal(r.total || 0);
|
||||
setPendingCount(r.pending_count || 0);
|
||||
setPage(r.page || p);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
useEffect(() => {
|
||||
load(1, status);
|
||||
}, [status, load]);
|
||||
|
||||
const openHandle = (rep: PostReport, act: 'dismiss' | 'resolve' | 'reject_post') => {
|
||||
setActive(rep);
|
||||
setAction(act);
|
||||
setNote('');
|
||||
setRejectReason('');
|
||||
};
|
||||
|
||||
const submitHandle = async () => {
|
||||
if (!active || !action) return;
|
||||
if (action === 'reject_post' && !rejectReason.trim()) {
|
||||
notify.warning('请填写拒绝原因(将私信通知作者)');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const r = await api.adminHandleReport(active.id, {
|
||||
action,
|
||||
handle_note: note.trim() || undefined,
|
||||
reject_reason: action === 'reject_post' ? rejectReason.trim() : undefined,
|
||||
});
|
||||
notify.success(r.message);
|
||||
setActive(null);
|
||||
setAction(null);
|
||||
load(page, status);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '处理失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { key: StatusTab; label: string }[] = [
|
||||
{ key: 'pending', label: `待处理${pendingCount ? ` (${pendingCount})` : ''}` },
|
||||
{ key: 'resolved', label: '已处理' },
|
||||
{ key: 'dismissed', label: '已忽略' },
|
||||
{ key: 'all', label: '全部' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<h1 className="admin-page-title">举报管理</h1>
|
||||
<p className="admin-page-desc">处理用户举报;拒绝帖子时将通过站内私信通知作者。</p>
|
||||
|
||||
<div className="admin-tabs">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', status === t.key && 'active')}
|
||||
onClick={() => setStatus(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : (
|
||||
<>
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>帖子</th>
|
||||
<th>原因</th>
|
||||
<th>举报人</th>
|
||||
<th>状态</th>
|
||||
<th>时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>{r.id}</td>
|
||||
<td className="max-w-[220px]">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link truncate block max-w-full text-left"
|
||||
onClick={() => nav(`/post/${r.post_id}`)}
|
||||
>
|
||||
{r.post?.title || `帖子 #${r.post_id}`}
|
||||
</button>
|
||||
{r.detail && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{r.detail}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>{reportReasonLabel(r.reason)}</td>
|
||||
<td>{r.reporter?.nickname || `#${r.reporter_id}`}</td>
|
||||
<td>
|
||||
<Badge variant={r.status === 'pending' ? 'orange' : r.status === 'resolved' ? 'green' : 'secondary'}>
|
||||
{reportStatusLabel(r.status)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(r.created_at)}</td>
|
||||
<td>
|
||||
{r.status === 'pending' ? (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'dismiss')}>忽略</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => openHandle(r, 'resolve')}>标记已处理</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => openHandle(r, 'reject_post')}>拒绝并通知</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{r.handle_note || '—'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{list.length === 0 && <div className="admin-empty">暂无举报</div>}
|
||||
{total > 20 && (
|
||||
<div className="flex justify-center gap-2 mt-4">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => load(page - 1)}>上一页</Button>
|
||||
<span className="text-sm text-muted-foreground self-center">第 {page} 页</span>
|
||||
<Button size="sm" variant="outline" disabled={list.length < 20} onClick={() => load(page + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={!!action && !!active} onOpenChange={(o) => { if (!o) { setAction(null); setActive(null); } }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{action === 'dismiss' && '忽略举报'}
|
||||
{action === 'resolve' && '标记已处理'}
|
||||
{action === 'reject_post' && '拒绝帖子并通知作者'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{action === 'reject_post'
|
||||
? '帖子将移入回收站,拒绝原因会通过站内私信发给作者;举报人也会收到处理结果通知。'
|
||||
: '举报人将收到处理结果的站内私信通知。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="pm-compose-fields">
|
||||
{action === 'reject_post' && (
|
||||
<label className="pm-field">
|
||||
<span>拒绝原因(发给作者)</span>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={4}
|
||||
maxLength={1000}
|
||||
placeholder="请说明未通过的原因…"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="pm-field">
|
||||
<span>处理备注(可选,发给举报人)</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="补充说明…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setAction(null); setActive(null); }}>取消</Button>
|
||||
<Button
|
||||
variant={action === 'reject_post' ? 'destructive' : 'default'}
|
||||
loading={submitting}
|
||||
onClick={submitHandle}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette } from 'lucide-react';
|
||||
import { Database, Mail, Shield, Server, SlidersHorizontal, KeyRound, FolderGit2, Palette, HardDrive } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -10,9 +10,9 @@ import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, SiteBranding } from '../../api/types';
|
||||
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, FriendLink } from '../../api/types';
|
||||
|
||||
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'filter' | 'system';
|
||||
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
|
||||
|
||||
type NumberLimitKey = {
|
||||
[K in keyof ForumLimits]: ForumLimits[K] extends number ? K : never;
|
||||
@@ -37,9 +37,10 @@ const SETTING_SECTIONS: SettingSection[] = [
|
||||
{
|
||||
id: 'rule',
|
||||
title: '编辑规则',
|
||||
summary: '控制普通用户修改自己帖子的时限',
|
||||
summary: '控制普通用户修改自己帖子 / 评论的时限(0 = 不限)',
|
||||
rows: [
|
||||
{ key: 'post_edit_window_hours', label: '可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'post_edit_window_hours', label: '帖子可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
{ key: 'comment_edit_window_hours', label: '评论可编辑时限', unit: '小时', hint: '0 = 不限', min: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,11 +104,12 @@ const NAV_TOGGLES: { key: BoolLimitKey; label: string; hint: string }[] = [
|
||||
];
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: typeof SlidersHorizontal }[] = [
|
||||
{ id: 'branding', label: '站点品牌', icon: Palette },
|
||||
{ id: 'branding', label: '站点与呈现', icon: Palette },
|
||||
{ id: 'limits', label: '论坛限制', icon: SlidersHorizontal },
|
||||
{ id: 'mail', label: '邮件服务', icon: Mail },
|
||||
{ id: 'oidc', label: 'OIDC / SSO', icon: KeyRound },
|
||||
{ id: 'gitea', label: 'Gitea 同步', icon: FolderGit2 },
|
||||
{ id: 'storage', label: '对象存储', icon: HardDrive },
|
||||
{ id: 'filter', label: '敏感词', icon: Shield },
|
||||
{ id: 'system', label: '系统维护', icon: Server },
|
||||
];
|
||||
@@ -154,6 +156,20 @@ const EMPTY_GITEA: GiteaSyncConfig = {
|
||||
repo_count: 0,
|
||||
};
|
||||
|
||||
const EMPTY_STORAGE: StorageConfig = {
|
||||
type: 'local',
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
access_key: '',
|
||||
public_base_url: '',
|
||||
prefix: '',
|
||||
force_path_style: true,
|
||||
has_secret_key: false,
|
||||
ready: true,
|
||||
image_delivery: 'webp',
|
||||
};
|
||||
|
||||
function giteaStatusLabel(gitea: GiteaSyncConfig): string {
|
||||
if (gitea.ready) return `已就绪 · ${gitea.repo_count} 个仓库`;
|
||||
if (!gitea.enabled) return '未启用';
|
||||
@@ -164,6 +180,19 @@ function giteaStatusLabel(gitea: GiteaSyncConfig): string {
|
||||
return `未就绪(需${reasons.join('、')})`;
|
||||
}
|
||||
|
||||
function storageStatusLabel(storage: StorageConfig): string {
|
||||
if (storage.type === 'local') return '本地磁盘';
|
||||
if (storage.ready) return 'S3 已就绪';
|
||||
const reasons: string[] = [];
|
||||
if (!storage.endpoint.trim()) reasons.push('Endpoint');
|
||||
if (!storage.bucket.trim()) reasons.push('Bucket');
|
||||
if (!storage.access_key.trim()) reasons.push('Access Key');
|
||||
if (!storage.has_secret_key) reasons.push('Secret Key');
|
||||
if (!storage.public_base_url.trim()) reasons.push('公开访问地址');
|
||||
if (reasons.length === 0) reasons.push('保存后生效');
|
||||
return `未就绪(需${reasons.join('、')})`;
|
||||
}
|
||||
|
||||
function SettingTable({
|
||||
sections,
|
||||
limits,
|
||||
@@ -216,6 +245,7 @@ export default function AdminSettingsPage() {
|
||||
const [mail, setMail] = useState<MailConfig>(EMPTY_MAIL);
|
||||
const [oidc, setOidc] = useState<OIDCConfig>(EMPTY_OIDC);
|
||||
const [gitea, setGitea] = useState<GiteaSyncConfig>(EMPTY_GITEA);
|
||||
const [storage, setStorage] = useState<StorageConfig>(EMPTY_STORAGE);
|
||||
const [oauthClients, setOauthClients] = useState<OAuthClient[]>([]);
|
||||
const [clientForm, setClientForm] = useState({
|
||||
client_id: 'gitea',
|
||||
@@ -231,11 +261,12 @@ export default function AdminSettingsPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [backing, setBacking] = useState(false);
|
||||
const [savingBranding, setSavingBranding] = useState(false);
|
||||
const [uploadingBrand, setUploadingBrand] = useState<'logo' | 'favicon' | null>(null);
|
||||
const [uploadingBrand, setUploadingBrand] = useState<'logo' | 'favicon' | 'og_image' | null>(null);
|
||||
const [savingForum, setSavingForum] = useState(false);
|
||||
const [savingMail, setSavingMail] = useState(false);
|
||||
const [savingOidc, setSavingOidc] = useState(false);
|
||||
const [savingGitea, setSavingGitea] = useState(false);
|
||||
const [savingStorage, setSavingStorage] = useState(false);
|
||||
const [syncingGitea, setSyncingGitea] = useState(false);
|
||||
const [savingClient, setSavingClient] = useState(false);
|
||||
const [testingMail, setTestingMail] = useState(false);
|
||||
@@ -249,12 +280,15 @@ export default function AdminSettingsPage() {
|
||||
setLimits({
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
...s.limits,
|
||||
});
|
||||
setBranding({ ...DEFAULT_BRANDING, ...(s.branding ?? {}) });
|
||||
setMail({ ...EMPTY_MAIL, ...s.mail, password: '' });
|
||||
setOidc({ ...EMPTY_OIDC, ...(s.oidc ?? {}) });
|
||||
setGitea({ ...EMPTY_GITEA, ...(s.gitea ?? {}), token: '' });
|
||||
setStorage({ ...EMPTY_STORAGE, ...(s.storage ?? {}), secret_key: '' });
|
||||
setOauthClients(s.oauth_clients ?? []);
|
||||
setFilterWords(s.filter_words);
|
||||
if (s.mail?.from) setTestTo(s.mail.from);
|
||||
@@ -279,11 +313,23 @@ export default function AdminSettingsPage() {
|
||||
};
|
||||
|
||||
const handleSaveBranding = async () => {
|
||||
if (!limits) return;
|
||||
const links = (branding.friend_links ?? [])
|
||||
.map(l => ({ name: l.name.trim(), url: l.url.trim() }))
|
||||
.filter(l => l.name || l.url);
|
||||
if (links.some(l => !l.name || !l.url)) {
|
||||
notify.warning('友情链接需同时填写名称与完整 URL');
|
||||
return;
|
||||
}
|
||||
setSavingBranding(true);
|
||||
try {
|
||||
const r = await api.adminUpdateBranding(branding);
|
||||
notify.success(r.message);
|
||||
const r = await api.adminUpdateBranding({ ...branding, friend_links: links });
|
||||
applyBranding(r.branding);
|
||||
// 伪静态与品牌同属站点呈现,一并保存
|
||||
const forum = await api.adminUpdateForumSettings(limits);
|
||||
setLimits(forum.limits);
|
||||
invalidateForumLimitsCache();
|
||||
notify.success('站点设置已保存');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
@@ -291,7 +337,7 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon', file: File | undefined) => {
|
||||
const handleUploadBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image', file: File | undefined) => {
|
||||
if (!file) return;
|
||||
setUploadingBrand(kind);
|
||||
try {
|
||||
@@ -305,7 +351,7 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearBrandAsset = async (kind: 'logo' | 'favicon') => {
|
||||
const handleClearBrandAsset = async (kind: 'logo' | 'favicon' | 'og_image') => {
|
||||
setUploadingBrand(kind);
|
||||
try {
|
||||
const r = await api.adminClearBrandingAsset(kind);
|
||||
@@ -385,6 +431,24 @@ export default function AdminSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveStorageSettings = async () => {
|
||||
setSavingStorage(true);
|
||||
try {
|
||||
const payload: StorageConfig = {
|
||||
...storage,
|
||||
secret_key: storage.secret_key?.trim() ? storage.secret_key : undefined,
|
||||
};
|
||||
const r = await api.adminUpdateStorageSettings(payload);
|
||||
notify.success(r.message);
|
||||
setStorage({ ...EMPTY_STORAGE, ...r.storage, secret_key: '' });
|
||||
setSettings(s => s ? { ...s, storage: r.storage } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSavingStorage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncGitea = async () => {
|
||||
setSyncingGitea(true);
|
||||
try {
|
||||
@@ -558,14 +622,14 @@ export default function AdminSettingsPage() {
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{activeTab === 'branding' && (
|
||||
{activeTab === 'branding' && limits && (
|
||||
<div className="admin-settings-panel admin-mail-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>站点品牌</span>
|
||||
<span>站点与呈现</span>
|
||||
<span className="admin-settings-card-badge">{branding.name}</span>
|
||||
</div>
|
||||
<div className="admin-card-body admin-mail-body">
|
||||
<div className="admin-card-body admin-mail-body admin-brand-sections">
|
||||
<div className="admin-brand-preview">
|
||||
{branding.logo ? (
|
||||
<img src={branding.logo} alt="" className="admin-brand-preview-logo" />
|
||||
@@ -574,119 +638,339 @@ export default function AdminSettingsPage() {
|
||||
)}
|
||||
<div>
|
||||
<strong>{branding.name}</strong>
|
||||
{branding.name_en && <div className="admin-mail-field-hint">{branding.name_en}</div>}
|
||||
{branding.slogan && <p className="admin-mail-field-hint" style={{ marginTop: 4 }}>{branding.slogan}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-name">论坛名称</label>
|
||||
<Input
|
||||
id="brand-name"
|
||||
value={branding.name}
|
||||
onChange={e => setBranding(b => ({ ...b, name: e.target.value }))}
|
||||
placeholder="姜十三论坛"
|
||||
maxLength={64}
|
||||
/>
|
||||
<section className="admin-settings-section" id="settings-brand-identity">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>品牌标识</h3>
|
||||
<p>名称、标语、简介与字标;简介用于首页展示与搜索引擎 description</p>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-name-en">英文名称</label>
|
||||
<Input
|
||||
id="brand-name-en"
|
||||
value={branding.name_en}
|
||||
onChange={e => setBranding(b => ({ ...b, name_en: e.target.value }))}
|
||||
placeholder="Jiang13 Forum"
|
||||
maxLength={64}
|
||||
/>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-name">论坛名称</label>
|
||||
<Input
|
||||
id="brand-name"
|
||||
value={branding.name}
|
||||
onChange={e => setBranding(b => ({ ...b, name: e.target.value }))}
|
||||
placeholder="姜十三论坛"
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-mark">字标(无 Logo 时显示)</label>
|
||||
<Input
|
||||
id="brand-mark"
|
||||
value={branding.logo_mark}
|
||||
onChange={e => setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))}
|
||||
placeholder="姜"
|
||||
maxLength={2}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">建议 1 个汉字或字母</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-slogan">标语</label>
|
||||
<Input
|
||||
id="brand-slogan"
|
||||
value={branding.slogan}
|
||||
onChange={e => setBranding(b => ({ ...b, slogan: e.target.value }))}
|
||||
placeholder="拾三一隅,自在交流"
|
||||
maxLength={200}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">短句,出现在浏览器标题与页脚</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-description">站点简介</label>
|
||||
<Textarea
|
||||
id="brand-description"
|
||||
value={branding.description ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, description: e.target.value }))}
|
||||
placeholder="一两段话介绍本站定位与内容,便于搜索引擎与访客理解"
|
||||
maxLength={500}
|
||||
rows={3}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">
|
||||
用于右侧栏介绍与 SEO description;未填写时回退到标语(建议 80–160 字)
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-keywords">SEO 关键词</label>
|
||||
<Input
|
||||
id="brand-keywords"
|
||||
value={branding.keywords ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, keywords: e.target.value }))}
|
||||
placeholder="论坛,社区,技术交流"
|
||||
maxLength={200}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">
|
||||
用于 meta keywords;用逗号分隔,最多 20 个(支持中文逗号)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-slogan">标语 / Slogan</label>
|
||||
<Input
|
||||
id="brand-slogan"
|
||||
value={branding.slogan}
|
||||
onChange={e => setBranding(b => ({ ...b, slogan: e.target.value }))}
|
||||
placeholder="拾三一隅,自在交流"
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-mark">字标(无 Logo 时显示)</label>
|
||||
<Input
|
||||
id="brand-mark"
|
||||
value={branding.logo_mark}
|
||||
onChange={e => setBranding(b => ({ ...b, logo_mark: e.target.value.slice(0, 2) }))}
|
||||
placeholder="姜"
|
||||
maxLength={2}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">建议 1 个汉字或字母</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-mail-grid" style={{ marginTop: 8 }}>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-logo-file">站点 Logo</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-logo-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('logo', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.logo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'logo'}
|
||||
onClick={() => void handleClearBrandAsset('logo')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
<section className="admin-settings-section" id="settings-brand-assets">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>视觉资源</h3>
|
||||
<p>站点 Logo、浏览器标签图标与默认社交分享图</p>
|
||||
</div>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-logo-file">站点 Logo</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-logo-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('logo', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.logo && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'logo'}
|
||||
onClick={() => void handleClearBrandAsset('logo')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{uploadingBrand === 'logo' ? '上传中…' : '保留原图并生成 WebP,最大 2MB'}
|
||||
</span>
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{uploadingBrand === 'logo' ? '上传中…' : 'jpg/png/gif/webp,最大 2MB'}
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-favicon-file">Favicon</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-favicon-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('favicon', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.favicon && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'favicon'}
|
||||
onClick={() => void handleClearBrandAsset('favicon')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{branding.favicon ? `当前:${branding.favicon}` : '浏览器标签图标'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="brand-og-image-file">默认社交分享图(OG Image)</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-og-image-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('og_image', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.og_image && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'og_image'}
|
||||
onClick={() => void handleClearBrandAsset('og_image')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{uploadingBrand === 'og_image'
|
||||
? '上传中…'
|
||||
: branding.og_image
|
||||
? `当前:${branding.og_image};建议 1200×630,用于微信/社交预览;未设置时回退 Logo`
|
||||
: '建议 1200×630,用于微信/社交预览;未设置时回退 Logo'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-brand-footer">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>页脚信息</h3>
|
||||
<p>备案号与友情链接,显示在站点底部</p>
|
||||
</div>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-icp">ICP 备案号</label>
|
||||
<Input
|
||||
id="brand-icp"
|
||||
value={branding.icp_beian ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, icp_beian: e.target.value }))}
|
||||
placeholder="京ICP备xxxxxxxx号"
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-icp-url">ICP 跳转链接</label>
|
||||
<Input
|
||||
id="brand-icp-url"
|
||||
value={branding.icp_beian_url ?? ''}
|
||||
onChange={e => setBranding(b => ({ ...b, icp_beian_url: e.target.value }))}
|
||||
placeholder="https://beian.miit.gov.cn/"
|
||||
maxLength={512}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">留空则用工信部默认页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-friend-links" style={{ marginTop: 12 }}>
|
||||
<div className="admin-friend-links-list">
|
||||
{(branding.friend_links ?? []).map((link, idx) => (
|
||||
<div key={idx} className="admin-friend-links-row">
|
||||
<Input
|
||||
value={link.name}
|
||||
placeholder="友链名称"
|
||||
maxLength={32}
|
||||
onChange={e => {
|
||||
const name = e.target.value;
|
||||
setBranding(b => {
|
||||
const next = [...(b.friend_links ?? [])];
|
||||
next[idx] = { ...next[idx], name };
|
||||
return { ...b, friend_links: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
placeholder="https://example.com"
|
||||
maxLength={512}
|
||||
onChange={e => {
|
||||
const url = e.target.value;
|
||||
setBranding(b => {
|
||||
const next = [...(b.friend_links ?? [])];
|
||||
next[idx] = { ...next[idx], url };
|
||||
return { ...b, friend_links: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBranding(b => ({
|
||||
...b,
|
||||
friend_links: (b.friend_links ?? []).filter((_, i) => i !== idx),
|
||||
}));
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={(branding.friend_links?.length ?? 0) >= 20}
|
||||
onClick={() => {
|
||||
setBranding(b => ({
|
||||
...b,
|
||||
friend_links: [...(b.friend_links ?? []), { name: '', url: '' } as FriendLink],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
添加友链
|
||||
</Button>
|
||||
<span className="admin-mail-field-hint" style={{ display: 'block', marginTop: 8 }}>
|
||||
最多 20 条,需填写完整 http(s) 地址
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="brand-favicon-file">Favicon</label>
|
||||
<div className="admin-brand-upload-row">
|
||||
<Input
|
||||
id="brand-favicon-file"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp,image/x-icon,image/vnd.microsoft.icon"
|
||||
onChange={e => {
|
||||
const f = e.target.files?.[0];
|
||||
void handleUploadBrandAsset('favicon', f);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
{branding.favicon && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
loading={uploadingBrand === 'favicon'}
|
||||
onClick={() => void handleClearBrandAsset('favicon')}
|
||||
>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<span className="admin-mail-field-hint">
|
||||
{branding.favicon ? `当前:${branding.favicon}` : '浏览器标签图标'}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-permalink">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>伪静态 URL</h3>
|
||||
<p>为帖子 / 用户生成带后缀的规范链接,非规范路径会 301 跳转</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label="伪静态">
|
||||
<div className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id="limit-label-permalink_enabled">
|
||||
启用伪静态
|
||||
</span>
|
||||
<div className="admin-settings-row-input">
|
||||
<button
|
||||
type="button"
|
||||
id="limit-permalink_enabled"
|
||||
role="switch"
|
||||
aria-checked={!!limits.permalink_enabled}
|
||||
aria-labelledby="limit-label-permalink_enabled"
|
||||
className={`admin-settings-switch${limits.permalink_enabled ? ' is-on' : ''}`}
|
||||
onClick={() => setLimits(prev => prev ? { ...prev, permalink_enabled: !prev.permalink_enabled } : prev)}
|
||||
>
|
||||
<span className="admin-settings-switch-ui" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">关闭:/post/123 · 开启:/post/123.后缀</span>
|
||||
</div>
|
||||
<div className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id="limit-label-permalink_ext">
|
||||
URL 后缀
|
||||
</span>
|
||||
<div className="admin-settings-row-input admin-settings-row-input--stack">
|
||||
<div className="admin-permalink-presets">
|
||||
{(['html', 'htm', 'shtml'] as const).map(ext => (
|
||||
<button
|
||||
key={ext}
|
||||
type="button"
|
||||
className={`admin-permalink-chip${limits.permalink_ext === ext ? ' is-active' : ''}`}
|
||||
disabled={!limits.permalink_enabled}
|
||||
onClick={() => setLimits(prev => prev ? { ...prev, permalink_ext: ext } : prev)}
|
||||
>
|
||||
.{ext}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Input
|
||||
id="limit-permalink_ext"
|
||||
value={limits.permalink_ext}
|
||||
disabled={!limits.permalink_enabled}
|
||||
placeholder="html"
|
||||
aria-labelledby="limit-label-permalink_ext"
|
||||
onChange={e => {
|
||||
const v = e.target.value.replace(/^\./, '').toLowerCase();
|
||||
setLimits(prev => prev ? { ...prev, permalink_ext: v } : prev);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">
|
||||
预览:
|
||||
<code className="admin-permalink-preview">
|
||||
/post/123{limits.permalink_enabled ? `.${(limits.permalink_ext || 'html').replace(/^\./, '')}` : ''}
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>保存后立即影响顶栏、登录页、浏览器标题与右栏介绍</p>
|
||||
<p>保存后立即影响顶栏、页脚、伪静态链接与浏览器标题</p>
|
||||
<Button onClick={handleSaveBranding} loading={savingBranding}>
|
||||
保存品牌设置
|
||||
保存站点设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1163,6 +1447,161 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'storage' && (
|
||||
<div className="admin-settings-panel admin-mail-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>上传对象存储</span>
|
||||
<span className={`admin-mail-status${storage.ready ? ' is-on' : ''}`}>
|
||||
<span className="admin-mail-status-dot" aria-hidden />
|
||||
{storageStatusLabel(storage)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-card-body admin-mail-body">
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-type">存储类型</label>
|
||||
<select
|
||||
id="storage-type"
|
||||
className="admin-mail-select"
|
||||
value={storage.type}
|
||||
onChange={e => setStorage(s => ({
|
||||
...s,
|
||||
type: e.target.value === 's3' ? 's3' : 'local',
|
||||
}))}
|
||||
>
|
||||
<option value="local">本地磁盘(data/uploads)</option>
|
||||
<option value="s3">S3 兼容(MinIO / OSS / 七牛等)</option>
|
||||
</select>
|
||||
<span className="admin-mail-field-hint">头像、帖子图、站点品牌图均走此后端</span>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-image-delivery">图片展示方案</label>
|
||||
<select
|
||||
id="storage-image-delivery"
|
||||
className="admin-mail-select"
|
||||
value={storage.image_delivery || 'webp'}
|
||||
onChange={e => setStorage(s => ({
|
||||
...s,
|
||||
image_delivery: e.target.value === 'original' ? 'original' : 'webp',
|
||||
}))}
|
||||
>
|
||||
<option value="webp">使用 WebP(省流量,仍保留原图)</option>
|
||||
<option value="original">使用原图(体积更大)</option>
|
||||
</select>
|
||||
<span className="admin-mail-field-hint">
|
||||
静态图上传后同时保存原图与 WebP;此项只影响新上传写入正文/头像的 URL。动图 GIF 始终保留原文件
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{storage.type === 's3' && (
|
||||
<>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="storage-endpoint">Endpoint</label>
|
||||
<Input
|
||||
id="storage-endpoint"
|
||||
value={storage.endpoint}
|
||||
onChange={e => setStorage(s => ({ ...s, endpoint: e.target.value }))}
|
||||
placeholder="https://s3.example.com"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-region">Region</label>
|
||||
<Input
|
||||
id="storage-region"
|
||||
value={storage.region}
|
||||
onChange={e => setStorage(s => ({ ...s, region: e.target.value }))}
|
||||
placeholder="us-east-1"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-bucket">Bucket</label>
|
||||
<Input
|
||||
id="storage-bucket"
|
||||
value={storage.bucket}
|
||||
onChange={e => setStorage(s => ({ ...s, bucket: e.target.value }))}
|
||||
placeholder="jiang13"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-access-key">Access Key</label>
|
||||
<Input
|
||||
id="storage-access-key"
|
||||
value={storage.access_key}
|
||||
onChange={e => setStorage(s => ({ ...s, access_key: e.target.value }))}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-secret-key">Secret Key</label>
|
||||
<Input
|
||||
id="storage-secret-key"
|
||||
type="password"
|
||||
value={storage.secret_key ?? ''}
|
||||
onChange={e => setStorage(s => ({ ...s, secret_key: e.target.value }))}
|
||||
placeholder={storage.has_secret_key ? '已配置,留空则保持不变' : 'Secret Key'}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
<label htmlFor="storage-public-base">公开访问地址</label>
|
||||
<Input
|
||||
id="storage-public-base"
|
||||
value={storage.public_base_url}
|
||||
onChange={e => setStorage(s => ({ ...s, public_base_url: e.target.value }))}
|
||||
placeholder="https://cdn.example.com/forum"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<span className="admin-mail-field-hint">无尾斜杠;上传后返回此前缀下的绝对 URL</span>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label htmlFor="storage-prefix">对象前缀(可选)</label>
|
||||
<Input
|
||||
id="storage-prefix"
|
||||
value={storage.prefix}
|
||||
onChange={e => setStorage(s => ({ ...s, prefix: e.target.value }))}
|
||||
placeholder="forum/"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-mail-field">
|
||||
<label className="admin-mail-switch" htmlFor="storage-path-style" style={{ marginTop: 22 }}>
|
||||
<input
|
||||
id="storage-path-style"
|
||||
type="checkbox"
|
||||
checked={storage.force_path_style}
|
||||
onChange={e => setStorage(s => ({ ...s, force_path_style: e.target.checked }))}
|
||||
/>
|
||||
<span className="admin-mail-switch-ui" aria-hidden />
|
||||
<span className="admin-mail-switch-copy">
|
||||
<strong>Path-Style</strong>
|
||||
<small>MinIO 等多为开启;AWS S3 官方多为关闭</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-mail-field-hint" style={{ marginTop: 8 }}>
|
||||
请确保 Bucket 已配置公开读或 CDN 回源可读;本程序不代设 ACL。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-settings-bar">
|
||||
<p>保存后立即生效,无需重启;切换存储后端或展示方案不会改写历史帖子里的图片 URL</p>
|
||||
<Button onClick={handleSaveStorageSettings} loading={savingStorage}>
|
||||
保存存储设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'filter' && (
|
||||
<div className="admin-settings-panel">
|
||||
<div className="admin-card admin-settings-card">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -40,11 +40,11 @@ export function validateAvatarOutput(file: File, maxMb: number): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 将裁剪区域渲染为 JPEG 文件 */
|
||||
/** 将裁剪区域渲染为 WebP 文件(体积更小;不支持时回退 JPEG) */
|
||||
export async function getCroppedAvatarFile(
|
||||
imageSrc: string,
|
||||
pixelCrop: Area,
|
||||
originalName = 'avatar.jpg',
|
||||
originalName = 'avatar.webp',
|
||||
): Promise<File> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement('canvas');
|
||||
@@ -67,14 +67,25 @@ export async function getCroppedAvatarFile(
|
||||
size,
|
||||
);
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
b => (b ? resolve(b) : reject(new Error('裁剪失败'))),
|
||||
'image/jpeg',
|
||||
0.92,
|
||||
);
|
||||
});
|
||||
const tryTypes: { mime: string; quality: number; ext: string }[] = [
|
||||
{ mime: 'image/webp', quality: 0.86, ext: 'webp' },
|
||||
{ mime: 'image/jpeg', quality: 0.92, ext: 'jpg' },
|
||||
];
|
||||
|
||||
let blob: Blob | null = null;
|
||||
let picked = tryTypes[1];
|
||||
for (const t of tryTypes) {
|
||||
blob = await new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(b => resolve(b), t.mime, t.quality);
|
||||
});
|
||||
if (blob && blob.type === t.mime) {
|
||||
picked = t;
|
||||
break;
|
||||
}
|
||||
blob = null;
|
||||
}
|
||||
if (!blob) throw new Error('裁剪失败');
|
||||
|
||||
const baseName = originalName.replace(/\.[^.]+$/, '') || 'avatar';
|
||||
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
|
||||
return new File([blob], `${baseName}.${picked.ext}`, { type: picked.mime });
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function isGuestComment(c: Comment): boolean {
|
||||
return !c.user_id || c.user_id === 0;
|
||||
}
|
||||
|
||||
/** 构建嵌套评论树(按 reply_to) */
|
||||
/** 构建嵌套评论树(优先 thread_parent_id,回退 reply_to) */
|
||||
export function buildCommentTree(comments: Comment[]): CommentNode[] {
|
||||
const map = new Map<number, CommentNode>();
|
||||
const roots: CommentNode[] = [];
|
||||
@@ -33,8 +33,9 @@ export function buildCommentTree(comments: Comment[]): CommentNode[] {
|
||||
|
||||
for (const c of comments) {
|
||||
const node = map.get(c.id)!;
|
||||
if (c.reply_to && map.has(c.reply_to)) {
|
||||
map.get(c.reply_to)!.children.push(node);
|
||||
const parentId = c.thread_parent_id ?? c.reply_to;
|
||||
if (parentId && map.has(parentId)) {
|
||||
map.get(parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
|
||||
@@ -15,34 +15,24 @@ export function highlightMentions(text: string, _onClick?: (name: string) => voi
|
||||
.replace(/@([\w\u4e00-\u9fa5_-]+)/g, '<span class="mention">@$1</span>');
|
||||
}
|
||||
|
||||
/** 相对时间:刚刚 / N分钟前 / N小时前 / N天前;更早用具体日期 */
|
||||
export function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||
if (diff < 60) return '刚刚';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`;
|
||||
const diffSec = Math.max(0, (now.getTime() - d.getTime()) / 1000);
|
||||
if (diffSec < 60) return '刚刚';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}分钟前`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}小时前`;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const clock = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (
|
||||
d.getFullYear() === yesterday.getFullYear()
|
||||
&& d.getMonth() === yesterday.getMonth()
|
||||
&& d.getDate() === yesterday.getDate()
|
||||
) {
|
||||
return `昨天 ${clock}`;
|
||||
}
|
||||
const diffDay = Math.floor(diffSec / 86400);
|
||||
if (diffDay < 30) return `${diffDay}天前`;
|
||||
|
||||
if (d.getFullYear() === now.getFullYear()) {
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${clock}`;
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
}
|
||||
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日 ${clock}`;
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
|
||||
}
|
||||
|
||||
/** 完整日期时间(用于帖子发布/修改时间展示) */
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { NavigateFunction } from 'react-router-dom';
|
||||
import { postPath, type PermalinkOpts } from './permalink';
|
||||
|
||||
export type OpenForumPostOpts = PermalinkOpts & {
|
||||
/** 跳转到指定楼层(#floor-N) */
|
||||
floor?: number;
|
||||
};
|
||||
|
||||
/** 按站点配置打开帖子详情(当前页跳转或新标签) */
|
||||
export function openForumPost(
|
||||
nav: NavigateFunction,
|
||||
postId: number,
|
||||
openInNewTab: boolean,
|
||||
opts?: OpenForumPostOpts,
|
||||
) {
|
||||
const path = `/post/${postId}`;
|
||||
const path = postPath(postId, opts) + (opts?.floor && opts.floor > 0 ? `#floor-${opts.floor}` : '');
|
||||
if (openInNewTab) {
|
||||
window.open(path, '_blank', 'noopener,noreferrer');
|
||||
return;
|
||||
|
||||
52
frontend/src/utils/permalink.ts
Normal file
52
frontend/src/utils/permalink.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { getCachedForumLimits } from '../hooks/useForumLimits';
|
||||
|
||||
export type PermalinkOpts = {
|
||||
permalink_enabled?: boolean;
|
||||
permalink_ext?: string;
|
||||
};
|
||||
|
||||
const EXT_RE = /^[a-z0-9]{1,16}$/i;
|
||||
|
||||
/** 规范化伪静态后缀(无点) */
|
||||
export function normalizePermalinkExt(raw?: string): string {
|
||||
let ext = (raw ?? 'html').trim().replace(/^\./, '').toLowerCase();
|
||||
if (!ext || !EXT_RE.test(ext)) return 'html';
|
||||
return ext;
|
||||
}
|
||||
|
||||
function suffix(opts?: PermalinkOpts): string {
|
||||
const limits = opts ?? getCachedForumLimits();
|
||||
if (!limits.permalink_enabled) return '';
|
||||
return `.${normalizePermalinkExt(limits.permalink_ext)}`;
|
||||
}
|
||||
|
||||
/** 帖子规范路径:/post/123 或 /post/123.html */
|
||||
export function postPath(id: number | string, opts?: PermalinkOpts): string {
|
||||
return `/post/${id}${suffix(opts)}`;
|
||||
}
|
||||
|
||||
/** 用户规范路径 */
|
||||
export function userPath(id: number | string, opts?: PermalinkOpts): string {
|
||||
return `/user/${id}${suffix(opts)}`;
|
||||
}
|
||||
|
||||
/** 从路由参数解析数字 ID(兼容 123 / 123.html) */
|
||||
export function parsePermalinkID(raw: string | undefined): number {
|
||||
if (!raw) return NaN;
|
||||
const m = String(raw).match(/^(\d+)(?:\.[A-Za-z0-9]{1,16})?$/);
|
||||
return m ? Number(m[1]) : NaN;
|
||||
}
|
||||
|
||||
/** 客户端:若当前 URL 非规范伪静态路径则返回应跳转的目标 */
|
||||
export function canonicalRedirectPath(
|
||||
kind: 'post' | 'user',
|
||||
id: number,
|
||||
currentPathname: string,
|
||||
opts?: PermalinkOpts,
|
||||
): string | null {
|
||||
if (!id || Number.isNaN(id)) return null;
|
||||
const target = kind === 'post' ? postPath(id, opts) : userPath(id, opts);
|
||||
const cur = currentPathname.replace(/\/$/, '') || '/';
|
||||
const want = target.replace(/\/$/, '') || '/';
|
||||
return cur === want ? null : target;
|
||||
}
|
||||
22
frontend/src/utils/report.ts
Normal file
22
frontend/src/utils/report.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ReportReason, ReportStatus } from '../api/types';
|
||||
|
||||
export const REPORT_REASON_OPTIONS: { value: ReportReason; label: string }[] = [
|
||||
{ value: 'spam', label: '垃圾广告' },
|
||||
{ value: 'abuse', label: '人身攻击 / 辱骂' },
|
||||
{ value: 'illegal', label: '违法违规' },
|
||||
{ value: 'irrelevant', label: '内容无关 / 灌水' },
|
||||
{ value: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
export function reportReasonLabel(reason: string) {
|
||||
return REPORT_REASON_OPTIONS.find(o => o.value === reason)?.label ?? reason;
|
||||
}
|
||||
|
||||
export function reportStatusLabel(status: ReportStatus | string) {
|
||||
switch (status) {
|
||||
case 'pending': return '待处理';
|
||||
case 'resolved': return '已处理';
|
||||
case 'dismissed': return '已忽略';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
18
frontend/src/utils/seoText.ts
Normal file
18
frontend/src/utils/seoText.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** 从 HTML 提取纯文本摘要(供页面 description / OG) */
|
||||
export function excerptFromHTML(html: string, max = 160): string {
|
||||
if (!html) return '';
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const text = (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text.length <= max) return text;
|
||||
return `${text.slice(0, Math.max(0, max - 1))}…`;
|
||||
}
|
||||
|
||||
/** 正文中第一张图片 URL */
|
||||
export function firstImageFromHTML(html: string): string {
|
||||
if (!html) return '';
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const img = doc.querySelector('img[src]');
|
||||
const src = img?.getAttribute('src')?.trim() || '';
|
||||
if (!src || src.startsWith('data:')) return '';
|
||||
return src;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
/** 用户公开主页路径 */
|
||||
export function userPath(id: number | string): string {
|
||||
return `/user/${id}`;
|
||||
import { userPath as permalinkUserPath, type PermalinkOpts } from './permalink';
|
||||
|
||||
/** 用户公开主页路径(遵循后台伪静态配置) */
|
||||
export function userPath(id: number | string, opts?: PermalinkOpts): string {
|
||||
return permalinkUserPath(id, opts);
|
||||
}
|
||||
|
||||
23
go.mod
23
go.mod
@@ -3,11 +3,14 @@ module git.iioio.com/freefire/jiang13-forum
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/KarpelesLab/gowebp v0.1.1
|
||||
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
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.44.0
|
||||
gopkg.in/ini.v1 v1.67.3
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
@@ -21,27 +24,35 @@ require (
|
||||
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-ini/ini v1.67.0 // 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/google/uuid v1.6.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/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // 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/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/image v0.44.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
46
go.sum
46
go.sum
@@ -1,3 +1,5 @@
|
||||
github.com/KarpelesLab/gowebp v0.1.1 h1:W11ZrRVx+Zk4ypW5NBEU31FQzghICXIrAbAbO5yd4M0=
|
||||
github.com/KarpelesLab/gowebp v0.1.1/go.mod h1:Js8OXPQ94yl94HqaO/9XuUqk0wOPod6uycryhzmTgsU=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
@@ -21,6 +23,8 @@ github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9g
|
||||
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-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
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=
|
||||
@@ -38,8 +42,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.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=
|
||||
@@ -48,14 +52,25 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
|
||||
github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
|
||||
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
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/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRisi0=
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
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=
|
||||
@@ -63,11 +78,15 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
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/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
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=
|
||||
@@ -81,26 +100,27 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
|
||||
245
handler/admin.go
245
handler/admin.go
@@ -1,245 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// --- 后台管理页面 ---
|
||||
|
||||
func (h *Handlers) adminPageData(c *gin.Context, title, activeNav string, data gin.H) gin.H {
|
||||
if data == nil {
|
||||
data = gin.H{}
|
||||
}
|
||||
data["ActiveNav"] = activeNav
|
||||
return h.pageData(c, title, data)
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminLoginPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/login.html", h.pageData(c, "后台登录", gin.H{
|
||||
"ActiveNav": "login",
|
||||
"QueryBanned": c.Query("banned"),
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminDashboard(c *gin.Context) {
|
||||
var userCount, postCount, boardCount, commentCount int64
|
||||
model.DB.Model(&model.User{}).Count(&userCount)
|
||||
model.DB.Model(&model.Post{}).Count(&postCount)
|
||||
model.DB.Model(&model.Board{}).Count(&boardCount)
|
||||
model.DB.Model(&model.Comment{}).Count(&commentCount)
|
||||
recentPosts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
|
||||
c.HTML(http.StatusOK, "admin/dashboard.html", h.adminPageData(c, "仪表盘", "dashboard", gin.H{
|
||||
"UserCount": userCount,
|
||||
"PostCount": postCount,
|
||||
"BoardCount": boardCount,
|
||||
"CommentCount": commentCount,
|
||||
"RecentPosts": recentPosts,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminBoardsPage(c *gin.Context) {
|
||||
boards, _ := h.Board.ListWithStats()
|
||||
c.HTML(http.StatusOK, "admin/boards.html", h.adminPageData(c, "板块管理", "boards", gin.H{
|
||||
"Boards": boards,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminPostsPage(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
posts, total, _ := h.Post.List(service.PostListQuery{Page: page, Size: 20, Keyword: keyword})
|
||||
c.HTML(http.StatusOK, "admin/posts.html", h.adminPageData(c, "帖子管理", "posts", gin.H{
|
||||
"Posts": posts,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"Keyword": keyword,
|
||||
"TotalPages": calcTotalPages(total, 20),
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminCommentsPage(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
comments, total, _ := h.Comment.ListRecent(page, 20)
|
||||
c.HTML(http.StatusOK, "admin/comments.html", h.adminPageData(c, "评论管理", "comments", gin.H{
|
||||
"Comments": comments,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"TotalPages": calcTotalPages(total, 20),
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminUsersPage(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
users, total, _ := h.User.ListUsers(page, 20)
|
||||
c.HTML(http.StatusOK, "admin/users.html", h.adminPageData(c, "用户管理", "users", gin.H{
|
||||
"Users": users,
|
||||
"Total": total,
|
||||
"Page": page,
|
||||
"TotalPages": calcTotalPages(total, 20),
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminSettingsPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "admin/settings.html", h.adminPageData(c, "系统设置", "settings", gin.H{
|
||||
"FilterPath": h.Cfg.FilterWordsPath(),
|
||||
"DataDir": h.Cfg.DataDir,
|
||||
"DBPath": h.Cfg.DBPath(),
|
||||
"Port": h.Cfg.Port,
|
||||
}))
|
||||
}
|
||||
|
||||
func calcTotalPages(total int64, size int) int {
|
||||
if total == 0 {
|
||||
return 1
|
||||
}
|
||||
pages := int(total) / size
|
||||
if int(total)%size > 0 {
|
||||
pages++
|
||||
}
|
||||
if pages < 1 {
|
||||
return 1
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// --- 后台 API ---
|
||||
|
||||
func (h *Handlers) AdminAPICreateBoard(c *gin.Context) {
|
||||
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
board, err := h.Board.Create(c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "板块已创建", "id": board.ID})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIUpdateBoard(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
sortOrder, _ := strconv.Atoi(c.PostForm("sort_order"))
|
||||
colorIndex, _ := strconv.Atoi(c.PostForm("color_index"))
|
||||
if err := h.Board.Update(uint(id), c.PostForm("name"), c.PostForm("description"), c.PostForm("icon"), colorIndex, sortOrder); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "板块已更新"})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIDeleteBoard(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Board.Delete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "板块已删除"})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIPinPost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
pinned := c.PostForm("pinned") == "true" || c.PostForm("pinned") == "1"
|
||||
if err := h.Post.SetPinned(uint(id), pinned); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已取消置顶"
|
||||
if pinned {
|
||||
msg = "已置顶"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": pinned})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIDeletePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.Delete(0, uint(id), true); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIDeleteComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Comment.AdminDelete(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIBanUser(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
banned := c.PostForm("banned") == "true" || c.PostForm("banned") == "1"
|
||||
if err := h.User.BanUser(uint(id), banned); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已解除禁言"
|
||||
if banned {
|
||||
msg = "已禁言"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPIBackup(c *gin.Context) {
|
||||
path, err := h.Backup.ExportSQLite()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
filename := filepath.Base(path)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "备份成功",
|
||||
"path": path,
|
||||
"filename": filename,
|
||||
"download": "/admin/api/backup/download/" + filename,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminDownloadBackup(c *gin.Context) {
|
||||
name := c.Param("name")
|
||||
if !strings.HasPrefix(name, "jiang13_backup_") || !strings.HasSuffix(name, ".db") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的备份文件名"})
|
||||
return
|
||||
}
|
||||
path := filepath.Join(h.Cfg.DataDir, name)
|
||||
c.FileAttachment(path, name)
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPILogin(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" form:"username" binding:"required"`
|
||||
Password string `json:"password" form:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if user.Role != model.RoleAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "需要管理员账号才能登录后台"})
|
||||
return
|
||||
}
|
||||
h.setAuthCookie(c, token)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "登录成功",
|
||||
"user": gin.H{
|
||||
"id": user.ID, "nickname": user.Nickname, "role": user.Role,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handlers) AdminAPILogout(c *gin.Context) {
|
||||
h.APILogout(c)
|
||||
}
|
||||
254
handler/api.go
254
handler/api.go
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -52,7 +51,7 @@ func (h *Handlers) APIBoards(c *gin.Context) {
|
||||
func (h *Handlers) APIStats(c *gin.Context) {
|
||||
var userCount, postCount, boardCount int64
|
||||
model.DB.Model(&model.User{}).Count(&userCount)
|
||||
model.DB.Model(&model.Post{}).Count(&postCount)
|
||||
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
|
||||
model.DB.Model(&model.Board{}).Count(&boardCount)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||
@@ -121,10 +120,12 @@ func (h *Handlers) APIAdminDeleteBoard(c *gin.Context) {
|
||||
func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
||||
var userCount, postCount, boardCount, commentCount int64
|
||||
model.DB.Model(&model.User{}).Count(&userCount)
|
||||
model.DB.Model(&model.Post{}).Count(&postCount)
|
||||
model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&postCount)
|
||||
model.DB.Model(&model.Board{}).Count(&boardCount)
|
||||
model.DB.Model(&model.Comment{}).Count(&commentCount)
|
||||
recentPosts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 8})
|
||||
model.DB.Model(&model.Comment{}).Where("status = ?", model.ContentStatusPublished).Count(&commentCount)
|
||||
recentPosts, _, _ := h.Post.List(service.PostListQuery{
|
||||
Page: 1, Size: 8, ViewerIsAdmin: true, Status: "all",
|
||||
})
|
||||
if recentPosts == nil {
|
||||
recentPosts = []model.Post{}
|
||||
}
|
||||
@@ -140,7 +141,11 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
posts, total, err := h.Post.ListItems(service.PostListQuery{Page: page, Size: size, Keyword: keyword})
|
||||
status := strings.TrimSpace(c.DefaultQuery("status", "all"))
|
||||
posts, total, err := h.Post.ListItems(service.PostListQuery{
|
||||
Page: page, Size: size, Keyword: keyword,
|
||||
ViewerIsAdmin: true, Status: status,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -148,9 +153,12 @@ func (h *Handlers) APIAdminPosts(c *gin.Context) {
|
||||
if posts == nil {
|
||||
posts = []service.PostListItem{}
|
||||
}
|
||||
pending, _ := h.Post.PendingPostCount()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"posts": posts, "total": total, "page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
"pending_count": pending,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,21 +204,82 @@ func (h *Handlers) APIAdminPinPost(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "pinned": req.Pinned})
|
||||
}
|
||||
|
||||
// APIAdminDeletePost 管理员删除帖子
|
||||
// APIAdminFeaturePost 设为精华/取消精华(JSON)
|
||||
func (h *Handlers) APIAdminFeaturePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Featured bool `json:"featured"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Post.SetFeatured(uint(id), req.Featured); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
msg := "已取消精华"
|
||||
if req.Featured {
|
||||
msg = "已设为精华"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "featured": req.Featured})
|
||||
}
|
||||
|
||||
// APIAdminDeletePost 管理员软删除帖子(进入回收站)
|
||||
func (h *Handlers) APIAdminDeletePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.Delete(0, uint(id), true); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已移入回收站"})
|
||||
}
|
||||
|
||||
// APIAdminTrashPosts 回收站帖子列表
|
||||
func (h *Handlers) APIAdminTrashPosts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
keyword := strings.TrimSpace(c.Query("keyword"))
|
||||
posts, total, err := h.Post.ListTrash(page, size, keyword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if posts == nil {
|
||||
posts = []service.TrashPostItem{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"posts": posts, "total": total, "page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminRestorePost 从回收站恢复帖子
|
||||
func (h *Handlers) APIAdminRestorePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.Restore(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已恢复"})
|
||||
}
|
||||
|
||||
// APIAdminPurgePost 永久删除回收站帖子
|
||||
func (h *Handlers) APIAdminPurgePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.Purge(uint(id)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已永久删除"})
|
||||
}
|
||||
|
||||
// APIAdminComments 管理员评论列表
|
||||
func (h *Handlers) APIAdminComments(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
comments, total, err := h.Comment.ListRecent(page, size)
|
||||
status := strings.TrimSpace(c.DefaultQuery("status", "all"))
|
||||
comments, total, err := h.Comment.ListRecent(page, size, status)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -218,12 +287,63 @@ func (h *Handlers) APIAdminComments(c *gin.Context) {
|
||||
if comments == nil {
|
||||
comments = []model.Comment{}
|
||||
}
|
||||
pending, _ := h.Comment.PendingCommentCount()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"comments": comments, "total": total, "page": page,
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
"total_pages": calcTotalPages(total, size),
|
||||
"pending_count": pending,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminApproveComment 通过评论审核
|
||||
func (h *Handlers) APIAdminApproveComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Comment.SetStatus(uint(id), model.ContentStatusPublished); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已通过审核", "status": model.ContentStatusPublished})
|
||||
}
|
||||
|
||||
// APIAdminRejectComment 拒绝评论并私信通知
|
||||
func (h *Handlers) APIAdminRejectComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
reason = "不符合社区规范"
|
||||
}
|
||||
comment, err := h.Comment.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.Comment.SetStatus(uint(id), model.ContentStatusRejected); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if comment.UserID > 0 {
|
||||
title := comment.Post.Title
|
||||
if title == "" {
|
||||
title = "未知帖子"
|
||||
}
|
||||
pid := comment.PostID
|
||||
_, _ = h.Message.SendSystem(
|
||||
comment.UserID,
|
||||
"评论未通过审核",
|
||||
service.FormatCommentRejectContent(title, comment.PostID, comment.Floor, reason),
|
||||
model.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已拒绝该评论并通知作者", "status": model.ContentStatusRejected})
|
||||
}
|
||||
|
||||
// APIAdminDeleteComment 管理员删除评论
|
||||
func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
@@ -234,6 +354,17 @@ func (h *Handlers) APIAdminDeleteComment(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已删除"})
|
||||
}
|
||||
|
||||
// APIAdminCommentRevisions 管理员查看评论编辑历史
|
||||
func (h *Handlers) APIAdminCommentRevisions(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
revs, err := h.Comment.ListRevisions(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"revisions": revs})
|
||||
}
|
||||
|
||||
// APIAdminUsers 管理员用户列表
|
||||
func (h *Handlers) APIAdminUsers(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
@@ -314,6 +445,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
||||
"oidc": h.Settings.OIDCConfigPublic(),
|
||||
"oauth_clients": clients,
|
||||
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||
"storage": h.Settings.StorageConfigPublic(),
|
||||
"branding": h.Settings.SiteBranding(),
|
||||
"filter_words": filterContent,
|
||||
"filter_word_count": service.CountFilterWords(filterContent),
|
||||
@@ -342,11 +474,11 @@ func (h *Handlers) APIAdminUpdateBranding(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUploadBrandingAsset 上传 Logo 或 Favicon(form: file + kind=logo|favicon)
|
||||
// APIAdminUploadBrandingAsset 上传 Logo / Favicon / 默认 OG 图(form: file + kind=logo|favicon|og_image)
|
||||
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"})
|
||||
if kind != "logo" && kind != "favicon" && kind != "og_image" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo、favicon 或 og_image"})
|
||||
return
|
||||
}
|
||||
file, err := c.FormFile("file")
|
||||
@@ -359,18 +491,22 @@ func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 2MB"})
|
||||
return
|
||||
}
|
||||
url, err := service.SaveUploadedImage(file, h.Cfg.SiteUploadDir(), "/uploads/site", kind)
|
||||
url, err := service.SaveUploadedImage(h.Store, file, service.UploadCategorySite, kind)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
prev := h.Settings.SiteBranding()
|
||||
if kind == "logo" {
|
||||
switch kind {
|
||||
case "logo":
|
||||
_ = h.Settings.SetSiteLogo(url)
|
||||
h.removeSiteUploadIfLocal(prev.Logo)
|
||||
} else {
|
||||
h.Store.DeleteByURL(prev.Logo)
|
||||
case "favicon":
|
||||
_ = h.Settings.SetSiteFavicon(url)
|
||||
h.removeSiteUploadIfLocal(prev.Favicon)
|
||||
h.Store.DeleteByURL(prev.Favicon)
|
||||
case "og_image":
|
||||
_ = h.Settings.SetSiteOGImage(url)
|
||||
h.Store.DeleteByURL(prev.OGImage)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "上传成功",
|
||||
@@ -379,7 +515,7 @@ func (h *Handlers) APIAdminUploadBrandingAsset(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminClearBrandingAsset 清除 Logo 或 Favicon
|
||||
// APIAdminClearBrandingAsset 清除 Logo / Favicon / 默认 OG 图
|
||||
func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
@@ -393,12 +529,15 @@ func (h *Handlers) APIAdminClearBrandingAsset(c *gin.Context) {
|
||||
switch kind {
|
||||
case "logo":
|
||||
_ = h.Settings.SetSiteLogo("")
|
||||
h.removeSiteUploadIfLocal(brand.Logo)
|
||||
h.Store.DeleteByURL(brand.Logo)
|
||||
case "favicon":
|
||||
_ = h.Settings.SetSiteFavicon("")
|
||||
h.removeSiteUploadIfLocal(brand.Favicon)
|
||||
h.Store.DeleteByURL(brand.Favicon)
|
||||
case "og_image":
|
||||
_ = h.Settings.SetSiteOGImage("")
|
||||
h.Store.DeleteByURL(brand.OGImage)
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo 或 favicon"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "kind 须为 logo、favicon 或 og_image"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -505,6 +644,27 @@ func (h *Handlers) APIAdminUpdateGiteaSettings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminUpdateStorageSettings 更新上传存储(本地 / S3 兼容),保存后立即热切换
|
||||
func (h *Handlers) APIAdminUpdateStorageSettings(c *gin.Context) {
|
||||
var req service.StorageConfig
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
if err := h.Settings.UpdateStorageConfig(req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.Store.ReloadFromSettings(h.Settings); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "配置已保存,但初始化存储失败:" + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "存储设置已保存",
|
||||
"storage": h.Settings.StorageConfigPublic(),
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminSyncGitea 立即同步 Gitea 公开仓库
|
||||
func (h *Handlers) APIAdminSyncGitea(c *gin.Context) {
|
||||
if h.Gitea == nil {
|
||||
@@ -666,12 +826,14 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
|
||||
q := service.PostListQuery{
|
||||
BoardID: uint(boardID),
|
||||
UserID: uint(userID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Keyword: keyword,
|
||||
Sort: c.DefaultQuery("sort", "latest"),
|
||||
BoardID: uint(boardID),
|
||||
UserID: uint(userID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Keyword: keyword,
|
||||
Sort: c.DefaultQuery("sort", "latest"),
|
||||
ViewerID: h.currentUserID(c),
|
||||
ViewerIsAdmin: h.isAdmin(c),
|
||||
}
|
||||
items, total, err := h.Post.ListItems(q)
|
||||
if err != nil {
|
||||
@@ -702,15 +864,19 @@ func (h *Handlers) APIPostDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
if c.Query("skip_view") != "1" {
|
||||
uid := h.currentUserID(c)
|
||||
isAdmin := h.isAdmin(c)
|
||||
if !service.CanViewPost(post, uid, isAdmin) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
if c.Query("skip_view") != "1" && post.Status == model.ContentStatusPublished {
|
||||
h.Post.RecordView(uint(id))
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
post.Content = service.RedactMembersOnlyHTML(post.Content)
|
||||
}
|
||||
comments, _ := h.Comment.ListByPost(uint(id), uid, h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
|
||||
isAdmin := h.isAdmin(c)
|
||||
comments, _ := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
|
||||
canEdit := h.Post.CanUserEdit(post, uid, isAdmin)
|
||||
editReason := ""
|
||||
if !canEdit && uid > 0 {
|
||||
@@ -737,7 +903,13 @@ func (h *Handlers) APIPostComments(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
comments, err := h.Comment.ListByPost(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID, h.parseGuestCommentIDs(c))
|
||||
uid := h.currentUserID(c)
|
||||
isAdmin := h.isAdmin(c)
|
||||
if !service.CanViewPost(post, uid, isAdmin) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
comments, err := h.Comment.ListByPost(uint(id), uid, isAdmin, post.UserID, h.parseGuestCommentIDs(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -842,20 +1014,6 @@ func (h *Handlers) APIPostRevisionDetail(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"revision": rev})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return errors.Is(err, service.ErrSearchKeywordTooShort) ||
|
||||
errors.Is(err, service.ErrSearchKeywordTooLong)
|
||||
|
||||
@@ -17,11 +17,14 @@ import (
|
||||
// Handlers 聚合所有 HTTP 处理器
|
||||
type Handlers struct {
|
||||
Cfg *config.Config
|
||||
Store *service.UploadStore
|
||||
Auth *service.AuthService
|
||||
User *service.UserService
|
||||
Board *service.BoardService
|
||||
Post *service.PostService
|
||||
Comment *service.CommentService
|
||||
Message *service.MessageService
|
||||
Report *service.ReportService
|
||||
Backup *service.BackupService
|
||||
Filter *service.SensitiveFilter
|
||||
Limiter *service.RateLimiter
|
||||
@@ -70,108 +73,18 @@ func (h *Handlers) parseGuestCommentIDs(c *gin.Context) []uint {
|
||||
return ids
|
||||
}
|
||||
|
||||
func (h *Handlers) pageData(c *gin.Context, title string, data gin.H) gin.H {
|
||||
if data == nil {
|
||||
data = gin.H{}
|
||||
func calcTotalPages(total int64, size int) int {
|
||||
if total == 0 {
|
||||
return 1
|
||||
}
|
||||
data["Title"] = title
|
||||
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 {
|
||||
data["CurrentUser"] = u
|
||||
}
|
||||
pages := int(total) / size
|
||||
if int(total)%size > 0 {
|
||||
pages++
|
||||
}
|
||||
data["IsAdmin"] = h.isAdmin(c)
|
||||
return data
|
||||
}
|
||||
|
||||
// --- 页面路由 ---
|
||||
|
||||
func (h *Handlers) IndexPage(c *gin.Context) {
|
||||
boards, _ := h.Board.List()
|
||||
posts, _, _ := h.Post.List(service.PostListQuery{Page: 1, Size: 10})
|
||||
c.HTML(http.StatusOK, "index.html", h.pageData(c, "首页", gin.H{
|
||||
"Boards": boards, "Posts": posts,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) LoginPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "login.html", h.pageData(c, "登录", nil))
|
||||
}
|
||||
|
||||
func (h *Handlers) RegisterPage(c *gin.Context) {
|
||||
c.HTML(http.StatusOK, "register.html", h.pageData(c, "注册", nil))
|
||||
}
|
||||
|
||||
func (h *Handlers) BoardPage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
board, err := h.Board.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "error.html", h.pageData(c, "板块不存在", gin.H{"Message": "板块不存在"}))
|
||||
return
|
||||
if pages < 1 {
|
||||
return 1
|
||||
}
|
||||
posts, total, _ := h.Post.List(service.PostListQuery{BoardID: uint(id), Page: page, Size: 20})
|
||||
c.HTML(http.StatusOK, "board.html", h.pageData(c, board.Name, gin.H{
|
||||
"Board": board, "Posts": posts, "Total": total, "Page": page,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) PostPage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "error.html", h.pageData(c, "帖子不存在", gin.H{"Message": "帖子不存在"}))
|
||||
return
|
||||
}
|
||||
comments, _ := h.Comment.ListByPost(uint(id), h.currentUserID(c), h.isAdmin(c), post.UserID, nil)
|
||||
uid := h.currentUserID(c)
|
||||
c.HTML(http.StatusOK, "post.html", h.pageData(c, post.Title, gin.H{
|
||||
"Post": post, "Comments": comments,
|
||||
"Liked": h.Post.IsLiked(uid, uint(id)),
|
||||
"Favorited": h.Post.IsFavorited(uid, uint(id)),
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *Handlers) PostNewPage(c *gin.Context) {
|
||||
boards, _ := h.Board.List()
|
||||
c.HTML(http.StatusOK, "post_new.html", h.pageData(c, "发帖", gin.H{"Boards": boards}))
|
||||
}
|
||||
|
||||
func (h *Handlers) PostEditPage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil || (!h.isAdmin(c) && post.UserID != h.currentUserID(c)) {
|
||||
c.Redirect(http.StatusFound, "/")
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "post_edit.html", h.pageData(c, "编辑帖子", gin.H{"Post": post}))
|
||||
}
|
||||
|
||||
func (h *Handlers) ProfilePage(c *gin.Context) {
|
||||
user, _ := h.User.GetByID(h.currentUserID(c))
|
||||
c.HTML(http.StatusOK, "profile.html", h.pageData(c, "个人主页", gin.H{"ProfileUser": user}))
|
||||
}
|
||||
|
||||
func (h *Handlers) UserProfilePage(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
user, err := h.User.GetByID(uint(id))
|
||||
if err != nil {
|
||||
c.HTML(http.StatusNotFound, "error.html", h.pageData(c, "用户不存在", gin.H{"Message": "用户不存在"}))
|
||||
return
|
||||
}
|
||||
c.HTML(http.StatusOK, "user_profile.html", h.pageData(c, user.Nickname, gin.H{"ProfileUser": user}))
|
||||
}
|
||||
|
||||
func (h *Handlers) FavoritesPage(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
favs, total, _ := h.Post.ListFavorites(h.currentUserID(c), page, 20)
|
||||
c.HTML(http.StatusOK, "favorites.html", h.pageData(c, "我的收藏", gin.H{
|
||||
"Favorites": favs, "Total": total, "Page": page,
|
||||
}))
|
||||
return pages
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
@@ -197,6 +110,7 @@ func (h *Handlers) APIRegisterConfig(c *gin.Context) {
|
||||
"mail_ready": mailReady,
|
||||
"require_email_code": mailReady,
|
||||
"register_open": userCount == 0 || mailReady,
|
||||
"email_code_len": service.EmailCodeLen,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -366,7 +280,7 @@ func (h *Handlers) APIUploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件过大"})
|
||||
return
|
||||
}
|
||||
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Cfg.UploadDir())
|
||||
url, err := h.User.UploadAvatar(h.currentUserID(c), file, h.Store)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -382,9 +296,9 @@ func (h *Handlers) APIUploadPostImage(c *gin.Context) {
|
||||
}
|
||||
uid := h.currentUserID(c)
|
||||
url, err := service.SaveUploadedImage(
|
||||
h.Store,
|
||||
file,
|
||||
h.Cfg.PostImageUploadDir(),
|
||||
"/uploads/posts",
|
||||
service.UploadCategoryPosts,
|
||||
fmt.Sprintf("%d", uid),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -399,18 +313,23 @@ func (h *Handlers) APICreatePost(c *gin.Context) {
|
||||
title := c.PostForm("title")
|
||||
content := c.PostForm("content")
|
||||
tags := c.PostForm("tags")
|
||||
post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags)
|
||||
post, err := h.Post.Create(h.currentUserID(c), uint(boardID), title, content, tags, h.isAdmin(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "发帖成功", "post_id": post.ID})
|
||||
msg := "发帖成功"
|
||||
if post.Status == model.ContentStatusPending {
|
||||
msg = "已提交审核,通过后将公开显示"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": post.ID, "status": post.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIUpdatePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
boardID, _ := strconv.ParseUint(c.PostForm("board_id"), 10, 64)
|
||||
err := h.Post.Update(h.currentUserID(c), uint(id), h.isAdmin(c),
|
||||
c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"))
|
||||
c.PostForm("title"), c.PostForm("content"), c.PostForm("tags"), uint(boardID))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -424,7 +343,7 @@ func (h *Handlers) APIDeletePost(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已删除"})
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已移入回收站"})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIToggleLike(c *gin.Context) {
|
||||
@@ -460,6 +379,10 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
|
||||
}
|
||||
isPrivate := c.PostForm("is_private") == "1" || c.PostForm("is_private") == "true"
|
||||
uid := h.currentUserID(c)
|
||||
if uid == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请登录后评论"})
|
||||
return
|
||||
}
|
||||
|
||||
in := service.CommentCreateInput{
|
||||
UserID: uid,
|
||||
@@ -468,18 +391,17 @@ func (h *Handlers) APICreateComment(c *gin.Context) {
|
||||
ReplyTo: replyTo,
|
||||
IsPrivate: isPrivate,
|
||||
}
|
||||
if uid == 0 {
|
||||
in.GuestNick = c.PostForm("guest_nick")
|
||||
in.GuestEmail = c.PostForm("guest_email")
|
||||
in.GuestURL = c.PostForm("guest_url")
|
||||
}
|
||||
|
||||
comment, err := h.Comment.Create(in)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论成功", "floor": comment.Floor, "id": comment.ID})
|
||||
msg := "评论成功"
|
||||
if comment.Status == model.ContentStatusPending {
|
||||
msg = "评论已提交,审核通过后公开显示"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "floor": comment.Floor, "id": comment.ID, "status": comment.Status})
|
||||
}
|
||||
|
||||
func (h *Handlers) APIDeleteComment(c *gin.Context) {
|
||||
@@ -499,5 +421,13 @@ func (h *Handlers) APIUpdateComment(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "评论已更新", "content": saved})
|
||||
msg := "评论已更新"
|
||||
status := ""
|
||||
if comment, e := h.Comment.GetByID(uint(id)); e == nil {
|
||||
status = comment.Status
|
||||
if status == model.ContentStatusPending && !h.isAdmin(c) {
|
||||
msg = "评论已更新,审核通过后公开显示"
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg, "content": saved, "status": status})
|
||||
}
|
||||
|
||||
70
handler/media.go
Normal file
70
handler/media.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// APIAdminMedia 列出媒体资源
|
||||
func (h *Handlers) APIAdminMedia(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "24"))
|
||||
category := c.DefaultQuery("category", "all")
|
||||
query := c.Query("q")
|
||||
result, err := h.Store.ListMedia(category, query, page, size)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// APIAdminDeleteMedia 批量删除媒体
|
||||
func (h *Handlers) APIAdminDeleteMedia(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "上传存储未初始化"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
URLs []string `json:"urls"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
urls := make([]string, 0, len(req.URLs)+1)
|
||||
for _, u := range req.URLs {
|
||||
u = strings.TrimSpace(u)
|
||||
if u != "" {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
}
|
||||
if u := strings.TrimSpace(req.URL); u != "" {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
if len(urls) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择要删除的文件"})
|
||||
return
|
||||
}
|
||||
if len(urls) > 100 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "单次最多删除 100 个文件"})
|
||||
return
|
||||
}
|
||||
n, err := h.Store.DeleteMedia(urls)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已删除 " + strconv.Itoa(n) + " 项媒体",
|
||||
"deleted": n,
|
||||
})
|
||||
}
|
||||
138
handler/message.go
Normal file
138
handler/message.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APIMessageConversations 会话列表(按对方聚合)
|
||||
func (h *Handlers) APIMessageConversations(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "30"))
|
||||
list, total, err := h.Message.ListConversations(service.ConversationListQuery{
|
||||
UserID: h.currentUserID(c),
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"conversations": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
})
|
||||
}
|
||||
|
||||
// APIConversationMessages 某会话内消息
|
||||
func (h *Handlers) APIConversationMessages(c *gin.Context) {
|
||||
peerID, err := strconv.ParseUint(c.Param("peerId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的会话"})
|
||||
return
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "50"))
|
||||
before, _ := strconv.ParseUint(c.DefaultQuery("before", "0"), 10, 64)
|
||||
uid := h.currentUserID(c)
|
||||
|
||||
list, total, err := h.Message.ListConversationMessages(service.ConversationMessagesQuery{
|
||||
UserID: uid,
|
||||
PeerID: uint(peerID),
|
||||
Page: page,
|
||||
Size: size,
|
||||
Before: uint(before),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 首次打开(非向上翻页)时标已读
|
||||
if before == 0 {
|
||||
_ = h.Message.MarkConversationRead(uid, uint(peerID))
|
||||
for i := range list {
|
||||
if list[i].ToUserID == uid {
|
||||
list[i].IsRead = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var peer *model.User
|
||||
if peerID > 0 {
|
||||
var u model.User
|
||||
if err := model.DB.First(&u, uint(peerID)).Error; err == nil {
|
||||
peer = &u
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"messages": list,
|
||||
"total": total,
|
||||
"peer_user_id": uint(peerID),
|
||||
"peer_user": peer,
|
||||
"is_system": peerID == 0,
|
||||
})
|
||||
}
|
||||
|
||||
// APIMarkConversationRead 将会话标为已读
|
||||
func (h *Handlers) APIMarkConversationRead(c *gin.Context) {
|
||||
peerID, err := strconv.ParseUint(c.Param("peerId"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的会话"})
|
||||
return
|
||||
}
|
||||
if err := h.Message.MarkConversationRead(h.currentUserID(c), uint(peerID)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已标为已读"})
|
||||
}
|
||||
|
||||
// APIMessageUnreadCount 未读私信数
|
||||
func (h *Handlers) APIMessageUnreadCount(c *gin.Context) {
|
||||
n, err := h.Message.UnreadCount(h.currentUserID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"count": n})
|
||||
}
|
||||
|
||||
// APISendMessage 发送私信
|
||||
func (h *Handlers) APISendMessage(c *gin.Context) {
|
||||
var req struct {
|
||||
ToUserID uint `json:"to_user_id"`
|
||||
Subject string `json:"subject"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
msg, err := h.Message.Send(service.MessageSendInput{
|
||||
FromUserID: h.currentUserID(c),
|
||||
ToUserID: req.ToUserID,
|
||||
Subject: req.Subject,
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": msg})
|
||||
}
|
||||
|
||||
// APIMarkAllMessagesRead 全部已读
|
||||
func (h *Handlers) APIMarkAllMessagesRead(c *gin.Context) {
|
||||
if err := h.Message.MarkAllRead(h.currentUserID(c)); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "已全部标为已读"})
|
||||
}
|
||||
144
handler/report.go
Normal file
144
handler/report.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// APICreatePostReport 举报帖子
|
||||
func (h *Handlers) APICreatePostReport(c *gin.Context) {
|
||||
postID, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
rep, err := h.Report.Create(h.currentUserID(c), uint(postID), req.Reason, req.Detail)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "举报已提交,感谢反馈", "report": rep})
|
||||
}
|
||||
|
||||
// APIAdminReports 举报列表
|
||||
func (h *Handlers) APIAdminReports(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
status := c.DefaultQuery("status", "pending")
|
||||
list, total, err := h.Report.ListAdmin(service.ReportListQuery{
|
||||
Status: status,
|
||||
Page: page,
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pending, _ := h.Report.PendingCount()
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"reports": list,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pending_count": pending,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
// APIAdminHandleReport 处理举报
|
||||
func (h *Handlers) APIAdminHandleReport(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
HandleNote string `json:"handle_note"`
|
||||
RejectReason string `json:"reject_reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
rep, err := h.Report.Handle(service.HandleReportInput{
|
||||
ReportID: uint(id),
|
||||
HandlerID: h.currentUserID(c),
|
||||
Action: req.Action,
|
||||
HandleNote: req.HandleNote,
|
||||
RejectReason: req.RejectReason,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "处理完成", "report": rep})
|
||||
}
|
||||
|
||||
// APIAdminApprovePost 通过帖子审核
|
||||
func (h *Handlers) APIAdminApprovePost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.Post.SetStatus(uint(id), model.ContentStatusPublished); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "帖子已通过审核", "status": model.ContentStatusPublished})
|
||||
}
|
||||
|
||||
// APIAdminRejectPost 拒绝帖子并私信通知作者(标记为 rejected,不进回收站)
|
||||
func (h *Handlers) APIAdminRejectPost(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
var req struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if reason == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写拒绝原因"})
|
||||
return
|
||||
}
|
||||
|
||||
post, err := h.Post.FindByID(uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
authorID := post.UserID
|
||||
title := post.Title
|
||||
postID := post.ID
|
||||
|
||||
if err := h.Post.SetStatus(postID, model.ContentStatusRejected); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pid := postID
|
||||
_, msgErr := h.Message.SendSystem(
|
||||
authorID,
|
||||
"帖子《"+title+"》未通过审核",
|
||||
service.FormatRejectContent(title, postID, reason),
|
||||
model.MessageKindReject,
|
||||
&pid,
|
||||
nil,
|
||||
)
|
||||
if msgErr != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "帖子已拒绝,但私信通知失败:" + msgErr.Error(),
|
||||
"notified": false,
|
||||
"status": model.ContentStatusRejected,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "已拒绝该帖并私信通知作者",
|
||||
"notified": true,
|
||||
"status": model.ContentStatusRejected,
|
||||
})
|
||||
}
|
||||
530
handler/seo.go
Normal file
530
handler/seo.go
Normal file
@@ -0,0 +1,530 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
var (
|
||||
seoPostEditRe = regexp.MustCompile(`^/post/(\d+)/edit/?$`)
|
||||
seoBoardPathRe = regexp.MustCompile(`^/board/(\d+)/?$`)
|
||||
)
|
||||
|
||||
const (
|
||||
seoDescMax = 160
|
||||
seoPrerenderMax = 4000
|
||||
seoSitemapLimit = 5000
|
||||
)
|
||||
|
||||
// RobotsTxt 搜索引擎抓取规则
|
||||
func (h *Handlers) RobotsTxt(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
var b strings.Builder
|
||||
b.WriteString("User-agent: *\n")
|
||||
b.WriteString("Allow: /\n")
|
||||
b.WriteString("Disallow: /api/\n")
|
||||
b.WriteString("Disallow: /admin\n")
|
||||
b.WriteString("Disallow: /compose\n")
|
||||
b.WriteString("Disallow: /login\n")
|
||||
b.WriteString("Disallow: /register\n")
|
||||
b.WriteString("Disallow: /profile\n")
|
||||
b.WriteString("Disallow: /favorites\n")
|
||||
b.WriteString("Disallow: /oauth/\n")
|
||||
b.WriteString("Disallow: /media/\n")
|
||||
b.WriteString("Disallow: /*/edit\n")
|
||||
if base != "" {
|
||||
b.WriteString("\nSitemap: ")
|
||||
b.WriteString(base)
|
||||
b.WriteString("/sitemap.xml\n")
|
||||
}
|
||||
c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// SitemapXML 公开页面站点地图
|
||||
func (h *Handlers) SitemapXML(c *gin.Context) {
|
||||
base := h.publicBaseURL(c)
|
||||
if base == "" {
|
||||
c.String(http.StatusServiceUnavailable, "未配置站点 ROOT_URL,无法生成 sitemap")
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
urls := []service.SitemapURL{
|
||||
{Loc: base + "/", LastMod: now, ChangeFreq: "hourly", Priority: "1.0"},
|
||||
{Loc: base + "/projects", LastMod: now, ChangeFreq: "daily", Priority: "0.6"},
|
||||
}
|
||||
|
||||
if boards, err := h.Board.List(); err == nil {
|
||||
for _, board := range boards {
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + service.QueryBoardHome(board.ID),
|
||||
LastMod: board.UpdatedAt.UTC(),
|
||||
ChangeFreq: "daily",
|
||||
Priority: "0.7",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
permalink := h.Settings.Permalink()
|
||||
if posts, err := h.Post.ListSitemap(seoSitemapLimit); err == nil {
|
||||
for _, p := range posts {
|
||||
lm := p.UpdatedAt
|
||||
if lm.IsZero() {
|
||||
lm = p.CreatedAt
|
||||
}
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + permalink.PostPath(p.ID),
|
||||
LastMod: lm.UTC(),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.8",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if users, err := h.User.ListSitemap(seoSitemapLimit); err == nil {
|
||||
for _, u := range users {
|
||||
urls = append(urls, service.SitemapURL{
|
||||
Loc: base + permalink.UserPath(u.ID),
|
||||
LastMod: u.UpdatedAt.UTC(),
|
||||
ChangeFreq: "weekly",
|
||||
Priority: "0.5",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8"?>`)
|
||||
b.WriteString(`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`)
|
||||
for _, u := range urls {
|
||||
b.WriteString("<url>")
|
||||
b.WriteString("<loc>")
|
||||
b.WriteString(xmlEscape(u.Loc))
|
||||
b.WriteString("</loc>")
|
||||
if !u.LastMod.IsZero() {
|
||||
b.WriteString("<lastmod>")
|
||||
b.WriteString(u.LastMod.Format("2006-01-02"))
|
||||
b.WriteString("</lastmod>")
|
||||
}
|
||||
if u.ChangeFreq != "" {
|
||||
b.WriteString("<changefreq>")
|
||||
b.WriteString(u.ChangeFreq)
|
||||
b.WriteString("</changefreq>")
|
||||
}
|
||||
if u.Priority != "" {
|
||||
b.WriteString("<priority>")
|
||||
b.WriteString(u.Priority)
|
||||
b.WriteString("</priority>")
|
||||
}
|
||||
b.WriteString("</url>")
|
||||
}
|
||||
b.WriteString("</urlset>")
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(b.String()))
|
||||
}
|
||||
|
||||
// ServePublicSPA 公开页入口:
|
||||
// - 普通用户:干净 SPA + <head> meta(无正文预渲染,避免刷新闪屏)
|
||||
// - 搜索/社交爬虫:服务端 HTML(动态渲染)
|
||||
// - 伪静态:按后台配置的后缀做规范 URL,非规范路径 301
|
||||
func (h *Handlers) ServePublicSPA(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
if m := seoBoardPathRe.FindStringSubmatch(path); len(m) == 2 {
|
||||
c.Redirect(http.StatusMovedPermanently, "/?board="+m[1])
|
||||
return
|
||||
}
|
||||
|
||||
brand := h.Settings.SiteBranding()
|
||||
base := h.publicBaseURL(c)
|
||||
siteName := strings.TrimSpace(brand.Name)
|
||||
if siteName == "" {
|
||||
siteName = "姜十三论坛"
|
||||
}
|
||||
defaultImage := service.AbsoluteURL(base, brand.DefaultShareImage())
|
||||
siteKeywords := brand.MetaKeywords()
|
||||
permalink := h.Settings.Permalink()
|
||||
|
||||
isBot := service.IsSEOCrawler(c.Request.UserAgent())
|
||||
if isBot {
|
||||
c.Header("Vary", "User-Agent")
|
||||
}
|
||||
|
||||
// 帖子详情(含可选伪静态后缀)
|
||||
if pm := permalink.MatchPostPath(path); pm.OK {
|
||||
if pm.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, pm.Canonical)
|
||||
return
|
||||
}
|
||||
post, err := h.Post.FindByID(pm.ID)
|
||||
if err != nil || !service.CanViewPost(post, h.currentUserID(c), h.isAdmin(c)) {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
postKeywords := service.JoinSEOKeywords(post.Board.Name, siteKeywords)
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botPostHTML(base, siteName, defaultImage, postKeywords, post)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, postKeywords))
|
||||
return
|
||||
}
|
||||
|
||||
// 用户主页
|
||||
if um := permalink.MatchUserPath(path); um.OK {
|
||||
if um.NeedsCanonicalRedirect(path) {
|
||||
c.Redirect(http.StatusMovedPermanently, um.Canonical)
|
||||
return
|
||||
}
|
||||
user, err := h.User.GetByID(um.ID)
|
||||
if err != nil || user.Banned {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
if isBot {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botUserHTML(base, siteName, defaultImage, siteKeywords, user)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, siteKeywords))
|
||||
return
|
||||
}
|
||||
|
||||
// 未知路径 → 404
|
||||
if !isKnownPublicPath(path) {
|
||||
h.serveNotFound(c, base, siteName, siteKeywords, path, isBot)
|
||||
return
|
||||
}
|
||||
|
||||
// 其余已知路由:SPA + head meta;首页对爬虫额外返回可读正文
|
||||
meta := h.buildSPAPageMeta(c, path, brand, base, siteName, defaultImage)
|
||||
if isBot && (path == "/" || path == "") {
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(h.botHomeHTML(meta, brand)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, meta)
|
||||
}
|
||||
|
||||
func (h *Handlers) serveNotFound(c *gin.Context, base, siteName, keywords, path string, isBot bool) {
|
||||
if isBot {
|
||||
c.Header("Vary", "User-Agent")
|
||||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(botNotFoundHTML(base, siteName, keywords, path)))
|
||||
return
|
||||
}
|
||||
embed_static.ServeSPAWithMeta(c, notFoundPageMeta(base, siteName, keywords, path))
|
||||
}
|
||||
|
||||
func notFoundPageMeta(base, siteName, keywords, path string) *embed_static.SPAPageMeta {
|
||||
return attachSiteSEO(&embed_static.SPAPageMeta{
|
||||
Title: pageTitle("页面不存在", siteName),
|
||||
Description: "您访问的页面不存在或已删除",
|
||||
Canonical: service.AbsoluteURL(base, path),
|
||||
OGType: "website",
|
||||
Robots: "noindex,follow",
|
||||
Status: http.StatusNotFound,
|
||||
}, siteName, keywords)
|
||||
}
|
||||
|
||||
// attachSiteSEO 填充站点级 keywords / og:site_name / og:locale
|
||||
func attachSiteSEO(meta *embed_static.SPAPageMeta, siteName, keywords string) *embed_static.SPAPageMeta {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
meta.SiteName = strings.TrimSpace(siteName)
|
||||
if strings.TrimSpace(meta.Keywords) == "" {
|
||||
meta.Keywords = strings.TrimSpace(keywords)
|
||||
}
|
||||
meta.Locale = "zh_CN"
|
||||
return meta
|
||||
}
|
||||
|
||||
func isKnownPublicPath(path string) bool {
|
||||
switch path {
|
||||
case "/", "/login", "/register", "/compose", "/profile", "/favorites", "/projects", "/boards":
|
||||
return true
|
||||
}
|
||||
if seoPostEditRe.MatchString(path) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Handlers) buildSPAPageMeta(c *gin.Context, path string, brand service.SiteBranding, base, siteName, defaultImage string) *embed_static.SPAPageMeta {
|
||||
siteTitle := brand.DocumentTitle()
|
||||
homeDesc := service.TruncateRunes(brand.MetaDescription(), seoDescMax)
|
||||
siteKeywords := brand.MetaKeywords()
|
||||
meta := attachSiteSEO(&embed_static.SPAPageMeta{
|
||||
Title: siteTitle,
|
||||
Description: homeDesc,
|
||||
Keywords: siteKeywords,
|
||||
Canonical: service.AbsoluteURL(base, pathWithQuery(c)),
|
||||
OGType: "website",
|
||||
OGImage: defaultImage,
|
||||
}, siteName, siteKeywords)
|
||||
|
||||
if isNoIndexPath(path) {
|
||||
meta.Robots = "noindex,nofollow"
|
||||
meta.Title = pageTitle(pathLabel(path), siteName)
|
||||
return meta
|
||||
}
|
||||
|
||||
if path == "/" || path == "" {
|
||||
boardID, _ := strconv.ParseUint(c.Query("board"), 10, 64)
|
||||
if boardID > 0 {
|
||||
if board, err := h.Board.GetByID(uint(boardID)); err == nil {
|
||||
desc := strings.TrimSpace(board.Description)
|
||||
if desc == "" {
|
||||
desc = brand.MetaDescription()
|
||||
}
|
||||
meta.Title = pageTitle(board.Name, siteName)
|
||||
meta.Description = service.TruncateRunes(desc, seoDescMax)
|
||||
meta.Canonical = service.AbsoluteURL(base, service.QueryBoardHome(board.ID))
|
||||
meta.Keywords = service.JoinSEOKeywords(board.Name, siteKeywords)
|
||||
return meta
|
||||
}
|
||||
// 无效板块 id:仍显示首页,但可标记 noindex
|
||||
meta.Robots = "noindex,follow"
|
||||
return meta
|
||||
}
|
||||
meta.JSONLD = mustJSON(map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": siteName,
|
||||
"description": meta.Description,
|
||||
"url": service.AbsoluteURL(base, "/"),
|
||||
})
|
||||
}
|
||||
|
||||
if path == "/projects" {
|
||||
meta.Title = pageTitle("项目", siteName)
|
||||
meta.Description = service.TruncateRunes(siteName+" 的公开项目列表", seoDescMax)
|
||||
meta.Keywords = service.JoinSEOKeywords("项目", siteKeywords)
|
||||
}
|
||||
|
||||
return meta
|
||||
}
|
||||
|
||||
func (h *Handlers) postPageMeta(base, siteName, defaultImage string, post *model.Post) *embed_static.SPAPageMeta {
|
||||
permalink := h.Settings.Permalink()
|
||||
content := service.RedactMembersOnlyHTML(post.Content)
|
||||
plain := post.ContentPlain
|
||||
if plain == "" {
|
||||
plain = service.StripHTMLForSearch(content)
|
||||
}
|
||||
desc := service.TruncateRunes(plain, seoDescMax)
|
||||
author := service.DisplayName(&post.User)
|
||||
canonical := service.AbsoluteURL(base, permalink.PostPath(post.ID))
|
||||
ogImage := service.AbsoluteURL(base, service.FirstImageURL(content))
|
||||
if ogImage == "" {
|
||||
ogImage = service.AbsoluteURL(base, post.User.Avatar)
|
||||
}
|
||||
if ogImage == "" {
|
||||
ogImage = defaultImage
|
||||
}
|
||||
|
||||
jsonld := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "DiscussionForumPosting",
|
||||
"headline": post.Title,
|
||||
"description": desc,
|
||||
"datePublished": post.CreatedAt.UTC().Format(time.RFC3339),
|
||||
"dateModified": post.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
"url": canonical,
|
||||
"mainEntityOfPage": canonical,
|
||||
"author": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": author,
|
||||
"url": service.AbsoluteURL(base, permalink.UserPath(post.UserID)),
|
||||
},
|
||||
"interactionStatistic": map[string]any{
|
||||
"@type": "InteractionCounter",
|
||||
"interactionType": "https://schema.org/ViewAction",
|
||||
"userInteractionCount": post.ViewCount,
|
||||
},
|
||||
}
|
||||
if post.Board.Name != "" {
|
||||
jsonld["articleSection"] = post.Board.Name
|
||||
}
|
||||
if ogImage != "" {
|
||||
jsonld["image"] = []string{ogImage}
|
||||
}
|
||||
body := service.TruncateRunes(plain, seoPrerenderMax)
|
||||
if body != "" {
|
||||
jsonld["articleBody"] = body
|
||||
}
|
||||
|
||||
return &embed_static.SPAPageMeta{
|
||||
Title: pageTitle(post.Title, siteName),
|
||||
Description: desc,
|
||||
Canonical: canonical,
|
||||
OGType: "article",
|
||||
OGImage: ogImage,
|
||||
JSONLD: mustJSON(jsonld),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) userPageMeta(base, siteName, defaultImage string, user *model.User) *embed_static.SPAPageMeta {
|
||||
permalink := h.Settings.Permalink()
|
||||
name := service.DisplayName(user)
|
||||
desc := strings.TrimSpace(user.Signature)
|
||||
if desc == "" {
|
||||
desc = name + " 的主页"
|
||||
}
|
||||
desc = service.TruncateRunes(desc, seoDescMax)
|
||||
canonical := service.AbsoluteURL(base, permalink.UserPath(user.ID))
|
||||
ogImage := service.AbsoluteURL(base, user.Avatar)
|
||||
if ogImage == "" {
|
||||
ogImage = defaultImage
|
||||
}
|
||||
|
||||
jsonld := map[string]any{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ProfilePage",
|
||||
"url": canonical,
|
||||
"mainEntity": map[string]any{
|
||||
"@type": "Person",
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"url": canonical,
|
||||
},
|
||||
}
|
||||
if ogImage != "" {
|
||||
jsonld["mainEntity"].(map[string]any)["image"] = ogImage
|
||||
}
|
||||
|
||||
return &embed_static.SPAPageMeta{
|
||||
Title: pageTitle(name+" 的主页", siteName),
|
||||
Description: desc,
|
||||
Canonical: canonical,
|
||||
OGType: "profile",
|
||||
OGImage: ogImage,
|
||||
JSONLD: mustJSON(jsonld),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) publicBaseURL(c *gin.Context) string {
|
||||
cfgRoot := ""
|
||||
if h.Cfg != nil {
|
||||
cfgRoot = h.Cfg.RootURL
|
||||
}
|
||||
origin := requestOrigin(c)
|
||||
return h.Settings.SitePublicBaseURL(cfgRoot, origin)
|
||||
}
|
||||
|
||||
func requestOrigin(c *gin.Context) string {
|
||||
proto := c.GetHeader("X-Forwarded-Proto")
|
||||
if proto == "" {
|
||||
if c.Request.TLS != nil {
|
||||
proto = "https"
|
||||
} else {
|
||||
proto = "http"
|
||||
}
|
||||
}
|
||||
host := c.GetHeader("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func pathWithQuery(c *gin.Context) string {
|
||||
path := c.Request.URL.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
if q := c.Request.URL.RawQuery; q != "" {
|
||||
// 首页排序/搜索不作为 canonical;板块筛选保留
|
||||
if path == "/" {
|
||||
board := c.Query("board")
|
||||
if board != "" {
|
||||
return service.QueryBoardHome(uint(parseUintOrZero(board)))
|
||||
}
|
||||
return "/"
|
||||
}
|
||||
return path + "?" + q
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func parseUintOrZero(s string) uint64 {
|
||||
n, _ := strconv.ParseUint(s, 10, 64)
|
||||
return n
|
||||
}
|
||||
|
||||
func isNoIndexPath(path string) bool {
|
||||
switch {
|
||||
case path == "/login", path == "/register", path == "/compose",
|
||||
path == "/profile", path == "/favorites":
|
||||
return true
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
return true
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func pathLabel(path string) string {
|
||||
switch {
|
||||
case path == "/login":
|
||||
return "登录"
|
||||
case path == "/register":
|
||||
return "注册"
|
||||
case path == "/compose":
|
||||
return "发帖"
|
||||
case path == "/profile":
|
||||
return "个人中心"
|
||||
case path == "/favorites":
|
||||
return "我的收藏"
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return "编辑帖子"
|
||||
case strings.HasPrefix(path, "/admin"):
|
||||
return "管理后台"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func pageTitle(page, siteName string) string {
|
||||
page = strings.TrimSpace(page)
|
||||
siteName = strings.TrimSpace(siteName)
|
||||
switch {
|
||||
case page == "" && siteName == "":
|
||||
return "姜十三论坛"
|
||||
case page == "":
|
||||
return siteName
|
||||
case siteName == "":
|
||||
return page
|
||||
default:
|
||||
return page + " - " + siteName
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
`&`, "&",
|
||||
`<`, "<",
|
||||
`>`, ">",
|
||||
`"`, """,
|
||||
`'`, "'",
|
||||
)
|
||||
return r.Replace(s)
|
||||
}
|
||||
146
handler/seo_bot.go
Normal file
146
handler/seo_bot.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.iioio.com/freefire/jiang13-forum/embed_static"
|
||||
"git.iioio.com/freefire/jiang13-forum/model"
|
||||
"git.iioio.com/freefire/jiang13-forum/service"
|
||||
)
|
||||
|
||||
// 爬虫专用伪静态 HTML(无 SPA;仅 User-Agent 命中爬虫时返回,避免用户刷新闪屏)
|
||||
|
||||
func renderBotHTML(meta *embed_static.SPAPageMeta, bodyInner string) string {
|
||||
if meta == nil {
|
||||
meta = &embed_static.SPAPageMeta{}
|
||||
}
|
||||
ogType := strings.TrimSpace(meta.OGType)
|
||||
if ogType == "" {
|
||||
ogType = "website"
|
||||
}
|
||||
locale := strings.TrimSpace(meta.Locale)
|
||||
if locale == "" {
|
||||
locale = "zh_CN"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("<!DOCTYPE html><html lang=\"zh-CN\"><head>")
|
||||
b.WriteString("<meta charset=\"UTF-8\"/>")
|
||||
b.WriteString("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"/>")
|
||||
writeEscapedTag(&b, "title", meta.Title)
|
||||
writeEscapedMeta(&b, "name", "description", meta.Description)
|
||||
writeEscapedMeta(&b, "name", "keywords", meta.Keywords)
|
||||
if meta.Robots != "" {
|
||||
writeEscapedMeta(&b, "name", "robots", meta.Robots)
|
||||
}
|
||||
if meta.Canonical != "" {
|
||||
b.WriteString(`<link rel="canonical" href="` + html.EscapeString(meta.Canonical) + `"/>`)
|
||||
}
|
||||
writeEscapedMeta(&b, "property", "og:type", ogType)
|
||||
writeEscapedMeta(&b, "property", "og:site_name", meta.SiteName)
|
||||
writeEscapedMeta(&b, "property", "og:locale", locale)
|
||||
writeEscapedMeta(&b, "property", "og:title", meta.Title)
|
||||
writeEscapedMeta(&b, "property", "og:description", meta.Description)
|
||||
writeEscapedMeta(&b, "property", "og:url", meta.Canonical)
|
||||
writeEscapedMeta(&b, "property", "og:image", meta.OGImage)
|
||||
card := "summary"
|
||||
if strings.TrimSpace(meta.OGImage) != "" {
|
||||
card = "summary_large_image"
|
||||
}
|
||||
writeEscapedMeta(&b, "name", "twitter:card", card)
|
||||
writeEscapedMeta(&b, "name", "twitter:title", meta.Title)
|
||||
writeEscapedMeta(&b, "name", "twitter:description", meta.Description)
|
||||
writeEscapedMeta(&b, "name", "twitter:image", meta.OGImage)
|
||||
if meta.JSONLD != "" {
|
||||
b.WriteString(`<script type="application/ld+json">`)
|
||||
b.WriteString(meta.JSONLD)
|
||||
b.WriteString(`</script>`)
|
||||
}
|
||||
b.WriteString(`<style>
|
||||
body{font-family:system-ui,sans-serif;line-height:1.6;max-width:800px;margin:24px auto;padding:0 16px;color:#222}
|
||||
a{color:#2d6a4f}img{max-width:100%;height:auto}
|
||||
.meta{color:#666;font-size:14px;margin:8px 0 20px}
|
||||
.nav{margin:32px 0;font-size:14px}
|
||||
</style>`)
|
||||
b.WriteString("</head><body>")
|
||||
b.WriteString(bodyInner)
|
||||
b.WriteString(`<p class="nav"><a href="/">← 返回首页</a></p>`)
|
||||
b.WriteString("</body></html>")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeEscapedTag(b *strings.Builder, tag, text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("<" + tag + ">" + html.EscapeString(text) + "</" + tag + ">")
|
||||
}
|
||||
|
||||
func writeEscapedMeta(b *strings.Builder, attr, key, content string) {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
b.WriteString("<meta " + attr + "=\"" + html.EscapeString(key) + "\" content=\"" + html.EscapeString(content) + "\"/>")
|
||||
}
|
||||
|
||||
func (h *Handlers) botHomeHTML(meta *embed_static.SPAPageMeta, brand service.SiteBranding) string {
|
||||
name := strings.TrimSpace(brand.Name)
|
||||
if name == "" {
|
||||
name = "姜十三论坛"
|
||||
}
|
||||
intro := brand.MetaDescription()
|
||||
if intro == "" {
|
||||
intro = brand.Slogan
|
||||
}
|
||||
var body strings.Builder
|
||||
body.WriteString("<h1>" + html.EscapeString(name) + "</h1>")
|
||||
if intro != "" {
|
||||
body.WriteString("<p>" + html.EscapeString(intro) + "</p>")
|
||||
}
|
||||
body.WriteString(`<p><a href="/projects">浏览项目</a></p>`)
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func (h *Handlers) botPostHTML(base, siteName, defaultImage, keywords string, post *model.Post) string {
|
||||
meta := attachSiteSEO(h.postPageMeta(base, siteName, defaultImage, post), siteName, keywords)
|
||||
content := service.RedactMembersOnlyHTML(post.Content)
|
||||
author := service.DisplayName(&post.User)
|
||||
var body strings.Builder
|
||||
body.WriteString("<article>")
|
||||
body.WriteString("<h1>" + html.EscapeString(post.Title) + "</h1>")
|
||||
body.WriteString(`<p class="meta">`)
|
||||
body.WriteString(html.EscapeString(author))
|
||||
body.WriteString(" · ")
|
||||
body.WriteString(html.EscapeString(post.CreatedAt.Local().Format("2006-01-02 15:04")))
|
||||
if post.Board.Name != "" {
|
||||
body.WriteString(" · ")
|
||||
body.WriteString(html.EscapeString(post.Board.Name))
|
||||
}
|
||||
body.WriteString("</p>")
|
||||
body.WriteString(content)
|
||||
body.WriteString("</article>")
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func (h *Handlers) botUserHTML(base, siteName, defaultImage, keywords string, user *model.User) string {
|
||||
meta := attachSiteSEO(h.userPageMeta(base, siteName, defaultImage, user), siteName, keywords)
|
||||
name := service.DisplayName(user)
|
||||
sig := strings.TrimSpace(user.Signature)
|
||||
var body strings.Builder
|
||||
body.WriteString("<h1>" + html.EscapeString(name) + " 的主页</h1>")
|
||||
if sig != "" {
|
||||
body.WriteString("<p>" + html.EscapeString(sig) + "</p>")
|
||||
}
|
||||
body.WriteString(fmt.Sprintf(`<p class="meta">加入于 %s</p>`, html.EscapeString(user.CreatedAt.Local().Format(time.DateOnly))))
|
||||
return renderBotHTML(meta, body.String())
|
||||
}
|
||||
|
||||
func botNotFoundHTML(base, siteName, keywords, path string) string {
|
||||
meta := notFoundPageMeta(base, siteName, keywords, path)
|
||||
body := `<h1>页面不存在</h1><p>您访问的页面不存在或已删除。</p>`
|
||||
return renderBotHTML(meta, body)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
// ServeImageThumb 帖子图片缩略图(按需生成并缓存)
|
||||
// GET /media/thumb/posts/xxx.jpg → 最长边 1280 的 JPEG 预览
|
||||
// GET /media/thumb/posts/xxx.webp → 最长边 1280 的 WebP 预览
|
||||
func (h *Handlers) ServeImageThumb(c *gin.Context) {
|
||||
rel := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
uploadsRoot := filepath.Join(h.Cfg.DataDir, "uploads")
|
||||
|
||||
@@ -101,12 +101,11 @@ func extractToken(c *gin.Context) string {
|
||||
if token, err := c.Cookie(CookieName); err == nil {
|
||||
return token
|
||||
}
|
||||
return c.Query("token")
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAPI(c *gin.Context) bool {
|
||||
p := c.Request.URL.Path
|
||||
return strings.HasPrefix(p, "/api/") || strings.HasPrefix(p, "/admin/api/")
|
||||
return strings.HasPrefix(c.Request.URL.Path, "/api/")
|
||||
}
|
||||
|
||||
func adminLoginPath(c *gin.Context) string {
|
||||
|
||||
@@ -35,13 +35,19 @@ func InitDB(dbPath string) error {
|
||||
|
||||
if err := db.AutoMigrate(
|
||||
&User{}, &Board{}, &Post{}, &Comment{},
|
||||
&PostLike{}, &PostFavorite{}, &PostRevision{}, &ForumSetting{},
|
||||
&PostLike{}, &PostFavorite{}, &PostRevision{}, &CommentRevision{}, &ForumSetting{},
|
||||
&OAuthClient{}, &OAuthAuthCode{},
|
||||
&GiteaRepo{},
|
||||
&PrivateMessage{}, &PostReport{},
|
||||
&Media{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 存量数据默认视为已公开,避免升级后内容全部进入待审
|
||||
_ = db.Model(&Post{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
|
||||
_ = db.Model(&Comment{}).Where("status = '' OR status IS NULL").Update("status", ContentStatusPublished).Error
|
||||
|
||||
DB = db
|
||||
log.Println("[model] SQLite 数据库初始化完成:", dbPath)
|
||||
return nil
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user