Compare commits
8 Commits
rebuild/gi
...
c6ce44f8b4
| Author | SHA1 | Date | |
|---|---|---|---|
| c6ce44f8b4 | |||
| 429956c594 | |||
| 16b6a18096 | |||
| 95c2154087 | |||
| 0abfb32194 | |||
| bcfd306643 | |||
| 335b708b04 | |||
| a83f8bd670 |
2
Makefile
2
Makefile
@@ -5,7 +5,7 @@ APP_NAME := jiang13
|
|||||||
MAIN_PKG := ./cmd/jiang13
|
MAIN_PKG := ./cmd/jiang13
|
||||||
BUILD_DIR := dist
|
BUILD_DIR := dist
|
||||||
DEV_DATA_DIR := dist/data
|
DEV_DATA_DIR := dist/data
|
||||||
VERSION := 1.0.0
|
VERSION := 1.1.3
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
REGISTRY_IMAGE := hangzhang714128/jiang13-forum
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -196,6 +196,7 @@ docker run -d --name jiang13 \
|
|||||||
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
| `JIANG13_JWT_SECRET` | JWT 密钥(留空则自动生成并写入 `/data/.jwt_secret`) |
|
||||||
| `JIANG13_CONFIG` | 配置文件路径 |
|
| `JIANG13_CONFIG` | 配置文件路径 |
|
||||||
| `JIANG13_WORK_PATH` | 工作目录 |
|
| `JIANG13_WORK_PATH` | 工作目录 |
|
||||||
|
| `JIANG13_COMMUNITY_HUB` | 维护者选项:设为 `1` 时本站作为社区枢纽收报。官方站 `bbs.iioio.com` 会按域名自动成为枢纽,无需此变量;仅非官网的枢纽镜像 / 预发需要显式开启(见 `app.ini.example`) |
|
||||||
|
|
||||||
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
**健康检查:** `GET /health` 返回 `{"status":"ok"}`,供 Docker / 负载均衡探活。
|
||||||
|
|
||||||
@@ -205,15 +206,15 @@ docker run -d --name jiang13 \
|
|||||||
docker login
|
docker login
|
||||||
.\build.bat -Target docker # Windows
|
.\build.bat -Target docker # Windows
|
||||||
# make docker # Linux/macOS
|
# make docker # Linux/macOS
|
||||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
docker push hangzhang714128/jiang13-forum:1.1.3
|
||||||
docker push hangzhang714128/jiang13-forum:latest
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
或直接构建:
|
或直接构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build --build-arg VERSION=1.0.0 -t hangzhang714128/jiang13-forum:1.0.0 -t hangzhang714128/jiang13-forum:latest .
|
docker build --build-arg VERSION=1.1.3 -t hangzhang714128/jiang13-forum:1.1.3 -t hangzhang714128/jiang13-forum:latest .
|
||||||
docker push hangzhang714128/jiang13-forum:1.0.0
|
docker push hangzhang714128/jiang13-forum:1.1.3
|
||||||
docker push hangzhang714128/jiang13-forum:latest
|
docker push hangzhang714128/jiang13-forum:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ JWT_SECRET =
|
|||||||
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
| `--jwt-secret` | 自动生成 | JWT 签名密钥(留空则持久化到 `data/.jwt_secret`) |
|
||||||
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
| `--service` | (空) | `install` / `uninstall` / `start` / `stop` / `restart` / `status` |
|
||||||
|
|
||||||
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`
|
**环境变量(容器 / 编排,优先级低于命令行):** `JIANG13_HTTP_PORT`、`JIANG13_DATA`、`JIANG13_JWT_SECRET`、`JIANG13_CONFIG`、`JIANG13_WORK_PATH`、`JIANG13_COMMUNITY_HUB`(非官网枢纽镜像用;`bbs.iioio.com` 按域名自动为枢纽,见上表)
|
||||||
|
|
||||||
### 5. 注册为系统服务(可选)
|
### 5. 注册为系统服务(可选)
|
||||||
|
|
||||||
@@ -405,13 +406,15 @@ jiang13-forum/
|
|||||||
|
|
||||||
```
|
```
|
||||||
data/
|
data/
|
||||||
├── jiang13.db # SQLite 主数据库
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
├── jiang13.log # 运行日志
|
├── jiang13.log # 运行日志
|
||||||
├── filter_words.txt # 敏感词配置
|
├── filter_words.txt # 敏感词配置
|
||||||
├── .jwt_secret # JWT 密钥(自动生成)
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── logs/access/ # 网站监控请求日志(按日 jsonl)
|
||||||
├── uploads/avatars/ # 用户头像
|
├── uploads/avatars/ # 用户头像
|
||||||
├── uploads/posts/ # 帖子正文图片
|
├── uploads/posts/ # 帖子正文图片
|
||||||
└── jiang13_backup_*.db # 后台导出的备份
|
└── jiang13_backup_*.db # 后台导出的主库备份
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -13,3 +13,13 @@ DATA = data
|
|||||||
[security]
|
[security]
|
||||||
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
; 留空则自动生成并持久化到 data/.jwt_secret(勿把生产密钥提交到仓库)
|
||||||
JWT_SECRET =
|
JWT_SECRET =
|
||||||
|
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; 维护者选项(默认关闭;普通自托管无需开启)
|
||||||
|
; 开启后本站可接收其它实例「自愿向官方演示站」的心跳,并在后台「公网实例」展示。
|
||||||
|
; 官方演示站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启本项。
|
||||||
|
; 本开关仅用于非 bbs.iioio.com 的枢纽镜像 / 预发环境。
|
||||||
|
; 等价环境变量:JIANG13_COMMUNITY_HUB=1
|
||||||
|
; ---------------------------------------------------------------------------
|
||||||
|
; [community]
|
||||||
|
; HUB = false
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ $AppName = 'jiang13'
|
|||||||
$MainPkg = './cmd/jiang13'
|
$MainPkg = './cmd/jiang13'
|
||||||
$BuildDir = 'dist'
|
$BuildDir = 'dist'
|
||||||
$DevDataDir = 'dist/data'
|
$DevDataDir = 'dist/data'
|
||||||
$Version = '1.0.0'
|
$Version = '1.1.3'
|
||||||
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
$RegistryImage = 'hangzhang714128/jiang13-forum'
|
||||||
$Ldlags = "-s -w -X main.version=$Version"
|
$Ldlags = "-s -w -X main.version=$Version"
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ import (
|
|||||||
"github.com/kardianos/service"
|
"github.com/kardianos/service"
|
||||||
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
|
appsvc "git.iioio.com/freefire/jiang13-forum/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
// version 由构建脚本通过 -ldflags "-X main.version=..." 注入
|
||||||
var version = "dev"
|
var version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
appsvc.SetAppVersion(version)
|
||||||
cfg, err := config.Parse()
|
cfg, err := config.Parse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("配置解析失败: %v", err)
|
log.Fatalf("配置解析失败: %v", err)
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ func (p *program) setup() error {
|
|||||||
if err := model.InitDB(cfg.DBPath()); err != nil {
|
if err := model.InitDB(cfg.DBPath()); err != nil {
|
||||||
return fmt.Errorf("数据库初始化失败: %w", err)
|
return fmt.Errorf("数据库初始化失败: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := model.InitMonitorDB(cfg.MonitorDBPath()); err != nil {
|
||||||
|
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
engine, err := router.Setup(cfg)
|
engine, err := router.Setup(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
313
cmd/monitor-geo-check/main.go
Normal file
313
cmd/monitor-geo-check/main.go
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
// 只读诊断:检查 Geo 库与 page_views 地理字段,或对指定 IP 试查中文映射结果。
|
||||||
|
//
|
||||||
|
// go run ./cmd/monitor-geo-check -db data/monitor.db
|
||||||
|
// go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dbPath := flag.String("db", filepath.Join("data", "monitor.db"), "含 page_views 的 SQLite(默认 data/monitor.db)")
|
||||||
|
ipLookup := flag.String("ip", "", "对指定 IP 试查 BIN+ASN(不写库)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
abs, err := filepath.Abs(*dbPath)
|
||||||
|
if err != nil {
|
||||||
|
fatalf("解析路径失败: %v", err)
|
||||||
|
}
|
||||||
|
dataDir := filepath.Dir(abs)
|
||||||
|
|
||||||
|
if *ipLookup == "" {
|
||||||
|
if st, err := os.Stat(abs); err != nil || st.IsDir() {
|
||||||
|
fatalf("数据库不存在: %s", abs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("数据库: %s\n", abs)
|
||||||
|
fmt.Printf("数据目录: %s\n\n", dataDir)
|
||||||
|
|
||||||
|
suite := service.NewGeoIPService(dataDir)
|
||||||
|
defer suite.Close()
|
||||||
|
printGeoFiles(dataDir, suite)
|
||||||
|
|
||||||
|
if ip := strings.TrimSpace(*ipLookup); ip != "" {
|
||||||
|
lookupIP(suite, ip)
|
||||||
|
fmt.Println()
|
||||||
|
if _, err := os.Stat(abs); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(abs), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fatalf("打开数据库失败: %v", err)
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
fatalf("获取连接失败: %v", err)
|
||||||
|
}
|
||||||
|
defer sqlDB.Close()
|
||||||
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
|
||||||
|
if !tableExists(db, "page_views") {
|
||||||
|
fatalf("表 page_views 不存在(请确认指向 data/monitor.db,或尚未写入过浏览量)")
|
||||||
|
}
|
||||||
|
|
||||||
|
have := columnSet(db)
|
||||||
|
printColumnPresence(have)
|
||||||
|
|
||||||
|
since := time.Now().AddDate(0, 0, -30)
|
||||||
|
var total, recentNonBot, withCountry, withRegion, withCity, withASN int64
|
||||||
|
_ = db.Table("page_views").Count(&total).Error
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ?", since, false).Count(&recentNonBot).Error
|
||||||
|
|
||||||
|
fmt.Println("=== page_views 统计(近 30 日非 bot)===")
|
||||||
|
fmt.Printf("总量: %d\n", total)
|
||||||
|
fmt.Printf("近 30 日非 bot: %d\n", recentNonBot)
|
||||||
|
|
||||||
|
if have["country"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND country <> ''", since, false).Count(&withCountry).Error
|
||||||
|
fmt.Printf("有国家: %d\n", withCountry)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有国家: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["region"] || have["region_iso"] {
|
||||||
|
cond := "created_at >= ? AND is_bot = ?"
|
||||||
|
parts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
parts = append(parts, "region <> ''")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
parts = append(parts, "region_iso <> ''")
|
||||||
|
}
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Where(cond+" AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||||
|
Count(&withRegion).Error
|
||||||
|
fmt.Printf("有省字段: %d\n", withRegion)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有省字段: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["city"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND city <> ''", since, false).Count(&withCity).Error
|
||||||
|
fmt.Printf("有城市: %d\n", withCity)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有城市: (列缺失)")
|
||||||
|
}
|
||||||
|
if have["asn"] {
|
||||||
|
_ = db.Table("page_views").Where("created_at >= ? AND is_bot = ? AND asn > 0", since, false).Count(&withASN).Error
|
||||||
|
fmt.Printf("有 ASN: %d\n", withASN)
|
||||||
|
} else {
|
||||||
|
fmt.Println("有 ASN: (列缺失)")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 近 30 日省级 Top(country / region / region_iso)===")
|
||||||
|
if !(have["region"] || have["region_iso"]) {
|
||||||
|
fmt.Println("(省列尚未迁移,跳过)")
|
||||||
|
} else {
|
||||||
|
type regionRow struct {
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
parts := []string{}
|
||||||
|
if have["region"] {
|
||||||
|
parts = append(parts, "region <> ''")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
parts = append(parts, "region_iso <> ''")
|
||||||
|
}
|
||||||
|
selParts := []string{}
|
||||||
|
groupParts := []string{}
|
||||||
|
if have["country"] {
|
||||||
|
selParts = append(selParts, "country")
|
||||||
|
groupParts = append(groupParts, "country")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as country")
|
||||||
|
}
|
||||||
|
if have["region"] {
|
||||||
|
selParts = append(selParts, "region")
|
||||||
|
groupParts = append(groupParts, "region")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as region")
|
||||||
|
}
|
||||||
|
if have["region_iso"] {
|
||||||
|
selParts = append(selParts, "region_iso")
|
||||||
|
groupParts = append(groupParts, "region_iso")
|
||||||
|
} else {
|
||||||
|
selParts = append(selParts, "'' as region_iso")
|
||||||
|
}
|
||||||
|
selParts = append(selParts, "COUNT(*) as count")
|
||||||
|
var regions []regionRow
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(strings.Join(selParts, ", ")).
|
||||||
|
Where("created_at >= ? AND is_bot = ? AND ("+strings.Join(parts, " OR ")+")", since, false).
|
||||||
|
Group(strings.Join(groupParts, ", ")).
|
||||||
|
Order("count DESC").
|
||||||
|
Limit(20).
|
||||||
|
Scan(®ions).Error
|
||||||
|
if len(regions) == 0 {
|
||||||
|
fmt.Println("(空)")
|
||||||
|
} else {
|
||||||
|
for i, r := range regions {
|
||||||
|
fmt.Printf("%2d. %s | %s | %s | %d\n", i+1, r.Country, r.Region, r.RegionISO, r.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
fmt.Println("=== 样例行(近 30 日,最多 10 条)===")
|
||||||
|
cols := []string{"id", "created_at", "path", "is_bot"}
|
||||||
|
for _, c := range []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"} {
|
||||||
|
if have[c] {
|
||||||
|
cols = append(cols, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type sample struct {
|
||||||
|
ID uint
|
||||||
|
CreatedAt time.Time
|
||||||
|
IP string
|
||||||
|
Country string
|
||||||
|
Region string
|
||||||
|
RegionISO string `gorm:"column:region_iso"`
|
||||||
|
City string
|
||||||
|
ASN uint
|
||||||
|
ASOrg string `gorm:"column:as_org"`
|
||||||
|
Path string
|
||||||
|
IsBot bool
|
||||||
|
}
|
||||||
|
var samples []sample
|
||||||
|
_ = db.Table("page_views").
|
||||||
|
Select(strings.Join(cols, ", ")).
|
||||||
|
Where("created_at >= ?", since).
|
||||||
|
Order("id DESC").
|
||||||
|
Limit(10).
|
||||||
|
Scan(&samples).Error
|
||||||
|
if len(samples) == 0 {
|
||||||
|
fmt.Println("(无近 30 日 pageview)")
|
||||||
|
} else {
|
||||||
|
for _, s := range samples {
|
||||||
|
fmt.Printf("#%d %s ip=%s country=%q region=%q iso=%q city=%q asn=%d org=%q bot=%v path=%s\n",
|
||||||
|
s.ID, s.CreatedAt.Format("2006-01-02 15:04:05"), s.IP,
|
||||||
|
s.Country, s.Region, s.RegionISO, s.City, s.ASN, s.ASOrg, s.IsBot, s.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regionReady := have["region"] || have["region_iso"]
|
||||||
|
if !regionReady || withRegion == 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
fmt.Fprintln(os.Stderr, "提示: 近 30 日无省级字段。中国地图「暂无省级访问数据」属预期。")
|
||||||
|
if !regionReady {
|
||||||
|
fmt.Fprintln(os.Stderr, "当前库缺 region/region_iso 列:请先启动一次主程序以完成 AutoMigrate。")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "请确认数据目录已放置 IP2LOCATION-LITE-DB3.BIN 与 GeoLite2-ASN.mmdb,且有公网访问产生的 pageview。")
|
||||||
|
fmt.Fprintln(os.Stderr, "本机/私网 IP 通常解不出省,属正常。")
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printGeoFiles(dataDir string, suite *service.GeoIPService) {
|
||||||
|
fmt.Println("=== Geo 数据文件 ===")
|
||||||
|
v4, v6, asn, country := suite.Paths()
|
||||||
|
for _, item := range []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"IP2LOCATION-LITE-DB3.BIN", v4, suite.BINV4Available()},
|
||||||
|
{"IP2LOCATION-LITE-DB3.IPV6.BIN", v6, suite.BINV6Available()},
|
||||||
|
{"GeoLite2-ASN.mmdb", asn, suite.ASNAvailable()},
|
||||||
|
{"GeoLite2-Country.mmdb", country, suite.CountryAvailable()},
|
||||||
|
} {
|
||||||
|
st, err := os.Stat(item.path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("%s: 不存在\n", item.name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loaded := "未加载"
|
||||||
|
if item.ok {
|
||||||
|
loaded = "已加载"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s: 存在 (%d bytes, mtime %s) · %s\n", item.name, st.Size(), st.ModTime().Format(time.RFC3339), loaded)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupIP(suite *service.GeoIPService, ipStr string) {
|
||||||
|
fmt.Printf("=== IP 试查 %s(中文映射后)===\n", ipStr)
|
||||||
|
info := suite.Lookup(ipStr)
|
||||||
|
if info.Country == "" && info.Region == "" && info.City == "" && info.ASN == 0 {
|
||||||
|
fmt.Println("未解析到地理信息(请确认 BIN/ASN 文件存在且 IP 为公网地址)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("国家 ISO: %s (%s)\n", info.Country, service.CountryLabelZh(info.Country))
|
||||||
|
if info.Region != "" || info.RegionISO != "" {
|
||||||
|
fmt.Printf("省/州: %s / %s\n", info.Region, info.RegionISO)
|
||||||
|
}
|
||||||
|
if info.City != "" {
|
||||||
|
fmt.Printf("城市: %s\n", info.City)
|
||||||
|
}
|
||||||
|
if info.ASN > 0 || info.ASOrg != "" {
|
||||||
|
fmt.Printf("ASN: AS%d %s\n", info.ASN, info.ASOrg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, name string) bool {
|
||||||
|
var n int
|
||||||
|
err := db.Raw(
|
||||||
|
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
|
||||||
|
).Scan(&n).Error
|
||||||
|
return err == nil && n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func columnSet(db *gorm.DB) map[string]bool {
|
||||||
|
type colRow struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
var cols []colRow
|
||||||
|
_ = db.Raw(`PRAGMA table_info(page_views)`).Scan(&cols).Error
|
||||||
|
have := map[string]bool{}
|
||||||
|
for _, c := range cols {
|
||||||
|
have[c.Name] = true
|
||||||
|
}
|
||||||
|
return have
|
||||||
|
}
|
||||||
|
|
||||||
|
func printColumnPresence(have map[string]bool) {
|
||||||
|
need := []string{"ip", "country", "region", "region_iso", "city", "asn", "as_org"}
|
||||||
|
fmt.Println("=== page_views 列 ===")
|
||||||
|
missing := false
|
||||||
|
for _, n := range need {
|
||||||
|
if have[n] {
|
||||||
|
fmt.Printf("%s: 有\n", n)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s: 缺失\n", n)
|
||||||
|
missing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
if missing {
|
||||||
|
fmt.Fprintln(os.Stderr, "提示: 缺列时请先启动一次主程序以完成监控库 AutoMigrate。")
|
||||||
|
fmt.Fprintln(os.Stderr, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fatalf(format string, args ...any) {
|
||||||
|
fmt.Fprintf(os.Stderr, format+"\n", args...)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -32,6 +32,9 @@ type Config struct {
|
|||||||
ServiceAction string
|
ServiceAction string
|
||||||
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
// 开发模式:后端代理前端请求到 Vite 开发服务器(非内嵌静态资源)
|
||||||
DevMode bool
|
DevMode bool
|
||||||
|
// CommunityHub 维护者选项:开启后本站接收其它实例自愿上报(默认关闭)。
|
||||||
|
// 官方站 bbs.iioio.com 会按域名自动识别为枢纽,无需开启;本项仅给非官网枢纽镜像。
|
||||||
|
CommunityHub bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
// Parse 解析命令行、环境变量与 app.ini,并初始化数据目录
|
||||||
@@ -108,6 +111,11 @@ func Parse() (*Config, error) {
|
|||||||
jwtSecret = strings.TrimSpace(*jwtFlag)
|
jwtSecret = strings.TrimSpace(*jwtFlag)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
communityHub := fileCfg.CommunityHub
|
||||||
|
if v := envBoolOrNil(envCommunityHub); v != nil {
|
||||||
|
communityHub = *v
|
||||||
|
}
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
WorkPath: workPath,
|
WorkPath: workPath,
|
||||||
ConfigFile: configFile,
|
ConfigFile: configFile,
|
||||||
@@ -117,6 +125,7 @@ func Parse() (*Config, error) {
|
|||||||
LogFile: filepath.Join(absData, "jiang13.log"),
|
LogFile: filepath.Join(absData, "jiang13.log"),
|
||||||
ServiceAction: action,
|
ServiceAction: action,
|
||||||
DevMode: *devFlag,
|
DevMode: *devFlag,
|
||||||
|
CommunityHub: communityHub,
|
||||||
}
|
}
|
||||||
|
|
||||||
needDirs := action == "" || action == "install"
|
needDirs := action == "" || action == "install"
|
||||||
@@ -184,6 +193,7 @@ func ensureDataDirs(dataDir string) error {
|
|||||||
filepath.Join(dataDir, "uploads", "avatars"),
|
filepath.Join(dataDir, "uploads", "avatars"),
|
||||||
filepath.Join(dataDir, "uploads", "posts"),
|
filepath.Join(dataDir, "uploads", "posts"),
|
||||||
filepath.Join(dataDir, "uploads", "site"),
|
filepath.Join(dataDir, "uploads", "site"),
|
||||||
|
filepath.Join(dataDir, "logs", "access"),
|
||||||
} {
|
} {
|
||||||
if err := os.MkdirAll(sub, 0755); err != nil {
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
return fmt.Errorf("创建上传目录失败: %w", err)
|
return fmt.Errorf("创建上传目录失败: %w", err)
|
||||||
@@ -218,11 +228,16 @@ func validServiceAction(action string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBPath 返回 SQLite 数据库文件路径
|
// DBPath 返回主库 SQLite 路径
|
||||||
func (c *Config) DBPath() string {
|
func (c *Config) DBPath() string {
|
||||||
return filepath.Join(c.DataDir, "jiang13.db")
|
return filepath.Join(c.DataDir, "jiang13.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MonitorDBPath 返回监控独立库路径(page_views)
|
||||||
|
func (c *Config) MonitorDBPath() string {
|
||||||
|
return filepath.Join(c.DataDir, "monitor.db")
|
||||||
|
}
|
||||||
|
|
||||||
// AvatarUploadDir 返回头像上传目录
|
// AvatarUploadDir 返回头像上传目录
|
||||||
func (c *Config) AvatarUploadDir() string {
|
func (c *Config) AvatarUploadDir() string {
|
||||||
return filepath.Join(c.DataDir, "uploads", "avatars")
|
return filepath.Join(c.DataDir, "uploads", "avatars")
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import (
|
|||||||
|
|
||||||
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
|
// 容器 / 编排常用环境变量(优先级:命令行 > 环境变量 > app.ini > 内置默认)
|
||||||
const (
|
const (
|
||||||
envWorkPath = "JIANG13_WORK_PATH"
|
envWorkPath = "JIANG13_WORK_PATH"
|
||||||
envConfig = "JIANG13_CONFIG"
|
envConfig = "JIANG13_CONFIG"
|
||||||
envHTTPPort = "JIANG13_HTTP_PORT"
|
envHTTPPort = "JIANG13_HTTP_PORT"
|
||||||
envData = "JIANG13_DATA"
|
envData = "JIANG13_DATA"
|
||||||
envJWTSecret = "JIANG13_JWT_SECRET"
|
envJWTSecret = "JIANG13_JWT_SECRET"
|
||||||
|
envCommunityHub = "JIANG13_COMMUNITY_HUB"
|
||||||
)
|
)
|
||||||
|
|
||||||
func envOrDefault(key string) string {
|
func envOrDefault(key string) string {
|
||||||
@@ -30,3 +31,21 @@ func envIntOrZero(key string) int {
|
|||||||
}
|
}
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envBoolOrNil 解析布尔环境变量;未设置返回 nil
|
||||||
|
func envBoolOrNil(key string) *bool {
|
||||||
|
v := strings.ToLower(envOrDefault(key))
|
||||||
|
if v == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
t := true
|
||||||
|
return &t
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
f := false
|
||||||
|
return &f
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,9 +18,10 @@ const (
|
|||||||
|
|
||||||
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
// fileSettings 从 app.ini 读出的原始值(尚未解析为绝对路径)
|
||||||
type fileSettings struct {
|
type fileSettings struct {
|
||||||
Port int
|
Port int
|
||||||
DataRel string
|
DataRel string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
CommunityHub bool // 维护者选项:是否作为社区枢纽收报
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultFileSettings() fileSettings {
|
func defaultFileSettings() fileSettings {
|
||||||
@@ -62,6 +63,12 @@ func loadAppINI(path string) (fileSettings, error) {
|
|||||||
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
out.JWTSecret = strings.TrimSpace(sec.Key("JWT_SECRET").String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sec, err := cfg.GetSection("community"); err == nil {
|
||||||
|
if k := sec.Key("HUB"); k.String() != "" {
|
||||||
|
out.CommunityHub = k.MustBool(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
105
docs/docker-data.md
Normal file
105
docs/docker-data.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Docker 数据持久化说明
|
||||||
|
|
||||||
|
Docker 部署时**默认只挂载一个目录**,不是分别映射「数据库文件夹」和「附件文件夹」两个卷。
|
||||||
|
|
||||||
|
## 挂载对应关系
|
||||||
|
|
||||||
|
| 宿主机 | 容器内 |
|
||||||
|
|--------|--------|
|
||||||
|
| 任意目录或 named volume(如 `jiang13-data`) | **`/data`** |
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name jiang13 \
|
||||||
|
-p 3000:3000 \
|
||||||
|
-v /你的路径/jiang13-data:/data \
|
||||||
|
--restart unless-stopped \
|
||||||
|
hangzhang714128/jiang13-forum:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
或在 `docker-compose.yml` 中:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
- jiang13-data:/data
|
||||||
|
# 或绑定宿主机目录:
|
||||||
|
# - ./data:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
## `/data` 目录结构
|
||||||
|
|
||||||
|
数据库与附件都在同一个 `/data` 挂载内:
|
||||||
|
|
||||||
|
```
|
||||||
|
/data/
|
||||||
|
├── jiang13.db # SQLite 主数据库(不含浏览量)
|
||||||
|
├── monitor.db # 网站监控 page_views
|
||||||
|
├── uploads/ # 附件根目录
|
||||||
|
│ ├── avatars/ # 用户头像
|
||||||
|
│ └── posts/ # 帖子图片等
|
||||||
|
├── logs/
|
||||||
|
│ └── access/ # 网站监控请求日志(按日 jsonl)
|
||||||
|
│ └── YYYY-MM-DD.jsonl
|
||||||
|
├── IP2LOCATION-LITE-DB3.BIN # 可选:IPv4 城市库(监控)
|
||||||
|
├── IP2LOCATION-LITE-DB3.IPV6.BIN # 可选:IPv6 城市库
|
||||||
|
├── GeoLite2-ASN.mmdb # 可选:ASN/运营商
|
||||||
|
├── GeoLite2-Country.mmdb # 可选:国家兜底
|
||||||
|
├── .jwt_secret # JWT 密钥(自动生成)
|
||||||
|
├── filter_words.txt # 敏感词配置
|
||||||
|
└── jiang13.log # 运行日志(若启用)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 主数据库 | `/data/jiang13.db` |
|
||||||
|
| 监控浏览量 | `/data/monitor.db` |
|
||||||
|
| 附件 | `/data/uploads/` |
|
||||||
|
| 请求日志(监控) | `/data/logs/access/` |
|
||||||
|
| 地理库(可选) | `/data/IP2LOCATION-LITE-DB3*.BIN`、`GeoLite2-ASN.mmdb` 等 |
|
||||||
|
|
||||||
|
官方镜像不要求拆成两个 volume;挂好 `/data` 即可同时持久化 SQLite、上传文件与监控日志。
|
||||||
|
|
||||||
|
地理库与 BIN **不内置**于 Docker 镜像。详见 [网站监控设计](monitor.md)。管理端展示完整客户端 IP(不做脱敏)。
|
||||||
|
|
||||||
|
## 设计说明:为何只挂一个 `/data`
|
||||||
|
|
||||||
|
对本项目(单容器、SQLite、默认可本地存附件)而言,**一个数据卷是合理默认**:
|
||||||
|
|
||||||
|
- 部署成本最低,不易漏挂导致「重启丢图 / 丢库」
|
||||||
|
- 备份、迁移时拷贝或快照整个 `/data` 即可恢复站点状态
|
||||||
|
- 密钥(`.jwt_secret`)、敏感词与库同目录,避免「库还在、登录全失效」这类半残状态
|
||||||
|
|
||||||
|
若你习惯「数据库文件夹 / 附件文件夹」分开填写:在本镜像中它们分别是 **`/data` 下的文件与子目录**,不是两个独立容器路径。
|
||||||
|
|
||||||
|
附件特别大、希望对象存储时:优先使用管理后台的 **S3 兼容存储**,而不是拆 Docker 卷。
|
||||||
|
|
||||||
|
## 后续计划(可选增强,非必须)
|
||||||
|
|
||||||
|
按优先级,仅在有明确需求时做:
|
||||||
|
|
||||||
|
1. **文档与面板提示(优先)**
|
||||||
|
- README / 1Panel 说明中固定话术:「只需挂载 `/data`;数据库=`jiang13.db`,监控=`monitor.db`,附件=`uploads/`」。
|
||||||
|
- 本页保持为权威说明,避免用户误以为漏了一个卷。
|
||||||
|
|
||||||
|
2. **进阶:同一 DataDir 下拆挂子路径(文档级,无需改代码)**
|
||||||
|
若必须把库与附件分到不同宿主机磁盘,可在仍使用容器 `/data` 的前提下分别绑定,例如:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
volumes:
|
||||||
|
# 推荐:父目录一次挂载
|
||||||
|
- /ssd/jiang13:/data
|
||||||
|
# 或仅把附件分到大容量盘(需保证 /data 下其余文件仍可写)
|
||||||
|
- /ssd/jiang13:/data
|
||||||
|
- /hdd/jiang13-uploads:/data/uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
拆挂时仍要保证 `/data` 下 `.jwt_secret` 等文件可写;不熟悉 Docker 时请继续只用单个 `/data` 挂载。
|
||||||
|
|
||||||
|
3. **产品级:可配置上传根目录(中期)**
|
||||||
|
- 环境变量或 `app.ini` 支持 `upload_dir` 与 `data_dir` 分离。
|
||||||
|
- Docker 示例改为可选双卷;**默认仍单卷**,避免破坏现有部署。
|
||||||
|
|
||||||
|
4. **不做的事**
|
||||||
|
- 不为「看起来像 WordPress」而强制双卷。
|
||||||
|
- 不在未支持分离配置前,在 UI 里假装有两个独立数据根。
|
||||||
76
docs/monitor.md
Normal file
76
docs/monitor.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 网站监控设计说明
|
||||||
|
|
||||||
|
管理端「网站监控」:轻量自建访问统计,对标 1Panel 思路——**请求流水写文件、访客地理写独立库**,开启后不拖慢前台读帖。
|
||||||
|
|
||||||
|
## 双通道数据
|
||||||
|
|
||||||
|
| 数据 | 存储 | 用途 |
|
||||||
|
|------|------|------|
|
||||||
|
| 请求日志 | `{DataDir}/logs/access/YYYY-MM-DD.jsonl` | 请求日志页、请求数/流量/4xx·5xx、实时曲线 |
|
||||||
|
| page_views | `{DataDir}/monitor.db`(SQLite) | 浏览量/访客、国家/省/城市/运营商排行与地图 |
|
||||||
|
|
||||||
|
请求日志**只写 jsonl**,不进 SQLite。删除过期 `.jsonl` 即释放磁盘。
|
||||||
|
|
||||||
|
`page_views` **不进主库** `jiang13.db`。后台「导出备份」只含主库,不含浏览量。
|
||||||
|
|
||||||
|
### 文件行格式(JSONL)
|
||||||
|
|
||||||
|
每行一条 JSON,字段包括:`t`(RFC3339)、`method`、`path`、`status`、`bytes`、`duration_ms`、`ip`、`ua`、`referer`、`country`、`region`、`region_iso`、`city`、`asn`、`as_org`、`is_bot`。
|
||||||
|
|
||||||
|
### 保留天数
|
||||||
|
|
||||||
|
- `monitor_retention_days`:仅 **page_views**(默认 30)
|
||||||
|
- `monitor_access_log_retention_days`:请求日志文件(默认 7)
|
||||||
|
|
||||||
|
## 地理数据文件
|
||||||
|
|
||||||
|
放在数据目录(与 `jiang13.db` 同级),有则加载、无则静默降级:
|
||||||
|
|
||||||
|
| 文件 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `IP2LOCATION-LITE-DB3.BIN` | IPv4 国家/省/城市 |
|
||||||
|
| `IP2LOCATION-LITE-DB3.IPV6.BIN` | IPv6 国家/省/城市 |
|
||||||
|
| `GeoLite2-ASN.mmdb` | ASN / 运营商 |
|
||||||
|
| `GeoLite2-Country.mmdb` | 可选:BIN 未命中时国家兜底 |
|
||||||
|
|
||||||
|
CDN 头(如 `CF-IPCountry`)仅在本地库无国家码时补全。不落 Lat/Lon/Zip/TimeZone。管理端展示**完整客户端 IP**。
|
||||||
|
|
||||||
|
写入时做本地中文映射(省/常见城市/运营商);`country` 存 ISO2,展示用中文名。
|
||||||
|
|
||||||
|
## 写入路径(性能)
|
||||||
|
|
||||||
|
1. 中间件:监控关闭或命中排除规则则直接放行;否则 `c.Next()` 后**仅入队**轻量字段(不查 BIN/ASN、不写盘)。
|
||||||
|
2. 后台 flush:出队 → Geo + 中文映射 → append 当日 jsonl + 更新内存实时环。
|
||||||
|
3. 队列上限 8192,满则丢弃新日志(保护内存与磁盘)。
|
||||||
|
4. pageview 信标 QPS 低,可在写入时同步 Geo 后写独立 `monitor.db`(不占用主库连接)。
|
||||||
|
|
||||||
|
**热路径零 Geo、零写库、零写文件。**
|
||||||
|
|
||||||
|
## 请求日志口径(排除规则)
|
||||||
|
|
||||||
|
默认只关心**前台访客**,排除管理员刷后台产生的壳请求:
|
||||||
|
|
||||||
|
- `/admin`、`/api/admin/`
|
||||||
|
- `/api/me`、`/api/site-branding`
|
||||||
|
- `/health`、`/uploads/`、`/media/`、静态后缀、`/api/monitor/pageview` 等
|
||||||
|
|
||||||
|
设置页可「恢复推荐排除规则」。不强制覆盖用户已保存的自定义列表。
|
||||||
|
|
||||||
|
浏览量/访客仍来自前台路由 pageview(后台路径本身不上报)。
|
||||||
|
|
||||||
|
## 实时与聚合
|
||||||
|
|
||||||
|
- 近 1 分钟 / 近 1 小时:内存环(flush 时更新),避免扫全文件。
|
||||||
|
- 今日请求数等:内存日累加器(进程内;重启后自新流量累积,论坛量级可接受)。
|
||||||
|
- 地理排行:仅 `page_views`。
|
||||||
|
|
||||||
|
## 诊断
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./cmd/monitor-geo-check -db data/monitor.db -ip 14.109.35.246
|
||||||
|
```
|
||||||
|
|
||||||
|
## 明确不做
|
||||||
|
|
||||||
|
- 不用 CIDR;不把请求日志写入 SQLite;不把 page_views 写入主库
|
||||||
|
- 不做街道级地图;Docker 镜像不内置 BIN/MMDB
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -18,6 +18,8 @@
|
|||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
|
"@svg-maps/china": "^2.0.0",
|
||||||
|
"@svg-maps/world": "^2.0.0",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tiptap/core": "^3.26.1",
|
"@tiptap/core": "^3.26.1",
|
||||||
"@tiptap/extension-code-block": "^3.26.1",
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
@@ -1971,6 +1973,18 @@
|
|||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@svg-maps/china": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svg-maps/china/-/china-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-/B0a4hYJHRtfcUG+x6BudQFWhLQyPoaN2WbqfdEeWeXnwef6oXaE93Msrq2odu72c09vE+G4Ds9+n60Qsr4p1g==",
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
|
},
|
||||||
|
"node_modules/@svg-maps/world": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@svg-maps/world/-/world-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VmYYnyygJ5Zhr48xPqukFBoq4aCBgVNIjBzqBnWKXSdHhrRK3pntdvbCa8lMsxwVCFzEw9a+smIMEGI2sC1xcQ==",
|
||||||
|
"license": "CC-BY-4.0"
|
||||||
|
},
|
||||||
"node_modules/@tanstack/react-virtual": {
|
"node_modules/@tanstack/react-virtual": {
|
||||||
"version": "3.14.2",
|
"version": "3.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.2.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
"@radix-ui/react-label": "^2.1.9",
|
"@radix-ui/react-label": "^2.1.9",
|
||||||
"@radix-ui/react-slot": "^1.2.5",
|
"@radix-ui/react-slot": "^1.2.5",
|
||||||
"@radix-ui/react-switch": "^1.3.0",
|
"@radix-ui/react-switch": "^1.3.0",
|
||||||
|
"@svg-maps/china": "^2.0.0",
|
||||||
|
"@svg-maps/world": "^2.0.0",
|
||||||
"@tanstack/react-virtual": "^3.11.2",
|
"@tanstack/react-virtual": "^3.11.2",
|
||||||
"@tiptap/core": "^3.26.1",
|
"@tiptap/core": "^3.26.1",
|
||||||
"@tiptap/extension-code-block": "^3.26.1",
|
"@tiptap/extension-code-block": "^3.26.1",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const FavoritesPage = lazyWithRetry(() => import('./pages/FavoritesPage'));
|
|||||||
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
|
const MessagesPage = lazyWithRetry(() => import('./pages/MessagesPage'));
|
||||||
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
|
const ProjectsPage = lazyWithRetry(() => import('./pages/ProjectsPage'));
|
||||||
const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
|
const LinksPage = lazyWithRetry(() => import('./pages/LinksPage'));
|
||||||
|
const ShowcasePage = lazyWithRetry(() => import('./pages/ShowcasePage'));
|
||||||
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
|
const AdminDashboardPage = lazyWithRetry(() => import('./pages/admin/AdminDashboardPage'));
|
||||||
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
|
const AdminPostsPage = lazyWithRetry(() => import('./pages/admin/AdminPostsPage'));
|
||||||
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
|
const AdminCommentsPage = lazyWithRetry(() => import('./pages/admin/AdminCommentsPage'));
|
||||||
@@ -39,11 +40,13 @@ const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsP
|
|||||||
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
|
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
|
||||||
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
|
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
|
||||||
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
||||||
|
const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage'));
|
||||||
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
|
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
|
||||||
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
|
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
|
||||||
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
|
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
|
||||||
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
|
const SitePageView = lazyWithRetry(() => import('./pages/SitePageView'));
|
||||||
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
const AdminSettingsPage = lazyWithRetry(() => import('./pages/admin/AdminSettingsPage'));
|
||||||
|
const AdminCommunityPage = lazyWithRetry(() => import('./pages/admin/AdminCommunityPage'));
|
||||||
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
|
const NotFoundPage = lazyWithRetry(() => import('./pages/NotFoundPage'));
|
||||||
|
|
||||||
const router = createBrowserRouter(
|
const router = createBrowserRouter(
|
||||||
@@ -61,12 +64,14 @@ const router = createBrowserRouter(
|
|||||||
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
|
<Route path="pages/:id/edit" element={<Suspense fallback={<PageLoader />}><AdminSitePageEditPage /></Suspense>} />
|
||||||
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
|
<Route path="pages" element={<Suspense fallback={<PageLoader />}><AdminPagesPage /></Suspense>} />
|
||||||
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
|
<Route path="links" element={<Suspense fallback={<PageLoader />}><AdminLinksPage /></Suspense>} />
|
||||||
|
<Route path="community" element={<Suspense fallback={<PageLoader />}><AdminCommunityPage /></Suspense>} />
|
||||||
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
<Route path="posts" element={<Suspense fallback={<PageLoader />}><AdminPostsPage /></Suspense>} />
|
||||||
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
|
<Route path="comments" element={<Suspense fallback={<PageLoader />}><AdminCommentsPage /></Suspense>} />
|
||||||
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
|
<Route path="reports" element={<Suspense fallback={<PageLoader />}><AdminReportsPage /></Suspense>} />
|
||||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
||||||
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
|
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
|
||||||
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
|
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
|
||||||
|
<Route path="monitor" element={<Suspense fallback={<PageLoader />}><AdminMonitorPage /></Suspense>} />
|
||||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
|
||||||
</Route>
|
</Route>
|
||||||
@@ -82,6 +87,7 @@ const router = createBrowserRouter(
|
|||||||
<Route path="/favorites" element={<FavoritesPage />} />
|
<Route path="/favorites" element={<FavoritesPage />} />
|
||||||
<Route path="/projects" element={<ProjectsPage />} />
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/links" element={<LinksPage />} />
|
<Route path="/links" element={<LinksPage />} />
|
||||||
|
<Route path="/showcase" element={<Suspense fallback={<PageLoader />}><ShowcasePage /></Suspense>} />
|
||||||
<Route path="/messages" element={<MessagesPage />} />
|
<Route path="/messages" element={<MessagesPage />} />
|
||||||
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
|
<Route path="/page/:slug" element={<Suspense fallback={<PageLoader />}><SitePageView /></Suspense>} />
|
||||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage /></Suspense>} />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply } from './types';
|
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem, MonitorConfig, MonitorOverview, MonitorGeoResult, MonitorStatItem, MonitorRealtime, MonitorLogItem } from './types';
|
||||||
|
|
||||||
const BASE = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -25,6 +25,27 @@ export const api = {
|
|||||||
me: () => request<{ user: User | null }>('/api/me'),
|
me: () => request<{ user: User | null }>('/api/me'),
|
||||||
stats: () => request<ForumStats>('/api/stats'),
|
stats: () => request<ForumStats>('/api/stats'),
|
||||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||||
|
/** 前台路由 pageview;失败静默(204 无 body) */
|
||||||
|
monitorPageview: (body: { path: string; referrer?: string }) => {
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||||
|
const blob = new Blob([payload], { type: 'application/json' });
|
||||||
|
if (navigator.sendBeacon('/api/monitor/pageview', blob)) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* 回退 fetch */
|
||||||
|
}
|
||||||
|
return fetch('/api/monitor/pageview', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'omit',
|
||||||
|
keepalive: true,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: payload,
|
||||||
|
}).then(() => undefined).catch(() => undefined);
|
||||||
|
},
|
||||||
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
||||||
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
|
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
|
||||||
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
|
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
|
||||||
@@ -63,6 +84,59 @@ export const api = {
|
|||||||
// 管理后台 API
|
// 管理后台 API
|
||||||
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
|
adminDashboard: () => request<AdminDashboard>('/api/admin/dashboard'),
|
||||||
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
|
adminSettings: () => request<AdminSettings>('/api/admin/settings'),
|
||||||
|
adminUpdateCommunitySettings: (body: CommunityConfig) =>
|
||||||
|
request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminMonitorSettings: () => request<MonitorConfig>('/api/admin/settings/monitor'),
|
||||||
|
adminUpdateMonitorSettings: (body: MonitorConfig) =>
|
||||||
|
request<{ message: string; monitor: MonitorConfig }>('/api/admin/settings/monitor', {
|
||||||
|
method: 'PUT', body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
adminMonitorOverview: () => request<MonitorOverview>('/api/admin/monitor/overview'),
|
||||||
|
adminMonitorGeo: (range = '30d') =>
|
||||||
|
request<MonitorGeoResult>(`/api/admin/monitor/geo?range=${encodeURIComponent(range)}`),
|
||||||
|
adminMonitorStats: (dim: string, range = '30d') =>
|
||||||
|
request<{ dim: string; range: string; items: MonitorStatItem[] }>(
|
||||||
|
`/api/admin/monitor/stats?dim=${encodeURIComponent(dim)}&range=${encodeURIComponent(range)}`,
|
||||||
|
),
|
||||||
|
adminMonitorLogs: (params: { page?: number; size?: number; method?: string; path?: string; status?: string; ip?: string }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (params.page) q.set('page', String(params.page));
|
||||||
|
if (params.size) q.set('size', String(params.size));
|
||||||
|
if (params.method) q.set('method', params.method);
|
||||||
|
if (params.path) q.set('path', params.path);
|
||||||
|
if (params.status) q.set('status', params.status);
|
||||||
|
if (params.ip) q.set('ip', params.ip);
|
||||||
|
const qs = q.toString();
|
||||||
|
return request<{ items: MonitorLogItem[]; total: number; page: number; size: number }>(
|
||||||
|
`/api/admin/monitor/logs${qs ? `?${qs}` : ''}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
adminMonitorRealtime: () => request<MonitorRealtime>('/api/admin/monitor/realtime'),
|
||||||
|
adminCommunityInstances: () =>
|
||||||
|
request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'),
|
||||||
|
adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) =>
|
||||||
|
request<{ message: string; instance: CommunityInstance }>(
|
||||||
|
`/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`,
|
||||||
|
{ method: 'PUT', body: JSON.stringify(body) },
|
||||||
|
),
|
||||||
|
adminUpdateShowcaseEntry: (body: {
|
||||||
|
nav_show_showcase?: boolean;
|
||||||
|
footer_show_showcase?: boolean;
|
||||||
|
aside_show_showcase?: boolean;
|
||||||
|
}) =>
|
||||||
|
request<{
|
||||||
|
message: string;
|
||||||
|
nav_show_showcase: boolean;
|
||||||
|
footer_show_showcase: boolean;
|
||||||
|
aside_show_showcase: boolean;
|
||||||
|
}>('/api/admin/community/showcase-entry', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
communityShowcase: () =>
|
||||||
|
request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'),
|
||||||
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
|
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
if (params.page) q.set('page', String(params.page));
|
if (params.page) q.set('page', String(params.page));
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ export interface Comment {
|
|||||||
reply_target?: Comment;
|
reply_target?: Comment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AdminDashboardTraffic {
|
||||||
|
enabled: boolean;
|
||||||
|
today_pv: number;
|
||||||
|
today_uv: number;
|
||||||
|
yesterday_pv: number;
|
||||||
|
total_pv: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminDashboard {
|
export interface AdminDashboard {
|
||||||
users: number;
|
users: number;
|
||||||
posts: number;
|
posts: number;
|
||||||
@@ -189,9 +197,10 @@ export interface AdminDashboard {
|
|||||||
pending_reports?: number;
|
pending_reports?: number;
|
||||||
pending_friend_links?: number;
|
pending_friend_links?: number;
|
||||||
recent_posts: PostItem[];
|
recent_posts: PostItem[];
|
||||||
|
traffic?: AdminDashboardTraffic;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links';
|
export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links' | 'showcase';
|
||||||
|
|
||||||
export interface AsideWidget {
|
export interface AsideWidget {
|
||||||
id: AsideWidgetId;
|
id: AsideWidgetId;
|
||||||
@@ -203,6 +212,7 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
|
|||||||
{ id: 'recent_comments', enabled: false },
|
{ id: 'recent_comments', enabled: false },
|
||||||
{ id: 'recent_users', enabled: false },
|
{ id: 'recent_users', enabled: false },
|
||||||
{ id: 'friend_links', enabled: true },
|
{ id: 'friend_links', enabled: true },
|
||||||
|
{ id: 'showcase', enabled: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
export interface ForumLimits {
|
export interface ForumLimits {
|
||||||
@@ -231,12 +241,18 @@ export interface ForumLimits {
|
|||||||
aside_show_recent_comments: boolean;
|
aside_show_recent_comments: boolean;
|
||||||
/** 右侧栏友情链接 */
|
/** 右侧栏友情链接 */
|
||||||
aside_show_friend_links: boolean;
|
aside_show_friend_links: boolean;
|
||||||
|
/** 右侧栏开源展柜 */
|
||||||
|
aside_show_showcase: boolean;
|
||||||
/** 右侧栏可选组件顺序与开关 */
|
/** 右侧栏可选组件顺序与开关 */
|
||||||
aside_widgets: AsideWidget[];
|
aside_widgets: AsideWidget[];
|
||||||
/** 左侧栏「站点」展示友情链接入口 */
|
/** 左侧栏「站点」展示友情链接入口 */
|
||||||
nav_show_friend_links: boolean;
|
nav_show_friend_links: boolean;
|
||||||
/** 页脚展示友情链接入口 */
|
/** 页脚展示友情链接入口 */
|
||||||
footer_show_friend_links: boolean;
|
footer_show_friend_links: boolean;
|
||||||
|
/** 左侧栏「站点」展示开源展柜入口 */
|
||||||
|
nav_show_showcase: boolean;
|
||||||
|
/** 页脚展示开源展柜入口 */
|
||||||
|
footer_show_showcase: boolean;
|
||||||
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
|
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
|
||||||
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
||||||
/** 伪静态(固定链接)开关 */
|
/** 伪静态(固定链接)开关 */
|
||||||
@@ -262,12 +278,17 @@ export interface ForumLimitsPublic {
|
|||||||
aside_show_tag_cloud: boolean;
|
aside_show_tag_cloud: boolean;
|
||||||
aside_show_recent_comments: boolean;
|
aside_show_recent_comments: boolean;
|
||||||
aside_show_friend_links: boolean;
|
aside_show_friend_links: boolean;
|
||||||
|
aside_show_showcase: boolean;
|
||||||
aside_widgets: AsideWidget[];
|
aside_widgets: AsideWidget[];
|
||||||
nav_show_friend_links: boolean;
|
nav_show_friend_links: boolean;
|
||||||
footer_show_friend_links: boolean;
|
footer_show_friend_links: boolean;
|
||||||
|
nav_show_showcase: boolean;
|
||||||
|
footer_show_showcase: boolean;
|
||||||
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
||||||
permalink_enabled: boolean;
|
permalink_enabled: boolean;
|
||||||
permalink_ext: string;
|
permalink_ext: string;
|
||||||
|
/** 是否上报前台路由 pageview(与后台监控采集开关同步) */
|
||||||
|
monitor_pageview: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FriendLink {
|
export interface FriendLink {
|
||||||
@@ -376,10 +397,148 @@ export interface AdminSettings {
|
|||||||
gitea?: GiteaSyncConfig;
|
gitea?: GiteaSyncConfig;
|
||||||
storage?: StorageConfig;
|
storage?: StorageConfig;
|
||||||
branding?: SiteBranding;
|
branding?: SiteBranding;
|
||||||
|
community?: CommunityConfig;
|
||||||
filter_words: string;
|
filter_words: string;
|
||||||
filter_word_count: number;
|
filter_word_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 社区上报配置(hub_url / site_url 只读) */
|
||||||
|
export interface CommunityConfig {
|
||||||
|
report_enabled: boolean;
|
||||||
|
/** 只读:是否作为社区枢纽 */
|
||||||
|
hub_enabled: boolean;
|
||||||
|
/** 只读:固定为官方演示站 */
|
||||||
|
hub_url: string;
|
||||||
|
/** 只读:服务端自动推断的本站地址 */
|
||||||
|
site_url: string;
|
||||||
|
instance_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 网站监控设置 */
|
||||||
|
export interface MonitorConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
retention_days: number;
|
||||||
|
access_log_retention_days: number;
|
||||||
|
exclude_rules: string[];
|
||||||
|
default_exclude_rules?: string[];
|
||||||
|
trust_proxy: boolean;
|
||||||
|
access_log_dir?: string;
|
||||||
|
ip2location_v4_path?: string;
|
||||||
|
ip2location_v6_path?: string;
|
||||||
|
ip2location_v4_available?: boolean;
|
||||||
|
ip2location_v6_available?: boolean;
|
||||||
|
geoip_available: boolean;
|
||||||
|
geoip_country_path?: string;
|
||||||
|
geoip_asn_path?: string;
|
||||||
|
geoip_country_available?: boolean;
|
||||||
|
geoip_asn_available?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorOverview {
|
||||||
|
enabled: boolean;
|
||||||
|
pageviews: number;
|
||||||
|
visitors: number;
|
||||||
|
unique_ips: number;
|
||||||
|
traffic: number;
|
||||||
|
bots: number;
|
||||||
|
requests: number;
|
||||||
|
status_4xx: number;
|
||||||
|
status_5xx: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorGeoItem {
|
||||||
|
country: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorRegionItem {
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
region_iso: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorCityItem {
|
||||||
|
country: string;
|
||||||
|
region: string;
|
||||||
|
city: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorASNItem {
|
||||||
|
asn: number;
|
||||||
|
as_org: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorGeoResult {
|
||||||
|
range: string;
|
||||||
|
countries: MonitorGeoItem[];
|
||||||
|
regions: MonitorRegionItem[];
|
||||||
|
cities: MonitorCityItem[];
|
||||||
|
asns: MonitorASNItem[];
|
||||||
|
has_data: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorStatItem {
|
||||||
|
key: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorRealtimePoint {
|
||||||
|
minute: string;
|
||||||
|
count: number;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorRealtime {
|
||||||
|
enabled: boolean;
|
||||||
|
requests_1m: number;
|
||||||
|
traffic_1m: number;
|
||||||
|
hourly_series: MonitorRealtimePoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitorLogItem {
|
||||||
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
status: number;
|
||||||
|
bytes: number;
|
||||||
|
duration_ms: number;
|
||||||
|
ip: string;
|
||||||
|
ua: string;
|
||||||
|
referer: string;
|
||||||
|
country: string;
|
||||||
|
region?: string;
|
||||||
|
city?: string;
|
||||||
|
asn?: number;
|
||||||
|
as_org?: string;
|
||||||
|
is_bot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommunityInstance {
|
||||||
|
instance_id: string;
|
||||||
|
site_url: string;
|
||||||
|
site_name: string;
|
||||||
|
version: string;
|
||||||
|
users: number;
|
||||||
|
posts: number;
|
||||||
|
first_seen_at: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
online: boolean;
|
||||||
|
featured: boolean;
|
||||||
|
featured_note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 公开展柜条目 */
|
||||||
|
export interface CommunityShowcaseItem {
|
||||||
|
site_url: string;
|
||||||
|
site_name: string;
|
||||||
|
version: string;
|
||||||
|
featured_note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StorageConfig {
|
export interface StorageConfig {
|
||||||
type: 'local' | 's3';
|
type: 'local' | 's3';
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -32,6 +31,8 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -144,6 +145,11 @@ function CommentItem({
|
|||||||
&& !!onApprove;
|
&& !!onApprove;
|
||||||
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
|
const showEdited = !hidden && !!c.updated_at && isTimeDiffSignificant(c.created_at, c.updated_at);
|
||||||
const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser);
|
const canReport = !hidden && !isEditing && !isCommentAuthor(c, currentUser);
|
||||||
|
const showHistory = isAdmin && showEdited;
|
||||||
|
const showManageMenu = !hidden && !isEditing && (
|
||||||
|
canApprove || canEdit || showHistory || canDelete || canReport
|
||||||
|
);
|
||||||
|
const showManageGroup = canApprove || canEdit || showHistory || canDelete;
|
||||||
const [editText, setEditText] = useState(c.content);
|
const [editText, setEditText] = useState(c.content);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -152,6 +158,7 @@ function CommentItem({
|
|||||||
const [liked, setLiked] = useState(!!c.liked);
|
const [liked, setLiked] = useState(!!c.liked);
|
||||||
const [likeCount, setLikeCount] = useState(c.like_count ?? 0);
|
const [likeCount, setLikeCount] = useState(c.like_count ?? 0);
|
||||||
const [revOpen, setRevOpen] = useState(false);
|
const [revOpen, setRevOpen] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||||
const [reportDetail, setReportDetail] = useState('');
|
const [reportDetail, setReportDetail] = useState('');
|
||||||
@@ -333,24 +340,6 @@ function CommentItem({
|
|||||||
{c.reply_target && (
|
{c.reply_target && (
|
||||||
<span className="waline-reply-at">@{commentNick(c.reply_target)}</span>
|
<span className="waline-reply-at">@{commentNick(c.reply_target)}</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 && !!renderReplyBox && (
|
{!hidden && !isEditing && !!renderReplyBox && (
|
||||||
isReplying ? (
|
isReplying ? (
|
||||||
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
<button type="button" className="waline-comment-reply-btn cancel" onClick={onCancelReply}>
|
||||||
@@ -366,12 +355,6 @@ function CommentItem({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{!hidden && !isEditing && canEdit && (
|
|
||||||
<button type="button" className="waline-comment-reply-btn" onClick={() => onStartEdit(c)}>
|
|
||||||
<Pencil size={14} />
|
|
||||||
编辑
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{bountyAward?.open && bountyAward.canAward && c.user_id !== bountyAward.postAuthorId
|
{bountyAward?.open && bountyAward.canAward && c.user_id !== bountyAward.postAuthorId
|
||||||
&& c.status === 'published' && !hidden && (
|
&& c.status === 'published' && !hidden && (
|
||||||
<button
|
<button
|
||||||
@@ -383,46 +366,7 @@ function CommentItem({
|
|||||||
采纳
|
采纳
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{!hidden && !isEditing && isAdmin && showEdited && (
|
{showManageMenu && (
|
||||||
<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}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
删除
|
|
||||||
</button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={async () => {
|
|
||||||
setDeleting(true);
|
|
||||||
try {
|
|
||||||
await onDelete(c);
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
|
||||||
{canReport && (
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -433,19 +377,98 @@ function CommentItem({
|
|||||||
<MoreHorizontal size={14} />
|
<MoreHorizontal size={14} />
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="center" className="report-more-menu">
|
<DropdownMenuContent align="end" className="comment-manage-menu">
|
||||||
<DropdownMenuItem
|
{showManageGroup && (
|
||||||
className="report-more-menu__item"
|
<>
|
||||||
onSelect={openReport}
|
<DropdownMenuLabel>管理</DropdownMenuLabel>
|
||||||
>
|
{canApprove && (
|
||||||
<Flag size={14} />
|
<DropdownMenuItem
|
||||||
举报
|
disabled={approving}
|
||||||
</DropdownMenuItem>
|
onSelect={() => {
|
||||||
|
void (async () => {
|
||||||
|
setApproving(true);
|
||||||
|
try {
|
||||||
|
await onApprove?.(c);
|
||||||
|
} finally {
|
||||||
|
setApproving(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check size={14} aria-hidden />
|
||||||
|
{approving ? '通过中…' : '通过审核'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<DropdownMenuItem onSelect={() => onStartEdit(c)}>
|
||||||
|
<Pencil size={14} aria-hidden />
|
||||||
|
编辑
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{showHistory && (
|
||||||
|
<DropdownMenuItem onSelect={() => setRevOpen(true)}>
|
||||||
|
<History size={14} aria-hidden />
|
||||||
|
编辑记录
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onSelect={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden />
|
||||||
|
删除
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{canReport && <DropdownMenuSeparator />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{canReport && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onSelect={openReport}
|
||||||
|
>
|
||||||
|
<Flag size={14} aria-hidden />
|
||||||
|
举报
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确定删除该评论?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
将同时移入回收站其下所有回复,可在后台恢复或永久删除。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void (async () => {
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await onDelete(c);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
|
<CommentRevisionDialog open={revOpen} onOpenChange={setRevOpen} comment={c} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
189
frontend/src/components/PostManageMenu.tsx
Normal file
189
frontend/src/components/PostManageMenu.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
CircleCheck,
|
||||||
|
CircleHelp,
|
||||||
|
EllipsisVertical,
|
||||||
|
History,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
MessageSquareOff,
|
||||||
|
Pencil,
|
||||||
|
Pin,
|
||||||
|
Sparkles,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import type { PostItem } from '../api/types';
|
||||||
|
|
||||||
|
export interface PostManageMenuProps {
|
||||||
|
post: PostItem;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isOwnerOrAdmin: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEdited: boolean;
|
||||||
|
isMobile?: boolean;
|
||||||
|
editRemaining?: string;
|
||||||
|
editBlockReason?: string;
|
||||||
|
deleting?: boolean;
|
||||||
|
onEdit: () => void;
|
||||||
|
onShowRevisions: () => void;
|
||||||
|
onToggleResolved: () => void;
|
||||||
|
onApprove: () => void;
|
||||||
|
onReject: () => void;
|
||||||
|
onFeature: () => void;
|
||||||
|
onPin: () => void;
|
||||||
|
onBoardPin: () => void;
|
||||||
|
onLock: () => void;
|
||||||
|
onCommentsLock: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 帖子右上角管理菜单:编辑、审核、置顶、锁定、删除等 */
|
||||||
|
export default function PostManageMenu({
|
||||||
|
post,
|
||||||
|
isAdmin,
|
||||||
|
isOwnerOrAdmin,
|
||||||
|
canEdit,
|
||||||
|
isEdited,
|
||||||
|
isMobile,
|
||||||
|
editRemaining,
|
||||||
|
editBlockReason,
|
||||||
|
deleting,
|
||||||
|
onEdit,
|
||||||
|
onShowRevisions,
|
||||||
|
onToggleResolved,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
onFeature,
|
||||||
|
onPin,
|
||||||
|
onBoardPin,
|
||||||
|
onLock,
|
||||||
|
onCommentsLock,
|
||||||
|
onDelete,
|
||||||
|
}: PostManageMenuProps) {
|
||||||
|
const showContent =
|
||||||
|
canEdit
|
||||||
|
|| (isOwnerOrAdmin && isEdited)
|
||||||
|
|| (isOwnerOrAdmin && post.post_type === 'question');
|
||||||
|
const hint = editRemaining || (!canEdit && isOwnerOrAdmin ? editBlockReason : '') || '';
|
||||||
|
|
||||||
|
if (!showContent && !isAdmin && !hint) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="post-manage-menu-trigger"
|
||||||
|
aria-label="管理帖子"
|
||||||
|
>
|
||||||
|
{!isMobile && <span>管理</span>}
|
||||||
|
<EllipsisVertical size={16} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="post-manage-menu">
|
||||||
|
{hint && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel className="post-manage-menu-hint">
|
||||||
|
{hint}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showContent && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>内容</DropdownMenuLabel>
|
||||||
|
{canEdit && (
|
||||||
|
<DropdownMenuItem onSelect={onEdit}>
|
||||||
|
<Pencil size={14} aria-hidden />
|
||||||
|
编辑
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isOwnerOrAdmin && isEdited && (
|
||||||
|
<DropdownMenuItem onSelect={onShowRevisions}>
|
||||||
|
<History size={14} aria-hidden />
|
||||||
|
编辑历史
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isOwnerOrAdmin && post.post_type === 'question' && (
|
||||||
|
<DropdownMenuItem onSelect={onToggleResolved}>
|
||||||
|
{post.question_resolved
|
||||||
|
? <CircleHelp size={14} aria-hidden />
|
||||||
|
: <CircleCheck size={14} aria-hidden />}
|
||||||
|
{post.question_resolved ? '标为未解决' : '标为已解决'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{isAdmin && <DropdownMenuSeparator />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuLabel>审核</DropdownMenuLabel>
|
||||||
|
{(post.status === 'pending' || post.status === 'rejected') && (
|
||||||
|
<DropdownMenuItem onSelect={onApprove}>
|
||||||
|
<CircleCheck size={14} aria-hidden />
|
||||||
|
通过审核
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{post.status !== 'rejected' && (
|
||||||
|
<DropdownMenuItem onSelect={onReject}>
|
||||||
|
<Ban size={14} aria-hidden />
|
||||||
|
拒绝并通知
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel>展示</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onSelect={onFeature}>
|
||||||
|
<Sparkles size={14} aria-hidden />
|
||||||
|
{post.featured ? '取消精华' : '设为精华'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={onPin}>
|
||||||
|
<Pin size={14} aria-hidden />
|
||||||
|
{post.pinned ? '取消全局置顶' : '全局置顶'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={onBoardPin}>
|
||||||
|
<Pin size={14} aria-hidden />
|
||||||
|
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel>讨论</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onSelect={onLock}>
|
||||||
|
<Lock size={14} aria-hidden />
|
||||||
|
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={onCommentsLock}>
|
||||||
|
{post.comments_locked
|
||||||
|
? <LockOpen size={14} aria-hidden />
|
||||||
|
: <MessageSquareOff size={14} aria-hidden />}
|
||||||
|
{post.comments_locked ? '开放讨论' : '锁定讨论'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel>危险</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onSelect={onDelete}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} aria-hidden />
|
||||||
|
删除
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import UserLink from './UserLink';
|
|||||||
import ArticleOutline from './ArticleOutline';
|
import ArticleOutline from './ArticleOutline';
|
||||||
import PostAuthorCard from './PostAuthorCard';
|
import PostAuthorCard from './PostAuthorCard';
|
||||||
import AsideCheckInStrip from './AsideCheckInStrip';
|
import AsideCheckInStrip from './AsideCheckInStrip';
|
||||||
|
import ShowcaseAsideWidget from './ShowcaseAsideWidget';
|
||||||
|
|
||||||
export type PostDetailAside = {
|
export type PostDetailAside = {
|
||||||
author?: User | null;
|
author?: User | null;
|
||||||
@@ -114,6 +115,8 @@ export default function RightPanel({
|
|||||||
|
|
||||||
const renderWidget = (widget: AsideWidget) => {
|
const renderWidget = (widget: AsideWidget) => {
|
||||||
switch (widget.id) {
|
switch (widget.id) {
|
||||||
|
case 'showcase':
|
||||||
|
return <ShowcaseAsideWidget key="showcase" />;
|
||||||
case 'friend_links':
|
case 'friend_links':
|
||||||
return (
|
return (
|
||||||
<div key="friend_links" className="widget-card widget-card--friend-links">
|
<div key="friend_links" className="widget-card widget-card--friend-links">
|
||||||
|
|||||||
72
frontend/src/components/ShowcaseAsideWidget.tsx
Normal file
72
frontend/src/components/ShowcaseAsideWidget.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Globe2 } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { CommunityShowcaseItem } from '../api/types';
|
||||||
|
|
||||||
|
/** 右侧栏:开源展柜精简列表 */
|
||||||
|
export default function ShowcaseAsideWidget() {
|
||||||
|
const nav = useNavigate();
|
||||||
|
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.communityShowcase()
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setItems([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card widget-card--showcase">
|
||||||
|
<div className="widget-card-head widget-card-head--split">
|
||||||
|
<span className="widget-card-head-main">
|
||||||
|
<Globe2 className="widget-card-icon widget-card-icon--showcase" aria-hidden />
|
||||||
|
<button type="button" className="widget-friend-links-title" onClick={() => nav('/showcase')}>
|
||||||
|
开源展柜
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
|
||||||
|
全部
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="widget-card-body widget-card-body--friend-links">
|
||||||
|
{loading ? (
|
||||||
|
<div className="widget-empty">加载中…</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="widget-empty">
|
||||||
|
暂无精选实例
|
||||||
|
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
|
||||||
|
查看展柜
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ul className="widget-friend-links-list">
|
||||||
|
{items.slice(0, 8).map((item) => (
|
||||||
|
<li key={item.site_url}>
|
||||||
|
<a href={item.site_url} target="_blank" rel="noopener noreferrer" title={item.site_name || item.site_url}>
|
||||||
|
{item.site_name || '未命名站点'}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{items.length > 8 && (
|
||||||
|
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
|
||||||
|
查看全部 {items.length} 个
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2,
|
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
|
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
|
||||||
import type { Board } from '../api/types';
|
import type { Board } from '../api/types';
|
||||||
@@ -30,6 +30,7 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st
|
|||||||
if (pathname.startsWith('/favorites')) return 'favorites';
|
if (pathname.startsWith('/favorites')) return 'favorites';
|
||||||
if (pathname.startsWith('/projects')) return 'projects';
|
if (pathname.startsWith('/projects')) return 'projects';
|
||||||
if (pathname.startsWith('/links')) return 'links';
|
if (pathname.startsWith('/links')) return 'links';
|
||||||
|
if (pathname.startsWith('/showcase')) return 'showcase';
|
||||||
if (pathname.startsWith('/page/')) return 'pages';
|
if (pathname.startsWith('/page/')) return 'pages';
|
||||||
if (pathname.startsWith('/admin')) return 'admin';
|
if (pathname.startsWith('/admin')) return 'admin';
|
||||||
return activeBoard === 0 ? 'all' : String(activeBoard);
|
return activeBoard === 0 ? 'all' : String(activeBoard);
|
||||||
@@ -67,7 +68,8 @@ export default function Sidebar({
|
|||||||
const { navPages } = useSitePages();
|
const { navPages } = useSitePages();
|
||||||
const { limits } = useForumLimits();
|
const { limits } = useForumLimits();
|
||||||
const showFriendLinksNav = limits.nav_show_friend_links !== false;
|
const showFriendLinksNav = limits.nav_show_friend_links !== false;
|
||||||
const showSiteSection = navPages.length > 0 || showFriendLinksNav;
|
const showShowcaseNav = !!limits.nav_show_showcase;
|
||||||
|
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
|
||||||
|
|
||||||
const keyword = params.get('keyword') || '';
|
const keyword = params.get('keyword') || '';
|
||||||
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
|
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
|
||||||
@@ -197,6 +199,7 @@ export default function Sidebar({
|
|||||||
<div className="sidebar-section sidebar-section--spaced">站点</div>
|
<div className="sidebar-section sidebar-section--spaced">站点</div>
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
|
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
|
||||||
|
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
|
||||||
{navPages.map(p => (
|
{navPages.map(p => (
|
||||||
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
|
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ function FooterSep() {
|
|||||||
return <span className="site-footer__sep" aria-hidden>·</span>;
|
return <span className="site-footer__sep" aria-hidden>·</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 站点页脚:版权、Sitemap、备案号 */
|
/** 站点页脚:版权、友链/展柜入口、单页、备案号 */
|
||||||
export default function SiteFooter() {
|
export default function SiteFooter() {
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
const { footerPages } = useSitePages();
|
const { footerPages } = useSitePages();
|
||||||
@@ -17,6 +17,10 @@ export default function SiteFooter() {
|
|||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
const icp = branding.icp_beian?.trim() || '';
|
const icp = branding.icp_beian?.trim() || '';
|
||||||
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
|
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
|
||||||
|
const showFriendLinks = limits.footer_show_friend_links !== false;
|
||||||
|
const showShowcase = !!limits.footer_show_showcase;
|
||||||
|
const hasNavBeforePages = showFriendLinks || showShowcase;
|
||||||
|
const hasNavBeforeIcp = hasNavBeforePages || footerPages.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="site-footer">
|
<footer className="site-footer">
|
||||||
@@ -34,20 +38,26 @@ export default function SiteFooter() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="site-footer__nav" aria-label="站点链接">
|
<nav className="site-footer__nav" aria-label="站点链接">
|
||||||
{limits.footer_show_friend_links !== false && (
|
{showFriendLinks && (
|
||||||
<span className="site-footer__friend">
|
<span className="site-footer__friend">
|
||||||
<Link to="/links">友情链接</Link>
|
<Link to="/links">友情链接</Link>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{showShowcase && (
|
||||||
|
<span className="site-footer__friend">
|
||||||
|
{showFriendLinks && <FooterSep />}
|
||||||
|
<Link to="/showcase">开源展柜</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{footerPages.map((p, i) => (
|
{footerPages.map((p, i) => (
|
||||||
<span key={p.slug} className="site-footer__friend">
|
<span key={p.slug} className="site-footer__friend">
|
||||||
{(limits.footer_show_friend_links !== false || i > 0) && <FooterSep />}
|
{(hasNavBeforePages || i > 0) && <FooterSep />}
|
||||||
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
|
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{icp && (
|
{icp && (
|
||||||
<>
|
<>
|
||||||
{(limits.footer_show_friend_links !== false || footerPages.length > 0) && <FooterSep />}
|
{hasNavBeforeIcp && <FooterSep />}
|
||||||
<a
|
<a
|
||||||
href={icpURL}
|
href={icpURL}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
|
|||||||
label: '友情链接',
|
label: '友情链接',
|
||||||
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
|
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
|
||||||
},
|
},
|
||||||
|
showcase: {
|
||||||
|
label: '开源展柜',
|
||||||
|
hint: '展示精选公网部署;关闭后仍可直接访问 /showcase',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
90
frontend/src/components/admin/CommunitySupportStrip.tsx
Normal file
90
frontend/src/components/admin/CommunitySupportStrip.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Heart } from 'lucide-react';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type { CommunityConfig } from '../../api/types';
|
||||||
|
|
||||||
|
const EMPTY_COMMUNITY: CommunityConfig = {
|
||||||
|
report_enabled: false,
|
||||||
|
hub_enabled: false,
|
||||||
|
hub_url: 'https://bbs.iioio.com',
|
||||||
|
site_url: '',
|
||||||
|
instance_id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存;枢纽站不显示) */
|
||||||
|
export default function CommunitySupportStrip() {
|
||||||
|
const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.adminSettings()
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setCommunity({ ...EMPTY_COMMUNITY, ...(s.community ?? {}) });
|
||||||
|
setReady(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
if (saving || !ready) return;
|
||||||
|
const next = !community.report_enabled;
|
||||||
|
const prev = community;
|
||||||
|
setCommunity((c) => ({ ...c, report_enabled: next }));
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const r = await api.adminUpdateCommunitySettings({
|
||||||
|
...EMPTY_COMMUNITY,
|
||||||
|
report_enabled: next,
|
||||||
|
});
|
||||||
|
setCommunity({ ...EMPTY_COMMUNITY, ...r.community });
|
||||||
|
if (r.heartbeat_error) {
|
||||||
|
notify.warning(`${r.message}:${r.heartbeat_error}`);
|
||||||
|
} else {
|
||||||
|
notify.success(next ? '已开启社区上报' : '已关闭社区上报');
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setCommunity(prev);
|
||||||
|
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 官网 / 枢纽站本身就是收报方,无需「支持开源」上报条
|
||||||
|
if (!ready || community.hub_enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
|
||||||
|
<div className="admin-community-support-strip-main">
|
||||||
|
<Heart size={16} className="admin-community-support-strip-icon" aria-hidden />
|
||||||
|
<div className="admin-community-support-strip-copy">
|
||||||
|
<strong>支持姜十三开源</strong>
|
||||||
|
<span>
|
||||||
|
匿名向 bbs.iioio.com 上报站点地址、版本与规模;开启后有机会获官方演示站展示与推荐,可随时关闭
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={community.report_enabled}
|
||||||
|
aria-busy={saving}
|
||||||
|
disabled={saving || !ready}
|
||||||
|
className={`admin-settings-switch${community.report_enabled ? ' is-on' : ''}`}
|
||||||
|
onClick={() => void handleToggle()}
|
||||||
|
>
|
||||||
|
<span className="admin-settings-switch-ui" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
frontend/src/components/admin/MonitorChinaMap.tsx
Normal file
72
frontend/src/components/admin/MonitorChinaMap.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import china from '@svg-maps/china';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils';
|
||||||
|
|
||||||
|
type Loc = { id: string; name: string; path: string };
|
||||||
|
|
||||||
|
type RegionStat = {
|
||||||
|
country: string;
|
||||||
|
region?: string;
|
||||||
|
region_iso?: string;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
regions: RegionStat[];
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 中国省区地图:按 BIN 解析出的省/区填色 */
|
||||||
|
export default function MonitorChinaMap({ regions, className }: Props) {
|
||||||
|
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
|
||||||
|
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
|
||||||
|
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
|
||||||
|
const hasData = max > 0;
|
||||||
|
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
|
||||||
|
const viewBox = (china as { viewBox: string }).viewBox;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('admin-monitor-svg-wrap', className)}>
|
||||||
|
<svg
|
||||||
|
className="admin-monitor-svg-map"
|
||||||
|
viewBox={viewBox}
|
||||||
|
role="img"
|
||||||
|
aria-label="中国访客地图"
|
||||||
|
>
|
||||||
|
{locations.map((loc) => {
|
||||||
|
const count = counts[loc.id] || 0;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={loc.id}
|
||||||
|
d={loc.path}
|
||||||
|
fill={heatFill(count, max, hasData)}
|
||||||
|
stroke="hsl(var(--background, 0 0% 100%))"
|
||||||
|
strokeWidth={0.6}
|
||||||
|
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
|
||||||
|
onMouseEnter={() => setHover({ name: loc.name, count })}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{loc.name}
|
||||||
|
{count > 0 ? ` · ${count}` : ''}
|
||||||
|
</title>
|
||||||
|
</path>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{hover && (
|
||||||
|
<div className="admin-monitor-map-tip" role="status">
|
||||||
|
<strong>{hover.name}</strong>
|
||||||
|
<span>{hover.count > 0 ? hover.count : '—'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasData && (
|
||||||
|
<div className="admin-monitor-map-empty-overlay">
|
||||||
|
<p>暂无省级访问数据</p>
|
||||||
|
<p>放置 IP2LOCATION-LITE-DB3.BIN 后按省/区填色</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
frontend/src/components/admin/MonitorWorldMap.tsx
Normal file
121
frontend/src/components/admin/MonitorWorldMap.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import world from '@svg-maps/world';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
buildCountMap,
|
||||||
|
countryLabelZh,
|
||||||
|
emptyFill,
|
||||||
|
heatFill,
|
||||||
|
type GeoCountMap,
|
||||||
|
} from './monitorMapUtils';
|
||||||
|
|
||||||
|
type Loc = { id: string; name: string; path: string };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
items: { country: string; count: number }[];
|
||||||
|
className?: string;
|
||||||
|
/** 裁切到东亚(中国模式) */
|
||||||
|
focusChina?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CHINA_VIEWBOX = '680 260 220 200';
|
||||||
|
|
||||||
|
export default function MonitorWorldMap({ items, className, focusChina }: Props) {
|
||||||
|
const [hover, setHover] = useState<{ code: string; name: string; count: number } | null>(null);
|
||||||
|
|
||||||
|
const counts = useMemo(() => buildCountMap(items), [items]);
|
||||||
|
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
|
||||||
|
const hasData = max > 0;
|
||||||
|
|
||||||
|
const locations = (world as { locations: Loc[]; viewBox: string }).locations;
|
||||||
|
const viewBox = focusChina ? CHINA_VIEWBOX : (world as { viewBox: string }).viewBox;
|
||||||
|
|
||||||
|
const filteredCounts: GeoCountMap = useMemo(() => {
|
||||||
|
if (!focusChina) return counts;
|
||||||
|
const allow = new Set(['CN', 'HK', 'MO', 'TW']);
|
||||||
|
const m: GeoCountMap = {};
|
||||||
|
for (const [k, v] of Object.entries(counts)) {
|
||||||
|
if (allow.has(k)) m[k] = v;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [counts, focusChina]);
|
||||||
|
|
||||||
|
const focusMax = useMemo(
|
||||||
|
() => Math.max(0, ...Object.values(filteredCounts)),
|
||||||
|
[filteredCounts],
|
||||||
|
);
|
||||||
|
const focusHas = focusMax > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('admin-monitor-svg-wrap', className)}>
|
||||||
|
<svg
|
||||||
|
className="admin-monitor-svg-map"
|
||||||
|
viewBox={viewBox}
|
||||||
|
role="img"
|
||||||
|
aria-label={focusChina ? '中国及周边访客地图' : '世界访客地图'}
|
||||||
|
>
|
||||||
|
{locations.map((loc) => {
|
||||||
|
const code = loc.id.toUpperCase();
|
||||||
|
if (focusChina) {
|
||||||
|
// 中国模式下非本区域保持极淡灰,本区域按数据着色
|
||||||
|
const inRegion = code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW'
|
||||||
|
|| code === 'JP' || code === 'KR' || code === 'MN' || code === 'KP'
|
||||||
|
|| code === 'RU' || code === 'IN' || code === 'VN' || code === 'LA'
|
||||||
|
|| code === 'MM' || code === 'BT' || code === 'NP' || code === 'KZ'
|
||||||
|
|| code === 'KG' || code === 'TJ' || code === 'UZ' || code === 'AF'
|
||||||
|
|| code === 'PK' || code === 'PH' || code === 'MY' || code === 'TH'
|
||||||
|
|| code === 'KH' || code === 'BD' || code === 'LK';
|
||||||
|
if (!inRegion) return null;
|
||||||
|
}
|
||||||
|
const count = filteredCounts[code] || (focusChina ? 0 : counts[code]) || 0;
|
||||||
|
const useMax = focusChina ? focusMax : max;
|
||||||
|
const useHas = focusChina ? focusHas : hasData;
|
||||||
|
const isCore = !focusChina || code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW';
|
||||||
|
const fill = isCore
|
||||||
|
? heatFill(count, useMax, useHas)
|
||||||
|
: emptyFill(useHas);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={loc.id}
|
||||||
|
d={loc.path}
|
||||||
|
data-code={code}
|
||||||
|
fill={fill}
|
||||||
|
stroke="hsl(var(--background, 0 0% 100%))"
|
||||||
|
strokeWidth={focusChina ? 0.35 : 0.4}
|
||||||
|
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
|
||||||
|
onMouseEnter={() => setHover({
|
||||||
|
code,
|
||||||
|
name: countryLabelZh(code, loc.name),
|
||||||
|
count: focusChina && !isCore ? 0 : count,
|
||||||
|
})}
|
||||||
|
onMouseLeave={() => setHover(null)}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{countryLabelZh(code, loc.name)}
|
||||||
|
{count > 0 ? ` · ${count}` : ''}
|
||||||
|
</title>
|
||||||
|
</path>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
{hover && (
|
||||||
|
<div className="admin-monitor-map-tip" role="status">
|
||||||
|
<strong>{hover.name}</strong>
|
||||||
|
<span>{hover.count > 0 ? hover.count : '—'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasData && !focusChina && (
|
||||||
|
<div className="admin-monitor-map-empty-overlay">
|
||||||
|
<p>暂无国家数据</p>
|
||||||
|
<p>配置 GeoIP 或 CDN 国家头后可见</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{focusChina && !focusHas && (
|
||||||
|
<div className="admin-monitor-map-empty-overlay">
|
||||||
|
<p>暂无中国地区访问数据</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
130
frontend/src/components/admin/monitorMapUtils.ts
Normal file
130
frontend/src/components/admin/monitorMapUtils.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/** 监控地图共用:国家码 → 数量色阶(本站绿色) */
|
||||||
|
|
||||||
|
export type GeoCountMap = Record<string, number>;
|
||||||
|
|
||||||
|
const EMPTY = 'var(--j13-border, #e5e7eb)';
|
||||||
|
const BASE = 'hsl(var(--muted, 210 20% 94%))';
|
||||||
|
|
||||||
|
/** 由浅到深的绿色阶 */
|
||||||
|
const GREEN_STEPS = [
|
||||||
|
'color-mix(in srgb, var(--j13-green) 28%, hsl(var(--card)))',
|
||||||
|
'color-mix(in srgb, var(--j13-green) 45%, hsl(var(--card)))',
|
||||||
|
'color-mix(in srgb, var(--j13-green) 62%, hsl(var(--card)))',
|
||||||
|
'color-mix(in srgb, var(--j13-green) 80%, hsl(var(--card)))',
|
||||||
|
'var(--j13-green)',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildCountMap(items: { country: string; count: number }[]): GeoCountMap {
|
||||||
|
const m: GeoCountMap = {};
|
||||||
|
for (const it of items) {
|
||||||
|
const code = (it.country || '').toUpperCase();
|
||||||
|
if (!code) continue;
|
||||||
|
m[code] = (m[code] || 0) + it.count;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MaxMind region_iso / 中英文省名 → @svg-maps/china 的 location id */
|
||||||
|
const CN_ISO_TO_SVG: Record<string, string> = {
|
||||||
|
AH: 'anhui', BJ: 'beijing', CQ: 'chongqing', FJ: 'fujian', GS: 'gansu',
|
||||||
|
GD: 'guangdong', GX: 'guangxi-zhuang', GZ: 'guizhou', HI: 'hainan', HE: 'hebei',
|
||||||
|
HL: 'heilongjiang', HA: 'henan', HB: 'hubei', HN: 'hunan', JS: 'jiangsu',
|
||||||
|
JX: 'jiangxi', JL: 'jilin', LN: 'liaoning', NM: 'nei-mongol', NX: 'ningxia-hui',
|
||||||
|
QH: 'quinghai', SN: 'shaanxi', SD: 'shandong', SH: 'shanghai', SX: 'shanxi',
|
||||||
|
SC: 'sichuan', TJ: 'tianjin', XJ: 'xinjiang-uygur', XZ: 'xizang', YN: 'yunnan',
|
||||||
|
ZJ: 'zhejiang', HK: 'hong-kong', MO: 'macau',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CN_NAME_TO_SVG: Record<string, string> = {
|
||||||
|
安徽: 'anhui', anhui: 'anhui',
|
||||||
|
北京: 'beijing', beijing: 'beijing',
|
||||||
|
重庆: 'chongqing', chongqing: 'chongqing',
|
||||||
|
福建: 'fujian', fujian: 'fujian',
|
||||||
|
甘肃: 'gansu', gansu: 'gansu',
|
||||||
|
广东: 'guangdong', guangdong: 'guangdong',
|
||||||
|
广西: 'guangxi-zhuang', '广西壮族自治区': 'guangxi-zhuang', 'guangxi zhuang': 'guangxi-zhuang', guangxi: 'guangxi-zhuang',
|
||||||
|
贵州: 'guizhou', guizhou: 'guizhou',
|
||||||
|
海南: 'hainan', hainan: 'hainan',
|
||||||
|
河北: 'hebei', hebei: 'hebei',
|
||||||
|
黑龙江: 'heilongjiang', heilongjiang: 'heilongjiang',
|
||||||
|
河南: 'henan', henan: 'henan',
|
||||||
|
湖北: 'hubei', hubei: 'hubei',
|
||||||
|
湖南: 'hunan', hunan: 'hunan',
|
||||||
|
江苏: 'jiangsu', jiangsu: 'jiangsu',
|
||||||
|
江西: 'jiangxi', jiangxi: 'jiangxi',
|
||||||
|
吉林: 'jilin', jilin: 'jilin',
|
||||||
|
辽宁: 'liaoning', liaoning: 'liaoning',
|
||||||
|
内蒙古: 'nei-mongol', '内蒙古自治区': 'nei-mongol', 'nei mongol': 'nei-mongol', 'inner mongolia': 'nei-mongol',
|
||||||
|
宁夏: 'ningxia-hui', '宁夏回族自治区': 'ningxia-hui', 'ningxia hui': 'ningxia-hui', ningxia: 'ningxia-hui',
|
||||||
|
青海: 'quinghai', qinghai: 'quinghai', quinghai: 'quinghai',
|
||||||
|
陕西: 'shaanxi', shaanxi: 'shaanxi',
|
||||||
|
山东: 'shandong', shandong: 'shandong',
|
||||||
|
上海: 'shanghai', shanghai: 'shanghai',
|
||||||
|
山西: 'shanxi', shanxi: 'shanxi',
|
||||||
|
四川: 'sichuan', sichuan: 'sichuan',
|
||||||
|
天津: 'tianjin', tianjin: 'tianjin',
|
||||||
|
新疆: 'xinjiang-uygur', '新疆维吾尔自治区': 'xinjiang-uygur', 'xinjiang uygur': 'xinjiang-uygur', xinjiang: 'xinjiang-uygur',
|
||||||
|
西藏: 'xizang', '西藏自治区': 'xizang', xizang: 'xizang', tibet: 'xizang',
|
||||||
|
云南: 'yunnan', yunnan: 'yunnan',
|
||||||
|
浙江: 'zhejiang', zhejiang: 'zhejiang',
|
||||||
|
香港: 'hong-kong', 'hong kong': 'hong-kong',
|
||||||
|
澳门: 'macau', macau: 'macau', macao: 'macau',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function chinaRegionToSvgId(regionISO?: string, regionName?: string): string | null {
|
||||||
|
const iso = (regionISO || '').trim().toUpperCase().replace(/^CN-/, '');
|
||||||
|
if (iso && CN_ISO_TO_SVG[iso]) return CN_ISO_TO_SVG[iso];
|
||||||
|
const name = (regionName || '').trim().toLowerCase();
|
||||||
|
if (!name) return null;
|
||||||
|
if (CN_NAME_TO_SVG[name]) return CN_NAME_TO_SVG[name];
|
||||||
|
// 尝试去掉「省/市/自治区」后缀后再匹配中文键
|
||||||
|
const zh = (regionName || '').trim()
|
||||||
|
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
|
||||||
|
.replace(/(省|市)$/, '');
|
||||||
|
if (CN_NAME_TO_SVG[zh]) return CN_NAME_TO_SVG[zh];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将省级统计聚合为 svg-maps/china 的 id → count */
|
||||||
|
export function buildChinaRegionCountMap(
|
||||||
|
regions: { country: string; region?: string; region_iso?: string; count: number }[],
|
||||||
|
): GeoCountMap {
|
||||||
|
const m: GeoCountMap = {};
|
||||||
|
for (const it of regions) {
|
||||||
|
const c = (it.country || '').toUpperCase();
|
||||||
|
if (c && c !== 'CN' && c !== 'HK' && c !== 'MO' && c !== 'TW') continue;
|
||||||
|
const id = chinaRegionToSvgId(it.region_iso, it.region);
|
||||||
|
if (!id) continue;
|
||||||
|
m[id] = (m[id] || 0) + it.count;
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function heatFill(count: number, max: number, hasAnyData: boolean): string {
|
||||||
|
if (!hasAnyData) return BASE;
|
||||||
|
if (!count || max <= 0) return BASE;
|
||||||
|
const t = Math.log1p(count) / Math.log1p(max);
|
||||||
|
const idx = Math.min(GREEN_STEPS.length - 1, Math.floor(t * GREEN_STEPS.length));
|
||||||
|
return GREEN_STEPS[Math.max(0, idx)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyFill(hasAnyData: boolean): string {
|
||||||
|
return hasAnyData ? BASE : EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COUNTRY_NAMES_ZH: Record<string, string> = {
|
||||||
|
CN: '中国', US: '美国', JP: '日本', KR: '韩国', HK: '中国香港', TW: '中国台湾', MO: '中国澳门',
|
||||||
|
SG: '新加坡', DE: '德国', GB: '英国', FR: '法国', RU: '俄罗斯', AU: '澳大利亚',
|
||||||
|
CA: '加拿大', IN: '印度', BR: '巴西', NL: '荷兰', IT: '意大利', ES: '西班牙',
|
||||||
|
MY: '马来西亚', TH: '泰国', VN: '越南', ID: '印尼', PH: '菲律宾', MX: '墨西哥',
|
||||||
|
TR: '土耳其', SA: '沙特', AE: '阿联酋', ZA: '南非', NZ: '新西兰', SE: '瑞典',
|
||||||
|
NO: '挪威', FI: '芬兰', DK: '丹麦', PL: '波兰', CH: '瑞士', AT: '奥地利',
|
||||||
|
BE: '比利时', IE: '爱尔兰', PT: '葡萄牙', AR: '阿根廷', CL: '智利', CO: '哥伦比亚',
|
||||||
|
PK: '巴基斯坦', BD: '孟加拉', EG: '埃及', NG: '尼日利亚', KE: '肯尼亚',
|
||||||
|
UA: '乌克兰', CZ: '捷克', RO: '罗马尼亚', HU: '匈牙利', GR: '希腊', IL: '以色列',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function countryLabelZh(code: string, fallbackName?: string) {
|
||||||
|
const c = code.toUpperCase();
|
||||||
|
return COUNTRY_NAMES_ZH[c] || fallbackName || c;
|
||||||
|
}
|
||||||
@@ -20,12 +20,16 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
|||||||
aside_show_tag_cloud: false,
|
aside_show_tag_cloud: false,
|
||||||
aside_show_recent_comments: false,
|
aside_show_recent_comments: false,
|
||||||
aside_show_friend_links: true,
|
aside_show_friend_links: true,
|
||||||
|
aside_show_showcase: false,
|
||||||
aside_widgets: DEFAULT_ASIDE_WIDGETS,
|
aside_widgets: DEFAULT_ASIDE_WIDGETS,
|
||||||
nav_show_friend_links: true,
|
nav_show_friend_links: true,
|
||||||
footer_show_friend_links: true,
|
footer_show_friend_links: true,
|
||||||
|
nav_show_showcase: false,
|
||||||
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
|
monitor_pageview: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let cached: ForumLimitsPublic | null = null;
|
let cached: ForumLimitsPublic | null = null;
|
||||||
|
|||||||
41
frontend/src/hooks/useMonitorPageview.ts
Normal file
41
frontend/src/hooks/useMonitorPageview.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { useForumLimits } from './useForumLimits';
|
||||||
|
|
||||||
|
const DEDUPE_MS = 800;
|
||||||
|
|
||||||
|
function shouldSkipPath(pathname: string) {
|
||||||
|
const p = pathname.toLowerCase();
|
||||||
|
return (
|
||||||
|
p === '/admin' ||
|
||||||
|
p.startsWith('/admin/') ||
|
||||||
|
p === '/login' ||
|
||||||
|
p === '/register' ||
|
||||||
|
p === '/forgot-password' ||
|
||||||
|
p.startsWith('/oauth')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主站布局:路由变化时上报第一方 pageview(采集关闭则不请求) */
|
||||||
|
export function useMonitorPageview() {
|
||||||
|
const loc = useLocation();
|
||||||
|
const { limits } = useForumLimits();
|
||||||
|
const lastRef = useRef<{ key: string; at: number }>({ key: '', at: 0 });
|
||||||
|
const enabled = !!limits.monitor_pageview;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (shouldSkipPath(loc.pathname)) return;
|
||||||
|
|
||||||
|
const path = `${loc.pathname}${loc.search || ''}`;
|
||||||
|
const now = Date.now();
|
||||||
|
if (lastRef.current.key === path && now - lastRef.current.at < DEDUPE_MS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastRef.current = { key: path, at: now };
|
||||||
|
|
||||||
|
const referrer = typeof document !== 'undefined' ? document.referrer || '' : '';
|
||||||
|
void api.monitorPageview({ path, referrer });
|
||||||
|
}, [enabled, loc.pathname, loc.search]);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen,
|
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
@@ -23,6 +23,8 @@ type NavItem = {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: typeof LayoutDashboard;
|
icon: typeof LayoutDashboard;
|
||||||
badgeKey?: BadgeKey;
|
badgeKey?: BadgeKey;
|
||||||
|
/** 仅社区枢纽开启时显示 */
|
||||||
|
hubOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type NavGroup = {
|
type NavGroup = {
|
||||||
@@ -36,6 +38,8 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: '概览',
|
label: '概览',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||||
|
{ to: '/admin/monitor', label: '网站监控', icon: Activity },
|
||||||
|
{ to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,6 +90,7 @@ export default function AdminLayout() {
|
|||||||
const isNarrow = useMediaQuery('(max-width: 768px)');
|
const isNarrow = useMediaQuery('(max-width: 768px)');
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
const [pending, setPending] = useState<PendingCounts>({ posts: 0, comments: 0, reports: 0, links: 0 });
|
const [pending, setPending] = useState<PendingCounts>({ posts: 0, comments: 0, reports: 0, links: 0 });
|
||||||
|
const [communityHub, setCommunityHub] = useState(false);
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const drawerRef = useRef<HTMLElement>(null);
|
const drawerRef = useRef<HTMLElement>(null);
|
||||||
@@ -107,6 +112,13 @@ export default function AdminLayout() {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || !user || user.role !== 'admin') return;
|
||||||
|
api.adminSettings()
|
||||||
|
.then(s => setCommunityHub(!!s.community?.hub_enabled))
|
||||||
|
.catch(() => setCommunityHub(false));
|
||||||
|
}, [loading, user]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading || !user || user.role !== 'admin') return;
|
if (loading || !user || user.role !== 'admin') return;
|
||||||
refreshPending();
|
refreshPending();
|
||||||
@@ -156,7 +168,7 @@ export default function AdminLayout() {
|
|||||||
NAV_GROUPS.map(group => (
|
NAV_GROUPS.map(group => (
|
||||||
<div key={group.label} className="admin-nav-group">
|
<div key={group.label} className="admin-nav-group">
|
||||||
<div className="admin-nav-group-label">{group.label}</div>
|
<div className="admin-nav-group-label">{group.label}</div>
|
||||||
{group.items.map(({ to, label, icon: Icon, badgeKey }) => {
|
{group.items.filter(item => !item.hubOnly || communityHub).map(({ to, label, icon: Icon, badgeKey }) => {
|
||||||
const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null;
|
const badge = badgeKey ? formatNavBadge(pending[badgeKey]) : null;
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { getBoardThemeIndex } from '../utils/boardTheme';
|
|||||||
import { loginPath } from '../utils/authRedirect';
|
import { loginPath } from '../utils/authRedirect';
|
||||||
import { openForumPost } from '../utils/openPost';
|
import { openForumPost } from '../utils/openPost';
|
||||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import { useMonitorPageview } from '../hooks/useMonitorPageview';
|
||||||
import SiteBrandMark from '../components/SiteBrandMark';
|
import SiteBrandMark from '../components/SiteBrandMark';
|
||||||
import SiteFooter from '../components/SiteFooter';
|
import SiteFooter from '../components/SiteFooter';
|
||||||
import { userPath } from '../utils/userPath';
|
import { userPath } from '../utils/userPath';
|
||||||
@@ -43,6 +44,7 @@ export default function MainLayout() {
|
|||||||
const { user, loading: authLoading, logout } = useAuth();
|
const { user, loading: authLoading, logout } = useAuth();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const { branding } = useSiteBranding();
|
const { branding } = useSiteBranding();
|
||||||
|
useMonitorPageview();
|
||||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||||
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom';
|
||||||
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, LockOpen, MessageSquare, MessageSquareOff, Trash2, Sparkles, Flag, Ban, CircleCheck, CircleHelp, MoreHorizontal } from 'lucide-react';
|
import { ArrowLeft, ThumbsUp, Star, Lock, MessageSquare, MessageSquareOff, Flag, MoreHorizontal } from 'lucide-react';
|
||||||
import FeaturedIcon from '@/components/FeaturedIcon';
|
import FeaturedIcon from '@/components/FeaturedIcon';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
AlertDialogFooter,
|
AlertDialogFooter,
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -44,6 +43,7 @@ import PostBountyBanner from '../components/PostBountyBanner';
|
|||||||
import { findCommentFloor } from '../utils/bounty';
|
import { findCommentFloor } from '../utils/bounty';
|
||||||
import PostLotteryCard from '../components/PostLotteryCard';
|
import PostLotteryCard from '../components/PostLotteryCard';
|
||||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||||
|
import PostManageMenu from '../components/PostManageMenu';
|
||||||
import ArticleOutline from '../components/ArticleOutline';
|
import ArticleOutline from '../components/ArticleOutline';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
@@ -102,6 +102,8 @@ export default function PostDetailPage() {
|
|||||||
const [editWindowHours, setEditWindowHours] = useState(0);
|
const [editWindowHours, setEditWindowHours] = useState(0);
|
||||||
const [showRevisions, setShowRevisions] = useState(false);
|
const [showRevisions, setShowRevisions] = useState(false);
|
||||||
const [deletingPost, setDeletingPost] = useState(false);
|
const [deletingPost, setDeletingPost] = useState(false);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [composerOpen, setComposerOpen] = useState(false);
|
||||||
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
const [headings, setHeadings] = useState<PostHeading[]>([]);
|
||||||
const [reportOpen, setReportOpen] = useState(false);
|
const [reportOpen, setReportOpen] = useState(false);
|
||||||
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
const [reportReason, setReportReason] = useState<ReportReason>('spam');
|
||||||
@@ -186,6 +188,7 @@ export default function PostDetailPage() {
|
|||||||
}
|
}
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setEditingCommentId(null);
|
setEditingCommentId(null);
|
||||||
|
setComposerOpen(false);
|
||||||
setHeadings([]);
|
setHeadings([]);
|
||||||
const seq = ++loadSeq.current;
|
const seq = ++loadSeq.current;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -244,15 +247,54 @@ export default function PostDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [postId]);
|
}, [postId]);
|
||||||
|
|
||||||
const scrollToCommentBox = useCallback(() => {
|
const focusCommentEditor = useCallback(() => {
|
||||||
const target = commentBoxRef.current || commentSectionRef.current;
|
const root = commentBoxRef.current;
|
||||||
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
if (!root) return;
|
||||||
window.setTimeout(() => {
|
const editable = root.querySelector<HTMLElement>('.ProseMirror, [contenteditable="true"]');
|
||||||
const ta = commentBoxRef.current?.querySelector('textarea');
|
editable?.focus({ preventScroll: true });
|
||||||
ta?.focus({ preventScroll: true });
|
|
||||||
}, 320);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const scrollToCommentBox = useCallback(() => {
|
||||||
|
setComposerOpen(true);
|
||||||
|
// 等折叠条展开后再滚入视口并聚焦 TipTap
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const target = commentBoxRef.current || commentSectionRef.current;
|
||||||
|
target?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
window.setTimeout(focusCommentEditor, 80);
|
||||||
|
}, 50);
|
||||||
|
}, [focusCommentEditor]);
|
||||||
|
|
||||||
|
// 手机端展开输入框后聚焦编辑器
|
||||||
|
useEffect(() => {
|
||||||
|
if (!composerOpen || !isMobile) return;
|
||||||
|
const timer = window.setTimeout(focusCommentEditor, 60);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [composerOpen, isMobile, focusCommentEditor]);
|
||||||
|
|
||||||
|
// 手机端:焦点离开评论输入区(含工具栏)后收起为「说点什么…」
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile || !composerOpen) return;
|
||||||
|
const root = commentBoxRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const onFocusOut = (e: FocusEvent) => {
|
||||||
|
const next = e.relatedTarget as Node | null;
|
||||||
|
if (next && root.contains(next)) return;
|
||||||
|
|
||||||
|
// 等焦点真正落定(工具栏 / Dialog / 系统弹窗)后再判断是否收起
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const active = document.activeElement;
|
||||||
|
if (active && root.contains(active)) return;
|
||||||
|
// 代码块 Dialog 等浮层打开时不收起
|
||||||
|
if (document.querySelector('[role="dialog"][data-state="open"]')) return;
|
||||||
|
setComposerOpen(false);
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
|
root.addEventListener('focusout', onFocusOut);
|
||||||
|
return () => root.removeEventListener('focusout', onFocusOut);
|
||||||
|
}, [isMobile, composerOpen]);
|
||||||
|
|
||||||
const jumpToFloor = useCallback((floor: number) => {
|
const jumpToFloor = useCallback((floor: number) => {
|
||||||
const el = document.getElementById(`floor-${floor}`);
|
const el = document.getElementById(`floor-${floor}`);
|
||||||
if (!el) return false;
|
if (!el) return false;
|
||||||
@@ -468,6 +510,7 @@ export default function PostDetailPage() {
|
|||||||
});
|
});
|
||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setSubmitCount(c => c + 1);
|
setSubmitCount(c => c + 1);
|
||||||
|
if (isMobile) setComposerOpen(false);
|
||||||
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
|
notify.success(r.message || (r.status === 'pending' ? '评论已提交审核' : '评论成功'));
|
||||||
await Promise.all([reloadComments(), reloadPostContent()]);
|
await Promise.all([reloadComments(), reloadPostContent()]);
|
||||||
setTimeout(() => jumpToFloor(r.floor), 100);
|
setTimeout(() => jumpToFloor(r.floor), 100);
|
||||||
@@ -738,12 +781,38 @@ export default function PostDetailPage() {
|
|||||||
<article 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-header">
|
||||||
<div className="post-detail-nav">
|
<div className="post-detail-nav">
|
||||||
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
|
<div className="post-detail-nav-left">
|
||||||
<ArrowLeft />
|
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
|
||||||
返回
|
<ArrowLeft />
|
||||||
</Button>
|
返回
|
||||||
{post.board && (
|
</Button>
|
||||||
<BoardBadge board={post.board} className="post-detail-board-tag" />
|
{post.board && (
|
||||||
|
<BoardBadge board={post.board} className="post-detail-board-tag" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(isOwnerOrAdmin || canEdit || isAdmin) && (
|
||||||
|
<PostManageMenu
|
||||||
|
post={post}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||||
|
canEdit={canEdit}
|
||||||
|
isEdited={isEdited}
|
||||||
|
isMobile={isMobile}
|
||||||
|
editRemaining={editRemaining}
|
||||||
|
editBlockReason={editBlockReason}
|
||||||
|
deleting={deletingPost}
|
||||||
|
onEdit={() => nav(`/post/${postId}/edit`)}
|
||||||
|
onShowRevisions={() => setShowRevisions(true)}
|
||||||
|
onToggleResolved={handleToggleResolved}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onReject={() => setRejectOpen(true)}
|
||||||
|
onFeature={handleFeature}
|
||||||
|
onPin={handlePin}
|
||||||
|
onBoardPin={handleBoardPin}
|
||||||
|
onLock={handleLock}
|
||||||
|
onCommentsLock={handleCommentsLock}
|
||||||
|
onDelete={() => setDeleteOpen(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -943,101 +1012,32 @@ export default function PostDetailPage() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(isOwnerOrAdmin || canEdit || isAdmin) && (
|
|
||||||
<div className="post-detail-actions-manage">
|
|
||||||
{isOwnerOrAdmin && post.post_type === 'question' && (
|
|
||||||
<Button
|
|
||||||
variant={post.question_resolved ? 'outline' : 'default'}
|
|
||||||
size="sm"
|
|
||||||
onClick={handleToggleResolved}
|
|
||||||
>
|
|
||||||
{post.question_resolved ? <CircleHelp /> : <CircleCheck />}
|
|
||||||
{post.question_resolved ? '标为未解决' : '标为已解决'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
|
|
||||||
<Pencil />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isOwnerOrAdmin && isEdited && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setShowRevisions(true)}>
|
|
||||||
<History />
|
|
||||||
编辑历史
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{isAdmin && (
|
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" disabled={deletingPost}>
|
|
||||||
<Trash2 />
|
|
||||||
删除
|
|
||||||
</Button>
|
|
||||||
</AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDeletePost}>删除</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
)}
|
|
||||||
{editRemaining && (
|
|
||||||
<span className="post-detail-edit-hint">{editRemaining}</span>
|
|
||||||
)}
|
|
||||||
{isOwnerOrAdmin && !canEdit && editBlockReason && (
|
|
||||||
<span className="post-detail-edit-hint" title={editBlockReason}>
|
|
||||||
{editBlockReason}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{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 ? '取消全局置顶' : '全局置顶'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleBoardPin}>
|
|
||||||
<Pin />
|
|
||||||
{post.board_pinned ? '取消板块置顶' : '板块置顶'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleLock}>
|
|
||||||
<Lock />
|
|
||||||
{post.edit_locked ? '解锁编辑' : '锁定编辑'}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleCommentsLock}>
|
|
||||||
{post.comments_locked ? <LockOpen /> : <MessageSquareOff />}
|
|
||||||
{post.comments_locked ? '开放讨论' : '锁定讨论'}
|
|
||||||
</Button>
|
|
||||||
{post.status !== 'rejected' && (
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setRejectOpen(true)}>
|
|
||||||
<Ban />
|
|
||||||
拒绝并通知
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确定删除该帖子?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
帖子与评论将移入回收站,可在后台恢复或永久删除。普通用户不可自行删除内容。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deletingPost}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={deletingPost}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleDeletePost();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
<Dialog open={reportOpen} onOpenChange={setReportOpen}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -1122,7 +1122,45 @@ export default function PostDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : !replyTo && (
|
) : !replyTo && (
|
||||||
<div className="comment-box-wrap" ref={commentBoxRef}>
|
<div className="comment-box-wrap" ref={commentBoxRef}>
|
||||||
<CommentBox {...commentBoxProps} />
|
{isMobile && !composerOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="comment-composer-collapsed"
|
||||||
|
onClick={() => {
|
||||||
|
if (!user) {
|
||||||
|
requireLogin('评论');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setComposerOpen(true);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.setTimeout(focusCommentEditor, 50);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<span className="comment-composer-collapsed-avatar" aria-hidden>
|
||||||
|
{user.avatar
|
||||||
|
? <img src={user.avatar} alt="" loading="lazy" decoding="async" />
|
||||||
|
: (user.nickname?.[0] || '?')}
|
||||||
|
</span>
|
||||||
|
<span className="comment-composer-collapsed-placeholder">说点什么…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="comment-composer-collapsed-placeholder">登录后即可评论</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isMobile && !composerOpen
|
||||||
|
? 'comment-box-expanded-slot is-collapsed'
|
||||||
|
: 'comment-box-expanded-slot'
|
||||||
|
}
|
||||||
|
aria-hidden={isMobile && !composerOpen}
|
||||||
|
>
|
||||||
|
<CommentBox {...commentBoxProps} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
87
frontend/src/pages/ShowcasePage.tsx
Normal file
87
frontend/src/pages/ShowcasePage.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { ExternalLink, Globe2 } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { CommunityShowcaseItem } from '../api/types';
|
||||||
|
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||||
|
import { getCachedSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
|
||||||
|
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||||
|
|
||||||
|
/** 官方精选的公网部署展柜(只读;仅人工精选条目) */
|
||||||
|
export default function ShowcasePage() {
|
||||||
|
const { branding } = useSiteBranding();
|
||||||
|
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
usePageSEO({
|
||||||
|
title: '开源部署展柜',
|
||||||
|
description: `${branding.name} 精选的姜十三论坛公网部署`,
|
||||||
|
keywords: joinSEOKeywords('开源', '部署', '展柜', getCachedSiteBranding().keywords),
|
||||||
|
canonicalPath: '/showcase',
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
api.communityShowcase()
|
||||||
|
.then((r) => {
|
||||||
|
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setItems([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="showcase-page">
|
||||||
|
<header className="showcase-head">
|
||||||
|
<div className="showcase-head-mark" aria-hidden>
|
||||||
|
<Globe2 size={22} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="showcase-title">开源部署展柜</h1>
|
||||||
|
<p className="showcase-desc">
|
||||||
|
以下站点自愿开启社区上报,并由官方演示站精选推荐(非全量目录)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="showcase-empty">暂无精选实例</p>
|
||||||
|
) : (
|
||||||
|
<ul className="showcase-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.site_url} className="showcase-item">
|
||||||
|
<a
|
||||||
|
href={item.site_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="showcase-item-link"
|
||||||
|
>
|
||||||
|
<span className="showcase-item-name">{item.site_name || '未命名站点'}</span>
|
||||||
|
<span className="showcase-item-url">
|
||||||
|
{item.site_url}
|
||||||
|
<ExternalLink size={12} aria-hidden />
|
||||||
|
</span>
|
||||||
|
{(item.featured_note || item.version) && (
|
||||||
|
<span className="showcase-item-meta">
|
||||||
|
{item.featured_note || null}
|
||||||
|
{item.featured_note && item.version ? ' · ' : null}
|
||||||
|
{item.version ? `v${item.version}` : null}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InFlowSiteFooter />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
302
frontend/src/pages/admin/AdminCommunityPage.tsx
Normal file
302
frontend/src/pages/admin/AdminCommunityPage.tsx
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Globe2, ExternalLink, Star } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type { CommunityInstance, ForumLimits } from '../../api/types';
|
||||||
|
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||||
|
import { formatTime } from '../../utils/content';
|
||||||
|
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||||
|
import {
|
||||||
|
mergeForumLimitsWithAsideWidgets,
|
||||||
|
normalizeAsideWidgets,
|
||||||
|
resolveAsideWidgets,
|
||||||
|
} from '../../utils/asideWidgets';
|
||||||
|
|
||||||
|
type EntryFlags = {
|
||||||
|
nav: boolean;
|
||||||
|
footer: boolean;
|
||||||
|
aside: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 后台:公网实例列表 + 开源展柜入口位置 */
|
||||||
|
export default function AdminCommunityPage() {
|
||||||
|
const { ready } = useAdminGuard();
|
||||||
|
const [hubEnabled, setHubEnabled] = useState(false);
|
||||||
|
const [list, setList] = useState<CommunityInstance[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [featuringId, setFeaturingId] = useState<string | null>(null);
|
||||||
|
const [forumLimits, setForumLimits] = useState<ForumLimits | null>(null);
|
||||||
|
const [entrySaving, setEntrySaving] = useState(false);
|
||||||
|
|
||||||
|
const entryFlags = useMemo<EntryFlags>(() => {
|
||||||
|
const widgets = resolveAsideWidgets({
|
||||||
|
aside_widgets: forumLimits?.aside_widgets,
|
||||||
|
aside_show_tag_cloud: forumLimits?.aside_show_tag_cloud ?? false,
|
||||||
|
aside_show_recent_comments: forumLimits?.aside_show_recent_comments ?? false,
|
||||||
|
aside_show_friend_links: forumLimits?.aside_show_friend_links ?? true,
|
||||||
|
aside_show_showcase: forumLimits?.aside_show_showcase ?? false,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
nav: !!forumLimits?.nav_show_showcase,
|
||||||
|
footer: !!forumLimits?.footer_show_showcase,
|
||||||
|
aside: widgets.find(w => w.id === 'showcase')?.enabled ?? false,
|
||||||
|
};
|
||||||
|
}, [forumLimits]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
setLoading(true);
|
||||||
|
Promise.all([
|
||||||
|
api.adminCommunityInstances(),
|
||||||
|
api.adminSettings().catch(() => null),
|
||||||
|
])
|
||||||
|
.then(([instancesRes, settings]) => {
|
||||||
|
setHubEnabled(!!instancesRes.hub_enabled);
|
||||||
|
setList(Array.isArray(instancesRes.instances) ? instancesRes.instances : []);
|
||||||
|
if (settings?.limits) {
|
||||||
|
const loaded = normalizeAsideWidgets(
|
||||||
|
resolveAsideWidgets({
|
||||||
|
aside_widgets: settings.limits.aside_widgets,
|
||||||
|
aside_show_tag_cloud: settings.limits.aside_show_tag_cloud ?? false,
|
||||||
|
aside_show_recent_comments: settings.limits.aside_show_recent_comments ?? false,
|
||||||
|
aside_show_friend_links: settings.limits.aside_show_friend_links ?? true,
|
||||||
|
aside_show_showcase: settings.limits.aside_show_showcase ?? false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setForumLimits(mergeForumLimitsWithAsideWidgets({
|
||||||
|
...settings.limits,
|
||||||
|
nav_show_showcase: !!settings.limits.nav_show_showcase,
|
||||||
|
footer_show_showcase: !!settings.limits.footer_show_showcase,
|
||||||
|
aside_show_showcase: !!settings.limits.aside_show_showcase,
|
||||||
|
}, loaded));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setList([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [ready]);
|
||||||
|
|
||||||
|
const patchEntryVisibility = async (patch: Partial<EntryFlags>) => {
|
||||||
|
if (entrySaving) return;
|
||||||
|
const prev = entryFlags;
|
||||||
|
const nextFlags = { ...prev, ...patch };
|
||||||
|
setForumLimits((fl) => {
|
||||||
|
if (!fl) return fl;
|
||||||
|
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
|
||||||
|
w.id === 'showcase' ? { ...w, enabled: nextFlags.aside } : w
|
||||||
|
));
|
||||||
|
return mergeForumLimitsWithAsideWidgets({
|
||||||
|
...fl,
|
||||||
|
nav_show_showcase: nextFlags.nav,
|
||||||
|
footer_show_showcase: nextFlags.footer,
|
||||||
|
aside_show_showcase: nextFlags.aside,
|
||||||
|
}, widgets);
|
||||||
|
});
|
||||||
|
setEntrySaving(true);
|
||||||
|
try {
|
||||||
|
const body: {
|
||||||
|
nav_show_showcase?: boolean;
|
||||||
|
footer_show_showcase?: boolean;
|
||||||
|
aside_show_showcase?: boolean;
|
||||||
|
} = {};
|
||||||
|
if (patch.nav !== undefined) body.nav_show_showcase = patch.nav;
|
||||||
|
if (patch.footer !== undefined) body.footer_show_showcase = patch.footer;
|
||||||
|
if (patch.aside !== undefined) body.aside_show_showcase = patch.aside;
|
||||||
|
const r = await api.adminUpdateShowcaseEntry(body);
|
||||||
|
setForumLimits((base) => {
|
||||||
|
if (!base) return base;
|
||||||
|
const widgets = normalizeAsideWidgets(base.aside_widgets).map(w => (
|
||||||
|
w.id === 'showcase' ? { ...w, enabled: r.aside_show_showcase } : w
|
||||||
|
));
|
||||||
|
return mergeForumLimitsWithAsideWidgets({
|
||||||
|
...base,
|
||||||
|
nav_show_showcase: r.nav_show_showcase,
|
||||||
|
footer_show_showcase: r.footer_show_showcase,
|
||||||
|
aside_show_showcase: r.aside_show_showcase,
|
||||||
|
}, widgets);
|
||||||
|
});
|
||||||
|
invalidateForumLimitsCache();
|
||||||
|
notify.success(r.message);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setForumLimits((fl) => {
|
||||||
|
if (!fl) return fl;
|
||||||
|
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
|
||||||
|
w.id === 'showcase' ? { ...w, enabled: prev.aside } : w
|
||||||
|
));
|
||||||
|
return mergeForumLimitsWithAsideWidgets({
|
||||||
|
...fl,
|
||||||
|
nav_show_showcase: prev.nav,
|
||||||
|
footer_show_showcase: prev.footer,
|
||||||
|
aside_show_showcase: prev.aside,
|
||||||
|
}, widgets);
|
||||||
|
});
|
||||||
|
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setEntrySaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleFeatured = async (row: CommunityInstance) => {
|
||||||
|
if (featuringId) return;
|
||||||
|
setFeaturingId(row.instance_id);
|
||||||
|
try {
|
||||||
|
const r = await api.adminFeatureCommunityInstance(row.instance_id, {
|
||||||
|
featured: !row.featured,
|
||||||
|
featured_note: row.featured_note || '',
|
||||||
|
});
|
||||||
|
setList((prev) => prev.map((item) => (
|
||||||
|
item.instance_id === row.instance_id ? { ...item, ...r.instance } : item
|
||||||
|
)));
|
||||||
|
notify.success(r.message);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||||
|
} finally {
|
||||||
|
setFeaturingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready || loading) {
|
||||||
|
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page">
|
||||||
|
<div className="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-page-title">
|
||||||
|
<Globe2 size={22} aria-hidden />
|
||||||
|
公网实例
|
||||||
|
</h1>
|
||||||
|
<p className="admin-page-desc">
|
||||||
|
接收自愿上报的心跳;设为精选后会出现在
|
||||||
|
{' '}
|
||||||
|
<Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer">开源部署展柜</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hubEnabled && (
|
||||||
|
<div className="admin-card admin-settings-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="admin-card-body">
|
||||||
|
<p>
|
||||||
|
本站未开启社区枢纽。该能力仅供官方主站运维配置开启(
|
||||||
|
<code>app.ini</code> 的 <code>[community] hub = true</code>
|
||||||
|
{' '}或环境变量 <code>JIANG13_COMMUNITY_HUB=1</code>
|
||||||
|
),普通部署无需也无法在后台打开。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hubEnabled && (
|
||||||
|
<div className="admin-card admin-links-entry-card" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="admin-card-head">展柜入口</div>
|
||||||
|
<p className="admin-card-desc">
|
||||||
|
控制「开源展柜」出现在何处;关闭后仍可直接访问 /showcase。右侧栏开关与「系统设置 → 右侧栏组件」同源。
|
||||||
|
</p>
|
||||||
|
<div className="admin-card-body admin-links-entry-body">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
{ key: 'nav' as const, label: '左侧栏(站点)', on: entryFlags.nav },
|
||||||
|
{ key: 'aside' as const, label: '右侧栏', on: entryFlags.aside },
|
||||||
|
{ key: 'footer' as const, label: '页脚', on: entryFlags.footer },
|
||||||
|
]
|
||||||
|
).map(item => (
|
||||||
|
<div key={item.key} className="admin-links-entry-row">
|
||||||
|
<span id={`admin-showcase-entry-${item.key}`}>{item.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={item.on}
|
||||||
|
aria-labelledby={`admin-showcase-entry-${item.key}`}
|
||||||
|
disabled={entrySaving}
|
||||||
|
className={cn('admin-settings-switch', item.on && 'is-on')}
|
||||||
|
onClick={() => void patchEntryVisibility({ [item.key]: !item.on })}
|
||||||
|
>
|
||||||
|
<span className="admin-settings-switch-ui" aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-card-head">
|
||||||
|
<span>实例列表</span>
|
||||||
|
<span className="admin-settings-card-badge">{list.length} 个</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
|
{list.length === 0 ? (
|
||||||
|
<p className="admin-empty" style={{ padding: 24 }}>暂无上报记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>站点</th>
|
||||||
|
<th>版本</th>
|
||||||
|
<th>用户</th>
|
||||||
|
<th>帖子</th>
|
||||||
|
<th>最近心跳</th>
|
||||||
|
<th>精选</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((row) => (
|
||||||
|
<tr key={row.instance_id}>
|
||||||
|
<td>
|
||||||
|
<Badge variant={row.online ? 'default' : 'secondary'}>
|
||||||
|
{row.online ? '在线' : '离线'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="admin-community-site">
|
||||||
|
<strong>
|
||||||
|
{row.featured && <Star size={12} className="admin-community-star" aria-hidden />}
|
||||||
|
{row.site_name || '未命名站点'}
|
||||||
|
</strong>
|
||||||
|
<a
|
||||||
|
href={row.site_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cn('admin-inline-link', 'admin-community-url')}
|
||||||
|
>
|
||||||
|
{row.site_url}
|
||||||
|
<ExternalLink size={12} aria-hidden />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><code>{row.version || '—'}</code></td>
|
||||||
|
<td>{row.users}</td>
|
||||||
|
<td>{row.posts}</td>
|
||||||
|
<td title={row.last_seen_at}>{formatTime(row.last_seen_at)}</td>
|
||||||
|
<td>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
loading={featuringId === row.instance_id}
|
||||||
|
disabled={!!featuringId}
|
||||||
|
onClick={() => void handleToggleFeatured(row)}
|
||||||
|
>
|
||||||
|
{row.featured ? '取消精选' : '精选'}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,37 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { FileText, Flag, MessageSquare, Link2 } from 'lucide-react';
|
import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||||
import type { AdminDashboard } from '../../api/types';
|
import type { AdminDashboard, AdminDashboardTraffic } from '../../api/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip';
|
||||||
|
|
||||||
|
function formatDashNum(n: number) {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return String(n ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pvTrendLabel(today: number, yesterday: number) {
|
||||||
|
if (yesterday <= 0) {
|
||||||
|
return today > 0 ? '昨日无数据' : '较昨日 —';
|
||||||
|
}
|
||||||
|
const delta = ((today - yesterday) / yesterday) * 100;
|
||||||
|
if (Math.abs(delta) < 0.5) return '与昨日持平';
|
||||||
|
const abs = Math.abs(delta).toFixed(0);
|
||||||
|
return delta > 0 ? `较昨日 ↑ ${abs}%` : `较昨日 ↓ ${abs}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trendTone(today: number, yesterday: number): 'up' | 'down' | 'flat' {
|
||||||
|
if (yesterday <= 0) return 'flat';
|
||||||
|
const delta = today - yesterday;
|
||||||
|
if (delta > 0) return 'up';
|
||||||
|
if (delta < 0) return 'down';
|
||||||
|
return 'flat';
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminDashboardPage() {
|
export default function AdminDashboardPage() {
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
@@ -31,20 +56,16 @@ export default function AdminDashboardPage() {
|
|||||||
const pendingReports = data.pending_reports ?? 0;
|
const pendingReports = data.pending_reports ?? 0;
|
||||||
const pendingFriendLinks = data.pending_friend_links ?? 0;
|
const pendingFriendLinks = data.pending_friend_links ?? 0;
|
||||||
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
|
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
|
||||||
|
const traffic: AdminDashboardTraffic = data.traffic ?? {
|
||||||
const stats = [
|
enabled: false, today_pv: 0, today_uv: 0, yesterday_pv: 0, total_pv: 0,
|
||||||
{ label: '注册用户', value: data.users },
|
};
|
||||||
{ label: '帖子总数', value: data.posts },
|
|
||||||
{ label: '板块数量', value: data.boards },
|
|
||||||
{ label: '评论总数', value: data.comments },
|
|
||||||
];
|
|
||||||
|
|
||||||
const queues = [
|
const queues = [
|
||||||
{
|
{
|
||||||
key: 'posts',
|
key: 'posts',
|
||||||
label: '待审帖子',
|
label: '待审帖子',
|
||||||
count: pendingPosts,
|
count: pendingPosts,
|
||||||
hint: '新帖与修改待审核',
|
hint: '新帖与修改',
|
||||||
to: '/admin/posts',
|
to: '/admin/posts',
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
},
|
},
|
||||||
@@ -52,7 +73,7 @@ export default function AdminDashboardPage() {
|
|||||||
key: 'comments',
|
key: 'comments',
|
||||||
label: '待审评论',
|
label: '待审评论',
|
||||||
count: pendingComments,
|
count: pendingComments,
|
||||||
hint: '评论与回复待审核',
|
hint: '评论与回复',
|
||||||
to: '/admin/comments',
|
to: '/admin/comments',
|
||||||
icon: MessageSquare,
|
icon: MessageSquare,
|
||||||
},
|
},
|
||||||
@@ -60,7 +81,7 @@ export default function AdminDashboardPage() {
|
|||||||
key: 'reports',
|
key: 'reports',
|
||||||
label: '待处理举报',
|
label: '待处理举报',
|
||||||
count: pendingReports,
|
count: pendingReports,
|
||||||
hint: '用户举报需人工处理',
|
hint: '用户举报',
|
||||||
to: '/admin/reports',
|
to: '/admin/reports',
|
||||||
icon: Flag,
|
icon: Flag,
|
||||||
},
|
},
|
||||||
@@ -68,65 +89,128 @@ export default function AdminDashboardPage() {
|
|||||||
key: 'links',
|
key: 'links',
|
||||||
label: '待审友链',
|
label: '待审友链',
|
||||||
count: pendingFriendLinks,
|
count: pendingFriendLinks,
|
||||||
hint: '用户提交的友情链接申请',
|
hint: '友链申请',
|
||||||
to: '/admin/links',
|
to: '/admin/links',
|
||||||
icon: Link2,
|
icon: Link2,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
const scale = [
|
||||||
<div className="admin-page">
|
{ label: '注册用户', value: data.users },
|
||||||
<div className="admin-page-head">
|
{ label: '帖子总数', value: data.posts },
|
||||||
<h1>仪表盘</h1>
|
{ label: '板块数量', value: data.boards },
|
||||||
<p>优先处理待办,再查看运行概览</p>
|
{ label: '评论总数', value: data.comments },
|
||||||
</div>
|
];
|
||||||
|
|
||||||
<section className="admin-queue-section" aria-label="待处理事项">
|
const tone = trendTone(traffic.today_pv, traffic.yesterday_pv);
|
||||||
<div className="admin-section-label">
|
|
||||||
<span>待处理</span>
|
return (
|
||||||
{pendingTotal > 0 ? (
|
<div className="admin-page admin-dash-page">
|
||||||
<Badge variant="orange">{pendingTotal} 项</Badge>
|
<div className="admin-page-head admin-dash-head">
|
||||||
) : (
|
<div className="admin-dash-head-title">
|
||||||
<span className="admin-section-muted">暂无积压</span>
|
<h1>仪表盘</h1>
|
||||||
|
{pendingTotal > 0 && (
|
||||||
|
<Badge variant="orange">{pendingTotal} 项待处理</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-queue-grid">
|
<p>处理待办,并一眼看到今日访问</p>
|
||||||
{queues.map(q => {
|
</div>
|
||||||
const Icon = q.icon;
|
|
||||||
const hasWork = q.count > 0;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={q.key}
|
|
||||||
type="button"
|
|
||||||
className={cn('admin-queue-card', hasWork && 'has-work')}
|
|
||||||
onClick={() => nav(q.to)}
|
|
||||||
>
|
|
||||||
<div className="admin-queue-card-top">
|
|
||||||
<Icon size={18} aria-hidden />
|
|
||||||
<span className="admin-queue-count">{q.count}</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-queue-label">{q.label}</div>
|
|
||||||
<div className="admin-queue-hint">{q.hint}</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="admin-stat-section" aria-label="运行概览">
|
<div className="admin-dash-bento">
|
||||||
<div className="admin-section-label">
|
<section className="admin-dash-traffic" aria-label="今日访问">
|
||||||
<span>运行概览</span>
|
<div className="admin-dash-traffic-top">
|
||||||
|
<div className="admin-dash-section-label">
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2>今日访问</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-text-link admin-dash-link"
|
||||||
|
onClick={() => nav('/admin/monitor')}
|
||||||
|
>
|
||||||
|
<Activity size={14} aria-hidden />
|
||||||
|
网站监控 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!traffic.enabled ? (
|
||||||
|
<div className="admin-dash-traffic-empty">
|
||||||
|
<p>访问采集尚未开启,流量统计暂不可用。</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-text-link"
|
||||||
|
onClick={() => nav('/admin/monitor?tab=settings')}
|
||||||
|
>
|
||||||
|
前往开启 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="admin-dash-traffic-pv">{formatDashNum(traffic.today_pv)}</div>
|
||||||
|
<div className="admin-dash-traffic-meta">
|
||||||
|
<span>今日访问 (PV)</span>
|
||||||
|
<span className="admin-dash-traffic-uv">今日访客 (UV) {formatDashNum(traffic.today_uv)}</span>
|
||||||
|
</div>
|
||||||
|
<div className={cn('admin-dash-traffic-trend', `is-${tone}`)}>
|
||||||
|
{pvTrendLabel(traffic.today_pv, traffic.yesterday_pv)}
|
||||||
|
</div>
|
||||||
|
<p className="admin-dash-traffic-total">
|
||||||
|
累计访问 {formatDashNum(traffic.total_pv)}
|
||||||
|
<span className="admin-dash-traffic-hint">(受日志保留天数影响)</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-dash-queue" aria-label="待处理事项">
|
||||||
|
<div className="admin-dash-section-label admin-dash-queue-head">
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2>待处理</h2>
|
||||||
|
{pendingTotal === 0 && (
|
||||||
|
<span className="admin-section-muted">暂无积压</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-dash-queue-grid">
|
||||||
|
{queues.map((q) => {
|
||||||
|
const Icon = q.icon;
|
||||||
|
const hasWork = q.count > 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={q.key}
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-dash-queue-card', hasWork && 'has-work')}
|
||||||
|
onClick={() => nav(q.to)}
|
||||||
|
>
|
||||||
|
<div className="admin-dash-queue-card-top">
|
||||||
|
<Icon size={16} aria-hidden />
|
||||||
|
<span className="admin-dash-queue-count">{q.count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-dash-queue-label">{q.label}</div>
|
||||||
|
<div className="admin-dash-queue-hint">{q.hint}</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="admin-dash-scale" aria-label="内容规模">
|
||||||
|
<div className="admin-dash-section-label">
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2>内容规模</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-stat-grid">
|
<div className="admin-dash-metric-grid">
|
||||||
{stats.map(s => (
|
{scale.map((s) => (
|
||||||
<div key={s.label} className="admin-stat-card">
|
<div key={s.label} className="admin-dash-metric-card">
|
||||||
<div className="admin-stat-value">{s.value}</div>
|
<div className="admin-dash-metric-value">{formatDashNum(s.value)}</div>
|
||||||
<div className="admin-stat-label">{s.label}</div>
|
<div className="admin-dash-metric-label">{s.label}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<CommunitySupportStrip />
|
||||||
|
|
||||||
<div className="admin-card">
|
<div className="admin-card">
|
||||||
<div className="admin-card-head">
|
<div className="admin-card-head">
|
||||||
<span>最新帖子</span>
|
<span>最新帖子</span>
|
||||||
@@ -145,7 +229,7 @@ export default function AdminDashboardPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.recent_posts.map(p => (
|
{data.recent_posts.map((p) => (
|
||||||
<tr key={p.id}>
|
<tr key={p.id}>
|
||||||
<td>{p.id}</td>
|
<td>{p.id}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|||||||
@@ -178,9 +178,12 @@ export default function AdminLinksPage() {
|
|||||||
aside_show_tag_cloud: false,
|
aside_show_tag_cloud: false,
|
||||||
aside_show_recent_comments: false,
|
aside_show_recent_comments: false,
|
||||||
aside_show_friend_links: true,
|
aside_show_friend_links: true,
|
||||||
|
aside_show_showcase: false,
|
||||||
aside_widgets: loadedAsideWidgets,
|
aside_widgets: loadedAsideWidgets,
|
||||||
nav_show_friend_links: true,
|
nav_show_friend_links: true,
|
||||||
footer_show_friend_links: true,
|
footer_show_friend_links: true,
|
||||||
|
nav_show_showcase: false,
|
||||||
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
@@ -350,14 +353,18 @@ export default function AdminLinksPage() {
|
|||||||
aside_show_tag_cloud: false,
|
aside_show_tag_cloud: false,
|
||||||
aside_show_recent_comments: false,
|
aside_show_recent_comments: false,
|
||||||
aside_show_friend_links: nextFlags.aside,
|
aside_show_friend_links: nextFlags.aside,
|
||||||
|
aside_show_showcase: false,
|
||||||
aside_widgets: normalizeAsideWidgets([
|
aside_widgets: normalizeAsideWidgets([
|
||||||
{ id: 'tag_cloud', enabled: false },
|
{ id: 'tag_cloud', enabled: false },
|
||||||
{ id: 'recent_comments', enabled: false },
|
{ id: 'recent_comments', enabled: false },
|
||||||
{ id: 'recent_users', enabled: false },
|
{ id: 'recent_users', enabled: false },
|
||||||
{ id: 'friend_links', enabled: nextFlags.aside },
|
{ id: 'friend_links', enabled: nextFlags.aside },
|
||||||
|
{ id: 'showcase', enabled: false },
|
||||||
]),
|
]),
|
||||||
nav_show_friend_links: nextFlags.nav,
|
nav_show_friend_links: nextFlags.nav,
|
||||||
footer_show_friend_links: nextFlags.footer,
|
footer_show_friend_links: nextFlags.footer,
|
||||||
|
nav_show_showcase: false,
|
||||||
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
|
|||||||
707
frontend/src/pages/admin/AdminMonitorPage.tsx
Normal file
707
frontend/src/pages/admin/AdminMonitorPage.tsx
Normal file
@@ -0,0 +1,707 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Activity, AlertTriangle } from 'lucide-react';
|
||||||
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { notify } from '@/lib/notify';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type {
|
||||||
|
MonitorASNItem,
|
||||||
|
MonitorCityItem,
|
||||||
|
MonitorConfig,
|
||||||
|
MonitorGeoResult,
|
||||||
|
MonitorLogItem,
|
||||||
|
MonitorOverview,
|
||||||
|
MonitorRealtime,
|
||||||
|
MonitorStatItem,
|
||||||
|
} from '../../api/types';
|
||||||
|
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||||
|
import { formatTime } from '../../utils/content';
|
||||||
|
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||||
|
import MonitorWorldMap from '../../components/admin/MonitorWorldMap';
|
||||||
|
import MonitorChinaMap from '../../components/admin/MonitorChinaMap';
|
||||||
|
import { countryLabelZh } from '../../components/admin/monitorMapUtils';
|
||||||
|
|
||||||
|
type TabKey = 'overview' | 'stats' | 'logs' | 'settings';
|
||||||
|
type StatsDim = 'url' | 'referer' | 'browser' | 'os' | 'device' | 'status';
|
||||||
|
type StatsRange = '1d' | '7d' | '30d' | '90d';
|
||||||
|
type MapMode = 'world' | 'china';
|
||||||
|
type RankMode = 'city' | 'asn';
|
||||||
|
|
||||||
|
function countryLabel(code: string) {
|
||||||
|
return countryLabelZh(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (!n || n < 0) return '0 B';
|
||||||
|
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
let v = n;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < u.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNum(n: number) {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return String(n ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiniSparkline({ series }: { series: { count: number }[] }) {
|
||||||
|
const max = Math.max(1, ...series.map((p) => p.count));
|
||||||
|
const w = 240;
|
||||||
|
const h = 48;
|
||||||
|
const pts = series.map((p, i) => {
|
||||||
|
const x = series.length <= 1 ? 0 : (i / (series.length - 1)) * w;
|
||||||
|
const y = h - (p.count / max) * (h - 4) - 2;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(' ');
|
||||||
|
return (
|
||||||
|
<svg className="admin-monitor-spark" viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden>
|
||||||
|
<polyline fill="none" stroke="var(--j13-green)" strokeWidth="2" points={pts} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHINA_CODES = new Set(['CN', 'HK', 'TW', 'MO']);
|
||||||
|
|
||||||
|
function geoCountries(geo: MonitorGeoResult | null) {
|
||||||
|
return geo?.countries || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeoRankTable({
|
||||||
|
cities,
|
||||||
|
asns,
|
||||||
|
mode,
|
||||||
|
mapMode,
|
||||||
|
rankMode,
|
||||||
|
onRankMode,
|
||||||
|
}: {
|
||||||
|
cities: MonitorCityItem[];
|
||||||
|
asns: MonitorASNItem[];
|
||||||
|
mode: MapMode;
|
||||||
|
rankMode: RankMode;
|
||||||
|
onRankMode: (m: RankMode) => void;
|
||||||
|
}) {
|
||||||
|
const cityRows = mode === 'china'
|
||||||
|
? cities.filter((i) => CHINA_CODES.has((i.country || '').toUpperCase()))
|
||||||
|
: cities;
|
||||||
|
const rows = rankMode === 'city' ? cityRows : asns;
|
||||||
|
const emptyHint = rankMode === 'city'
|
||||||
|
? '放置 IP2Location DB3 BIN 后可见城市排行'
|
||||||
|
: '放置 GeoLite2-ASN.mmdb 后可见运营商排行';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-monitor-rank">
|
||||||
|
<div className="admin-monitor-seg" role="group" aria-label="排行维度">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-monitor-seg-btn', rankMode === 'city' && 'active')}
|
||||||
|
onClick={() => onRankMode('city')}
|
||||||
|
>
|
||||||
|
城市
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-monitor-seg-btn', rankMode === 'asn' && 'active')}
|
||||||
|
onClick={() => onRankMode('asn')}
|
||||||
|
>
|
||||||
|
运营商
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="admin-monitor-geo-empty">
|
||||||
|
<p>暂无来源数据</p>
|
||||||
|
<p className="admin-monitor-muted">{emptyHint}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="admin-monitor-geo-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{rankMode === 'city' ? '城市' : '运营商'}</th>
|
||||||
|
<th>数量</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rankMode === 'city'
|
||||||
|
? cityRows.slice(0, 12).map((item, idx) => (
|
||||||
|
<tr key={`${item.country}-${item.city}-${idx}`}>
|
||||||
|
<td title={[item.region, item.city].filter(Boolean).join(' · ')}>
|
||||||
|
{item.city}
|
||||||
|
{item.region ? <span className="admin-monitor-muted"> · {item.region}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td>{formatNum(item.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
: asns.slice(0, 12).map((item) => (
|
||||||
|
<tr key={item.asn}>
|
||||||
|
<td title={item.as_org || `AS${item.asn}`}>
|
||||||
|
{item.as_org || `AS${item.asn}`}
|
||||||
|
<span className="admin-monitor-muted"> · AS{item.asn}</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatNum(item.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMonitorTab(raw: string | null): TabKey {
|
||||||
|
if (raw === 'stats' || raw === 'logs' || raw === 'settings' || raw === 'overview') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return 'overview';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminMonitorPage() {
|
||||||
|
const { ready } = useAdminGuard();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const [tab, setTab] = useState<TabKey>(() => parseMonitorTab(searchParams.get('tab')));
|
||||||
|
const [overview, setOverview] = useState<MonitorOverview | null>(null);
|
||||||
|
const [geo, setGeo] = useState<MonitorGeoResult | null>(null);
|
||||||
|
const [realtime, setRealtime] = useState<MonitorRealtime | null>(null);
|
||||||
|
const [mapMode, setMapMode] = useState<MapMode>('world');
|
||||||
|
const [rankMode, setRankMode] = useState<RankMode>('city');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const [statsDim, setStatsDim] = useState<StatsDim>('url');
|
||||||
|
const [statsRange, setStatsRange] = useState<StatsRange>('30d');
|
||||||
|
const [statsItems, setStatsItems] = useState<MonitorStatItem[]>([]);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [logs, setLogs] = useState<MonitorLogItem[]>([]);
|
||||||
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
|
const [logsPage, setLogsPage] = useState(1);
|
||||||
|
const [logMethod, setLogMethod] = useState('');
|
||||||
|
const [logPath, setLogPath] = useState('');
|
||||||
|
const [logStatus, setLogStatus] = useState('');
|
||||||
|
const [logIP, setLogIP] = useState('');
|
||||||
|
const [logsLoading, setLogsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<MonitorConfig | null>(null);
|
||||||
|
const [excludeText, setExcludeText] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const next = parseMonitorTab(searchParams.get('tab'));
|
||||||
|
setTab((prev) => (prev === next ? prev : next));
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
|
const selectTab = (key: TabKey) => {
|
||||||
|
setTab(key);
|
||||||
|
const next = new URLSearchParams(searchParams);
|
||||||
|
if (key === 'overview') next.delete('tab');
|
||||||
|
else next.set('tab', key);
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
const [ov, g, rt] = await Promise.all([
|
||||||
|
api.adminMonitorOverview(),
|
||||||
|
api.adminMonitorGeo('30d'),
|
||||||
|
api.adminMonitorRealtime(),
|
||||||
|
]);
|
||||||
|
setOverview(ov);
|
||||||
|
setGeo(g);
|
||||||
|
setRealtime(rt);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready) return;
|
||||||
|
setLoading(true);
|
||||||
|
loadOverview()
|
||||||
|
.catch(() => notify.error('加载监控概览失败'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [ready, loadOverview]);
|
||||||
|
|
||||||
|
// 概览页实时轮询
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || tab !== 'overview') return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
api.adminMonitorRealtime().then(setRealtime).catch(() => {});
|
||||||
|
api.adminMonitorOverview().then(setOverview).catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [ready, tab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || tab !== 'stats') return;
|
||||||
|
setStatsLoading(true);
|
||||||
|
api.adminMonitorStats(statsDim, statsRange)
|
||||||
|
.then((r) => setStatsItems(r.items || []))
|
||||||
|
.catch(() => notify.error('加载统计失败'))
|
||||||
|
.finally(() => setStatsLoading(false));
|
||||||
|
}, [ready, tab, statsDim, statsRange]);
|
||||||
|
|
||||||
|
const loadLogs = useCallback(async (page = 1) => {
|
||||||
|
setLogsLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await api.adminMonitorLogs({
|
||||||
|
page,
|
||||||
|
size: 20,
|
||||||
|
method: logMethod || undefined,
|
||||||
|
path: logPath || undefined,
|
||||||
|
status: logStatus || undefined,
|
||||||
|
ip: logIP || undefined,
|
||||||
|
});
|
||||||
|
setLogs(r.items || []);
|
||||||
|
setLogsTotal(r.total || 0);
|
||||||
|
setLogsPage(r.page || page);
|
||||||
|
} catch {
|
||||||
|
notify.error('加载请求日志失败');
|
||||||
|
} finally {
|
||||||
|
setLogsLoading(false);
|
||||||
|
}
|
||||||
|
}, [logMethod, logPath, logStatus, logIP]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || tab !== 'logs') return;
|
||||||
|
loadLogs(1);
|
||||||
|
}, [ready, tab, loadLogs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ready || tab !== 'settings') return;
|
||||||
|
api.adminMonitorSettings()
|
||||||
|
.then((cfg) => {
|
||||||
|
setSettings(cfg);
|
||||||
|
setExcludeText((cfg.exclude_rules || []).join('\n'));
|
||||||
|
})
|
||||||
|
.catch(() => notify.error('加载设置失败'));
|
||||||
|
}, [ready, tab]);
|
||||||
|
|
||||||
|
const enabled = overview?.enabled ?? settings?.enabled ?? false;
|
||||||
|
|
||||||
|
const metrics = useMemo(() => {
|
||||||
|
if (!overview) return [];
|
||||||
|
return [
|
||||||
|
{ label: '浏览量', value: formatNum(overview.pageviews) },
|
||||||
|
{ label: '访客数', value: formatNum(overview.visitors) },
|
||||||
|
{ label: '独立 IP', value: formatNum(overview.unique_ips) },
|
||||||
|
{ label: '流量', value: formatBytes(overview.traffic) },
|
||||||
|
{ label: '蜘蛛', value: formatNum(overview.bots) },
|
||||||
|
{ label: '请求数', value: formatNum(overview.requests) },
|
||||||
|
{ label: '4xx', value: formatNum(overview.status_4xx) },
|
||||||
|
{ label: '5xx', value: formatNum(overview.status_5xx) },
|
||||||
|
];
|
||||||
|
}, [overview]);
|
||||||
|
|
||||||
|
const saveSettings = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const rules = excludeText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
||||||
|
const r = await api.adminUpdateMonitorSettings({
|
||||||
|
...settings,
|
||||||
|
exclude_rules: rules,
|
||||||
|
});
|
||||||
|
setSettings(r.monitor);
|
||||||
|
setExcludeText((r.monitor.exclude_rules || []).join('\n'));
|
||||||
|
invalidateForumLimitsCache();
|
||||||
|
notify.success(r.message);
|
||||||
|
const ov = await api.adminMonitorOverview();
|
||||||
|
setOverview(ov);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!ready || (loading && tab === 'overview')) {
|
||||||
|
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { key: TabKey; label: string }[] = [
|
||||||
|
{ key: 'overview', label: '概览' },
|
||||||
|
{ key: 'stats', label: '访问统计' },
|
||||||
|
{ key: 'logs', label: '请求日志' },
|
||||||
|
{ key: 'settings', label: '设置' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const dims: { key: StatsDim; label: string }[] = [
|
||||||
|
{ key: 'url', label: 'URL' },
|
||||||
|
{ key: 'referer', label: '来源' },
|
||||||
|
{ key: 'browser', label: '浏览器' },
|
||||||
|
{ key: 'os', label: '系统' },
|
||||||
|
{ key: 'device', label: '设备' },
|
||||||
|
{ key: 'status', label: '状态码' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ranges: { key: StatsRange; label: string }[] = [
|
||||||
|
{ key: '1d', label: '今日' },
|
||||||
|
{ key: '7d', label: '7 日' },
|
||||||
|
{ key: '30d', label: '30 日' },
|
||||||
|
{ key: '90d', label: '90 日' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page admin-monitor-page">
|
||||||
|
<div className="admin-page-head">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-page-title">
|
||||||
|
<Activity size={22} aria-hidden />
|
||||||
|
网站监控
|
||||||
|
</h1>
|
||||||
|
<p className="admin-page-desc">
|
||||||
|
浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="admin-tabs" role="tablist" aria-label="监控分类">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === t.key}
|
||||||
|
className={cn('admin-tab', tab === t.key && 'active')}
|
||||||
|
onClick={() => selectTab(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!enabled && tab !== 'settings' && (
|
||||||
|
<div className="admin-monitor-banner" role="status">
|
||||||
|
<AlertTriangle size={16} aria-hidden />
|
||||||
|
<span>访问采集尚未开启,指标可能为空。</span>
|
||||||
|
<button type="button" className="admin-text-link" onClick={() => selectTab('settings')}>
|
||||||
|
前往设置开启
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'overview' && overview && (
|
||||||
|
<>
|
||||||
|
<section className="admin-card admin-monitor-today" aria-label="今日状态">
|
||||||
|
<div className="admin-monitor-today-head">
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2 className="admin-monitor-section-title">今日状态</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-monitor-today-grid">
|
||||||
|
{metrics.map((m) => (
|
||||||
|
<div key={m.label} className="admin-monitor-today-item">
|
||||||
|
<div className="admin-monitor-today-label">{m.label}</div>
|
||||||
|
<div className="admin-monitor-today-value">{m.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="admin-monitor-main">
|
||||||
|
<section className="admin-card admin-monitor-map-panel">
|
||||||
|
<div className="admin-card-head admin-monitor-map-head">
|
||||||
|
<div className="admin-monitor-today-head" style={{ marginBottom: 0 }}>
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2 className="admin-monitor-section-title">访客地图(30 日)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-monitor-seg" role="group" aria-label="地图范围">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-monitor-seg-btn', mapMode === 'world' && 'active')}
|
||||||
|
onClick={() => setMapMode('world')}
|
||||||
|
>
|
||||||
|
世界
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-monitor-seg-btn', mapMode === 'china' && 'active')}
|
||||||
|
onClick={() => setMapMode('china')}
|
||||||
|
>
|
||||||
|
中国
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body admin-monitor-map-body">
|
||||||
|
<div className="admin-monitor-map-visual">
|
||||||
|
{mapMode === 'world' ? (
|
||||||
|
<MonitorWorldMap items={geoCountries(geo)} />
|
||||||
|
) : (
|
||||||
|
<MonitorChinaMap regions={geo?.regions || []} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-monitor-map-side">
|
||||||
|
<GeoRankTable
|
||||||
|
cities={geo?.cities || []}
|
||||||
|
asns={geo?.asns || []}
|
||||||
|
mode={mapMode}
|
||||||
|
rankMode={rankMode}
|
||||||
|
onRankMode={setRankMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="admin-monitor-rt-stack">
|
||||||
|
<section className="admin-card admin-monitor-rt-card">
|
||||||
|
<div className="admin-monitor-today-head">
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2 className="admin-monitor-section-title">实时请求数(1 分钟)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-monitor-rt-value">{formatNum(realtime?.requests_1m || 0)}</div>
|
||||||
|
</section>
|
||||||
|
<section className="admin-card admin-monitor-rt-card">
|
||||||
|
<div className="admin-monitor-today-head">
|
||||||
|
<span className="admin-monitor-section-bar" aria-hidden />
|
||||||
|
<h2 className="admin-monitor-section-title">实时流量(1 分钟)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="admin-monitor-rt-value">{formatBytes(realtime?.traffic_1m || 0)}</div>
|
||||||
|
<p className="admin-monitor-muted admin-monitor-rt-spark-label">近 1 小时请求</p>
|
||||||
|
<MiniSparkline series={realtime?.hourly_series || []} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'stats' && (
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-card-head">
|
||||||
|
<div className="admin-tabs" style={{ marginBottom: 0 }}>
|
||||||
|
{dims.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.key}
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-tab', statsDim === d.key && 'active')}
|
||||||
|
onClick={() => setStatsDim(d.key)}
|
||||||
|
>
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="admin-tabs" style={{ marginBottom: 0 }}>
|
||||||
|
{ranges.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r.key}
|
||||||
|
type="button"
|
||||||
|
className={cn('admin-tab', statsRange === r.key && 'active')}
|
||||||
|
onClick={() => setStatsRange(r.key)}
|
||||||
|
>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
|
{statsLoading ? (
|
||||||
|
<div className="flex justify-center py-12"><Spinner /></div>
|
||||||
|
) : statsItems.length === 0 ? (
|
||||||
|
<p className="admin-empty">暂无统计数据</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 48 }}>#</th>
|
||||||
|
<th>名称</th>
|
||||||
|
<th style={{ width: 100 }}>数量</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{statsItems.map((item, idx) => (
|
||||||
|
<tr key={`${item.key}-${idx}`}>
|
||||||
|
<td>{idx + 1}</td>
|
||||||
|
<td className="admin-table-mono" title={item.key}>{item.key}</td>
|
||||||
|
<td>{formatNum(item.count)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'logs' && (
|
||||||
|
<div className="admin-card">
|
||||||
|
<div className="admin-form-row admin-monitor-log-filters">
|
||||||
|
<Input value={logMethod} onChange={(e) => setLogMethod(e.target.value)} placeholder="方法 GET" aria-label="方法" style={{ width: 100 }} />
|
||||||
|
<Input value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder="路径包含…" aria-label="路径" />
|
||||||
|
<Input value={logStatus} onChange={(e) => setLogStatus(e.target.value)} placeholder="状态码" aria-label="状态码" style={{ width: 90 }} />
|
||||||
|
<Input value={logIP} onChange={(e) => setLogIP(e.target.value)} placeholder="IP 包含…" aria-label="IP" style={{ width: 140 }} />
|
||||||
|
<Button type="button" variant="outline" onClick={() => loadLogs(1)}>筛选</Button>
|
||||||
|
</div>
|
||||||
|
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||||
|
{logsLoading ? (
|
||||||
|
<div className="flex justify-center py-12"><Spinner /></div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<p className="admin-empty">暂无请求日志</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>时间</th>
|
||||||
|
<th>方法</th>
|
||||||
|
<th>路径</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>耗时</th>
|
||||||
|
<th>地区</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{logs.map((row) => {
|
||||||
|
const geoBits = [
|
||||||
|
row.country ? countryLabel(row.country) : '',
|
||||||
|
row.city || '',
|
||||||
|
row.as_org || '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td>{formatTime(row.created_at)}</td>
|
||||||
|
<td>{row.method}</td>
|
||||||
|
<td className="admin-table-mono" title={row.path}>{row.path}</td>
|
||||||
|
<td>{row.status}</td>
|
||||||
|
<td className="admin-table-mono">
|
||||||
|
{row.ip || '—'}
|
||||||
|
{row.is_bot ? ' · bot' : ''}
|
||||||
|
{(row.city || row.as_org) ? (
|
||||||
|
<div className="admin-monitor-muted" style={{ fontSize: 12 }}>
|
||||||
|
{[row.city, row.as_org].filter(Boolean).join(' · ')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td>{row.duration_ms}ms</td>
|
||||||
|
<td title={geoBits.join(' · ')}>{row.country ? countryLabel(row.country) : '—'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{logsTotal > 20 && (
|
||||||
|
<div className="admin-form-row" style={{ justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="button" variant="outline" disabled={logsPage <= 1} onClick={() => loadLogs(logsPage - 1)}>上一页</Button>
|
||||||
|
<span className="admin-monitor-muted">第 {logsPage} 页 / 共 {logsTotal} 条</span>
|
||||||
|
<Button type="button" variant="outline" disabled={logsPage * 20 >= logsTotal} onClick={() => loadLogs(logsPage + 1)}>下一页</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === 'settings' && settings && (
|
||||||
|
<div className="admin-card admin-settings-card">
|
||||||
|
<div className="admin-card-head">采集设置</div>
|
||||||
|
<div className="admin-card-body admin-monitor-settings">
|
||||||
|
<label className="admin-monitor-switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>启用访问采集</strong>
|
||||||
|
<p className="admin-monitor-muted">
|
||||||
|
默认关闭。开启后:前台路由上报浏览量/访客;服务端记录请求日志与请求数(含 API)。后台与登录页不上报 pageview。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.enabled}
|
||||||
|
onCheckedChange={(v) => setSettings({ ...settings, enabled: v })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="admin-monitor-field">
|
||||||
|
<span>浏览量保留天数(独立 monitor.db,1–365)</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.retention_days}
|
||||||
|
onChange={(e) => setSettings({ ...settings, retention_days: Number(e.target.value) || 30 })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="admin-monitor-field">
|
||||||
|
<span>请求日志保留天数(JSONL,1–365)</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.access_log_retention_days ?? 7}
|
||||||
|
onChange={(e) => setSettings({ ...settings, access_log_retention_days: Number(e.target.value) || 7 })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="admin-monitor-switch-row">
|
||||||
|
<div>
|
||||||
|
<strong>信任代理头</strong>
|
||||||
|
<p className="admin-monitor-muted">从 X-Forwarded-For / CF-Connecting-IP 等读取真实 IP</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={settings.trust_proxy}
|
||||||
|
onCheckedChange={(v) => setSettings({ ...settings, trust_proxy: v })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="admin-monitor-field">
|
||||||
|
<span>排除规则(每行一条:路径前缀或扩展名如 .js)</span>
|
||||||
|
<textarea
|
||||||
|
className="admin-monitor-textarea"
|
||||||
|
rows={8}
|
||||||
|
value={excludeText}
|
||||||
|
onChange={(e) => setExcludeText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={() => setExcludeText((settings.default_exclude_rules || []).join('\n'))}
|
||||||
|
>
|
||||||
|
恢复推荐排除规则
|
||||||
|
</Button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="admin-monitor-geo-note">
|
||||||
|
<strong>GeoIP(地理与运营商)</strong>
|
||||||
|
<p className="admin-monitor-muted">
|
||||||
|
国家/省/市由 IP2Location DB3 BIN 解析;ASN 由 GeoLite2-ASN.mmdb 解析(Docker 镜像不内置,需放入数据目录):
|
||||||
|
</p>
|
||||||
|
<ul className="admin-monitor-muted" style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||||
|
<li>
|
||||||
|
IPv4 BIN:<code>{settings.ip2location_v4_path || 'data/IP2LOCATION-LITE-DB3.BIN'}</code>
|
||||||
|
{' · '}{settings.ip2location_v4_available ? '已加载' : '未检测到'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
IPv6 BIN:<code>{settings.ip2location_v6_path || 'data/IP2LOCATION-LITE-DB3.IPV6.BIN'}</code>
|
||||||
|
{' · '}{settings.ip2location_v6_available ? '已加载' : '未检测到'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
ASN:<code>{settings.geoip_asn_path || 'data/GeoLite2-ASN.mmdb'}</code>
|
||||||
|
{' · '}{settings.geoip_asn_available ? '已加载' : '未检测到'}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Country 兜底:<code>{settings.geoip_country_path || 'data/GeoLite2-Country.mmdb'}</code>
|
||||||
|
{' · '}{settings.geoip_country_available ? '已加载' : '未检测到(可选)'}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className="admin-monitor-muted" style={{ marginTop: 8 }}>
|
||||||
|
请求日志目录:<code>{settings.access_log_dir || 'data/logs/access'}</code>
|
||||||
|
(按日 JSONL 文件,删除过期文件即可释放磁盘)
|
||||||
|
</p>
|
||||||
|
<p className="admin-monitor-muted" style={{ marginTop: 8 }}>
|
||||||
|
CDN 国家头(如 CF-IPCountry)仅在本地库无国家码时补全。管理端展示完整客户端 IP。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Button type="button" onClick={saveSettings} disabled={saving}>
|
||||||
|
{saving ? '保存中…' : '保存设置'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -298,9 +298,12 @@ export default function AdminSettingsPage() {
|
|||||||
aside_show_tag_cloud: false,
|
aside_show_tag_cloud: false,
|
||||||
aside_show_recent_comments: false,
|
aside_show_recent_comments: false,
|
||||||
aside_show_friend_links: true,
|
aside_show_friend_links: true,
|
||||||
|
aside_show_showcase: false,
|
||||||
aside_widgets: loadedAsideWidgets,
|
aside_widgets: loadedAsideWidgets,
|
||||||
nav_show_friend_links: true,
|
nav_show_friend_links: true,
|
||||||
footer_show_friend_links: true,
|
footer_show_friend_links: true,
|
||||||
|
nav_show_showcase: false,
|
||||||
|
footer_show_showcase: false,
|
||||||
feed_list_style: 'title',
|
feed_list_style: 'title',
|
||||||
permalink_enabled: false,
|
permalink_enabled: false,
|
||||||
permalink_ext: 'html',
|
permalink_ext: 'html',
|
||||||
@@ -1676,7 +1679,7 @@ export default function AdminSettingsPage() {
|
|||||||
<div className="admin-card admin-settings-card">
|
<div className="admin-card admin-settings-card">
|
||||||
<div className="admin-card-head">数据备份</div>
|
<div className="admin-card-head">数据备份</div>
|
||||||
<div className="admin-card-body admin-settings-backup-body">
|
<div className="admin-card-body admin-settings-backup-body">
|
||||||
<p>导出当前 SQLite 数据库副本,便于迁移或灾难恢复。</p>
|
<p>导出当前主库 SQLite 副本,便于迁移或灾难恢复。不含 <code>monitor.db</code> 浏览量。</p>
|
||||||
<p className="admin-settings-backup-name">
|
<p className="admin-settings-backup-name">
|
||||||
文件名:<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
|
文件名:<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4308,12 +4308,40 @@ a.post-title:visited {
|
|||||||
.post-detail-nav {
|
.post-detail-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-detail-nav-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.post-detail-board-tag { margin: 0 !important; }
|
.post-detail-board-tag { margin: 0 !important; }
|
||||||
|
|
||||||
|
.post-manage-menu-trigger {
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-manage-menu {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-width: 11.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-manage-menu-hint {
|
||||||
|
font-weight: 400 !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: var(--color-text-3) !important;
|
||||||
|
white-space: normal;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.post-detail-head { margin-bottom: 12px; }
|
.post-detail-head { margin-bottom: 12px; }
|
||||||
|
|
||||||
.post-detail-title {
|
.post-detail-title {
|
||||||
@@ -4379,12 +4407,6 @@ a.post-title:visited {
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-edit-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-3);
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-revision-overlay {
|
.post-revision-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -4960,8 +4982,7 @@ a.post-title:visited {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-actions-primary,
|
.post-detail-actions-primary {
|
||||||
.post-detail-actions-manage {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -4969,12 +4990,6 @@ a.post-title:visited {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-detail-actions-manage {
|
|
||||||
width: 100%;
|
|
||||||
padding-top: 12px;
|
|
||||||
border-top: 1px dashed var(--j13-border-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-detail-more-btn {
|
.post-detail-more-btn {
|
||||||
min-width: 36px;
|
min-width: 36px;
|
||||||
padding-inline: 10px;
|
padding-inline: 10px;
|
||||||
@@ -5002,6 +5017,13 @@ a.post-title:visited {
|
|||||||
background: color-mix(in srgb, hsl(var(--destructive)) 10%, transparent);
|
background: color-mix(in srgb, hsl(var(--destructive)) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 评论管理菜单(编辑 / 审核 / 删除 / 举报) */
|
||||||
|
.comment-manage-menu {
|
||||||
|
min-width: 9.5rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.report-more-menu__item svg {
|
.report-more-menu__item svg {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
@@ -6215,6 +6237,61 @@ a.post-title:visited {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment-box-expanded-slot.is-collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-composer-collapsed {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--j13-bg-workspace, hsl(var(--muted)));
|
||||||
|
color: var(--color-text-3);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-composer-collapsed:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green) 40%, var(--j13-border-light));
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-composer-collapsed-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: color-mix(in srgb, var(--j13-green) 18%, transparent);
|
||||||
|
color: var(--j13-green);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-composer-collapsed-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-composer-collapsed-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.comment-box-wrap.inline {
|
.comment-box-wrap.inline {
|
||||||
padding: 12px 0 4px;
|
padding: 12px 0 4px;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
@@ -7096,8 +7173,7 @@ a.waline-comment-author:hover {
|
|||||||
.post-detail-header { padding: 12px 14px 16px; }
|
.post-detail-header { padding: 12px 14px 16px; }
|
||||||
.post-detail-title { font-size: 17px; }
|
.post-detail-title { font-size: 17px; }
|
||||||
.post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; }
|
.post-detail-actions { margin-top: 20px; padding-top: 16px; gap: 10px; }
|
||||||
.post-detail-actions-primary,
|
.post-detail-actions-primary { gap: 8px; }
|
||||||
.post-detail-actions-manage { gap: 8px; }
|
|
||||||
.comment-section-bar { padding: 12px 14px 10px; }
|
.comment-section-bar { padding: 12px 14px 10px; }
|
||||||
.comment-section-bar::after { left: 14px; width: 32px; }
|
.comment-section-bar::after { left: 14px; width: 32px; }
|
||||||
.comment-section:hover .comment-section-bar::after { width: 46px; }
|
.comment-section:hover .comment-section-bar::after { width: 46px; }
|
||||||
@@ -7134,6 +7210,7 @@ a.waline-comment-author:hover {
|
|||||||
.widget-card-icon--hot { color: #e74c3c; }
|
.widget-card-icon--hot { color: #e74c3c; }
|
||||||
.widget-card-icon--notice { color: #3498db; }
|
.widget-card-icon--notice { color: #3498db; }
|
||||||
.widget-card-icon--links { color: #2d6a4f; }
|
.widget-card-icon--links { color: #2d6a4f; }
|
||||||
|
.widget-card-icon--showcase { color: #1d4ed8; }
|
||||||
.widget-card-icon--users { color: #8b5cf6; }
|
.widget-card-icon--users { color: #8b5cf6; }
|
||||||
|
|
||||||
.widget-card-head--split {
|
.widget-card-head--split {
|
||||||
@@ -11123,6 +11200,216 @@ button.profile-stat:hover strong {
|
|||||||
.admin-page-head { margin-bottom: 20px; }
|
.admin-page-head { margin-bottom: 20px; }
|
||||||
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||||
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
|
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||||
|
|
||||||
|
/* ========== 仪表盘 bento ========== */
|
||||||
|
.admin-dash-head-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.admin-dash-head-title h1 { margin-bottom: 0; }
|
||||||
|
.admin-dash-section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.admin-dash-section-label h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-dash-bento {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic {
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-top .admin-dash-section-label { margin-bottom: 0; }
|
||||||
|
.admin-dash-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-pv {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--j13-green);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-uv {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-trend {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-trend.is-up { color: var(--j13-green); }
|
||||||
|
.admin-dash-traffic-trend.is-down { color: hsl(24 90% 42%); }
|
||||||
|
.admin-dash-traffic-total {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-hint { opacity: 0.85; margin-left: 4px; }
|
||||||
|
.admin-dash-traffic-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dash-queue {
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: hsl(var(--card));
|
||||||
|
padding: 18px 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 220px;
|
||||||
|
}
|
||||||
|
.admin-dash-queue-head { margin-bottom: 12px; }
|
||||||
|
.admin-dash-queue-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.admin-dash-queue-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
background: hsl(var(--background));
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s, background 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.admin-dash-queue-card:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--j13-green) 45%, var(--j13-border));
|
||||||
|
background: var(--j13-green-bg);
|
||||||
|
}
|
||||||
|
.admin-dash-queue-card.has-work {
|
||||||
|
border-color: hsl(24 80% 55% / 0.45);
|
||||||
|
background: hsl(24 90% 48% / 0.06);
|
||||||
|
}
|
||||||
|
.admin-dash-queue-card.has-work:hover {
|
||||||
|
border-color: hsl(24 80% 50%);
|
||||||
|
background: hsl(24 90% 48% / 0.1);
|
||||||
|
}
|
||||||
|
.admin-dash-queue-card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.admin-dash-queue-count {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.admin-dash-queue-card.has-work .admin-dash-queue-count {
|
||||||
|
color: hsl(24 90% 40%);
|
||||||
|
}
|
||||||
|
.dark .admin-dash-queue-card.has-work .admin-dash-queue-count {
|
||||||
|
color: hsl(24 95% 68%);
|
||||||
|
}
|
||||||
|
.admin-dash-queue-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.admin-dash-queue-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-dash-scale {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.admin-dash-metric-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.admin-dash-metric-card {
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
background: hsl(var(--card));
|
||||||
|
}
|
||||||
|
.admin-dash-metric-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.admin-dash-metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.admin-dash-bento {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.admin-dash-traffic-pv {
|
||||||
|
font-size: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-dash-metric-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.admin-section-label {
|
.admin-section-label {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
margin-bottom: 10px; font-size: 13px; font-weight: 600;
|
margin-bottom: 10px; font-size: 13px; font-weight: 600;
|
||||||
@@ -13930,6 +14217,201 @@ button.post-poll__option,
|
|||||||
}
|
}
|
||||||
.admin-inline-link:hover { text-decoration: underline; }
|
.admin-inline-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.admin-community-support-strip {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 16px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--j13-green) 22%, var(--j13-border-light));
|
||||||
|
border-left: 3px solid var(--j13-green);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--j13-green) 5%, var(--j13-bg-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--j13-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 28rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip-copy strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip-copy span {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip .admin-settings-switch {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip .admin-settings-switch:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-community-support-strip {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-support-strip-copy {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-site {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-site strong {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-star {
|
||||||
|
color: var(--j13-green);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-community-url {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-page {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 16px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-head-mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--j13-green);
|
||||||
|
background: color-mix(in srgb, var(--j13-green) 10%, var(--j13-bg-surface));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--j13-green) 20%, var(--j13-border-light));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-desc {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-item {
|
||||||
|
border: 1px solid var(--j13-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--j13-bg-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-item-link {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-item-link:hover {
|
||||||
|
background: color-mix(in srgb, var(--j13-green) 6%, var(--j13-bg-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-item-name {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-item-url {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--j13-green);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.showcase-item-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-links-logo-thumb {
|
.admin-links-logo-thumb {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
@@ -14820,3 +15302,339 @@ button.post-poll__option,
|
|||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========== 网站监控 ========== */
|
||||||
|
.admin-monitor-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--j13-green) 30%, var(--j13-border));
|
||||||
|
background: var(--j13-green-bg);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-section-bar {
|
||||||
|
display: inline-block;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--j13-green);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.admin-monitor-today-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.admin-monitor-section-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-today {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.admin-monitor-today-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||||
|
gap: 8px 12px;
|
||||||
|
}
|
||||||
|
.admin-monitor-today-item {
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding: 4px 8px;
|
||||||
|
}
|
||||||
|
.admin-monitor-today-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.admin-monitor-today-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--j13-green);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.75fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-panel .admin-card-head,
|
||||||
|
.admin-monitor-map-head {
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(160px, 0.7fr);
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 320px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-visual {
|
||||||
|
position: relative;
|
||||||
|
min-height: 280px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, hsl(var(--muted)) 35%, hsl(var(--card)));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-side {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-seg {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
.admin-monitor-seg-btn {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-monitor-seg-btn + .admin-monitor-seg-btn {
|
||||||
|
border-left: 1px solid var(--j13-border);
|
||||||
|
}
|
||||||
|
.admin-monitor-seg-btn.active {
|
||||||
|
background: var(--j13-green);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-svg-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
.admin-monitor-svg-map {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 280px;
|
||||||
|
}
|
||||||
|
.admin-monitor-svg-path {
|
||||||
|
cursor: default;
|
||||||
|
transition: fill 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.admin-monitor-svg-path.has-data {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.admin-monitor-svg-path.has-data:hover {
|
||||||
|
opacity: 0.88;
|
||||||
|
filter: brightness(1.05);
|
||||||
|
}
|
||||||
|
.admin-monitor-map-tip {
|
||||||
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, hsl(var(--card)) 92%, transparent);
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
font-size: 12px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-tip strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-tip span {
|
||||||
|
color: var(--j13-green);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.admin-monitor-map-empty-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
background: color-mix(in srgb, hsl(var(--card)) 55%, transparent);
|
||||||
|
pointer-events: none;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-map-side .admin-monitor-seg {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.admin-monitor-rank .admin-monitor-geo-empty {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table th,
|
||||||
|
.admin-monitor-geo-table td {
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table th:last-child,
|
||||||
|
.admin-monitor-geo-table td:last-child {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table thead th {
|
||||||
|
background: var(--j13-green);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table thead th:first-child {
|
||||||
|
border-radius: 6px 0 0 0;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table thead th:last-child {
|
||||||
|
border-radius: 0 6px 0 0;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table tbody tr:nth-child(even) {
|
||||||
|
background: var(--j13-green-bg);
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-table tbody td {
|
||||||
|
border-bottom: 1px solid var(--j13-border);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-empty {
|
||||||
|
padding: 24px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-monitor-rt-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.admin-monitor-rt-card {
|
||||||
|
padding: 16px 20px 20px;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.admin-monitor-rt-value {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--j13-green);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.admin-monitor-rt-spark-label {
|
||||||
|
margin: 16px 0 6px;
|
||||||
|
}
|
||||||
|
.admin-monitor-spark {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.admin-monitor-muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.admin-monitor-log-filters {
|
||||||
|
border-bottom: 1px solid var(--j13-border);
|
||||||
|
}
|
||||||
|
.admin-monitor-settings {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
.admin-monitor-switch-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.admin-monitor-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.admin-monitor-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 140px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--j13-border);
|
||||||
|
background: hsl(var(--background));
|
||||||
|
color: inherit;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
.admin-monitor-geo-note code {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--j13-green-bg);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.admin-monitor-today-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.admin-monitor-main,
|
||||||
|
.admin-monitor-map-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.admin-monitor-rt-value {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.admin-monitor-today-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .admin-monitor-today-value,
|
||||||
|
.dark .admin-monitor-rt-value {
|
||||||
|
color: var(--j13-green);
|
||||||
|
}
|
||||||
|
.dark .admin-monitor-map-visual {
|
||||||
|
background: color-mix(in srgb, hsl(var(--muted)) 40%, hsl(var(--card)));
|
||||||
|
}
|
||||||
|
.dark .admin-monitor-geo-table thead th {
|
||||||
|
background: var(--j13-green);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
17
frontend/src/types/svg-maps.d.ts
vendored
Normal file
17
frontend/src/types/svg-maps.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
declare module '@svg-maps/world' {
|
||||||
|
const map: {
|
||||||
|
label: string;
|
||||||
|
viewBox: string;
|
||||||
|
locations: Array<{ id: string; name: string; path: string }>;
|
||||||
|
};
|
||||||
|
export default map;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@svg-maps/china' {
|
||||||
|
const map: {
|
||||||
|
label: string;
|
||||||
|
viewBox: string;
|
||||||
|
locations: Array<{ id: string; name: string; path: string }>;
|
||||||
|
};
|
||||||
|
export default map;
|
||||||
|
}
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
|
import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
|
||||||
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
|
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
|
||||||
|
|
||||||
const ASIDE_WIDGET_IDS: AsideWidgetId[] = ['tag_cloud', 'recent_comments', 'recent_users', 'friend_links'];
|
const ASIDE_WIDGET_IDS: AsideWidgetId[] = [
|
||||||
|
'tag_cloud',
|
||||||
|
'recent_comments',
|
||||||
|
'recent_users',
|
||||||
|
'friend_links',
|
||||||
|
'showcase',
|
||||||
|
];
|
||||||
|
|
||||||
/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
|
/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
|
||||||
export function resolveAsideWidgets(
|
export function resolveAsideWidgets(
|
||||||
limits: Pick<ForumLimitsPublic, 'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'>,
|
limits: Partial<Pick<
|
||||||
|
ForumLimitsPublic,
|
||||||
|
'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
|
||||||
|
>>,
|
||||||
): AsideWidget[] {
|
): AsideWidget[] {
|
||||||
if (limits.aside_widgets?.length) {
|
if (limits.aside_widgets?.length) {
|
||||||
return normalizeAsideWidgets(limits.aside_widgets);
|
return normalizeAsideWidgets(limits.aside_widgets);
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ id: 'tag_cloud', enabled: limits.aside_show_tag_cloud },
|
{ id: 'tag_cloud', enabled: !!limits.aside_show_tag_cloud },
|
||||||
{ id: 'recent_comments', enabled: limits.aside_show_recent_comments },
|
{ id: 'recent_comments', enabled: !!limits.aside_show_recent_comments },
|
||||||
{ id: 'friend_links', enabled: limits.aside_show_friend_links },
|
{ id: 'friend_links', enabled: limits.aside_show_friend_links !== false },
|
||||||
|
{ id: 'showcase', enabled: !!limits.aside_show_showcase },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,12 +43,16 @@ export function normalizeAsideWidgets(widgets: AsideWidget[]): AsideWidget[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
|
/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
|
||||||
export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<ForumLimits, 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'> {
|
export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<
|
||||||
|
ForumLimits,
|
||||||
|
'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
|
||||||
|
> {
|
||||||
const normalized = normalizeAsideWidgets(widgets);
|
const normalized = normalizeAsideWidgets(widgets);
|
||||||
return {
|
return {
|
||||||
aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
|
aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
|
||||||
aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
|
aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
|
||||||
aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
|
aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
|
||||||
|
aside_show_showcase: normalized.find(w => w.id === 'showcase')?.enabled ?? false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +78,7 @@ export function isAsideWidgetEnabled(widgets: AsideWidget[], id: AsideWidgetId):
|
|||||||
aside_show_tag_cloud: false,
|
aside_show_tag_cloud: false,
|
||||||
aside_show_recent_comments: false,
|
aside_show_recent_comments: false,
|
||||||
aside_show_friend_links: false,
|
aside_show_friend_links: false,
|
||||||
|
aside_show_showcase: false,
|
||||||
}).find(w => w.id === id)?.enabled ?? false;
|
}).find(w => w.id === id)?.enabled ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
go.mod
5
go.mod
@@ -7,9 +7,12 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/ip2location/ip2location-go/v9 v9.8.0
|
||||||
github.com/kardianos/service v1.2.2
|
github.com/kardianos/service v1.2.2
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27
|
github.com/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/minio/minio-go/v7 v7.0.98
|
github.com/minio/minio-go/v7 v7.0.98
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1
|
||||||
golang.org/x/crypto v0.46.0
|
golang.org/x/crypto v0.46.0
|
||||||
golang.org/x/image v0.44.0
|
golang.org/x/image v0.44.0
|
||||||
gopkg.in/ini.v1 v1.67.3
|
gopkg.in/ini.v1 v1.67.3
|
||||||
@@ -31,7 +34,6 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/gorilla/css v1.0.1 // indirect
|
github.com/gorilla/css v1.0.1 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
@@ -59,6 +61,7 @@ require (
|
|||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.1 // indirect
|
google.golang.org/protobuf v1.34.1 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
modernc.org/libc v1.22.5 // indirect
|
modernc.org/libc v1.22.5 // indirect
|
||||||
modernc.org/mathutil v1.5.0 // indirect
|
modernc.org/mathutil v1.5.0 // indirect
|
||||||
modernc.org/memory v1.5.0 // indirect
|
modernc.org/memory v1.5.0 // indirect
|
||||||
|
|||||||
6
go.sum
6
go.sum
@@ -48,6 +48,8 @@ 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||||
|
github.com/ip2location/ip2location-go/v9 v9.8.0 h1:drPzGjj1EBl45I33ErMHFtIfsQ3mR85dAQbqMDbi9mc=
|
||||||
|
github.com/ip2location/ip2location-go/v9 v9.8.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -82,6 +84,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
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/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 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
@@ -142,6 +146,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
|
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||||
|
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
"git.iioio.com/freefire/jiang13-forum/service"
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// APIMe 当前登录用户
|
// APIMe 当前登录用户
|
||||||
@@ -158,14 +158,21 @@ func (h *Handlers) APIAdminDashboard(c *gin.Context) {
|
|||||||
if recentPosts == nil {
|
if recentPosts == nil {
|
||||||
recentPosts = []model.Post{}
|
recentPosts = []model.Post{}
|
||||||
}
|
}
|
||||||
|
traffic := service.DashboardTraffic{}
|
||||||
|
if h.Monitor != nil {
|
||||||
|
traffic = h.Monitor.DashboardTraffic()
|
||||||
|
} else {
|
||||||
|
traffic.Enabled = h.Settings.MonitorEnabled()
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"users": userCount, "posts": postCount, "boards": boardCount,
|
"users": userCount, "posts": postCount, "boards": boardCount,
|
||||||
"comments": commentCount,
|
"comments": commentCount,
|
||||||
"pending_posts": pendingPosts,
|
"pending_posts": pendingPosts,
|
||||||
"pending_comments": pendingComments,
|
"pending_comments": pendingComments,
|
||||||
"pending_reports": pendingReports,
|
"pending_reports": pendingReports,
|
||||||
"pending_friend_links": pendingFriendLinks,
|
"pending_friend_links": pendingFriendLinks,
|
||||||
"recent_posts": recentPosts,
|
"recent_posts": recentPosts,
|
||||||
|
"traffic": traffic,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,6 +581,7 @@ func (h *Handlers) APIAdminSettings(c *gin.Context) {
|
|||||||
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
"gitea": h.Settings.GiteaSyncConfigPublic(),
|
||||||
"storage": h.Settings.StorageConfigPublic(),
|
"storage": h.Settings.StorageConfigPublic(),
|
||||||
"branding": h.Settings.SiteBranding(),
|
"branding": h.Settings.SiteBranding(),
|
||||||
|
"community": h.Settings.CommunityConfigForRequest(communityRequestOrigin(c)),
|
||||||
"filter_words": filterContent,
|
"filter_words": filterContent,
|
||||||
"filter_word_count": service.CountFilterWords(filterContent),
|
"filter_word_count": service.CountFilterWords(filterContent),
|
||||||
})
|
})
|
||||||
@@ -994,10 +1002,10 @@ func (h *Handlers) APIPosts(c *gin.Context) {
|
|||||||
h.Badge.AttachBadgeSummaries(users, 2)
|
h.Badge.AttachBadgeSummaries(users, 2)
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"posts": items,
|
"posts": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page,
|
"page": page,
|
||||||
"size": size,
|
"size": size,
|
||||||
"has_more": int64(page*size) < total,
|
"has_more": int64(page*size) < total,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
180
handler/community.go
Normal file
180
handler/community.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APICommunityHeartbeat 公开心跳入口(仅枢纽开启时写入)
|
||||||
|
func (h *Handlers) APICommunityHeartbeat(c *gin.Context) {
|
||||||
|
if h.Community == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未启用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.CommunityHeartbeatPayload
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Community.ReceiveHeartbeat(req, c.ClientIP(), requestOrigin(c)); err != nil {
|
||||||
|
if errors.Is(err, service.ErrCommunityHubDisabled) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "本站未开启社区枢纽"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, service.ErrCommunityBadPayload) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "心跳参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APICommunityShowcase 公开展柜(仅精选)
|
||||||
|
func (h *Handlers) APICommunityShowcase(c *gin.Context) {
|
||||||
|
if h.Community == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": []any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.Community.ListShowcase(requestOrigin(c))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": list})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminCommunityInstances 公网实例列表
|
||||||
|
func (h *Handlers) APIAdminCommunityInstances(c *gin.Context) {
|
||||||
|
if h.Community == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"instances": []any{}, "hub_enabled": false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := h.Settings.CommunityConfigForRequest(communityRequestOrigin(c))
|
||||||
|
list, err := h.Community.ListInstances()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"hub_enabled": cfg.HubEnabled,
|
||||||
|
"instances": list,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminFeatureCommunityInstance 精选 / 取消精选
|
||||||
|
func (h *Handlers) APIAdminFeatureCommunityInstance(c *gin.Context) {
|
||||||
|
if h.Community == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "未启用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.CommunityFeatureInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view, err := h.Community.SetInstanceFeatured(c.Param("id"), req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, service.ErrCommunityBadPayload) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "实例无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "实例不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := "已取消精选"
|
||||||
|
if view.Featured {
|
||||||
|
msg = "已设为精选,将出现在公开展柜"
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": msg, "instance": view})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateCommunitySettings 更新社区上报设置
|
||||||
|
func (h *Handlers) APIAdminUpdateCommunitySettings(c *gin.Context) {
|
||||||
|
var req service.CommunityConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.Settings.UpdateCommunityConfig(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin := communityRequestOrigin(c)
|
||||||
|
cfg := h.Settings.CommunityConfigForRequest(origin)
|
||||||
|
out := gin.H{
|
||||||
|
"message": "社区设置已保存",
|
||||||
|
"community": cfg,
|
||||||
|
}
|
||||||
|
if cfg.ReportEnabled && !cfg.HubEnabled && h.Community != nil {
|
||||||
|
if err := h.Community.SendHeartbeatOnce(origin); err != nil {
|
||||||
|
out["message"] = "社区设置已保存,但心跳未成功"
|
||||||
|
out["heartbeat_error"] = err.Error()
|
||||||
|
// 刷新 site_url(可能已由 Origin 持久化)
|
||||||
|
out["community"] = h.Settings.CommunityConfigForRequest(origin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateShowcaseEntry 更新开源展柜入口展示位置(左栏 / 右栏 / 页脚)
|
||||||
|
func (h *Handlers) APIAdminUpdateShowcaseEntry(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
NavShowShowcase *bool `json:"nav_show_showcase"`
|
||||||
|
FooterShowShowcase *bool `json:"footer_show_showcase"`
|
||||||
|
AsideShowShowcase *bool `json:"aside_show_showcase"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NavShowShowcase == nil && req.FooterShowShowcase == nil && req.AsideShowShowcase == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.NavShowShowcase != nil {
|
||||||
|
if err := h.Settings.SetNavShowShowcase(*req.NavShowShowcase); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.FooterShowShowcase != nil {
|
||||||
|
if err := h.Settings.SetFooterShowShowcase(*req.FooterShowShowcase); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.AsideShowShowcase != nil {
|
||||||
|
if err := h.Settings.SetAsideShowcaseEnabled(*req.AsideShowShowcase); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"message": "展柜入口已保存",
|
||||||
|
"nav_show_showcase": h.Settings.NavShowShowcase(),
|
||||||
|
"footer_show_showcase": h.Settings.FooterShowShowcase(),
|
||||||
|
"aside_show_showcase": h.Settings.AsideShowShowcase(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// communityRequestOrigin 优先用浏览器 Origin(Vite 代理时 Host 可能是后端端口)
|
||||||
|
func communityRequestOrigin(c *gin.Context) string {
|
||||||
|
if o := strings.TrimSpace(c.GetHeader("Origin")); o != "" {
|
||||||
|
return o
|
||||||
|
}
|
||||||
|
if ref := strings.TrimSpace(c.GetHeader("Referer")); ref != "" {
|
||||||
|
if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
|
||||||
|
return u.Scheme + "://" + u.Host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return requestOrigin(c)
|
||||||
|
}
|
||||||
@@ -8,37 +8,39 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"git.iioio.com/freefire/jiang13-forum/config"
|
"git.iioio.com/freefire/jiang13-forum/config"
|
||||||
"git.iioio.com/freefire/jiang13-forum/middleware"
|
"git.iioio.com/freefire/jiang13-forum/middleware"
|
||||||
"git.iioio.com/freefire/jiang13-forum/model"
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
"git.iioio.com/freefire/jiang13-forum/service"
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers 聚合所有 HTTP 处理器
|
// Handlers 聚合所有 HTTP 处理器
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
Store *service.UploadStore
|
Store *service.UploadStore
|
||||||
Auth *service.AuthService
|
Auth *service.AuthService
|
||||||
User *service.UserService
|
User *service.UserService
|
||||||
Board *service.BoardService
|
Board *service.BoardService
|
||||||
Post *service.PostService
|
Post *service.PostService
|
||||||
Comment *service.CommentService
|
Comment *service.CommentService
|
||||||
Message *service.MessageService
|
Message *service.MessageService
|
||||||
Notify *service.NotifyService
|
Notify *service.NotifyService
|
||||||
Report *service.ReportService
|
Report *service.ReportService
|
||||||
Backup *service.BackupService
|
Backup *service.BackupService
|
||||||
Filter *service.SensitiveFilter
|
Community *service.CommunityService
|
||||||
Limiter *service.RateLimiter
|
Monitor *service.MonitorService
|
||||||
Settings *service.ForumSettingsService
|
Filter *service.SensitiveFilter
|
||||||
Captcha *service.CaptchaService
|
Limiter *service.RateLimiter
|
||||||
Mail *service.MailService
|
Settings *service.ForumSettingsService
|
||||||
EmailCode *service.EmailCodeService
|
Captcha *service.CaptchaService
|
||||||
OIDC *service.OIDCService
|
Mail *service.MailService
|
||||||
Gitea *service.GiteaService
|
EmailCode *service.EmailCodeService
|
||||||
Points *service.PointsService
|
OIDC *service.OIDCService
|
||||||
Badge *service.BadgeService
|
Gitea *service.GiteaService
|
||||||
SitePage *service.SitePageService
|
Points *service.PointsService
|
||||||
|
Badge *service.BadgeService
|
||||||
|
SitePage *service.SitePageService
|
||||||
FriendLinkApply *service.FriendLinkApplyService
|
FriendLinkApply *service.FriendLinkApplyService
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
handler/monitor.go
Normal file
108
handler/monitor.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// APIAdminMonitorOverview 今日概览
|
||||||
|
func (h *Handlers) APIAdminMonitorOverview(c *gin.Context) {
|
||||||
|
if h.Monitor == nil {
|
||||||
|
c.JSON(http.StatusOK, service.MonitorOverview{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, h.Monitor.OverviewToday())
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminMonitorGeo 地理分布
|
||||||
|
func (h *Handlers) APIAdminMonitorGeo(c *gin.Context) {
|
||||||
|
if h.Monitor == nil {
|
||||||
|
c.JSON(http.StatusOK, service.MonitorGeoResult{
|
||||||
|
Countries: []service.MonitorGeoItem{},
|
||||||
|
Regions: []service.MonitorRegionItem{},
|
||||||
|
Cities: []service.MonitorCityItem{},
|
||||||
|
ASNs: []service.MonitorASNItem{},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, h.Monitor.GeoStats(c.Query("range")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminMonitorStats 维度排行
|
||||||
|
func (h *Handlers) APIAdminMonitorStats(c *gin.Context) {
|
||||||
|
if h.Monitor == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": []service.MonitorStatItem{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := h.Monitor.DimStats(c.Query("dim"), c.Query("range"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"dim": c.Query("dim"), "range": c.Query("range"), "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminMonitorLogs 请求日志
|
||||||
|
func (h *Handlers) APIAdminMonitorLogs(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
if h.Monitor == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": []service.MonitorLogItem{}, "total": 0, "page": page, "size": size})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, total := h.Monitor.ListLogs(page, size, c.Query("method"), c.Query("path"), c.Query("status"), c.Query("ip"))
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "size": size})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminMonitorRealtime 实时
|
||||||
|
func (h *Handlers) APIAdminMonitorRealtime(c *gin.Context) {
|
||||||
|
if h.Monitor == nil {
|
||||||
|
c.JSON(http.StatusOK, service.MonitorRealtime{HourlySeries: []service.MonitorRealtimePoint{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, h.Monitor.Realtime())
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminGetMonitorSettings 读取监控设置
|
||||||
|
func (h *Handlers) APIAdminGetMonitorSettings(c *gin.Context) {
|
||||||
|
cfg := h.Settings.MonitorConfig()
|
||||||
|
if h.Monitor != nil {
|
||||||
|
h.Monitor.EnrichGeoMeta(&cfg)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIAdminUpdateMonitorSettings 更新监控设置
|
||||||
|
func (h *Handlers) APIAdminUpdateMonitorSettings(c *gin.Context) {
|
||||||
|
var req service.MonitorConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Settings.UpdateMonitorConfig(req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg := h.Settings.MonitorConfig()
|
||||||
|
if h.Monitor != nil {
|
||||||
|
h.Monitor.EnrichGeoMeta(&cfg)
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "监控设置已保存", "monitor": cfg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// APIMonitorPageview 前台 SPA 路由 pageview 信标(公开,受 monitor_enabled 控制)
|
||||||
|
func (h *Handlers) APIMonitorPageview(c *gin.Context) {
|
||||||
|
if h.Monitor == nil || !h.Settings.MonitorEnabled() {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.PageViewInput
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Monitor.RecordPageView(c.Request, c.Request.RemoteAddr, req); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "记录失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
33
middleware/accesslog.go
Normal file
33
middleware/accesslog.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AccessLogMiddleware 服务端访问日志采集(受 monitor_enabled 控制;热路径仅入队,后台写 jsonl)
|
||||||
|
func AccessLogMiddleware(mon *service.MonitorService) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if mon == nil || !mon.Enabled() {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
if mon.ShouldSkip(path) {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
c.Next()
|
||||||
|
status := c.Writer.Status()
|
||||||
|
bytes := int64(c.Writer.Size())
|
||||||
|
if bytes < 0 {
|
||||||
|
bytes = 0
|
||||||
|
}
|
||||||
|
dur := int(time.Since(start).Milliseconds())
|
||||||
|
row := mon.BuildAccessLog(c.Request, c.Request.RemoteAddr, status, bytes, dur)
|
||||||
|
mon.Enqueue(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
36
model/db.go
36
model/db.go
@@ -13,25 +13,36 @@ import (
|
|||||||
|
|
||||||
var DB *gorm.DB
|
var DB *gorm.DB
|
||||||
|
|
||||||
// InitDB 初始化 SQLite 并自动迁移
|
// MonitorDB 网站监控独立库(page_views),与主库 jiang13.db 分离以免撑大备份
|
||||||
func InitDB(dbPath string) error {
|
var MonitorDB *gorm.DB
|
||||||
|
|
||||||
|
func openSQLite(dbPath string) (*gorm.DB, error) {
|
||||||
dir := filepath.Dir(dbPath)
|
dir := filepath.Dir(dbPath)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return fmt.Errorf("创建数据库目录失败: %w", err)
|
return nil, fmt.Errorf("创建数据库目录失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(logger.Warn),
|
Logger: logger.Default.LogMode(logger.Warn),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("连接 SQLite 失败: %w", err)
|
return nil, fmt.Errorf("连接 SQLite 失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(1)
|
sqlDB.SetMaxOpenConns(1)
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDB 初始化主库 SQLite 并自动迁移(不含 page_views)
|
||||||
|
func InitDB(dbPath string) error {
|
||||||
|
db, err := openSQLite(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&User{}, &Board{}, &Post{}, &Comment{},
|
&User{}, &Board{}, &Post{}, &Comment{},
|
||||||
@@ -43,6 +54,7 @@ func InitDB(dbPath string) error {
|
|||||||
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
|
&PointLedger{}, &CheckIn{}, &LotteryDraw{}, &PostContentUnlock{},
|
||||||
&BadgeDef{}, &UserBadge{},
|
&BadgeDef{}, &UserBadge{},
|
||||||
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
|
&SitePage{}, &Poll{}, &PollOption{}, &PollVote{}, &PostLotteryWinner{},
|
||||||
|
&CommunityInstance{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -59,6 +71,20 @@ func InitDB(dbPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitMonitorDB 打开独立监控库(page_views 只写此处)
|
||||||
|
func InitMonitorDB(monitorDBPath string) error {
|
||||||
|
db, err := openSQLite(monitorDBPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("监控库初始化失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&PageView{}); err != nil {
|
||||||
|
return fmt.Errorf("监控库迁移失败: %w", err)
|
||||||
|
}
|
||||||
|
MonitorDB = db
|
||||||
|
log.Println("[model] 监控库初始化完成:", monitorDBPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// PingDB 检测数据库连接是否可用(供健康检查使用)
|
// PingDB 检测数据库连接是否可用(供健康检查使用)
|
||||||
func PingDB() error {
|
func PingDB() error {
|
||||||
if DB == nil {
|
if DB == nil {
|
||||||
|
|||||||
231
model/models.go
231
model/models.go
@@ -47,26 +47,26 @@ const (
|
|||||||
// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
|
// Email / Password / LastLogin* / LastAccessAt 默认不随帖子等嵌套 User 序列化;
|
||||||
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
// 个人中心与后台列表请用 UserSelf / UserAdmin。
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
||||||
Email string `gorm:"index;size:128;default:''" json:"-"`
|
Email string `gorm:"index;size:128;default:''" json:"-"`
|
||||||
Password string `gorm:"size:128;not null" json:"-"`
|
Password string `gorm:"size:128;not null" json:"-"`
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
Nickname string `gorm:"size:64" json:"nickname"`
|
||||||
Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
|
Signature string `gorm:"size:512;default:''" json:"signature"` // 个人签名
|
||||||
Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
|
Avatar string `gorm:"size:512" json:"avatar"` // 兼容 CDN / S3 较长绝对 URL
|
||||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||||
Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
|
Verified bool `gorm:"default:false;index" json:"verified"` // 站长认证:免审发帖/评论
|
||||||
Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
|
Exp int `gorm:"default:0" json:"exp"` // 经验(不可消费)
|
||||||
Points int `gorm:"default:0" json:"points"` // 可用积分
|
Points int `gorm:"default:0" json:"points"` // 可用积分
|
||||||
CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
|
CreatorIncomeTotal int `gorm:"default:0" json:"creator_income_total"` // 累计创作分成
|
||||||
Banned bool `gorm:"default:false" json:"banned"`
|
Banned bool `gorm:"default:false" json:"banned"`
|
||||||
BannedAt *time.Time `json:"banned_at,omitempty"`
|
BannedAt *time.Time `json:"banned_at,omitempty"`
|
||||||
LastLoginAt *time.Time `json:"-"`
|
LastLoginAt *time.Time `json:"-"`
|
||||||
LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
|
LastLoginIP string `gorm:"size:45;default:''" json:"-"` // 兼容 IPv6
|
||||||
LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
|
LastAccessAt *time.Time `json:"-"` // 最近一次带鉴权的访问(与登录分开)
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
// 仅序列化展示用,不落库
|
// 仅序列化展示用,不落库
|
||||||
Level int `gorm:"-" json:"level"`
|
Level int `gorm:"-" json:"level"`
|
||||||
@@ -99,31 +99,31 @@ type Board struct {
|
|||||||
|
|
||||||
// Post 帖子
|
// Post 帖子
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
BoardID uint `gorm:"index;not null" json:"board_id"`
|
BoardID uint `gorm:"index;not null" json:"board_id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Title string `gorm:"size:256;not null" json:"title"`
|
Title string `gorm:"size:256;not null" json:"title"`
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
|
ContentPlain string `gorm:"type:text" json:"-"` // 正文纯文本,供搜索索引
|
||||||
Tags string `gorm:"size:256" json:"tags"`
|
Tags string `gorm:"size:256" json:"tags"`
|
||||||
PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
|
PostType string `gorm:"size:16;default:normal;index" json:"post_type"` // normal|question|poll|bounty|lottery
|
||||||
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
|
QuestionResolved bool `gorm:"default:false;index" json:"question_resolved"` // 仅 question 有意义
|
||||||
BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
|
BountyPoints int `gorm:"default:0" json:"bounty_points"` // 悬赏积分(仅 bounty)
|
||||||
BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
|
BountyStatus string `gorm:"size:16;default:'';index" json:"bounty_status"` // open|awarded|refunded
|
||||||
BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
|
BountyCommentID uint `gorm:"default:0" json:"bounty_comment_id"` // 采纳的评论
|
||||||
LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
|
LotteryWinnerCount int `gorm:"default:1" json:"lottery_winner_count"` // 抽奖人数(仅 lottery)
|
||||||
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
LotteryStatus string `gorm:"size:16;default:'';index" json:"lottery_status"` // open|drawn
|
||||||
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
Pinned bool `gorm:"default:false" json:"pinned"` // 全局置顶
|
||||||
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
BoardPinned bool `gorm:"default:false" json:"board_pinned"` // 板块内置顶
|
||||||
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
Featured bool `gorm:"default:false;index" json:"featured"` // 精华帖
|
||||||
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
EditLocked bool `gorm:"default:false" json:"edit_locked"`
|
||||||
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
CommentsLocked bool `gorm:"default:false" json:"comments_locked"` // 禁止评论(结贴)
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
||||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
|
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
|
||||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
@@ -162,21 +162,21 @@ type ForumSetting struct {
|
|||||||
|
|
||||||
// Comment 楼层评论
|
// Comment 楼层评论
|
||||||
type Comment struct {
|
type Comment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"index;not null" json:"post_id"`
|
PostID uint `gorm:"index;not null" json:"post_id"`
|
||||||
UserID uint `gorm:"index" json:"user_id"` // 0 表示游客
|
UserID uint `gorm:"index" json:"user_id"` // 0 表示游客
|
||||||
Floor int `gorm:"not null" json:"floor"`
|
Floor int `gorm:"not null" json:"floor"`
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"`
|
ReplyTo *uint `gorm:"index" json:"reply_to,omitempty"`
|
||||||
GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"`
|
GuestNick string `gorm:"size:64" json:"guest_nick,omitempty"`
|
||||||
GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"`
|
GuestEmail string `gorm:"size:128" json:"guest_email,omitempty"`
|
||||||
GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
|
GuestURL string `gorm:"size:256" json:"guest_url,omitempty"`
|
||||||
IsPrivate bool `gorm:"default:false" json:"is_private"`
|
IsPrivate bool `gorm:"default:false" json:"is_private"`
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
Status string `gorm:"size:16;default:published;index" json:"status"` // pending|published|rejected
|
||||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||||
@@ -266,23 +266,23 @@ const (
|
|||||||
|
|
||||||
// FriendLinkApply 友情链接申请
|
// FriendLinkApply 友情链接申请
|
||||||
type FriendLinkApply struct {
|
type FriendLinkApply struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Name string `gorm:"size:32;not null" json:"name"`
|
Name string `gorm:"size:32;not null" json:"name"`
|
||||||
URL string `gorm:"size:512;not null" json:"url"`
|
URL string `gorm:"size:512;not null" json:"url"`
|
||||||
Description string `gorm:"size:200;default:''" json:"description,omitempty"`
|
Description string `gorm:"size:200;default:''" json:"description,omitempty"`
|
||||||
Logo string `gorm:"size:512;default:''" json:"logo"`
|
Logo string `gorm:"size:512;default:''" json:"logo"`
|
||||||
ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
|
ReciprocalPageURL string `gorm:"size:512;default:''" json:"reciprocal_page_url"`
|
||||||
LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
|
LinkOnHomepage bool `gorm:"default:true" json:"link_on_homepage"`
|
||||||
ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
|
ReciprocalVerified bool `gorm:"default:false" json:"reciprocal_verified"`
|
||||||
ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
|
ReciprocalCheckNote string `gorm:"size:256;default:''" json:"reciprocal_check_note,omitempty"`
|
||||||
ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
|
ReciprocalCheckedAt *time.Time `json:"reciprocal_checked_at,omitempty"`
|
||||||
Status string `gorm:"size:16;default:pending;index" json:"status"`
|
Status string `gorm:"size:16;default:pending;index" json:"status"`
|
||||||
ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
|
ReviewNote string `gorm:"size:256;default:''" json:"review_note,omitempty"`
|
||||||
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
|
||||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -301,10 +301,10 @@ type PostReport struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
HandledAt *time.Time `json:"handled_at,omitempty"`
|
HandledAt *time.Time `json:"handled_at,omitempty"`
|
||||||
|
|
||||||
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
Post Post `gorm:"foreignKey:PostID" json:"post,omitempty"`
|
||||||
Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
|
Comment *Comment `gorm:"foreignKey:CommentID" json:"comment,omitempty"`
|
||||||
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
Reporter User `gorm:"foreignKey:ReporterID" json:"reporter,omitempty"`
|
||||||
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
|
Handler *User `gorm:"foreignKey:HandlerID" json:"handler,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
|
// Media 上传媒体索引(真实文件在本地 uploads 或 S3;本表供后台列表与统计)
|
||||||
@@ -377,17 +377,17 @@ type PostContentUnlock struct {
|
|||||||
|
|
||||||
// SitePage 自定义单页(关于我们、版规等)
|
// SitePage 自定义单页(关于我们、版规等)
|
||||||
type SitePage struct {
|
type SitePage struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Title string `gorm:"size:128;not null" json:"title"`
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
|
Slug string `gorm:"uniqueIndex;size:64;not null" json:"slug"`
|
||||||
Content string `gorm:"type:text;not null" json:"content"`
|
Content string `gorm:"type:text;not null" json:"content"`
|
||||||
Published bool `gorm:"default:false;index" json:"published"`
|
Published bool `gorm:"default:false;index" json:"published"`
|
||||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
|
ShowInFooter bool `gorm:"default:false" json:"show_in_footer"`
|
||||||
ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
|
ShowInNav bool `gorm:"default:false" json:"show_in_nav"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll 投票帖配置
|
// Poll 投票帖配置
|
||||||
@@ -446,7 +446,7 @@ type BadgeDef struct {
|
|||||||
Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
|
Code string `gorm:"uniqueIndex;size:64;not null" json:"code"`
|
||||||
Name string `gorm:"size:64;not null" json:"name"`
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
Description string `gorm:"size:256;default:''" json:"description"`
|
Description string `gorm:"size:256;default:''" json:"description"`
|
||||||
Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
|
Icon string `gorm:"size:64;default:''" json:"icon"` // lucide / 固定 key
|
||||||
Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited
|
Kind string `gorm:"size:16;index;not null" json:"kind"` // auto|limited
|
||||||
Metric string `gorm:"size:32;default:''" json:"metric"`
|
Metric string `gorm:"size:32;default:''" json:"metric"`
|
||||||
Threshold int `gorm:"default:0" json:"threshold"`
|
Threshold int `gorm:"default:0" json:"threshold"`
|
||||||
@@ -458,10 +458,43 @@ type BadgeDef struct {
|
|||||||
|
|
||||||
// UserBadge 用户已获徽章
|
// UserBadge 用户已获徽章
|
||||||
type UserBadge struct {
|
type UserBadge struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
|
UserID uint `gorm:"uniqueIndex:idx_user_badge;not null" json:"user_id"`
|
||||||
BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
|
BadgeID uint `gorm:"uniqueIndex:idx_user_badge;index;not null" json:"badge_id"`
|
||||||
AwardedAt time.Time `json:"awarded_at"`
|
AwardedAt time.Time `json:"awarded_at"`
|
||||||
AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
|
AwardedBy uint `gorm:"default:0" json:"awarded_by"` // 0=系统
|
||||||
Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
|
Badge BadgeDef `gorm:"foreignKey:BadgeID" json:"badge,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityInstance 社区枢纽收到的公网实例心跳
|
||||||
|
type CommunityInstance struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
InstanceID string `gorm:"size:64;uniqueIndex;not null" json:"instance_id"`
|
||||||
|
SiteURL string `gorm:"size:512;not null" json:"site_url"`
|
||||||
|
SiteName string `gorm:"size:128" json:"site_name"`
|
||||||
|
Version string `gorm:"size:32" json:"version"`
|
||||||
|
Users int64 `gorm:"default:0" json:"users"`
|
||||||
|
Posts int64 `gorm:"default:0" json:"posts"`
|
||||||
|
RemoteIP string `gorm:"size:64" json:"remote_ip,omitempty"`
|
||||||
|
Featured bool `gorm:"default:false;index" json:"featured"` // 人工精选后进入公开展柜
|
||||||
|
FeaturedNote string `gorm:"size:64" json:"featured_note"` // 展柜短注
|
||||||
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||||
|
LastSeenAt time.Time `gorm:"index" json:"last_seen_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageView 前台路由浏览量(第一方 SPA 信标;存独立 monitor.db;请求日志走 jsonl)
|
||||||
|
type PageView struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `gorm:"index;not null" json:"created_at"`
|
||||||
|
Path string `gorm:"size:512;index" json:"path"`
|
||||||
|
Referrer string `gorm:"size:512" json:"referrer"`
|
||||||
|
IP string `gorm:"size:64;index" json:"ip"` // 完整客户端 IP
|
||||||
|
UA string `gorm:"size:512" json:"ua"`
|
||||||
|
Country string `gorm:"size:8;index" json:"country"`
|
||||||
|
Region string `gorm:"size:64" json:"region"`
|
||||||
|
RegionISO string `gorm:"size:16;index" json:"region_iso"`
|
||||||
|
City string `gorm:"size:64;index" json:"city"`
|
||||||
|
ASN uint `gorm:"index" json:"asn"`
|
||||||
|
ASOrg string `gorm:"size:128" json:"as_org"`
|
||||||
|
IsBot bool `gorm:"default:false;index" json:"is_bot"`
|
||||||
}
|
}
|
||||||
|
|||||||
54
model/monitor_db_test.go
Normal file
54
model/monitor_db_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitMonitorDBUsesSeparateFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
mainPath := filepath.Join(dir, "jiang13.db")
|
||||||
|
monPath := filepath.Join(dir, "monitor.db")
|
||||||
|
|
||||||
|
prevDB, prevMon := DB, MonitorDB
|
||||||
|
t.Cleanup(func() {
|
||||||
|
closeGorm(MonitorDB)
|
||||||
|
closeGorm(DB)
|
||||||
|
DB, MonitorDB = prevDB, prevMon
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := InitDB(mainPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := InitMonitorDB(monPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tableExists(DB, "page_views") {
|
||||||
|
t.Fatal("主库不应创建 page_views")
|
||||||
|
}
|
||||||
|
if !tableExists(MonitorDB, "page_views") {
|
||||||
|
t.Fatal("监控库缺少 page_views")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableExists(db *gorm.DB, name string) bool {
|
||||||
|
if db == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
_ = db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", name).Scan(&n).Error
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeGorm(db *gorm.DB) {
|
||||||
|
if db == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
@@ -21,6 +21,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
r.Use(gin.Recovery())
|
r.Use(gin.Recovery())
|
||||||
r.Use(gin.Logger())
|
r.Use(gin.Logger())
|
||||||
|
|
||||||
|
filter := service.NewSensitiveFilter()
|
||||||
|
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
|
||||||
|
filter.LoadFromFile(cfg.FilterWordsPath())
|
||||||
|
|
||||||
|
settingsSvc := service.NewForumSettingsService()
|
||||||
|
settingsSvc.SetCommunityHubEnabled(cfg.CommunityHub)
|
||||||
|
monitorSvc := service.NewMonitorService(settingsSvc, cfg.DataDir, cfg.JWTSecret)
|
||||||
|
monitorSvc.StartBackground()
|
||||||
|
r.Use(middleware.AccessLogMiddleware(monitorSvc))
|
||||||
|
|
||||||
// dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供
|
// dev 模式:跳过内嵌静态资源,前端由 Vite 开发服务器(:5173)提供
|
||||||
// 用户应访问 5173 端口,Vite 通过 proxy 将 /api 等请求转发到本服务(:3000)
|
// 用户应访问 5173 端口,Vite 通过 proxy 将 /api 等请求转发到本服务(:3000)
|
||||||
if !cfg.DevMode {
|
if !cfg.DevMode {
|
||||||
@@ -31,11 +41,11 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n")
|
fmt.Fprintf(os.Stderr, "[dev] 后端仅提供 API,前端请访问 http://localhost:5173\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
filter := service.NewSensitiveFilter()
|
communitySvc := service.NewCommunityService(settingsSvc)
|
||||||
_ = service.WriteDefaultFilterWords(cfg.FilterWordsPath())
|
communitySvc.StartBackground()
|
||||||
filter.LoadFromFile(cfg.FilterWordsPath())
|
if cfg.CommunityHub {
|
||||||
|
fmt.Fprintf(os.Stderr, "[community] 社区枢纽已开启(运维配置),可接收自愿上报\n")
|
||||||
settingsSvc := service.NewForumSettingsService()
|
}
|
||||||
// SPA 入口 HTML 注入标题与品牌 JSON,避免刷新时先闪默认文案
|
// SPA 入口 HTML 注入标题与品牌 JSON,避免刷新时先闪默认文案
|
||||||
embed_static.SetSPADocumentTitle(func() string {
|
embed_static.SetSPADocumentTitle(func() string {
|
||||||
return settingsSvc.SiteBranding().DocumentTitle()
|
return settingsSvc.SiteBranding().DocumentTitle()
|
||||||
@@ -87,7 +97,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
h := &handler.Handlers{
|
h := &handler.Handlers{
|
||||||
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
|
Cfg: cfg, Store: uploadStore, Auth: authSvc, User: userSvc, Board: boardSvc,
|
||||||
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
|
Post: postSvc, Comment: commentSvc, Message: messageSvc, Notify: notifySvc, Report: reportSvc,
|
||||||
Backup: backupSvc,
|
Backup: backupSvc, Community: communitySvc, Monitor: monitorSvc,
|
||||||
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
Filter: filter, Limiter: limiter, Settings: settingsSvc,
|
||||||
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
Captcha: captchaSvc, Mail: mailSvc, EmailCode: emailCodeSvc,
|
||||||
OIDC: oidcSvc, Gitea: giteaSvc,
|
OIDC: oidcSvc, Gitea: giteaSvc,
|
||||||
@@ -124,6 +134,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.GET("/me", h.APIMe)
|
pubAPI.GET("/me", h.APIMe)
|
||||||
pubAPI.GET("/boards", h.APIBoards)
|
pubAPI.GET("/boards", h.APIBoards)
|
||||||
pubAPI.GET("/stats", h.APIStats)
|
pubAPI.GET("/stats", h.APIStats)
|
||||||
|
pubAPI.POST("/community/heartbeat", middleware.RateLimitMiddleware(limiter, "community_heartbeat"), h.APICommunityHeartbeat)
|
||||||
|
pubAPI.GET("/community/showcase", h.APICommunityShowcase)
|
||||||
|
pubAPI.POST("/monitor/pageview", middleware.RateLimitMiddleware(limiter, "monitor_pageview"), h.APIMonitorPageview)
|
||||||
pubAPI.GET("/forum-limits", h.APIForumLimits)
|
pubAPI.GET("/forum-limits", h.APIForumLimits)
|
||||||
pubAPI.GET("/site-branding", h.APISiteBranding)
|
pubAPI.GET("/site-branding", h.APISiteBranding)
|
||||||
pubAPI.GET("/pages", h.APIPages)
|
pubAPI.GET("/pages", h.APIPages)
|
||||||
@@ -205,6 +218,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
adminAPI.GET("/dashboard", h.APIAdminDashboard)
|
||||||
adminAPI.GET("/settings", h.APIAdminSettings)
|
adminAPI.GET("/settings", h.APIAdminSettings)
|
||||||
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
|
adminAPI.PUT("/settings/forum", h.APIAdminUpdateForumSettings)
|
||||||
|
adminAPI.PUT("/settings/community", h.APIAdminUpdateCommunitySettings)
|
||||||
|
adminAPI.GET("/settings/monitor", h.APIAdminGetMonitorSettings)
|
||||||
|
adminAPI.PUT("/settings/monitor", h.APIAdminUpdateMonitorSettings)
|
||||||
|
adminAPI.GET("/monitor/overview", h.APIAdminMonitorOverview)
|
||||||
|
adminAPI.GET("/monitor/geo", h.APIAdminMonitorGeo)
|
||||||
|
adminAPI.GET("/monitor/stats", h.APIAdminMonitorStats)
|
||||||
|
adminAPI.GET("/monitor/logs", h.APIAdminMonitorLogs)
|
||||||
|
adminAPI.GET("/monitor/realtime", h.APIAdminMonitorRealtime)
|
||||||
|
adminAPI.GET("/community/instances", h.APIAdminCommunityInstances)
|
||||||
|
adminAPI.PUT("/community/instances/:id/feature", h.APIAdminFeatureCommunityInstance)
|
||||||
|
adminAPI.PUT("/community/showcase-entry", h.APIAdminUpdateShowcaseEntry)
|
||||||
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
|
adminAPI.PUT("/settings/mail", h.APIAdminUpdateMailSettings)
|
||||||
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
|
adminAPI.POST("/settings/mail/test", h.APIAdminTestMail)
|
||||||
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
|
adminAPI.PUT("/settings/oidc", h.APIAdminUpdateOIDCSettings)
|
||||||
@@ -282,7 +306,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
|
adminAuth := admin.Group("/", authMW.RequireAuth(), authMW.RequireAdmin())
|
||||||
{
|
{
|
||||||
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
|
adminAuth.GET("/", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/dashboard") })
|
||||||
for _, page := range []string{"dashboard", "boards", "pages", "links", "posts", "comments", "reports", "users", "badges", "media", "settings"} {
|
for _, page := range []string{
|
||||||
|
"dashboard", "boards", "pages", "links", "community", "posts",
|
||||||
|
"comments", "reports", "users", "badges", "media", "monitor", "settings",
|
||||||
|
} {
|
||||||
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
|
adminAuth.GET("/"+page, embed_static.ServeSPANoIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,16 @@ func TestNormalizeAsideWidgetsPreservesOrder(t *testing.T) {
|
|||||||
{ID: AsideWidgetRecentComments, Enabled: false},
|
{ID: AsideWidgetRecentComments, Enabled: false},
|
||||||
}
|
}
|
||||||
out := NormalizeAsideWidgets(in)
|
out := NormalizeAsideWidgets(in)
|
||||||
if len(out) != 4 {
|
if len(out) != 5 {
|
||||||
t.Fatalf("want 4 widgets, got %d", len(out))
|
t.Fatalf("want 5 widgets, got %d", len(out))
|
||||||
}
|
}
|
||||||
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers}
|
want := []string{AsideWidgetFriendLinks, AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetShowcase}
|
||||||
for i, id := range want {
|
for i, id := range want {
|
||||||
if out[i].ID != id {
|
if out[i].ID != id {
|
||||||
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
|
t.Fatalf("index %d: want %s, got %s", i, id, out[i].ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled {
|
if !out[0].Enabled || !out[1].Enabled || out[2].Enabled || out[3].Enabled || out[4].Enabled {
|
||||||
t.Fatalf("enabled flags mismatch: %+v", out)
|
t.Fatalf("enabled flags mismatch: %+v", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ func NewBackupService(dbPath, dataDir string) *BackupService {
|
|||||||
return &BackupService{dbPath: dbPath, dataDir: dataDir}
|
return &BackupService{dbPath: dbPath, dataDir: dataDir}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportSQLite 导出 SQLite 备份文件到 data 目录
|
// ExportSQLite 导出主库 SQLite 备份到 data 目录(不含 monitor.db)
|
||||||
func (s *BackupService) ExportSQLite() (string, error) {
|
func (s *BackupService) ExportSQLite() (string, error) {
|
||||||
src, err := os.Open(s.dbPath)
|
src, err := os.Open(s.dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
383
service/community.go
Normal file
383
service/community.go
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppVersion 由 cmd 通过 SetAppVersion 注入(ldflags)
|
||||||
|
var AppVersion = "dev"
|
||||||
|
|
||||||
|
// SetAppVersion 设置运行时版本号
|
||||||
|
func SetAppVersion(v string) {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v != "" {
|
||||||
|
AppVersion = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCommunityInstanceID() string {
|
||||||
|
return uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
communityHeartbeatInterval = 24 * time.Hour
|
||||||
|
communityHeartbeatTimeout = 8 * time.Second
|
||||||
|
communityOnlineWithin = 72 * time.Hour
|
||||||
|
maxCommunitySiteURLLen = 512
|
||||||
|
maxCommunitySiteNameLen = 128
|
||||||
|
maxCommunityVersionLen = 32
|
||||||
|
maxCommunityInstanceIDLen = 64
|
||||||
|
maxCommunityFeaturedNoteLen = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrCommunityHubDisabled = errors.New("本站未开启社区枢纽")
|
||||||
|
ErrCommunityBadPayload = errors.New("心跳参数无效")
|
||||||
|
|
||||||
|
// communityHubBaseURL 出站枢纽根地址(写死官方站;测试可临时覆盖)
|
||||||
|
communityHubBaseURL = DefaultCommunityHubURL
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommunityHeartbeatPayload 出站 / 入站心跳体
|
||||||
|
type CommunityHeartbeatPayload struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
SiteURL string `json:"site_url"`
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Users int64 `json:"users"`
|
||||||
|
Posts int64 `json:"posts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityInstanceView 管理端列表项
|
||||||
|
type CommunityInstanceView struct {
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
SiteURL string `json:"site_url"`
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Users int64 `json:"users"`
|
||||||
|
Posts int64 `json:"posts"`
|
||||||
|
FirstSeenAt time.Time `json:"first_seen_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Featured bool `json:"featured"`
|
||||||
|
FeaturedNote string `json:"featured_note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityShowcaseItem 公开展柜条目(不含敏感字段)
|
||||||
|
type CommunityShowcaseItem struct {
|
||||||
|
SiteURL string `json:"site_url"`
|
||||||
|
SiteName string `json:"site_name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
FeaturedNote string `json:"featured_note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityFeatureInput 管理端精选请求
|
||||||
|
type CommunityFeatureInput struct {
|
||||||
|
Featured bool `json:"featured"`
|
||||||
|
FeaturedNote string `json:"featured_note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityService 可选社区上报 + 枢纽接收
|
||||||
|
type CommunityService struct {
|
||||||
|
settings *ForumSettingsService
|
||||||
|
client *http.Client
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
kickCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCommunityService 创建社区服务
|
||||||
|
func NewCommunityService(settings *ForumSettingsService) *CommunityService {
|
||||||
|
return &CommunityService{
|
||||||
|
settings: settings,
|
||||||
|
client: &http.Client{Timeout: communityHeartbeatTimeout},
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
kickCh: make(chan struct{}, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartBackground 启动 24h 心跳循环
|
||||||
|
func (c *CommunityService) StartBackground() {
|
||||||
|
c.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer c.wg.Done()
|
||||||
|
timer := time.NewTimer(30 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopCh:
|
||||||
|
return
|
||||||
|
case <-c.kickCh:
|
||||||
|
c.trySendHeartbeat()
|
||||||
|
if !timer.Stop() {
|
||||||
|
select {
|
||||||
|
case <-timer.C:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer.Reset(communityHeartbeatInterval)
|
||||||
|
case <-timer.C:
|
||||||
|
c.trySendHeartbeat()
|
||||||
|
timer.Reset(communityHeartbeatInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop 停止后台心跳
|
||||||
|
func (c *CommunityService) Stop() {
|
||||||
|
select {
|
||||||
|
case <-c.stopCh:
|
||||||
|
default:
|
||||||
|
close(c.stopCh)
|
||||||
|
}
|
||||||
|
c.wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KickHeartbeat 请求尽快发送一次心跳(开启上报时调用)
|
||||||
|
func (c *CommunityService) KickHeartbeat() {
|
||||||
|
select {
|
||||||
|
case c.kickCh <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CommunityService) trySendHeartbeat() {
|
||||||
|
_ = c.SendHeartbeatOnce("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendHeartbeatOnce 立即发送一次心跳;requestOrigin 可在管理端保存时传入以补全本站地址
|
||||||
|
func (c *CommunityService) SendHeartbeatOnce(requestOrigin string) error {
|
||||||
|
// 枢纽站(含官网)不出站上报,避免自己心跳给自己
|
||||||
|
if c.settings.CommunityHubEnabled(requestOrigin) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cfg := c.settings.CommunityConfig()
|
||||||
|
if !cfg.ReportEnabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if requestOrigin != "" {
|
||||||
|
if _, err := c.settings.EnsureCommunitySiteURL(requestOrigin); err != nil {
|
||||||
|
log.Printf("[community] 组装心跳失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload, err := c.buildPayload(requestOrigin)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[community] 组装心跳失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hub := strings.TrimRight(communityHubBaseURL, "/")
|
||||||
|
if hub == "" {
|
||||||
|
hub = DefaultCommunityHubURL
|
||||||
|
}
|
||||||
|
endpoint := hub + "/api/community/heartbeat"
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[community] 创建请求失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "jiang13-forum/"+AppVersion)
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[community] 上报失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
err := fmt.Errorf("上报被拒绝: HTTP %d", resp.StatusCode)
|
||||||
|
log.Printf("[community] %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CommunityService) buildPayload(requestOrigin string) (*CommunityHeartbeatPayload, error) {
|
||||||
|
id, err := c.settings.EnsureCommunityInstanceID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
siteURL := c.settings.CommunitySiteURL(requestOrigin)
|
||||||
|
if siteURL == "" {
|
||||||
|
return nil, fmt.Errorf("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报")
|
||||||
|
}
|
||||||
|
var users, posts int64
|
||||||
|
_ = model.DB.Model(&model.User{}).Count(&users).Error
|
||||||
|
_ = model.DB.Model(&model.Post{}).Where("status = ?", model.ContentStatusPublished).Count(&posts).Error
|
||||||
|
brand := c.settings.SiteBranding()
|
||||||
|
return &CommunityHeartbeatPayload{
|
||||||
|
InstanceID: id,
|
||||||
|
SiteURL: truncateRunes(siteURL, maxCommunitySiteURLLen),
|
||||||
|
SiteName: truncateRunes(brand.Name, maxCommunitySiteNameLen),
|
||||||
|
Version: truncateRunes(AppVersion, maxCommunityVersionLen),
|
||||||
|
Users: users,
|
||||||
|
Posts: posts,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReceiveHeartbeat 枢纽接收心跳并 upsert;requestHint 用于按请求 Host 识别官网
|
||||||
|
func (c *CommunityService) ReceiveHeartbeat(in CommunityHeartbeatPayload, remoteIP, requestHint string) error {
|
||||||
|
if !c.settings.CommunityHubEnabled(requestHint) {
|
||||||
|
return ErrCommunityHubDisabled
|
||||||
|
}
|
||||||
|
in.InstanceID = strings.TrimSpace(in.InstanceID)
|
||||||
|
in.SiteURL = strings.TrimSpace(in.SiteURL)
|
||||||
|
in.SiteName = strings.TrimSpace(in.SiteName)
|
||||||
|
in.Version = strings.TrimSpace(in.Version)
|
||||||
|
if in.InstanceID == "" || len(in.InstanceID) > maxCommunityInstanceIDLen {
|
||||||
|
return ErrCommunityBadPayload
|
||||||
|
}
|
||||||
|
if err := validateCommunitySiteURL(in.SiteURL); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.SiteURL = truncateRunes(in.SiteURL, maxCommunitySiteURLLen)
|
||||||
|
in.SiteName = truncateRunes(in.SiteName, maxCommunitySiteNameLen)
|
||||||
|
in.Version = truncateRunes(in.Version, maxCommunityVersionLen)
|
||||||
|
if in.Users < 0 {
|
||||||
|
in.Users = 0
|
||||||
|
}
|
||||||
|
if in.Posts < 0 {
|
||||||
|
in.Posts = 0
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
var row model.CommunityInstance
|
||||||
|
res := model.DB.Where("instance_id = ?", in.InstanceID).Limit(1).Find(&row)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
row = model.CommunityInstance{
|
||||||
|
InstanceID: in.InstanceID,
|
||||||
|
SiteURL: in.SiteURL,
|
||||||
|
SiteName: in.SiteName,
|
||||||
|
Version: in.Version,
|
||||||
|
Users: in.Users,
|
||||||
|
Posts: in.Posts,
|
||||||
|
RemoteIP: truncateRunes(remoteIP, 64),
|
||||||
|
FirstSeenAt: now,
|
||||||
|
LastSeenAt: now,
|
||||||
|
}
|
||||||
|
return model.DB.Create(&row).Error
|
||||||
|
}
|
||||||
|
row.SiteURL = in.SiteURL
|
||||||
|
row.SiteName = in.SiteName
|
||||||
|
row.Version = in.Version
|
||||||
|
row.Users = in.Users
|
||||||
|
row.Posts = in.Posts
|
||||||
|
row.RemoteIP = truncateRunes(remoteIP, 64)
|
||||||
|
row.LastSeenAt = now
|
||||||
|
return model.DB.Save(&row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInstances 管理端实例列表(按最近心跳倒序)
|
||||||
|
func (c *CommunityService) ListInstances() ([]CommunityInstanceView, error) {
|
||||||
|
var rows []model.CommunityInstance
|
||||||
|
if err := model.DB.Order("last_seen_at DESC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
out := make([]CommunityInstanceView, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, CommunityInstanceView{
|
||||||
|
InstanceID: r.InstanceID,
|
||||||
|
SiteURL: r.SiteURL,
|
||||||
|
SiteName: r.SiteName,
|
||||||
|
Version: r.Version,
|
||||||
|
Users: r.Users,
|
||||||
|
Posts: r.Posts,
|
||||||
|
FirstSeenAt: r.FirstSeenAt,
|
||||||
|
LastSeenAt: r.LastSeenAt,
|
||||||
|
Online: now.Sub(r.LastSeenAt) <= communityOnlineWithin,
|
||||||
|
Featured: r.Featured,
|
||||||
|
FeaturedNote: r.FeaturedNote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInstanceFeatured 人工精选 / 取消;心跳无法自助上柜
|
||||||
|
func (c *CommunityService) SetInstanceFeatured(instanceID string, in CommunityFeatureInput) (*CommunityInstanceView, error) {
|
||||||
|
instanceID = strings.TrimSpace(instanceID)
|
||||||
|
if instanceID == "" {
|
||||||
|
return nil, ErrCommunityBadPayload
|
||||||
|
}
|
||||||
|
var row model.CommunityInstance
|
||||||
|
if err := model.DB.Where("instance_id = ?", instanceID).First(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
row.Featured = in.Featured
|
||||||
|
if in.Featured {
|
||||||
|
row.FeaturedNote = truncateRunes(strings.TrimSpace(in.FeaturedNote), maxCommunityFeaturedNoteLen)
|
||||||
|
} else {
|
||||||
|
row.FeaturedNote = ""
|
||||||
|
}
|
||||||
|
if err := model.DB.Save(&row).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
return &CommunityInstanceView{
|
||||||
|
InstanceID: row.InstanceID,
|
||||||
|
SiteURL: row.SiteURL,
|
||||||
|
SiteName: row.SiteName,
|
||||||
|
Version: row.Version,
|
||||||
|
Users: row.Users,
|
||||||
|
Posts: row.Posts,
|
||||||
|
FirstSeenAt: row.FirstSeenAt,
|
||||||
|
LastSeenAt: row.LastSeenAt,
|
||||||
|
Online: now.Sub(row.LastSeenAt) <= communityOnlineWithin,
|
||||||
|
Featured: row.Featured,
|
||||||
|
FeaturedNote: row.FeaturedNote,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShowcase 公开展柜:仅精选;枢纽关闭时返回空;requestHint 用于按 Host 识别官网
|
||||||
|
func (c *CommunityService) ListShowcase(requestHint string) ([]CommunityShowcaseItem, error) {
|
||||||
|
if !c.settings.CommunityHubEnabled(requestHint) {
|
||||||
|
return []CommunityShowcaseItem{}, nil
|
||||||
|
}
|
||||||
|
var rows []model.CommunityInstance
|
||||||
|
if err := model.DB.Where("featured = ?", true).Order("last_seen_at DESC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]CommunityShowcaseItem, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
if validateCommunitySiteURL(r.SiteURL) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, CommunityShowcaseItem{
|
||||||
|
SiteURL: r.SiteURL,
|
||||||
|
SiteName: r.SiteName,
|
||||||
|
Version: r.Version,
|
||||||
|
FeaturedNote: r.FeaturedNote,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCommunitySiteURL(raw string) error {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
return ErrCommunityBadPayload
|
||||||
|
}
|
||||||
|
if u.Scheme != "http" && u.Scheme != "https" {
|
||||||
|
return ErrCommunityBadPayload
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
434
service/community_test.go
Normal file
434
service/community_test.go
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.iioio.com/freefire/jiang13-forum/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupCommunityTest(t *testing.T) (*ForumSettingsService, *CommunityService) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.ForumSetting{},
|
||||||
|
&model.CommunityInstance{},
|
||||||
|
&model.User{},
|
||||||
|
&model.Post{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prev := model.DB
|
||||||
|
model.DB = db
|
||||||
|
t.Cleanup(func() { model.DB = prev })
|
||||||
|
|
||||||
|
settings := NewForumSettingsService()
|
||||||
|
svc := NewCommunityService(settings)
|
||||||
|
return settings, svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityHeartbeatHubDisabled(t *testing.T) {
|
||||||
|
_, svc := setupCommunityTest(t)
|
||||||
|
err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
|
||||||
|
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
SiteURL: "https://example.com",
|
||||||
|
SiteName: "测试站",
|
||||||
|
Version: "1.0.0",
|
||||||
|
Users: 1,
|
||||||
|
Posts: 2,
|
||||||
|
}, "127.0.0.1", "https://other.example")
|
||||||
|
if !errors.Is(err, ErrCommunityHubDisabled) {
|
||||||
|
t.Fatalf("want ErrCommunityHubDisabled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityHeartbeatAcceptAndList(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
settings.SetCommunityHubEnabled(true)
|
||||||
|
|
||||||
|
payload := CommunityHeartbeatPayload{
|
||||||
|
InstanceID: "11111111-2222-3333-4444-555555555555",
|
||||||
|
SiteURL: "https://forum.example.org",
|
||||||
|
SiteName: "示例论坛",
|
||||||
|
Version: "1.2.3",
|
||||||
|
Users: 10,
|
||||||
|
Posts: 20,
|
||||||
|
}
|
||||||
|
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
payload.Users = 11
|
||||||
|
payload.Posts = 21
|
||||||
|
if err := svc.ReceiveHeartbeat(payload, "203.0.113.9", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := svc.ListInstances()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("want 1 instance, got %d", len(list))
|
||||||
|
}
|
||||||
|
got := list[0]
|
||||||
|
if got.Users != 11 || got.Posts != 21 || !got.Online {
|
||||||
|
t.Fatalf("unexpected row: %+v", got)
|
||||||
|
}
|
||||||
|
if got.SiteURL != payload.SiteURL || got.SiteName != payload.SiteName {
|
||||||
|
t.Fatalf("site fields mismatch: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityUpdateIgnoresHubFields(t *testing.T) {
|
||||||
|
settings, _ := setupCommunityTest(t)
|
||||||
|
if settings.CommunityConfig().HubEnabled {
|
||||||
|
t.Fatal("hub should be off by default")
|
||||||
|
}
|
||||||
|
if _, err := settings.UpdateCommunityConfig(CommunityConfig{
|
||||||
|
ReportEnabled: true,
|
||||||
|
HubEnabled: true,
|
||||||
|
HubURL: "https://evil.example",
|
||||||
|
SiteURL: "https://should-be-ignored.example",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := settings.CommunityConfig()
|
||||||
|
if cfg.HubEnabled {
|
||||||
|
t.Fatal("UpdateCommunityConfig must not enable hub")
|
||||||
|
}
|
||||||
|
if cfg.HubURL != DefaultCommunityHubURL {
|
||||||
|
t.Fatalf("hub_url must stay official, got %s", cfg.HubURL)
|
||||||
|
}
|
||||||
|
if cfg.SiteURL == "https://should-be-ignored.example" {
|
||||||
|
t.Fatal("client site_url must be ignored")
|
||||||
|
}
|
||||||
|
if !cfg.ReportEnabled {
|
||||||
|
t.Fatal("report should be enabled")
|
||||||
|
}
|
||||||
|
settings.SetCommunityHubEnabled(true)
|
||||||
|
if !settings.CommunityConfig().HubEnabled {
|
||||||
|
t.Fatal("SetCommunityHubEnabled should enable hub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityHeartbeatBadURL(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
settings.SetCommunityHubEnabled(true)
|
||||||
|
err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
|
||||||
|
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
SiteURL: "javascript:alert(1)",
|
||||||
|
SiteName: "坏",
|
||||||
|
}, "127.0.0.1", "")
|
||||||
|
if !errors.Is(err, ErrCommunityBadPayload) {
|
||||||
|
t.Fatalf("want ErrCommunityBadPayload, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityOutboundHeartbeat(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
var hits atomic.Int32
|
||||||
|
var lastBody CommunityHeartbeatPayload
|
||||||
|
|
||||||
|
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/community/heartbeat" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &lastBody)
|
||||||
|
hits.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(hub.Close)
|
||||||
|
|
||||||
|
prevHub := communityHubBaseURL
|
||||||
|
communityHubBaseURL = hub.URL
|
||||||
|
t.Cleanup(func() { communityHubBaseURL = prevHub })
|
||||||
|
|
||||||
|
svc.trySendHeartbeat()
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatal("report disabled should not send")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := settings.setString(SettingOIDCRootURL, "http://reporter.local"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := settings.setString(SettingSiteName, "上报测试站"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := svc.SendHeartbeatOnce(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Fatalf("want 1 outbound hit, got %d", hits.Load())
|
||||||
|
}
|
||||||
|
if lastBody.InstanceID == "" || lastBody.SiteURL == "" {
|
||||||
|
t.Fatalf("empty payload: %+v", lastBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: false}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.SendHeartbeatOnce(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 1 {
|
||||||
|
t.Fatalf("after disable want still 1 hit, got %d", hits.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityFeatureAndShowcase(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
settings.SetCommunityHubEnabled(true)
|
||||||
|
|
||||||
|
payload := CommunityHeartbeatPayload{
|
||||||
|
InstanceID: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
|
||||||
|
SiteURL: "https://forum.example.org",
|
||||||
|
SiteName: "示例论坛",
|
||||||
|
Version: "2.0.0",
|
||||||
|
}
|
||||||
|
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
empty, err := svc.ListShowcase("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(empty) != 0 {
|
||||||
|
t.Fatal("showcase should be empty before feature")
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := svc.SetInstanceFeatured(payload.InstanceID, CommunityFeatureInput{
|
||||||
|
Featured: true,
|
||||||
|
FeaturedNote: "精选自托管",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !view.Featured || view.FeaturedNote != "精选自托管" {
|
||||||
|
t.Fatalf("unexpected view: %+v", view)
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := svc.ListShowcase("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].SiteURL != payload.SiteURL {
|
||||||
|
t.Fatalf("showcase=%+v", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 心跳更新不得清掉精选
|
||||||
|
payload.Users = 9
|
||||||
|
if err := svc.ReceiveHeartbeat(payload, "127.0.0.1", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err = svc.ListShowcase("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 || items[0].FeaturedNote != "精选自托管" {
|
||||||
|
t.Fatalf("featured lost after heartbeat: %+v", items)
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.SetCommunityHubEnabled(false)
|
||||||
|
items, err = svc.ListShowcase("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
t.Fatal("hub off should hide showcase")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunitySiteURLFromOrigin(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
u, err := settings.EnsureCommunitySiteURL("http://localhost:5173")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if u != "http://localhost:5173" {
|
||||||
|
t.Fatalf("got %s", u)
|
||||||
|
}
|
||||||
|
if settings.CommunitySiteURL("") != "http://localhost:5173" {
|
||||||
|
t.Fatal("should persist for ticker")
|
||||||
|
}
|
||||||
|
payload, err := svc.buildPayload("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload.SiteURL != "http://localhost:5173" {
|
||||||
|
t.Fatalf("payload site_url=%s", payload.SiteURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityHubByOfficialHost(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
|
||||||
|
// 其它域名、未开运维开关 → 拒绝
|
||||||
|
err := svc.ReceiveHeartbeat(CommunityHeartbeatPayload{
|
||||||
|
InstanceID: "bbbbbbbb-cccc-dddd-eeee-ffffffffffff",
|
||||||
|
SiteURL: "https://forum.example.org",
|
||||||
|
SiteName: "他站",
|
||||||
|
Version: "1.0.0",
|
||||||
|
}, "127.0.0.1", "https://other.example")
|
||||||
|
if !errors.Is(err, ErrCommunityHubDisabled) {
|
||||||
|
t.Fatalf("want disabled for other host, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求 Host 为官网 → 自动枢纽
|
||||||
|
payload := CommunityHeartbeatPayload{
|
||||||
|
InstanceID: "cccccccc-dddd-eeee-ffff-000000000001",
|
||||||
|
SiteURL: "https://forum.example.org",
|
||||||
|
SiteName: "上报站",
|
||||||
|
Version: "1.1.0",
|
||||||
|
Users: 3,
|
||||||
|
Posts: 5,
|
||||||
|
}
|
||||||
|
if err := svc.ReceiveHeartbeat(payload, "203.0.113.1", "https://bbs.iioio.com"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !settings.CommunityHubEnabled("https://www.bbs.iioio.com") {
|
||||||
|
t.Fatal("www 前缀也应识别为官网")
|
||||||
|
}
|
||||||
|
if !settings.CommunityConfigForRequest("https://bbs.iioio.com").HubEnabled {
|
||||||
|
t.Fatal("CommunityConfigForRequest should enable hub for official host")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := svc.ListInstances()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list) != 1 {
|
||||||
|
t.Fatalf("want 1 instance, got %d", len(list))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已存 ROOT_URL 为官网时,无请求上下文也应开启
|
||||||
|
if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !settings.CommunityHubEnabled("") {
|
||||||
|
t.Fatal("ROOT_URL as official should enable hub without request hint")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCommunityHubSkipsOutboundHeartbeat(t *testing.T) {
|
||||||
|
settings, svc := setupCommunityTest(t)
|
||||||
|
var hits atomic.Int32
|
||||||
|
|
||||||
|
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(hub.Close)
|
||||||
|
|
||||||
|
prevHub := communityHubBaseURL
|
||||||
|
communityHubBaseURL = hub.URL
|
||||||
|
t.Cleanup(func() { communityHubBaseURL = prevHub })
|
||||||
|
|
||||||
|
if err := settings.setString(SettingOIDCRootURL, DefaultCommunityHubURL); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := settings.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := svc.SendHeartbeatOnce(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatal("hub site must not send outbound heartbeat")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运维开关开启同样跳过
|
||||||
|
settings2, svc2 := setupCommunityTest(t)
|
||||||
|
settings2.SetCommunityHubEnabled(true)
|
||||||
|
if err := settings2.setString(SettingOIDCRootURL, "http://mirror.local"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := settings2.UpdateCommunityConfig(CommunityConfig{ReportEnabled: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prevHub2 := communityHubBaseURL
|
||||||
|
communityHubBaseURL = hub.URL
|
||||||
|
t.Cleanup(func() { communityHubBaseURL = prevHub2 })
|
||||||
|
if err := svc2.SendHeartbeatOnce(""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Fatal("ops hub must not send outbound heartbeat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHostFromURLOrHost(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in, want string
|
||||||
|
}{
|
||||||
|
{"https://bbs.iioio.com", "bbs.iioio.com"},
|
||||||
|
{"https://www.bbs.iioio.com/", "bbs.iioio.com"},
|
||||||
|
{"https://bbs.iioio.com:443/path", "bbs.iioio.com"},
|
||||||
|
{"BBS.IIOIO.COM", "bbs.iioio.com"},
|
||||||
|
{"https://other.example", "other.example"},
|
||||||
|
{"", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := hostFromURLOrHost(tc.in); got != tc.want {
|
||||||
|
t.Fatalf("hostFromURLOrHost(%q)=%q want %q", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsideShowcaseEntrySync(t *testing.T) {
|
||||||
|
settings, _ := setupCommunityTest(t)
|
||||||
|
if settings.NavShowShowcase() || settings.FooterShowShowcase() || settings.AsideShowShowcase() {
|
||||||
|
t.Fatal("showcase entry should be off by default")
|
||||||
|
}
|
||||||
|
if err := settings.SetNavShowShowcase(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := settings.SetFooterShowShowcase(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := settings.SetAsideShowcaseEnabled(true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !settings.NavShowShowcase() || !settings.FooterShowShowcase() || !settings.AsideShowShowcase() {
|
||||||
|
t.Fatal("showcase entry should be enabled")
|
||||||
|
}
|
||||||
|
cfg := settings.Limits()
|
||||||
|
if !cfg.NavShowShowcase || !cfg.FooterShowShowcase || !cfg.AsideShowShowcase {
|
||||||
|
t.Fatalf("limits missing showcase flags: %+v", cfg)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, w := range cfg.AsideWidgets {
|
||||||
|
if w.ID == AsideWidgetShowcase {
|
||||||
|
found = true
|
||||||
|
if !w.Enabled {
|
||||||
|
t.Fatal("aside showcase widget should be enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("aside widgets should include showcase")
|
||||||
|
}
|
||||||
|
}
|
||||||
258
service/geo_zh.go
Normal file
258
service/geo_zh.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countryZh ISO2 → 中文国名(展示用;存储仍用 ISO)
|
||||||
|
var countryZh = map[string]string{
|
||||||
|
"CN": "中国", "HK": "中国香港", "MO": "中国澳门", "TW": "中国台湾",
|
||||||
|
"US": "美国", "JP": "日本", "KR": "韩国", "SG": "新加坡",
|
||||||
|
"GB": "英国", "DE": "德国", "FR": "法国", "RU": "俄罗斯",
|
||||||
|
"CA": "加拿大", "AU": "澳大利亚", "IN": "印度", "TH": "泰国",
|
||||||
|
"VN": "越南", "MY": "马来西亚", "ID": "印度尼西亚", "PH": "菲律宾",
|
||||||
|
"NL": "荷兰", "IT": "意大利", "ES": "西班牙", "BR": "巴西",
|
||||||
|
"MX": "墨西哥", "SE": "瑞典", "CH": "瑞士", "PL": "波兰",
|
||||||
|
"TR": "土耳其", "SA": "沙特阿拉伯", "AE": "阿联酋", "NZ": "新西兰",
|
||||||
|
"IE": "爱尔兰", "BE": "比利时", "AT": "奥地利", "NO": "挪威",
|
||||||
|
"DK": "丹麦", "FI": "芬兰", "PT": "葡萄牙", "CZ": "捷克",
|
||||||
|
"UA": "乌克兰", "IL": "以色列", "ZA": "南非", "AR": "阿根廷",
|
||||||
|
}
|
||||||
|
|
||||||
|
type cnRegionMeta struct {
|
||||||
|
Name string
|
||||||
|
ISO string
|
||||||
|
}
|
||||||
|
|
||||||
|
// cnRegionMap 中国省级英文名/别名 → 中文名 + 地图 ISO
|
||||||
|
var cnRegionMap = map[string]cnRegionMeta{
|
||||||
|
"beijing": {Name: "北京", ISO: "BJ"},
|
||||||
|
"peking": {Name: "北京", ISO: "BJ"},
|
||||||
|
"tianjin": {Name: "天津", ISO: "TJ"},
|
||||||
|
"shanghai": {Name: "上海", ISO: "SH"},
|
||||||
|
"chongqing": {Name: "重庆", ISO: "CQ"},
|
||||||
|
"chungking": {Name: "重庆", ISO: "CQ"},
|
||||||
|
"hebei": {Name: "河北", ISO: "HE"},
|
||||||
|
"shanxi": {Name: "山西", ISO: "SX"},
|
||||||
|
"liaoning": {Name: "辽宁", ISO: "LN"},
|
||||||
|
"jilin": {Name: "吉林", ISO: "JL"},
|
||||||
|
"heilongjiang": {Name: "黑龙江", ISO: "HL"},
|
||||||
|
"jiangsu": {Name: "江苏", ISO: "JS"},
|
||||||
|
"zhejiang": {Name: "浙江", ISO: "ZJ"},
|
||||||
|
"anhui": {Name: "安徽", ISO: "AH"},
|
||||||
|
"fujian": {Name: "福建", ISO: "FJ"},
|
||||||
|
"jiangxi": {Name: "江西", ISO: "JX"},
|
||||||
|
"shandong": {Name: "山东", ISO: "SD"},
|
||||||
|
"henan": {Name: "河南", ISO: "HA"},
|
||||||
|
"hubei": {Name: "湖北", ISO: "HB"},
|
||||||
|
"hunan": {Name: "湖南", ISO: "HN"},
|
||||||
|
"guangdong": {Name: "广东", ISO: "GD"},
|
||||||
|
"guangxi": {Name: "广西", ISO: "GX"},
|
||||||
|
"hainan": {Name: "海南", ISO: "HI"},
|
||||||
|
"sichuan": {Name: "四川", ISO: "SC"},
|
||||||
|
"guizhou": {Name: "贵州", ISO: "GZ"},
|
||||||
|
"yunnan": {Name: "云南", ISO: "YN"},
|
||||||
|
"xizang": {Name: "西藏", ISO: "XZ"},
|
||||||
|
"tibet": {Name: "西藏", ISO: "XZ"},
|
||||||
|
"shaanxi": {Name: "陕西", ISO: "SN"},
|
||||||
|
"shaanxi province": {Name: "陕西", ISO: "SN"},
|
||||||
|
"gansu": {Name: "甘肃", ISO: "GS"},
|
||||||
|
"qinghai": {Name: "青海", ISO: "QH"},
|
||||||
|
"ningxia": {Name: "宁夏", ISO: "NX"},
|
||||||
|
"xinjiang": {Name: "新疆", ISO: "XJ"},
|
||||||
|
"inner mongolia": {Name: "内蒙古", ISO: "NM"},
|
||||||
|
"nei mongol": {Name: "内蒙古", ISO: "NM"},
|
||||||
|
"hong kong": {Name: "香港", ISO: "HK"},
|
||||||
|
"macau": {Name: "澳门", ISO: "MO"},
|
||||||
|
"macao": {Name: "澳门", ISO: "MO"},
|
||||||
|
"taiwan": {Name: "台湾", ISO: "TW"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// cityZh 常见城市英文名 → 中文
|
||||||
|
var cityZh = map[string]string{
|
||||||
|
"beijing": "北京", "peking": "北京",
|
||||||
|
"shanghai": "上海",
|
||||||
|
"chongqing": "重庆", "chungking": "重庆",
|
||||||
|
"tianjin": "天津",
|
||||||
|
"hangzhou": "杭州",
|
||||||
|
"shenzhen": "深圳",
|
||||||
|
"guangzhou": "广州", "canton": "广州",
|
||||||
|
"chengdu": "成都",
|
||||||
|
"wuhan": "武汉",
|
||||||
|
"xi'an": "西安", "xian": "西安",
|
||||||
|
"nanjing": "南京", "nanking": "南京",
|
||||||
|
"suzhou": "苏州",
|
||||||
|
"qingdao": "青岛",
|
||||||
|
"dalian": "大连",
|
||||||
|
"ningbo": "宁波",
|
||||||
|
"xiamen": "厦门", "amoy": "厦门",
|
||||||
|
"changsha": "长沙",
|
||||||
|
"zhengzhou": "郑州",
|
||||||
|
"jinan": "济南",
|
||||||
|
"harbin": "哈尔滨",
|
||||||
|
"shenyang": "沈阳",
|
||||||
|
"kunming": "昆明",
|
||||||
|
"nanning": "南宁",
|
||||||
|
"urumqi": "乌鲁木齐",
|
||||||
|
"lhasa": "拉萨",
|
||||||
|
"hohhot": "呼和浩特",
|
||||||
|
"taipei": "台北",
|
||||||
|
"hong kong": "香港",
|
||||||
|
}
|
||||||
|
|
||||||
|
// asnOrgZh 常见 ASN / AS 组织名 → 中文运营商
|
||||||
|
var asnOrgZh = map[uint]string{
|
||||||
|
4134: "中国电信",
|
||||||
|
4812: "中国电信",
|
||||||
|
4837: "中国联通",
|
||||||
|
4808: "中国联通",
|
||||||
|
9808: "中国移动",
|
||||||
|
56040: "中国移动",
|
||||||
|
56041: "中国移动",
|
||||||
|
56042: "中国移动",
|
||||||
|
4538: "教育网",
|
||||||
|
23910: "教育网",
|
||||||
|
58539: "教育网",
|
||||||
|
7497: "教育网",
|
||||||
|
}
|
||||||
|
|
||||||
|
var asnOrgKeywordZh = []struct {
|
||||||
|
kw string
|
||||||
|
zh string
|
||||||
|
}{
|
||||||
|
{"chinanet", "中国电信"},
|
||||||
|
{"china telecom", "中国电信"},
|
||||||
|
{"chinatelecom", "中国电信"},
|
||||||
|
{"unicom", "中国联通"},
|
||||||
|
{"china unicom", "中国联通"},
|
||||||
|
{"mobile", "中国移动"},
|
||||||
|
{"china mobile", "中国移动"},
|
||||||
|
{"cernet", "教育网"},
|
||||||
|
{"education", "教育网"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountryLabelZh ISO2 → 中文国名(未知则返回原码)
|
||||||
|
func CountryLabelZh(iso string) string {
|
||||||
|
iso = strings.ToUpper(strings.TrimSpace(iso))
|
||||||
|
if iso == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if v, ok := countryZh[iso]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyGeoZh 就地补充中文省/市/运营商;country 保持 ISO2
|
||||||
|
func ApplyGeoZh(g *GeoInfo) {
|
||||||
|
if g == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g.Country = normalizeCountryCode(g.Country)
|
||||||
|
if g.Country == "CN" || g.Country == "HK" || g.Country == "MO" || g.Country == "TW" {
|
||||||
|
mapCNRegionCity(g)
|
||||||
|
}
|
||||||
|
if g.ASN > 0 {
|
||||||
|
if zh, ok := asnOrgZh[g.ASN]; ok {
|
||||||
|
g.ASOrg = zh
|
||||||
|
} else if g.ASOrg != "" {
|
||||||
|
g.ASOrg = mapASOrgZh(g.ASOrg)
|
||||||
|
}
|
||||||
|
} else if g.ASOrg != "" {
|
||||||
|
g.ASOrg = mapASOrgZh(g.ASOrg)
|
||||||
|
}
|
||||||
|
g.Region = truncateRunes(strings.TrimSpace(g.Region), 64)
|
||||||
|
g.City = truncateRunes(strings.TrimSpace(g.City), 64)
|
||||||
|
g.ASOrg = truncateRunes(strings.TrimSpace(g.ASOrg), 128)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapCNRegionCity(g *GeoInfo) {
|
||||||
|
if g.RegionISO == "" {
|
||||||
|
if meta, ok := lookupCNRegion(g.Region); ok {
|
||||||
|
g.Region = meta.Name
|
||||||
|
g.RegionISO = meta.ISO
|
||||||
|
}
|
||||||
|
} else if g.Region != "" {
|
||||||
|
if meta, ok := lookupCNRegion(g.Region); ok {
|
||||||
|
if g.RegionISO == "" {
|
||||||
|
g.RegionISO = meta.ISO
|
||||||
|
}
|
||||||
|
g.Region = meta.Name
|
||||||
|
}
|
||||||
|
} else if g.RegionISO != "" {
|
||||||
|
for _, meta := range cnRegionMap {
|
||||||
|
if meta.ISO == strings.ToUpper(g.RegionISO) {
|
||||||
|
g.Region = meta.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.City != "" {
|
||||||
|
if zh, ok := lookupCityZh(g.City); ok {
|
||||||
|
g.City = zh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 直辖市:BIN 有时只给城市不给省
|
||||||
|
if g.Region == "" && g.City != "" {
|
||||||
|
switch g.City {
|
||||||
|
case "北京", "上海", "天津", "重庆":
|
||||||
|
g.Region = g.City
|
||||||
|
if meta, ok := cnRegionMap[strings.ToLower(g.City)]; ok {
|
||||||
|
g.RegionISO = meta.ISO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupCNRegion(name string) (cnRegionMeta, bool) {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if key == "" {
|
||||||
|
return cnRegionMeta{}, false
|
||||||
|
}
|
||||||
|
if meta, ok := cnRegionMap[key]; ok {
|
||||||
|
return meta, true
|
||||||
|
}
|
||||||
|
// 去掉常见后缀再试
|
||||||
|
for _, suffix := range []string{" province", " sheng", " autonomous region", " municipality"} {
|
||||||
|
if strings.HasSuffix(key, suffix) {
|
||||||
|
if meta, ok := cnRegionMap[strings.TrimSuffix(key, suffix)]; ok {
|
||||||
|
return meta, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cnRegionMeta{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func lookupCityZh(name string) (string, bool) {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if zh, ok := cityZh[key]; ok {
|
||||||
|
return zh, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapASOrgZh(org string) string {
|
||||||
|
l := strings.ToLower(strings.TrimSpace(org))
|
||||||
|
if l == "" {
|
||||||
|
return org
|
||||||
|
}
|
||||||
|
for _, rule := range asnOrgKeywordZh {
|
||||||
|
if strings.Contains(l, rule.kw) {
|
||||||
|
return rule.zh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// "AS4134 Chinanet" 等形式
|
||||||
|
if strings.HasPrefix(l, "as") {
|
||||||
|
num := strings.TrimPrefix(l, "as")
|
||||||
|
if i := strings.IndexByte(num, ' '); i >= 0 {
|
||||||
|
num = num[:i]
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseUint(num, 10, 32); err == nil {
|
||||||
|
if zh, ok := asnOrgZh[uint(n)]; ok {
|
||||||
|
return zh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return org
|
||||||
|
}
|
||||||
333
service/geoip.go
Normal file
333
service/geoip.go
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ip2location/ip2location-go/v9"
|
||||||
|
"github.com/oschwald/maxminddb-golang"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GeoInfo IP 地理与运营商解析结果
|
||||||
|
type GeoInfo struct {
|
||||||
|
Country string
|
||||||
|
Region string // 省/州(写入前经 ApplyGeoZh 中文化)
|
||||||
|
RegionISO string // 省/州 ISO,如 GD
|
||||||
|
City string
|
||||||
|
ASN uint
|
||||||
|
ASOrg string
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoIPSuite struct {
|
||||||
|
dataDir string
|
||||||
|
mu sync.RWMutex
|
||||||
|
binV4 *binHandle
|
||||||
|
binV6 *binHandle
|
||||||
|
asn *mmdbHandle
|
||||||
|
country *mmdbHandle // 可选:BIN 未命中时国家兜底
|
||||||
|
}
|
||||||
|
|
||||||
|
type binHandle struct {
|
||||||
|
path string
|
||||||
|
db *ip2location.DB
|
||||||
|
mod time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type mmdbHandle struct {
|
||||||
|
path string
|
||||||
|
db *maxminddb.Reader
|
||||||
|
mod time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGeoIPSuite(dataDir string) *geoIPSuite {
|
||||||
|
s := &geoIPSuite{dataDir: dataDir}
|
||||||
|
s.binV4 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.BIN")}
|
||||||
|
s.binV6 = &binHandle{path: filepath.Join(dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN")}
|
||||||
|
s.asn = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-ASN.mmdb")}
|
||||||
|
s.country = &mmdbHandle{path: filepath.Join(dataDir, "GeoLite2-Country.mmdb")}
|
||||||
|
s.openAll()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) openAll() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
openBIN(s.binV4)
|
||||||
|
openBIN(s.binV6)
|
||||||
|
openMMDB(s.asn)
|
||||||
|
openMMDB(s.country)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openBIN(h *binHandle) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(h.path)
|
||||||
|
if err != nil {
|
||||||
|
closeBIN(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := ip2location.OpenDB(h.path)
|
||||||
|
if err != nil {
|
||||||
|
closeBIN(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeBIN(h)
|
||||||
|
h.db = db
|
||||||
|
h.mod = st.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeBIN(h *binHandle) {
|
||||||
|
if h != nil && h.db != nil {
|
||||||
|
h.db.Close()
|
||||||
|
h.db = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMMDB(h *mmdbHandle) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(h.path)
|
||||||
|
if err != nil {
|
||||||
|
closeMMDB(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := maxminddb.Open(h.path)
|
||||||
|
if err != nil {
|
||||||
|
closeMMDB(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
closeMMDB(h)
|
||||||
|
h.db = db
|
||||||
|
h.mod = st.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeMMDB(h *mmdbHandle) {
|
||||||
|
if h != nil && h.db != nil {
|
||||||
|
_ = h.db.Close()
|
||||||
|
h.db = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) ReloadIfNeeded() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
reloadBIN(s.binV4)
|
||||||
|
reloadBIN(s.binV6)
|
||||||
|
reloadMMDB(s.asn)
|
||||||
|
reloadMMDB(s.country)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadBIN(h *binHandle) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(h.path)
|
||||||
|
if err != nil {
|
||||||
|
closeBIN(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openBIN(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadMMDB(h *mmdbHandle) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := os.Stat(h.path)
|
||||||
|
if err != nil {
|
||||||
|
closeMMDB(h)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.db != nil && st.ModTime().Equal(h.mod) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
openMMDB(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
closeBIN(s.binV4)
|
||||||
|
closeBIN(s.binV6)
|
||||||
|
closeMMDB(s.asn)
|
||||||
|
closeMMDB(s.country)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) BINV4Available() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.binV4 != nil && s.binV4.db != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) BINV6Available() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.binV6 != nil && s.binV6.db != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) ASNAvailable() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.asn != nil && s.asn.db != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) CountryAvailable() bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.country != nil && s.country.db != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *geoIPSuite) AnyAvailable() bool {
|
||||||
|
return s.BINV4Available() || s.BINV6Available() || s.ASNAvailable() || s.CountryAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths 返回 v4 BIN、v6 BIN、ASN、Country 兜底库路径
|
||||||
|
func (s *geoIPSuite) Paths() (v4, v6, asn, country string) {
|
||||||
|
return filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.BIN"),
|
||||||
|
filepath.Join(s.dataDir, "IP2LOCATION-LITE-DB3.IPV6.BIN"),
|
||||||
|
filepath.Join(s.dataDir, "GeoLite2-ASN.mmdb"),
|
||||||
|
filepath.Join(s.dataDir, "GeoLite2-Country.mmdb")
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoCountryOnlyRecord struct {
|
||||||
|
Country struct {
|
||||||
|
ISOCode string `maxminddb:"iso_code"`
|
||||||
|
} `maxminddb:"country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type geoASNRecord struct {
|
||||||
|
AutonomousSystemNumber uint `maxminddb:"autonomous_system_number"`
|
||||||
|
AutonomousSystemOrganization string `maxminddb:"autonomous_system_organization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup 解析 IP 地理与 ASN(BIN 负责国家/省/市,ASN 独立查询)
|
||||||
|
func (s *geoIPSuite) Lookup(ipStr string) GeoInfo {
|
||||||
|
var out GeoInfo
|
||||||
|
ip := net.ParseIP(ipStr)
|
||||||
|
if ip == nil || s == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.RLock()
|
||||||
|
v4DB := (*ip2location.DB)(nil)
|
||||||
|
v6DB := (*ip2location.DB)(nil)
|
||||||
|
asnDB := (*maxminddb.Reader)(nil)
|
||||||
|
countryDB := (*maxminddb.Reader)(nil)
|
||||||
|
if s.binV4 != nil {
|
||||||
|
v4DB = s.binV4.db
|
||||||
|
}
|
||||||
|
if s.binV6 != nil {
|
||||||
|
v6DB = s.binV6.db
|
||||||
|
}
|
||||||
|
if s.asn != nil {
|
||||||
|
asnDB = s.asn.db
|
||||||
|
}
|
||||||
|
if s.country != nil {
|
||||||
|
countryDB = s.country.db
|
||||||
|
}
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
binDB := v6DB
|
||||||
|
if ip.To4() != nil {
|
||||||
|
binDB = v4DB
|
||||||
|
}
|
||||||
|
if binDB != nil {
|
||||||
|
rec, err := binDB.Get_all(ipStr)
|
||||||
|
if err == nil {
|
||||||
|
out.Country = normalizeCountryCode(rec.Country_short)
|
||||||
|
out.Region = strings.TrimSpace(rec.Region)
|
||||||
|
out.City = strings.TrimSpace(rec.City)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if out.Country == "" && countryDB != nil {
|
||||||
|
var rec geoCountryOnlyRecord
|
||||||
|
if err := countryDB.Lookup(ip, &rec); err == nil {
|
||||||
|
out.Country = normalizeCountryCode(rec.Country.ISOCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if asnDB != nil {
|
||||||
|
var rec geoASNRecord
|
||||||
|
if err := asnDB.Lookup(ip, &rec); err == nil {
|
||||||
|
out.ASN = rec.AutonomousSystemNumber
|
||||||
|
out.ASOrg = strings.TrimSpace(rec.AutonomousSystemOrganization)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyGeoZh(&out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GeoIPService 对外暴露的 Geo 查询(诊断命令等)
|
||||||
|
type GeoIPService struct {
|
||||||
|
inner *geoIPSuite
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGeoIPService 打开数据目录下的 BIN/MMDB
|
||||||
|
func NewGeoIPService(dataDir string) *GeoIPService {
|
||||||
|
return &GeoIPService{inner: newGeoIPSuite(dataDir)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) Close() {
|
||||||
|
if s != nil && s.inner != nil {
|
||||||
|
s.inner.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) Lookup(ip string) GeoInfo {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return GeoInfo{}
|
||||||
|
}
|
||||||
|
return s.inner.Lookup(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) Paths() (v4, v6, asn, country string) {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return "", "", "", ""
|
||||||
|
}
|
||||||
|
return s.inner.Paths()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) BINV4Available() bool {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.inner.BINV4Available()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) BINV6Available() bool {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.inner.BINV6Available()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) ASNAvailable() bool {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.inner.ASNAvailable()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GeoIPService) CountryAvailable() bool {
|
||||||
|
if s == nil || s.inner == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.inner.CountryAvailable()
|
||||||
|
}
|
||||||
1225
service/monitor.go
Normal file
1225
service/monitor.go
Normal file
File diff suppressed because it is too large
Load Diff
85
service/monitor_test.go
Normal file
85
service/monitor_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeCountryCode(t *testing.T) {
|
||||||
|
if normalizeCountryCode("cn") != "CN" {
|
||||||
|
t.Fatal("expected CN")
|
||||||
|
}
|
||||||
|
if normalizeCountryCode("XX") != "" {
|
||||||
|
t.Fatal("XX should be empty")
|
||||||
|
}
|
||||||
|
if normalizeCountryCode("USA") != "" {
|
||||||
|
t.Fatal("USA should be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldSkipDefaults(t *testing.T) {
|
||||||
|
rules := DefaultMonitorExcludeRules()
|
||||||
|
for _, path := range []string{
|
||||||
|
"/health", "/uploads/a.png", "/api/admin/monitor/overview",
|
||||||
|
"/assets/app.js", "/admin", "/admin/dashboard", "/api/me", "/api/site-branding",
|
||||||
|
} {
|
||||||
|
skip := false
|
||||||
|
lower := strings.ToLower(path)
|
||||||
|
for _, rule := range rules {
|
||||||
|
r := strings.ToLower(strings.TrimSpace(rule))
|
||||||
|
if r == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r, ".") {
|
||||||
|
if strings.HasSuffix(lower, r) {
|
||||||
|
skip = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(lower, r) || lower == strings.TrimSuffix(r, "/") {
|
||||||
|
skip = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !skip {
|
||||||
|
t.Fatalf("expected skip for %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizePageViewPath(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
ok bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"/", true, "/"},
|
||||||
|
{"/post/1", true, "/post/1"},
|
||||||
|
{"/post/1?x=1", true, "/post/1?x=1"},
|
||||||
|
{"/admin", false, ""},
|
||||||
|
{"/admin/dashboard", false, ""},
|
||||||
|
{"/login", false, ""},
|
||||||
|
{"/api/posts", false, ""},
|
||||||
|
{"https://evil.com/", false, ""},
|
||||||
|
{"", false, ""},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := NormalizePageViewPath(c.in)
|
||||||
|
if ok != c.ok || got != c.want {
|
||||||
|
t.Fatalf("NormalizePageViewPath(%q)=(%q,%v) want (%q,%v)", c.in, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyUA(t *testing.T) {
|
||||||
|
if classifyUA("Mozilla/5.0 Chrome/120", "browser") != "Chrome" {
|
||||||
|
t.Fatal("browser")
|
||||||
|
}
|
||||||
|
if classifyUA("Mozilla/5.0 (Windows NT 10.0)", "os") != "Windows" {
|
||||||
|
t.Fatal("os")
|
||||||
|
}
|
||||||
|
if classifyUA("iPhone", "device") != "Mobile" {
|
||||||
|
t.Fatal("device")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,12 @@ func (r *RateLimiter) limitFor(action string) int {
|
|||||||
if action == "friend_link" {
|
if action == "friend_link" {
|
||||||
return 5
|
return 5
|
||||||
}
|
}
|
||||||
|
if action == "community_heartbeat" {
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
if action == "monitor_pageview" {
|
||||||
|
return 120 // SPA 路由切换较频繁
|
||||||
|
}
|
||||||
return r.settings.RateLimitFor(action)
|
return r.settings.RateLimitFor(action)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +67,12 @@ func (r *RateLimiter) windowFor(action string) time.Duration {
|
|||||||
if action == "friend_link" {
|
if action == "friend_link" {
|
||||||
return time.Hour
|
return time.Hour
|
||||||
}
|
}
|
||||||
|
if action == "community_heartbeat" {
|
||||||
|
return time.Hour
|
||||||
|
}
|
||||||
|
if action == "monitor_pageview" {
|
||||||
|
return time.Minute
|
||||||
|
}
|
||||||
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
|
return time.Duration(r.settings.RateLimitWindowSec()) * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -42,9 +43,12 @@ const (
|
|||||||
SettingAsideShowTagCloud = "aside_show_tag_cloud"
|
SettingAsideShowTagCloud = "aside_show_tag_cloud"
|
||||||
SettingAsideShowRecentComments = "aside_show_recent_comments"
|
SettingAsideShowRecentComments = "aside_show_recent_comments"
|
||||||
SettingAsideShowFriendLinks = "aside_show_friend_links"
|
SettingAsideShowFriendLinks = "aside_show_friend_links"
|
||||||
|
SettingAsideShowShowcase = "aside_show_showcase"
|
||||||
SettingAsideWidgets = "aside_widgets"
|
SettingAsideWidgets = "aside_widgets"
|
||||||
SettingNavShowFriendLinks = "nav_show_friend_links"
|
SettingNavShowFriendLinks = "nav_show_friend_links"
|
||||||
SettingFooterShowFriendLinks = "footer_show_friend_links"
|
SettingFooterShowFriendLinks = "footer_show_friend_links"
|
||||||
|
SettingNavShowShowcase = "nav_show_showcase"
|
||||||
|
SettingFooterShowShowcase = "footer_show_showcase"
|
||||||
SettingFeedListStyle = "feed_list_style"
|
SettingFeedListStyle = "feed_list_style"
|
||||||
|
|
||||||
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
|
// 伪静态键名见 permalink.go:SettingPermalinkEnabled / SettingPermalinkExt
|
||||||
@@ -93,6 +97,24 @@ const (
|
|||||||
SettingSiteFriendLinks = "site_friend_links"
|
SettingSiteFriendLinks = "site_friend_links"
|
||||||
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
|
SettingFriendLinkReciprocalCheck = "friend_link_reciprocal_check"
|
||||||
|
|
||||||
|
SettingCommunityReportEnabled = "community_report_enabled"
|
||||||
|
SettingCommunityHubEnabled = "community_hub_enabled" // 遗留键,不再作为开关来源
|
||||||
|
SettingCommunityInstanceID = "community_instance_id"
|
||||||
|
SettingCommunityHubURL = "community_hub_url"
|
||||||
|
SettingCommunitySiteURL = "community_site_url" // 上报用的本站公开地址(可回退 OIDC ROOT_URL)
|
||||||
|
|
||||||
|
SettingMonitorEnabled = "monitor_enabled"
|
||||||
|
SettingMonitorRetention = "monitor_retention_days"
|
||||||
|
SettingMonitorAccessLogRetention = "monitor_access_log_retention_days"
|
||||||
|
SettingMonitorExclude = "monitor_exclude_json"
|
||||||
|
SettingMonitorTrustProxy = "monitor_trust_proxy"
|
||||||
|
|
||||||
|
// DefaultCommunityHubURL 官方演示站(社区枢纽默认地址)
|
||||||
|
DefaultCommunityHubURL = "https://bbs.iioio.com"
|
||||||
|
|
||||||
|
// DefaultMonitorExcludeJSON 默认排除路径/后缀(JSON 数组)
|
||||||
|
DefaultMonitorExcludeJSON = `["/admin","/api/admin/","/api/me","/api/site-branding","/health","/uploads/","/media/","/static/","/spa/","/api/admin/monitor","/api/monitor/pageview","/favicon.ico",".js",".css",".map",".woff",".woff2",".ttf",".png",".jpg",".jpeg",".gif",".webp",".avif",".svg",".ico"]`
|
||||||
|
|
||||||
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
// pageSizeAPIMax 单次列表请求条数硬上限(防客户端传超大 size),非后台可配项
|
||||||
pageSizeAPIMax = 100
|
pageSizeAPIMax = 100
|
||||||
)
|
)
|
||||||
@@ -129,9 +151,12 @@ type ForumLimits struct {
|
|||||||
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
|
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
|
||||||
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
|
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
|
||||||
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
|
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
|
||||||
|
AsideShowShowcase bool `json:"aside_show_showcase"`
|
||||||
AsideWidgets []AsideWidget `json:"aside_widgets"`
|
AsideWidgets []AsideWidget `json:"aside_widgets"`
|
||||||
NavShowFriendLinks bool `json:"nav_show_friend_links"`
|
NavShowFriendLinks bool `json:"nav_show_friend_links"`
|
||||||
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
|
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
|
||||||
|
NavShowShowcase bool `json:"nav_show_showcase"`
|
||||||
|
FooterShowShowcase bool `json:"footer_show_showcase"`
|
||||||
|
|
||||||
FeedListStyle string `json:"feed_list_style"`
|
FeedListStyle string `json:"feed_list_style"`
|
||||||
|
|
||||||
@@ -150,6 +175,7 @@ const (
|
|||||||
AsideWidgetRecentComments = "recent_comments"
|
AsideWidgetRecentComments = "recent_comments"
|
||||||
AsideWidgetRecentUsers = "recent_users"
|
AsideWidgetRecentUsers = "recent_users"
|
||||||
AsideWidgetFriendLinks = "friend_links"
|
AsideWidgetFriendLinks = "friend_links"
|
||||||
|
AsideWidgetShowcase = "showcase"
|
||||||
)
|
)
|
||||||
|
|
||||||
var asideWidgetDefaultOrder = []string{
|
var asideWidgetDefaultOrder = []string{
|
||||||
@@ -157,6 +183,7 @@ var asideWidgetDefaultOrder = []string{
|
|||||||
AsideWidgetRecentComments,
|
AsideWidgetRecentComments,
|
||||||
AsideWidgetRecentUsers,
|
AsideWidgetRecentUsers,
|
||||||
AsideWidgetFriendLinks,
|
AsideWidgetFriendLinks,
|
||||||
|
AsideWidgetShowcase,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
// ForumLimitsPublic 前台可见的限制(不含限流等内部配置)
|
||||||
@@ -180,14 +207,20 @@ type ForumLimitsPublic struct {
|
|||||||
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
|
AsideShowTagCloud bool `json:"aside_show_tag_cloud"`
|
||||||
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
|
AsideShowRecentComments bool `json:"aside_show_recent_comments"`
|
||||||
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
|
AsideShowFriendLinks bool `json:"aside_show_friend_links"`
|
||||||
|
AsideShowShowcase bool `json:"aside_show_showcase"`
|
||||||
AsideWidgets []AsideWidget `json:"aside_widgets"`
|
AsideWidgets []AsideWidget `json:"aside_widgets"`
|
||||||
NavShowFriendLinks bool `json:"nav_show_friend_links"`
|
NavShowFriendLinks bool `json:"nav_show_friend_links"`
|
||||||
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
|
FooterShowFriendLinks bool `json:"footer_show_friend_links"`
|
||||||
|
NavShowShowcase bool `json:"nav_show_showcase"`
|
||||||
|
FooterShowShowcase bool `json:"footer_show_showcase"`
|
||||||
|
|
||||||
FeedListStyle string `json:"feed_list_style"`
|
FeedListStyle string `json:"feed_list_style"`
|
||||||
|
|
||||||
PermalinkEnabled bool `json:"permalink_enabled"`
|
PermalinkEnabled bool `json:"permalink_enabled"`
|
||||||
PermalinkExt string `json:"permalink_ext"`
|
PermalinkExt string `json:"permalink_ext"`
|
||||||
|
|
||||||
|
// MonitorPageview 是否接受前台路由 pageview 信标(与 monitor_enabled 同步)
|
||||||
|
MonitorPageview bool `json:"monitor_pageview"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type settingDef struct {
|
type settingDef struct {
|
||||||
@@ -234,7 +267,8 @@ var asideSettingDefaults = map[string]string{
|
|||||||
SettingAsideShowTagCloud: "0",
|
SettingAsideShowTagCloud: "0",
|
||||||
SettingAsideShowRecentComments: "0",
|
SettingAsideShowRecentComments: "0",
|
||||||
SettingAsideShowFriendLinks: "1",
|
SettingAsideShowFriendLinks: "1",
|
||||||
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true}]`,
|
SettingAsideShowShowcase: "0",
|
||||||
|
SettingAsideWidgets: `[{"id":"tag_cloud","enabled":false},{"id":"recent_comments","enabled":false},{"id":"friend_links","enabled":true},{"id":"showcase","enabled":false}]`,
|
||||||
}
|
}
|
||||||
|
|
||||||
var mailSettingDefaults = map[string]string{
|
var mailSettingDefaults = map[string]string{
|
||||||
@@ -280,6 +314,24 @@ var friendLinkSettingDefaults = map[string]string{
|
|||||||
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
|
SettingFriendLinkReciprocalCheck: "0", // 默认关闭回链检测
|
||||||
SettingNavShowFriendLinks: "1",
|
SettingNavShowFriendLinks: "1",
|
||||||
SettingFooterShowFriendLinks: "1",
|
SettingFooterShowFriendLinks: "1",
|
||||||
|
SettingNavShowShowcase: "0",
|
||||||
|
SettingFooterShowShowcase: "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
var communitySettingDefaults = map[string]string{
|
||||||
|
SettingCommunityReportEnabled: "0",
|
||||||
|
SettingCommunityHubEnabled: "0",
|
||||||
|
SettingCommunityInstanceID: "",
|
||||||
|
SettingCommunityHubURL: DefaultCommunityHubURL,
|
||||||
|
SettingCommunitySiteURL: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
var monitorSettingDefaults = map[string]string{
|
||||||
|
SettingMonitorEnabled: "0",
|
||||||
|
SettingMonitorRetention: "30",
|
||||||
|
SettingMonitorAccessLogRetention: "7",
|
||||||
|
SettingMonitorExclude: DefaultMonitorExcludeJSON,
|
||||||
|
SettingMonitorTrustProxy: "1",
|
||||||
}
|
}
|
||||||
|
|
||||||
var siteBrandingDefaults = map[string]string{
|
var siteBrandingDefaults = map[string]string{
|
||||||
@@ -375,6 +427,35 @@ type GiteaSyncConfig struct {
|
|||||||
RepoCount int64 `json:"repo_count"`
|
RepoCount int64 `json:"repo_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommunityConfig 社区上报配置(HubEnabled 只读:运维开关或官网域名)
|
||||||
|
type CommunityConfig struct {
|
||||||
|
ReportEnabled bool `json:"report_enabled"`
|
||||||
|
HubEnabled bool `json:"hub_enabled"` // 只读:app.ini / 环境变量 / 官网 Host
|
||||||
|
HubURL string `json:"hub_url"`
|
||||||
|
SiteURL string `json:"site_url"` // 上报用的本站公开地址
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorConfig 网站监控采集设置
|
||||||
|
type MonitorConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
RetentionDays int `json:"retention_days"` // page_views 保留
|
||||||
|
AccessLogRetentionDays int `json:"access_log_retention_days"` // JSONL 请求日志保留
|
||||||
|
ExcludeRules []string `json:"exclude_rules"`
|
||||||
|
DefaultExcludeRules []string `json:"default_exclude_rules"` // 只读:恢复推荐规则
|
||||||
|
TrustProxy bool `json:"trust_proxy"`
|
||||||
|
AccessLogDir string `json:"access_log_dir"`
|
||||||
|
IP2LocationV4Path string `json:"ip2location_v4_path"`
|
||||||
|
IP2LocationV6Path string `json:"ip2location_v6_path"`
|
||||||
|
IP2LocationV4Available bool `json:"ip2location_v4_available"`
|
||||||
|
IP2LocationV6Available bool `json:"ip2location_v6_available"`
|
||||||
|
GeoIPAvailable bool `json:"geoip_available"`
|
||||||
|
GeoIPCountryPath string `json:"geoip_country_path"`
|
||||||
|
GeoIPASNPath string `json:"geoip_asn_path"`
|
||||||
|
GeoIPCountryAvailable bool `json:"geoip_country_available"`
|
||||||
|
GeoIPASNAvailable bool `json:"geoip_asn_available"`
|
||||||
|
}
|
||||||
|
|
||||||
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
|
// OIDCConfig OIDC Provider 全局配置(应用凭证见 oauth_clients)
|
||||||
type OIDCConfig struct {
|
type OIDCConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
@@ -391,7 +472,8 @@ type OIDCConfig struct {
|
|||||||
|
|
||||||
// ForumSettingsService 论坛全局设置
|
// ForumSettingsService 论坛全局设置
|
||||||
type ForumSettingsService struct {
|
type ForumSettingsService struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
communityHubEnabled bool // 运维配置注入,非后台可改
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewForumSettingsService() *ForumSettingsService {
|
func NewForumSettingsService() *ForumSettingsService {
|
||||||
@@ -400,6 +482,13 @@ func NewForumSettingsService() *ForumSettingsService {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCommunityHubEnabled 由启动配置注入是否作为社区枢纽
|
||||||
|
func (s *ForumSettingsService) SetCommunityHubEnabled(enabled bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.communityHubEnabled = enabled
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ForumSettingsService) ensureDefaults() {
|
func (s *ForumSettingsService) ensureDefaults() {
|
||||||
for _, def := range forumSettingDefs {
|
for _, def := range forumSettingDefs {
|
||||||
var count int64
|
var count int64
|
||||||
@@ -464,6 +553,20 @@ func (s *ForumSettingsService) ensureDefaults() {
|
|||||||
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for key, val := range communitySettingDefaults {
|
||||||
|
var count int64
|
||||||
|
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key, val := range monitorSettingDefaults {
|
||||||
|
var count int64
|
||||||
|
model.DB.Model(&model.ForumSetting{}).Where("`key` = ?", key).Count(&count)
|
||||||
|
if count == 0 {
|
||||||
|
model.DB.Create(&model.ForumSetting{Key: key, Value: val})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ForumSettingsService) getString(key, fallback string) string {
|
func (s *ForumSettingsService) getString(key, fallback string) string {
|
||||||
@@ -545,9 +648,12 @@ func (s *ForumSettingsService) Limits() ForumLimits {
|
|||||||
AsideShowTagCloud: bools.tagCloud,
|
AsideShowTagCloud: bools.tagCloud,
|
||||||
AsideShowRecentComments: bools.recentComments,
|
AsideShowRecentComments: bools.recentComments,
|
||||||
AsideShowFriendLinks: bools.friendLinks,
|
AsideShowFriendLinks: bools.friendLinks,
|
||||||
|
AsideShowShowcase: bools.showcase,
|
||||||
AsideWidgets: widgets,
|
AsideWidgets: widgets,
|
||||||
NavShowFriendLinks: s.NavShowFriendLinks(),
|
NavShowFriendLinks: s.NavShowFriendLinks(),
|
||||||
FooterShowFriendLinks: s.FooterShowFriendLinks(),
|
FooterShowFriendLinks: s.FooterShowFriendLinks(),
|
||||||
|
NavShowShowcase: s.NavShowShowcase(),
|
||||||
|
FooterShowShowcase: s.FooterShowShowcase(),
|
||||||
|
|
||||||
FeedListStyle: s.FeedListStyle(),
|
FeedListStyle: s.FeedListStyle(),
|
||||||
|
|
||||||
@@ -578,14 +684,19 @@ func (s *ForumSettingsService) PublicLimits() ForumLimitsPublic {
|
|||||||
AsideShowTagCloud: limits.AsideShowTagCloud,
|
AsideShowTagCloud: limits.AsideShowTagCloud,
|
||||||
AsideShowRecentComments: limits.AsideShowRecentComments,
|
AsideShowRecentComments: limits.AsideShowRecentComments,
|
||||||
AsideShowFriendLinks: limits.AsideShowFriendLinks,
|
AsideShowFriendLinks: limits.AsideShowFriendLinks,
|
||||||
|
AsideShowShowcase: limits.AsideShowShowcase,
|
||||||
AsideWidgets: limits.AsideWidgets,
|
AsideWidgets: limits.AsideWidgets,
|
||||||
NavShowFriendLinks: limits.NavShowFriendLinks,
|
NavShowFriendLinks: limits.NavShowFriendLinks,
|
||||||
FooterShowFriendLinks: limits.FooterShowFriendLinks,
|
FooterShowFriendLinks: limits.FooterShowFriendLinks,
|
||||||
|
NavShowShowcase: limits.NavShowShowcase,
|
||||||
|
FooterShowShowcase: limits.FooterShowShowcase,
|
||||||
|
|
||||||
FeedListStyle: limits.FeedListStyle,
|
FeedListStyle: limits.FeedListStyle,
|
||||||
|
|
||||||
PermalinkEnabled: limits.PermalinkEnabled,
|
PermalinkEnabled: limits.PermalinkEnabled,
|
||||||
PermalinkExt: limits.PermalinkExt,
|
PermalinkExt: limits.PermalinkExt,
|
||||||
|
|
||||||
|
MonitorPageview: s.MonitorEnabled(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,8 +739,11 @@ func (s *ForumSettingsService) UpdateLimits(in ForumLimits) error {
|
|||||||
SettingAsideShowTagCloud: bools.tagCloud,
|
SettingAsideShowTagCloud: bools.tagCloud,
|
||||||
SettingAsideShowRecentComments: bools.recentComments,
|
SettingAsideShowRecentComments: bools.recentComments,
|
||||||
SettingAsideShowFriendLinks: bools.friendLinks,
|
SettingAsideShowFriendLinks: bools.friendLinks,
|
||||||
|
SettingAsideShowShowcase: bools.showcase,
|
||||||
SettingNavShowFriendLinks: in.NavShowFriendLinks,
|
SettingNavShowFriendLinks: in.NavShowFriendLinks,
|
||||||
SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
|
SettingFooterShowFriendLinks: in.FooterShowFriendLinks,
|
||||||
|
SettingNavShowShowcase: in.NavShowShowcase,
|
||||||
|
SettingFooterShowShowcase: in.FooterShowShowcase,
|
||||||
SettingPermalinkEnabled: in.PermalinkEnabled,
|
SettingPermalinkEnabled: in.PermalinkEnabled,
|
||||||
}
|
}
|
||||||
for key, on := range boolUpdates {
|
for key, on := range boolUpdates {
|
||||||
@@ -729,6 +843,26 @@ func (s *ForumSettingsService) FooterShowFriendLinks() bool {
|
|||||||
return s.getString(SettingFooterShowFriendLinks, "1") == "1"
|
return s.getString(SettingFooterShowFriendLinks, "1") == "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NavShowShowcase 左侧栏「站点」是否展示开源展柜入口;缺省关闭
|
||||||
|
func (s *ForumSettingsService) NavShowShowcase() bool {
|
||||||
|
return s.getString(SettingNavShowShowcase, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// FooterShowShowcase 页脚是否展示开源展柜入口;缺省关闭
|
||||||
|
func (s *ForumSettingsService) FooterShowShowcase() bool {
|
||||||
|
return s.getString(SettingFooterShowShowcase, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// AsideShowShowcase 右侧栏是否展示开源展柜组件
|
||||||
|
func (s *ForumSettingsService) AsideShowShowcase() bool {
|
||||||
|
for _, w := range s.AsideWidgets() {
|
||||||
|
if w.ID == AsideWidgetShowcase {
|
||||||
|
return w.Enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.getString(SettingAsideShowShowcase, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
|
func (s *ForumSettingsService) SetFriendLinkReciprocalCheckEnabled(enabled bool) error {
|
||||||
v := "0"
|
v := "0"
|
||||||
if enabled {
|
if enabled {
|
||||||
@@ -753,19 +887,44 @@ func (s *ForumSettingsService) SetFooterShowFriendLinks(enabled bool) error {
|
|||||||
return s.setString(SettingFooterShowFriendLinks, v)
|
return s.setString(SettingFooterShowFriendLinks, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) SetNavShowShowcase(enabled bool) error {
|
||||||
|
v := "0"
|
||||||
|
if enabled {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
return s.setString(SettingNavShowShowcase, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) SetFooterShowShowcase(enabled bool) error {
|
||||||
|
v := "0"
|
||||||
|
if enabled {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
return s.setString(SettingFooterShowShowcase, v)
|
||||||
|
}
|
||||||
|
|
||||||
// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
|
// SetAsideFriendLinksEnabled 更新右侧栏友链组件开关(与 aside_widgets 同步)
|
||||||
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
|
func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
|
||||||
|
return s.setAsideWidgetEnabled(AsideWidgetFriendLinks, SettingAsideShowFriendLinks, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAsideShowcaseEnabled 更新右侧栏开源展柜组件开关(与 aside_widgets 同步)
|
||||||
|
func (s *ForumSettingsService) SetAsideShowcaseEnabled(enabled bool) error {
|
||||||
|
return s.setAsideWidgetEnabled(AsideWidgetShowcase, SettingAsideShowShowcase, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ForumSettingsService) setAsideWidgetEnabled(widgetID, boolSettingKey string, enabled bool) error {
|
||||||
widgets := s.AsideWidgets()
|
widgets := s.AsideWidgets()
|
||||||
found := false
|
found := false
|
||||||
for i := range widgets {
|
for i := range widgets {
|
||||||
if widgets[i].ID == AsideWidgetFriendLinks {
|
if widgets[i].ID == widgetID {
|
||||||
widgets[i].Enabled = enabled
|
widgets[i].Enabled = enabled
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
widgets = append(widgets, AsideWidget{ID: AsideWidgetFriendLinks, Enabled: enabled})
|
widgets = append(widgets, AsideWidget{ID: widgetID, Enabled: enabled})
|
||||||
}
|
}
|
||||||
widgets = NormalizeAsideWidgets(widgets)
|
widgets = NormalizeAsideWidgets(widgets)
|
||||||
bools := asideBoolsFromWidgets(widgets)
|
bools := asideBoolsFromWidgets(widgets)
|
||||||
@@ -777,16 +936,28 @@ func (s *ForumSettingsService) SetAsideFriendLinksEnabled(enabled bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
v := "0"
|
v := "0"
|
||||||
if bools.friendLinks {
|
switch widgetID {
|
||||||
v = "1"
|
case AsideWidgetFriendLinks:
|
||||||
|
if bools.friendLinks {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
case AsideWidgetShowcase:
|
||||||
|
if bools.showcase {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if enabled {
|
||||||
|
v = "1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return s.setString(SettingAsideShowFriendLinks, v)
|
return s.setString(boolSettingKey, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
type asideWidgetBools struct {
|
type asideWidgetBools struct {
|
||||||
tagCloud bool
|
tagCloud bool
|
||||||
recentComments bool
|
recentComments bool
|
||||||
friendLinks bool
|
friendLinks bool
|
||||||
|
showcase bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
|
func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWidget {
|
||||||
@@ -794,6 +965,7 @@ func asideWidgetsFromBools(tagCloud, recentComments, friendLinks bool) []AsideWi
|
|||||||
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
|
{ID: AsideWidgetTagCloud, Enabled: tagCloud},
|
||||||
{ID: AsideWidgetRecentComments, Enabled: recentComments},
|
{ID: AsideWidgetRecentComments, Enabled: recentComments},
|
||||||
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
|
{ID: AsideWidgetFriendLinks, Enabled: friendLinks},
|
||||||
|
{ID: AsideWidgetShowcase, Enabled: false},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -807,6 +979,8 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
|
|||||||
out.recentComments = w.Enabled
|
out.recentComments = w.Enabled
|
||||||
case AsideWidgetFriendLinks:
|
case AsideWidgetFriendLinks:
|
||||||
out.friendLinks = w.Enabled
|
out.friendLinks = w.Enabled
|
||||||
|
case AsideWidgetShowcase:
|
||||||
|
out.showcase = w.Enabled
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -814,7 +988,7 @@ func asideBoolsFromWidgets(widgets []AsideWidget) asideWidgetBools {
|
|||||||
|
|
||||||
func isValidAsideWidgetID(id string) bool {
|
func isValidAsideWidgetID(id string) bool {
|
||||||
switch id {
|
switch id {
|
||||||
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks:
|
case AsideWidgetTagCloud, AsideWidgetRecentComments, AsideWidgetRecentUsers, AsideWidgetFriendLinks, AsideWidgetShowcase:
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -1090,6 +1264,227 @@ func (s *ForumSettingsService) UpdateGiteaSyncConfig(in GiteaSyncConfig) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CommunityConfig 读取社区上报配置(无请求上下文;官网需靠已存 ROOT_URL / site_url)
|
||||||
|
func (s *ForumSettingsService) CommunityConfig() CommunityConfig {
|
||||||
|
return s.CommunityConfigForRequest("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityConfigForRequest 读取社区配置;requestHint 可为 Origin / 公开 URL,用于识别官网 Host
|
||||||
|
func (s *ForumSettingsService) CommunityConfigForRequest(requestHint string) CommunityConfig {
|
||||||
|
return CommunityConfig{
|
||||||
|
ReportEnabled: s.getString(SettingCommunityReportEnabled, "0") == "1",
|
||||||
|
HubEnabled: s.CommunityHubEnabled(requestHint),
|
||||||
|
HubURL: DefaultCommunityHubURL,
|
||||||
|
SiteURL: s.CommunitySiteURL(requestHint),
|
||||||
|
InstanceID: strings.TrimSpace(s.getString(SettingCommunityInstanceID, "")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunityHubEnabled 是否作为社区枢纽:运维开关,或本站即为官方演示站域名
|
||||||
|
func (s *ForumSettingsService) CommunityHubEnabled(requestHint string) bool {
|
||||||
|
s.mu.RLock()
|
||||||
|
ops := s.communityHubEnabled
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if ops {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
official := officialCommunityHubHost()
|
||||||
|
if official == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, cand := range []string{
|
||||||
|
s.getString(SettingOIDCRootURL, ""),
|
||||||
|
s.getString(SettingCommunitySiteURL, ""),
|
||||||
|
requestHint,
|
||||||
|
} {
|
||||||
|
if hostFromURLOrHost(cand) == official {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// officialCommunityHubHost 官方枢纽规范化主机名(如 bbs.iioio.com)
|
||||||
|
func officialCommunityHubHost() string {
|
||||||
|
return hostFromURLOrHost(DefaultCommunityHubURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostFromURLOrHost 从 URL 或裸 Host 提取规范化主机名(小写、去 www、去端口)
|
||||||
|
func hostFromURLOrHost(raw string) string {
|
||||||
|
raw = strings.TrimSpace(strings.ToLower(raw))
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(raw, "://") {
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || u.Host == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw = u.Host
|
||||||
|
} else if i := strings.IndexAny(raw, "/?"); i >= 0 {
|
||||||
|
raw = raw[:i]
|
||||||
|
}
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if h, _, err := net.SplitHostPort(raw); err == nil {
|
||||||
|
raw = h
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(raw, "www.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommunitySiteURL 上报用的本站公开地址:已持久化 > OIDC ROOT_URL > 请求 Origin
|
||||||
|
func (s *ForumSettingsService) CommunitySiteURL(requestOrigin string) string {
|
||||||
|
if u := normalizeRootURL(s.getString(SettingCommunitySiteURL, "")); u != "" {
|
||||||
|
return strings.TrimRight(u, "/")
|
||||||
|
}
|
||||||
|
return s.SitePublicBaseURL(requestOrigin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCommunitySiteURL 在开启上报时确保有可用的本站公开地址;origin 可来自当前管理请求
|
||||||
|
func (s *ForumSettingsService) EnsureCommunitySiteURL(requestOrigin string) (string, error) {
|
||||||
|
if u := s.CommunitySiteURL(requestOrigin); u != "" {
|
||||||
|
// 若仅靠 Origin 推断,持久化以便后台 ticker 使用
|
||||||
|
if normalizeRootURL(s.getString(SettingCommunitySiteURL, "")) == "" &&
|
||||||
|
normalizeRootURL(s.getString(SettingOIDCRootURL, "")) == "" {
|
||||||
|
if err := s.setString(SettingCommunitySiteURL, u); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("无法确定本站公开地址:请先在 OIDC 设置中填写 ROOT_URL,或通过浏览器管理端开启上报")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureCommunityInstanceID 确保本机有稳定的匿名实例 ID
|
||||||
|
func (s *ForumSettingsService) EnsureCommunityInstanceID() (string, error) {
|
||||||
|
id := strings.TrimSpace(s.getString(SettingCommunityInstanceID, ""))
|
||||||
|
if id != "" {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
id = newCommunityInstanceID()
|
||||||
|
if err := s.setString(SettingCommunityInstanceID, id); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCommunityConfig 仅更新上报开关;忽略客户端传入的 hub_url / site_url
|
||||||
|
func (s *ForumSettingsService) UpdateCommunityConfig(in CommunityConfig) (wasReportEnabled bool, err error) {
|
||||||
|
wasReportEnabled = s.getString(SettingCommunityReportEnabled, "0") == "1"
|
||||||
|
report := "0"
|
||||||
|
if in.ReportEnabled {
|
||||||
|
report = "1"
|
||||||
|
}
|
||||||
|
if err := s.setString(SettingCommunityReportEnabled, report); err != nil {
|
||||||
|
return wasReportEnabled, err
|
||||||
|
}
|
||||||
|
if in.ReportEnabled {
|
||||||
|
if _, err := s.EnsureCommunityInstanceID(); err != nil {
|
||||||
|
return wasReportEnabled, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wasReportEnabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMonitorExcludeRules 导出推荐排除规则(供设置页「恢复默认」)
|
||||||
|
func DefaultMonitorExcludeRules() []string {
|
||||||
|
return parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMonitorRetentionDays(v int) int {
|
||||||
|
if v < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if v > 365 {
|
||||||
|
return 365
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorConfig 读取网站监控设置
|
||||||
|
func (s *ForumSettingsService) MonitorConfig() MonitorConfig {
|
||||||
|
retention, _ := strconv.Atoi(s.getString(SettingMonitorRetention, "30"))
|
||||||
|
accessRetention, _ := strconv.Atoi(s.getString(SettingMonitorAccessLogRetention, "7"))
|
||||||
|
return MonitorConfig{
|
||||||
|
Enabled: s.getString(SettingMonitorEnabled, "0") == "1",
|
||||||
|
RetentionDays: normalizeMonitorRetentionDays(retention),
|
||||||
|
AccessLogRetentionDays: normalizeMonitorRetentionDays(accessRetention),
|
||||||
|
ExcludeRules: parseMonitorExcludeJSON(s.getString(SettingMonitorExclude, DefaultMonitorExcludeJSON)),
|
||||||
|
DefaultExcludeRules: DefaultMonitorExcludeRules(),
|
||||||
|
TrustProxy: s.getString(SettingMonitorTrustProxy, "1") == "1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MonitorEnabled 采集是否开启
|
||||||
|
func (s *ForumSettingsService) MonitorEnabled() bool {
|
||||||
|
return s.getString(SettingMonitorEnabled, "0") == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMonitorConfig 更新网站监控设置
|
||||||
|
func (s *ForumSettingsService) UpdateMonitorConfig(in MonitorConfig) error {
|
||||||
|
enabled := "0"
|
||||||
|
if in.Enabled {
|
||||||
|
enabled = "1"
|
||||||
|
}
|
||||||
|
retention := normalizeMonitorRetentionDays(in.RetentionDays)
|
||||||
|
accessRetention := normalizeMonitorRetentionDays(in.AccessLogRetentionDays)
|
||||||
|
trust := "0"
|
||||||
|
if in.TrustProxy {
|
||||||
|
trust = "1"
|
||||||
|
}
|
||||||
|
rules := in.ExcludeRules
|
||||||
|
if rules == nil {
|
||||||
|
rules = parseMonitorExcludeJSON(DefaultMonitorExcludeJSON)
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(normalizeMonitorExclude(rules))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updates := map[string]string{
|
||||||
|
SettingMonitorEnabled: enabled,
|
||||||
|
SettingMonitorRetention: strconv.Itoa(retention),
|
||||||
|
SettingMonitorAccessLogRetention: strconv.Itoa(accessRetention),
|
||||||
|
SettingMonitorExclude: string(raw),
|
||||||
|
SettingMonitorTrustProxy: trust,
|
||||||
|
}
|
||||||
|
for key, val := range updates {
|
||||||
|
if err := s.setString(key, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMonitorExcludeJSON(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
raw = DefaultMonitorExcludeJSON
|
||||||
|
}
|
||||||
|
var rules []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &rules); err != nil {
|
||||||
|
_ = json.Unmarshal([]byte(DefaultMonitorExcludeJSON), &rules)
|
||||||
|
}
|
||||||
|
return normalizeMonitorExclude(rules)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMonitorExclude(rules []string) []string {
|
||||||
|
seen := make(map[string]struct{}, len(rules))
|
||||||
|
out := make([]string, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
r = strings.TrimSpace(r)
|
||||||
|
if r == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[r]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[r] = struct{}{}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
|
// StorageConfig 读取上传存储配置(含密钥明文,供内部使用)
|
||||||
func (s *ForumSettingsService) StorageConfig() StorageConfig {
|
func (s *ForumSettingsService) StorageConfig() StorageConfig {
|
||||||
secret := s.getString(SettingStorageSecretKey, "")
|
secret := s.getString(SettingStorageSecretKey, "")
|
||||||
|
|||||||
Reference in New Issue
Block a user