feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -53,3 +53,7 @@ desktop.ini
|
||||
|
||||
# ===== Trae 工具内部目录 =====
|
||||
.trae/
|
||||
.agents/
|
||||
|
||||
# ===== 构建产物输出 =====
|
||||
dist/
|
||||
|
||||
223
backend/cmd/importusers/main.go
Normal file
223
backend/cmd/importusers/main.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// 一次性迁移工具:把旧论坛(jiang13-forum,SQLite)的用户导入本站(PostgreSQL)。
|
||||
//
|
||||
// 迁移范围:用户名 / bcrypt 密码哈希 / 昵称 / 签名 / 邮箱 / 头像(含文件复制)。
|
||||
// 旧站 role=admin 的账号不导入;积分、经验、徽章等一律不迁。
|
||||
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
|
||||
//
|
||||
// 幂等:已存在(含软删)同名用户自动跳过,可安全重复运行。
|
||||
//
|
||||
// 用法示例:
|
||||
//
|
||||
// go run ./cmd/importusers --dry-run # 只统计打印,不写库不复制文件
|
||||
// go run ./cmd/importusers # 实际导入
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
const signatureMaxRunes = 255 // 新站 Signature 列上限
|
||||
|
||||
// oldUser 只映射迁移所需列,显式指向旧库 users 表
|
||||
type oldUser struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
Signature string `gorm:"column:signature"`
|
||||
Avatar string `gorm:"column:avatar"`
|
||||
Role string `gorm:"column:role"`
|
||||
Banned bool `gorm:"column:banned"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤
|
||||
}
|
||||
|
||||
func (oldUser) TableName() string { return "users" }
|
||||
|
||||
type report struct {
|
||||
avatarMissing []string
|
||||
conflicts []string
|
||||
failed []string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
sqlitePath = flag.String("sqlite", `C:\Users\freefire\Documents\jiang13-forum\data\jiang13.db`, "旧站 SQLite 库路径")
|
||||
avatarsSrc = flag.String("avatars-src", `C:\Users\freefire\Documents\jiang13-forum\data\uploads\avatars`, "旧站头像文件目录")
|
||||
avatarsDst = flag.String("avatars-dst", `./data/uploads/avatars`, "新站头像文件目录({DataDir}/uploads/avatars)")
|
||||
dsn = flag.String("dsn", "postgres://postgres:postgres@localhost:5432/jiang13?sslmode=disable", "新站 PostgreSQL DSN")
|
||||
dryRun = flag.Bool("dry-run", false, "只统计打印,不写库不复制文件")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
// ---- 打开旧库(只读) ----
|
||||
oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(*sqlitePath)+"?mode=ro"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("打开旧库失败 %s: %v", *sqlitePath, err)
|
||||
}
|
||||
|
||||
var users []oldUser
|
||||
if err := oldDB.Where("role <> ?", "admin").Order("id ASC").Find(&users).Error; err != nil {
|
||||
log.Fatalf("读取旧库用户失败: %v", err)
|
||||
}
|
||||
log.Printf("旧库可迁移用户(role != admin): %d 人", len(users))
|
||||
|
||||
// ---- 打开新库 ----
|
||||
newDB, err := gorm.Open(postgres.Open(*dsn), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("连接新库失败: %v", err)
|
||||
}
|
||||
|
||||
if !*dryRun {
|
||||
if err := os.MkdirAll(*avatarsDst, 0o755); err != nil {
|
||||
log.Fatalf("创建头像目标目录失败 %s: %v", *avatarsDst, err)
|
||||
}
|
||||
}
|
||||
|
||||
rep := &report{}
|
||||
var imported int
|
||||
for _, u := range users {
|
||||
// 用户名查重(含软删行)
|
||||
var n int64
|
||||
if err := newDB.Unscoped().Model(&model.User{}).Where("username = ?", u.Username).Count(&n).Error; err != nil {
|
||||
rep.failed = append(rep.failed, fmt.Sprintf("%s (查重失败: %v)", u.Username, err))
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
rep.conflicts = append(rep.conflicts, u.Username)
|
||||
continue
|
||||
}
|
||||
|
||||
avatarURL, err := resolveAvatar(u.Avatar, *avatarsSrc, *avatarsDst, *dryRun)
|
||||
if err != nil {
|
||||
rep.failed = append(rep.failed, fmt.Sprintf("%s (头像处理失败: %v)", u.Username, err))
|
||||
continue
|
||||
}
|
||||
if u.Avatar != "" && avatarURL == "" {
|
||||
rep.avatarMissing = append(rep.avatarMissing, fmt.Sprintf("%s: %s", u.Username, u.Avatar))
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
Password: u.Password, // bcrypt 哈希原样复制
|
||||
Nickname: u.Nickname,
|
||||
Signature: truncateRunes(u.Signature, signatureMaxRunes),
|
||||
Avatar: avatarURL,
|
||||
Role: model.RoleUser,
|
||||
Banned: u.Banned,
|
||||
CreatedAt: u.CreatedAt, // 保留原注册时间
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
imported++
|
||||
continue
|
||||
}
|
||||
if err := newDB.Create(&user).Error; err != nil {
|
||||
rep.failed = append(rep.failed, fmt.Sprintf("%s (写入失败: %v)", u.Username, err))
|
||||
continue
|
||||
}
|
||||
imported++
|
||||
}
|
||||
|
||||
printSummary(len(users), imported, rep, *dryRun)
|
||||
}
|
||||
|
||||
// resolveAvatar 把旧头像相对路径落成新站文件 + URL。
|
||||
// 返回新 Avatar 字段值;空串表示无法迁移(源缺失或格式不支持)。
|
||||
func resolveAvatar(oldAvatar, srcDir, dstDir string, dryRun bool) (string, error) {
|
||||
if oldAvatar == "" {
|
||||
return "", nil
|
||||
}
|
||||
const prefix = "/uploads/avatars/"
|
||||
if len(oldAvatar) <= len(prefix) || filepath.Base(oldAvatar) != oldAvatar[len(prefix):] {
|
||||
// 不符合旧站 "/uploads/avatars/<文件名>" 约定(如外部 URL),无法本地迁移
|
||||
return "", nil
|
||||
}
|
||||
filename := filepath.Base(oldAvatar)
|
||||
src := filepath.Join(srcDir, filename)
|
||||
if _, err := os.Stat(src); err != nil {
|
||||
return "", nil // 源文件缺失
|
||||
}
|
||||
dst := filepath.Join(dstDir, filename)
|
||||
if !dryRun {
|
||||
if _, err := os.Stat(dst); err != nil {
|
||||
if err := copyFile(src, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
// dst 已存在则跳过复制,URL 仍然有效
|
||||
}
|
||||
return prefix + filename, nil
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
|
||||
func printSummary(total, imported int, rep *report, dryRun bool) {
|
||||
mode := "导入完成"
|
||||
if dryRun {
|
||||
mode = "dry-run 统计"
|
||||
}
|
||||
fmt.Println("==========")
|
||||
fmt.Printf("[%s] 旧库用户 %d | 成功 %d | 用户名冲突跳过 %d | 头像缺失 %d | 失败 %d\n",
|
||||
mode, total, imported, len(rep.conflicts), len(rep.avatarMissing), len(rep.failed))
|
||||
if len(rep.conflicts) > 0 {
|
||||
fmt.Println("-- 用户名冲突(新库已存在,跳过):")
|
||||
for _, c := range rep.conflicts {
|
||||
fmt.Println(" ", c)
|
||||
}
|
||||
}
|
||||
if len(rep.avatarMissing) > 0 {
|
||||
fmt.Println("-- 头像源文件缺失(Avatar 置空):")
|
||||
for _, c := range rep.avatarMissing {
|
||||
fmt.Println(" ", c)
|
||||
}
|
||||
}
|
||||
if len(rep.failed) > 0 {
|
||||
fmt.Println("-- 失败明细:")
|
||||
for _, c := range rep.failed {
|
||||
fmt.Println(" ", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.9.2 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.2 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
@@ -25,6 +28,8 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/glebarez/sqlite v1.11.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -47,6 +52,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
@@ -56,4 +62,8 @@ require (
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0 h1:04VXMiE50YYfCfLboJCLcgqF5x+rHJnb1ssNmqpLH/k=
|
||||
github.com/JohannesKaufmann/html-to-markdown v1.6.0/go.mod h1:NUI78lGg/a7vpEJTz/0uOcYMaibytE4BUOQS8k78yPQ=
|
||||
github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE=
|
||||
github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk=
|
||||
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
|
||||
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
@@ -20,6 +26,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -62,8 +72,11 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
@@ -81,17 +94,25 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
@@ -103,33 +124,88 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
|
||||
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
|
||||
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -137,5 +213,13 @@ gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
|
||||
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
198
backend/handler/admin_board.go
Normal file
198
backend/handler/admin_board.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ===== 板块管理(仅站长;路由层 RequirePerm(PermBoards) 兜底) =====
|
||||
|
||||
// adminBoardBody 板块新建/更新请求体
|
||||
type adminBoardBody struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
ColorIndex *int `json:"color_index"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
PostPolicy string `json:"post_policy"`
|
||||
Visible *bool `json:"visible"`
|
||||
}
|
||||
|
||||
// toBoardInput 把请求体转 service 入参
|
||||
func toBoardInput(b adminBoardBody) service.BoardInput {
|
||||
return service.BoardInput{
|
||||
Name: b.Name,
|
||||
Description: b.Description,
|
||||
Icon: b.Icon,
|
||||
ColorIndex: b.ColorIndex,
|
||||
SortOrder: b.SortOrder,
|
||||
PostPolicy: b.PostPolicy,
|
||||
Visible: b.Visible,
|
||||
}
|
||||
}
|
||||
|
||||
// AdminListBoards 板块列表(管理端:含隐藏板块;附帖子计数)
|
||||
func (h *Handlers) AdminListBoards(c *gin.Context) {
|
||||
boards, err := h.Board.List()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取板块列表失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"boards": nonNilSlice(boards)})
|
||||
}
|
||||
|
||||
// AdminCreateBoard 创建板块
|
||||
func (h *Handlers) AdminCreateBoard(c *gin.Context) {
|
||||
var body adminBoardBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
board, err := h.Board.Create(toBoardInput(body))
|
||||
if err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"board": board})
|
||||
}
|
||||
|
||||
// AdminUpdateBoard 更新板块
|
||||
func (h *Handlers) AdminUpdateBoard(c *gin.Context) {
|
||||
id, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body adminBoardBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
board, err := h.Board.Update(id, toBoardInput(body))
|
||||
if err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"board": board})
|
||||
}
|
||||
|
||||
// AdminDeleteBoard 软删板块(仍有帖子则拒绝)
|
||||
func (h *Handlers) AdminDeleteBoard(c *gin.Context) {
|
||||
id, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Board.Delete(id); err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// adminReorderBody 排序请求体
|
||||
type adminReorderBody struct {
|
||||
IDs []uint `json:"ids"`
|
||||
}
|
||||
|
||||
// AdminReorderBoards 批量设置 sort_order
|
||||
func (h *Handlers) AdminReorderBoards(c *gin.Context) {
|
||||
var body adminReorderBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.Reorder(body.IDs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "排序失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListBoardModerators 板块版主列表
|
||||
func (h *Handlers) AdminListBoardModerators(c *gin.Context) {
|
||||
id, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mods, err := h.Board.ListModerators(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取版主失败"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"moderators": nonNilSlice(mods)})
|
||||
}
|
||||
|
||||
// adminBoardModBody 添加版主请求体(按用户名定位)
|
||||
type adminBoardModBody struct {
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// AdminAddBoardModerator 按用户名添加板块版主
|
||||
func (h *Handlers) AdminAddBoardModerator(c *gin.Context) {
|
||||
boardID, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body adminBoardModBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效"})
|
||||
return
|
||||
}
|
||||
userID := h.Auth.GetUserIDByUsername(body.Username)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.AddModerator(boardID, userID); err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminRemoveBoardModerator 移除版主
|
||||
func (h *Handlers) AdminRemoveBoardModerator(c *gin.Context) {
|
||||
boardID, ok := parseBoardID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
uid, err := strconv.ParseUint(c.Param("uid"), 10, 64)
|
||||
if err != nil || uid == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的用户 ID"})
|
||||
return
|
||||
}
|
||||
if err := h.Board.RemoveModerator(boardID, uint(uid)); err != nil {
|
||||
respondBoardError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// parseBoardID 解析路径中的板块 ID
|
||||
func parseBoardID(c *gin.Context) (uint, bool) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的板块 ID"})
|
||||
return 0, false
|
||||
}
|
||||
return uint(id), true
|
||||
}
|
||||
|
||||
// respondBoardError 板块业务错误映射
|
||||
func respondBoardError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "板块不存在"})
|
||||
case errors.Is(err, service.ErrBoardNameRequired),
|
||||
errors.Is(err, service.ErrProtectedOwner):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrBoardInUse):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "操作失败"})
|
||||
}
|
||||
}
|
||||
139
backend/handler/admin_legacy_import.go
Normal file
139
backend/handler/admin_legacy_import.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ===== 数据导入(settingsAPI 组内,PermSettings 门控:仅超管/站长) =====
|
||||
//
|
||||
// POST /api/admin/import/:source multipart:
|
||||
// - db_file 旧站 SQLite 库(必填)
|
||||
// - avatars_zip 头像压缩包(可选)
|
||||
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
|
||||
// - dry_run "true"/"false",预检模式不写库(默认 false)
|
||||
//
|
||||
// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。
|
||||
|
||||
// AdminImportLegacy 导入外部站点数据,返回分类统计报告。幂等可重复导入。
|
||||
func (h *Handlers) AdminImportLegacy(c *gin.Context) {
|
||||
source := c.Param("source")
|
||||
if !service.LegacyImportSource(source) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "暂不支持该数据源(当前支持:jiang13)"})
|
||||
return
|
||||
}
|
||||
|
||||
const overhead = 64 << 10 // multipart 边界与表单开销
|
||||
limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
|
||||
|
||||
claims := middleware.CurrentUser(c)
|
||||
if claims == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
|
||||
return
|
||||
}
|
||||
|
||||
dbFH, err := c.FormFile("db_file")
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "请上传旧站数据库文件(jiang13.db)")
|
||||
return
|
||||
}
|
||||
if dbFH.Size > service.LegacyDBMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "数据库文件不能超过 64MB"})
|
||||
return
|
||||
}
|
||||
zipPath := ""
|
||||
if zipFH, ferr := c.FormFile("avatars_zip"); ferr == nil && zipFH != nil && zipFH.Size > 0 {
|
||||
if zipFH.Size > service.LegacyZipMaxBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "头像压缩包不能超过 128MB"})
|
||||
return
|
||||
}
|
||||
zipPath, err = saveMultipartToTemp(zipFH, "legacy-avatars-*.zip")
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "读取头像压缩包失败")
|
||||
return
|
||||
}
|
||||
defer os.Remove(zipPath)
|
||||
} else if ferr != nil {
|
||||
// 表单整体解析失败(超限等);「字段不存在」直接忽略
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(ferr, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db")
|
||||
if err != nil {
|
||||
respondLegacyUploadError(c, err, "读取数据库文件失败")
|
||||
return
|
||||
}
|
||||
defer os.Remove(dbPath)
|
||||
|
||||
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{
|
||||
WithContent: formBool(c, "with_content", true),
|
||||
DryRun: formBool(c, "dry_run", false),
|
||||
}, claims.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "导入失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"report": rep})
|
||||
}
|
||||
|
||||
func formBool(c *gin.Context, key string, def bool) bool {
|
||||
v := c.PostForm(key)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func respondLegacyUploadError(c *gin.Context, err error, fallback string) {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fallback})
|
||||
}
|
||||
|
||||
// saveMultipartToTemp 把上传文件落盘为临时文件(大文件不宜整读进内存,Docker 下 /tmp 可写)
|
||||
func saveMultipartToTemp(fh *multipart.FileHeader, pattern string) (string, error) {
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
tmp, err := os.CreateTemp("", pattern)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := tmp.Name()
|
||||
if _, err := io.Copy(tmp, src); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(name)
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
@@ -137,7 +137,13 @@ func (h *Handlers) Register(c *gin.Context) {
|
||||
}
|
||||
if security.VerifyEmail {
|
||||
if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil {
|
||||
var te *service.CodeThrottleError
|
||||
if errors.As(err, &te) {
|
||||
c.Header("Retry-After", strconv.Itoa(te.Wait))
|
||||
c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait})
|
||||
} else {
|
||||
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ type Handlers struct {
|
||||
Points *service.PointsService
|
||||
Setting *service.SettingService
|
||||
AdminUser *service.AdminUserService
|
||||
LegacyImport *service.LegacyImportService
|
||||
Moderation *service.ModerationService
|
||||
Chat *service.ChatService
|
||||
Telemetry *service.TelemetryService
|
||||
@@ -37,11 +38,14 @@ type Handlers struct {
|
||||
}
|
||||
|
||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||
// staff 直发;若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。
|
||||
// staff 直发;全站免审开启时所有人直发;否则若开启「已过审免审」且用户已有 published 内容则直发;否则进 pending。
|
||||
func (h *Handlers) resolvePublishStatus(userID uint, role string) string {
|
||||
if model.IsStaff(model.Role(role)) {
|
||||
return model.ContentStatusPublished
|
||||
}
|
||||
if withoutReview, err := h.Setting.PublishWithoutReview(); err == nil && withoutReview {
|
||||
return model.ContentStatusPublished
|
||||
}
|
||||
trust, err := h.Setting.TrustReviewedPublish()
|
||||
if err != nil || !trust {
|
||||
return model.ContentStatusPending
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"io"
|
||||
@@ -202,6 +203,10 @@ func (h *Handlers) BusinessQuota(c *gin.Context) {
|
||||
}
|
||||
p := c.FullPath()
|
||||
user := middleware.CurrentUser(c)
|
||||
if user != nil && model.IsStaff(model.Role(user.Role)) {
|
||||
c.Next() // 管理角色不受发帖/评论间隔与搜索频控限制
|
||||
return
|
||||
}
|
||||
identity := "ip:" + c.ClientIP()
|
||||
if user != nil {
|
||||
identity = "user:" + strconv.Itoa(int(user.ID))
|
||||
@@ -272,7 +277,13 @@ func (h *Handlers) ResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if e := h.Ops.ResetPassword(req.Email, req.Code, req.Password); e != nil {
|
||||
var te *service.CodeThrottleError
|
||||
if errors.As(e, &te) {
|
||||
c.Header("Retry-After", strconv.Itoa(te.Wait))
|
||||
c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait})
|
||||
} else {
|
||||
c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(200, gin.H{"message": "密码已更新,请重新登录"})
|
||||
|
||||
@@ -23,6 +23,7 @@ func (h *Handlers) PublicSettings(c *gin.Context) {
|
||||
type updateSettingsRequest struct {
|
||||
Accent *string `json:"accent"`
|
||||
TrustReviewedPublish *bool `json:"trust_reviewed_publish"`
|
||||
PublishWithoutReview *bool `json:"publish_without_review"`
|
||||
SiteName *string `json:"site_name"`
|
||||
SiteDescription *string `json:"site_description"`
|
||||
AllowRegister *bool `json:"allow_register"`
|
||||
@@ -62,6 +63,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
return gin.H{
|
||||
"accent": saved.Accent,
|
||||
"trust_reviewed_publish": saved.TrustReviewedPublish,
|
||||
"publish_without_review": saved.PublishWithoutReview,
|
||||
"site_name": saved.SiteName,
|
||||
"site_description": saved.SiteDescription,
|
||||
"allow_register": saved.AllowRegister,
|
||||
@@ -99,7 +101,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||
}
|
||||
|
||||
func (req *updateSettingsRequest) hasAny() bool {
|
||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
|
||||
return req.Accent != nil || req.TrustReviewedPublish != nil || req.PublishWithoutReview != nil || req.SiteName != nil ||
|
||||
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
|
||||
req.CommentsRequireLogin != nil || req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
|
||||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
|
||||
@@ -156,6 +158,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.PublishWithoutReview != nil {
|
||||
if err := h.Setting.SetPublishWithoutReview(*req.PublishWithoutReview); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.SiteName != nil {
|
||||
if err := h.Setting.SetSiteName(*req.SiteName); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -38,3 +39,16 @@ func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.Handl
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// StaffExempt 内容类频控豁免:管理角色直接放行,其余走原限流。
|
||||
// 必须挂在 RequireAuth 之后(依赖 CurrentUser);角色变更会递增
|
||||
// token_version 强制重登,故 JWT 内 role 可视为实时。
|
||||
func StaffExempt(inner gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if claims := CurrentUser(c); claims != nil && model.IsStaff(model.Role(claims.Role)) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
inner(c)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ func InitDB(dsn string) error {
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&PostPollVote{}, &PostLotteryEntry{}, &Ad{},
|
||||
&ImportRecord{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
@@ -96,8 +97,20 @@ func InitDB(dsn string) error {
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("私聊唯一索引创建失败: %w", err)
|
||||
}
|
||||
|
||||
// 昵称全站唯一(忽略大小写,软删用户不占用),与用户名同级约束:
|
||||
// 保证 @提及 昵称→用户名 映射无歧义。应用层另有友好错误提示(service/auth.go UpdateProfile)。
|
||||
// 存量重名会令本索引创建失败(开发期数据可重置)。
|
||||
if err := db.Exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_nickname_lower
|
||||
ON users (LOWER(nickname))
|
||||
WHERE deleted_at IS NULL
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("昵称唯一索引创建失败(存量数据可能存在重名昵称,请清理或重置数据库): %w", err)
|
||||
}
|
||||
backfillBoardDefaults(db)
|
||||
seedDefaultBoards(db)
|
||||
if err := seedDefaultChatRoom(db); err != nil {
|
||||
if err := EnsureDefaultChatRoom(db); err != nil {
|
||||
return fmt.Errorf("默认群聊大厅初始化失败: %w", err)
|
||||
}
|
||||
if err := ensureDefaultChatMemberships(db); err != nil {
|
||||
@@ -321,13 +334,27 @@ func ensureOwnerRole(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillBoardDefaults 为升级前已存在的板块补齐新字段默认值。
|
||||
// 以 post_policy 为空作为「迁移前旧行」的判定标志:仅对这些行一次性回填
|
||||
// post_policy=all、visible=true;后续已被显式设置过的行(含站长隐藏的板块)不会被覆盖。
|
||||
func backfillBoardDefaults(db *gorm.DB) {
|
||||
if err := db.Model(&Board{}).
|
||||
Where("post_policy = '' OR post_policy IS NULL").
|
||||
Updates(map[string]interface{}{
|
||||
"post_policy": BoardPostPolicyAll,
|
||||
"visible": true,
|
||||
}).Error; err != nil {
|
||||
log.Printf("[model] 板块默认值回填失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultBoards 写入默认板块
|
||||
func seedDefaultBoards(db *gorm.DB) {
|
||||
defaults := []Board{
|
||||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1},
|
||||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2},
|
||||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3},
|
||||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4},
|
||||
{Name: "综合讨论", Description: "什么都可以聊", Icon: "message-circle", SortOrder: 1, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "技术分享", Description: "分享技术心得与问题", Icon: "code", SortOrder: 2, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "问答求助", Description: "提问与解答", Icon: "help-circle", SortOrder: 3, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
{Name: "闲聊灌水", Description: "轻松闲聊", Icon: "coffee", SortOrder: 4, PostPolicy: BoardPostPolicyAll, Visible: true},
|
||||
}
|
||||
for _, b := range defaults {
|
||||
var count int64
|
||||
@@ -338,8 +365,9 @@ func seedDefaultBoards(db *gorm.DB) {
|
||||
}
|
||||
}
|
||||
|
||||
// seedDefaultChatRoom 确保存在唯一的全站大厅(公开默认群)
|
||||
func seedDefaultChatRoom(db *gorm.DB) error {
|
||||
// EnsureDefaultChatRoom 确保存在唯一的全站大厅(公开默认群)。
|
||||
// 导出供注册链路在启动播种时因空库跳过后就地补建(自愈,不依赖重启)。
|
||||
func EnsureDefaultChatRoom(db *gorm.DB) error {
|
||||
var n int64
|
||||
if err := db.Model(&ChatRoom{}).Where("is_default = ?", true).Count(&n).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -116,7 +116,7 @@ type User struct {
|
||||
Username string `gorm:"uniqueIndex;size:128;not null" json:"username"`
|
||||
Email string `gorm:"index;size:128;default:''" json:"-"`
|
||||
Password string `gorm:"size:128;not null" json:"-"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"` // 全站唯一:LOWER 函数唯一索引在 db.go InitDB 中创建(应用层校验见 service/auth.go)
|
||||
Avatar string `gorm:"size:512" json:"avatar"`
|
||||
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
|
||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||
@@ -161,11 +161,40 @@ type Board struct {
|
||||
Icon string `gorm:"size:64;default:''" json:"icon"`
|
||||
ColorIndex int `gorm:"default:-1" json:"color_index"`
|
||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||
PostPolicy string `gorm:"size:16;default:'all'" json:"post_policy"` // all=全员可发;staff=仅管理员及以上
|
||||
Visible bool `gorm:"default:true" json:"visible"` // false 时不在前台列表/导航展示(仅隐藏入口)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// 板块发帖策略常量
|
||||
const (
|
||||
BoardPostPolicyAll = "all" // 全员可发帖
|
||||
BoardPostPolicyStaff = "staff" // 仅管理员及以上(含管辖该板的版主)可发帖
|
||||
)
|
||||
|
||||
// NormalizeBoardPostPolicy 归一化发帖策略,非法值回落 all
|
||||
func NormalizeBoardPostPolicy(v string) string {
|
||||
switch v {
|
||||
case BoardPostPolicyStaff:
|
||||
return BoardPostPolicyStaff
|
||||
default:
|
||||
return BoardPostPolicyAll
|
||||
}
|
||||
}
|
||||
|
||||
// ImportRecord 外部数据导入去重记录(旧站/WordPress/Typecho 等来源):
|
||||
// 同一来源同一源对象只导入一次,保证「预检→正式导入→重复导入」全程幂等。
|
||||
type ImportRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Source string `gorm:"size:32;not null;uniqueIndex:idx_import_dedupe,priority:1" json:"source"` // jiang13 / wordpress / typecho
|
||||
Kind string `gorm:"size:16;not null;uniqueIndex:idx_import_dedupe,priority:2" json:"kind"` // board / post / comment
|
||||
SourceID string `gorm:"size:64;not null;uniqueIndex:idx_import_dedupe,priority:3" json:"source_id"`
|
||||
NewID uint `json:"new_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Post 帖子
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@@ -56,7 +56,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
settingSvc := service.NewSettingService(model.DB)
|
||||
ops := service.NewOperations(model.DB, cfg)
|
||||
go ops.Run(context.Background())
|
||||
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
|
||||
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithBoard(boardSvc).WithDevMode(cfg.DevMode)
|
||||
commentSvc := service.NewCommentService(model.DB)
|
||||
likeSvc := service.NewLikeService(model.DB)
|
||||
notifSvc := service.NewNotificationService(model.DB)
|
||||
@@ -70,6 +70,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
postFileSvc.WithOperations(ops)
|
||||
pointsSvc := service.NewPointsService(model.DB)
|
||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||
telemetrySvc := service.NewTelemetryService(model.DB)
|
||||
@@ -103,6 +104,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
Points: pointsSvc,
|
||||
Setting: settingSvc,
|
||||
AdminUser: adminUserSvc,
|
||||
LegacyImport: legacyImportSvc,
|
||||
Moderation: moderationSvc,
|
||||
Chat: chatSvc,
|
||||
Telemetry: telemetrySvc,
|
||||
@@ -176,22 +178,22 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.GET("/me/login-devices", h.MyLoginDevices)
|
||||
api.DELETE("/me/login-devices/:id", h.RevokeLoginDevice)
|
||||
api.PUT("/profile", h.UpdateProfile)
|
||||
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
|
||||
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
|
||||
api.PUT("/posts/:id", h.UpdatePost)
|
||||
api.POST("/posts/:id/unlock", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.UnlockPostContent)
|
||||
api.POST("/posts/:id/poll/vote", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.VotePoll)
|
||||
api.POST("/posts/:id/unlock", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.UnlockPostContent)
|
||||
api.POST("/posts/:id/poll/vote", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.VotePoll)
|
||||
api.POST("/posts/:id/poll/close", h.ClosePoll)
|
||||
api.POST("/posts/:id/question/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptQuestion)
|
||||
api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
|
||||
api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
|
||||
api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
|
||||
api.POST("/posts/:id/question/accept", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.AcceptQuestion)
|
||||
api.POST("/posts/:id/question/solve", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.SolveQuestion)
|
||||
api.POST("/posts/:id/question/reopen", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.ReopenQuestion)
|
||||
api.POST("/posts/:id/bounty/accept", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.AcceptBounty)
|
||||
api.POST("/posts/:id/lottery/draw", h.DrawLottery)
|
||||
api.DELETE("/posts/:id", h.DeletePost)
|
||||
api.PUT("/posts/:id/pin", h.TogglePin)
|
||||
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
|
||||
api.POST("/posts/:id/like", h.ToggleLike)
|
||||
api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment)
|
||||
api.PUT("/posts/:id/comments/:cid", middleware.RateLimitMiddleware(limiter, service.RateComment), h.UpdateComment)
|
||||
api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment)
|
||||
api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment)
|
||||
api.DELETE("/posts/:id/comments/:cid", h.DeleteComment)
|
||||
api.PUT("/posts/:id/comments/:cid/restore", h.RestoreComment)
|
||||
api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment)
|
||||
@@ -202,13 +204,13 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.PUT("/notifications/read-all", h.MarkAllRead)
|
||||
// 每日签到 / 积分
|
||||
api.GET("/checkin", h.GetCheckin)
|
||||
api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin)
|
||||
api.POST("/checkin", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.DoCheckin)
|
||||
api.GET("/points", h.GetPointsBalance)
|
||||
api.GET("/points/ledger", h.GetPointsLedger)
|
||||
// 头像上传(裁剪后的 WebP)/ 帖子插图 / 媒体库 / 附件删除
|
||||
api.POST("/upload/avatar", h.UploadAvatar)
|
||||
api.POST("/upload/image", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadImage)
|
||||
api.POST("/upload/file", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadPostFile)
|
||||
api.POST("/upload/image", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateUpload)), h.UploadImage)
|
||||
api.POST("/upload/file", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateUpload)), h.UploadPostFile)
|
||||
api.PUT("/upload/file/:id/price", h.UpdatePostFilePrice)
|
||||
api.DELETE("/upload/file/:id", h.DeletePostFile)
|
||||
api.PUT("/avatar/use", h.UseAvatar)
|
||||
@@ -216,7 +218,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.DELETE("/my/attachments/:id", h.DeleteAttachment)
|
||||
|
||||
// 时间线:从 Git commits 页导入(登录用户;适配器由超管配置)
|
||||
api.POST("/timeline/from-git", middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit), h.TimelineFromGit)
|
||||
api.POST("/timeline/from-git", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateTimelineGit)), h.TimelineFromGit)
|
||||
|
||||
// 群聊(二期):建群/成员/消息/未读;全站关闭消息时前台一律 403
|
||||
chatAPI := api.Group("/chat", h.RequireMessagesOpen)
|
||||
@@ -237,7 +239,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
chatAPI.PUT("/rooms/:id/members/:uid/mute", h.SetChatMemberMute)
|
||||
chatAPI.PUT("/rooms/:id/members/:uid/role", h.SetChatMemberRole)
|
||||
chatAPI.GET("/rooms/:id/messages", h.ListChatMessages)
|
||||
chatAPI.POST("/rooms/:id/messages", middleware.RateLimitMiddleware(limiter, service.RateChat), h.SendChatMessage)
|
||||
chatAPI.POST("/rooms/:id/messages", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateChat)), h.SendChatMessage)
|
||||
chatAPI.DELETE("/rooms/:id/messages/:mid", h.RecallChatMessage)
|
||||
chatAPI.PUT("/rooms/:id/read", h.MarkChatRead)
|
||||
chatAPI.GET("/unread-summary", h.ChatUnreadSummary)
|
||||
@@ -312,6 +314,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
opsAPI.GET("/:module/records", h.ModuleRecords)
|
||||
staffAPI.GET("/diagnostics", authMW.RequirePerm(service.PermSettings), h.Diagnostics)
|
||||
staffAPI.POST("/maintenance/actions", authMW.RequirePerm(service.PermSettings), h.MaintenanceAction)
|
||||
// 数据导入(旧站 / WordPress / Typecho 等,source 白名单见 service.LegacyImportSource)
|
||||
staffAPI.POST("/import/:source", authMW.RequirePerm(service.PermSettings), h.AdminImportLegacy)
|
||||
staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings)
|
||||
staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground)
|
||||
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
|
||||
@@ -320,6 +324,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
|
||||
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
|
||||
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))
|
||||
{
|
||||
usersAPI.GET("/users", h.AdminListUsers)
|
||||
usersAPI.PUT("/users/:id/role", h.AdminUpdateUserRole)
|
||||
usersAPI.PUT("/users/:id/messages", h.AdminSetUserMessages)
|
||||
@@ -332,6 +337,20 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
usersAPI.GET("/users/:id/messages", h.AdminUserAuditMessages)
|
||||
}
|
||||
|
||||
// 板块管理(仅站长):增删改、排序、版主授权
|
||||
boardsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermBoards))
|
||||
{
|
||||
boardsAPI.GET("/boards", h.AdminListBoards)
|
||||
boardsAPI.POST("/boards", h.AdminCreateBoard)
|
||||
boardsAPI.PUT("/boards/:id", h.AdminUpdateBoard)
|
||||
boardsAPI.DELETE("/boards/:id", h.AdminDeleteBoard)
|
||||
boardsAPI.PUT("/boards/reorder", h.AdminReorderBoards)
|
||||
boardsAPI.GET("/boards/:id/moderators", h.AdminListBoardModerators)
|
||||
boardsAPI.POST("/boards/:id/moderators", h.AdminAddBoardModerator)
|
||||
boardsAPI.DELETE("/boards/:id/moderators/:uid", h.AdminRemoveBoardModerator)
|
||||
}
|
||||
}
|
||||
|
||||
// 消息管理后台:登录即可(handler 内校验站点消息权或群管理员)
|
||||
msgAPI := r.Group("/api/admin", authMW.RequireAuth(), middleware.CSRFMiddleware())
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
PermSettings = "settings" // 站点外观设置(超管/站长)
|
||||
PermModeration = "moderation" // 内容审核(任意管理角色,板块范围受限)
|
||||
PermMessages = "messages" // 后台消息管理(站长/超管/站点消息 flag;群管另有业务层放行)
|
||||
PermBoards = "boards" // 板块管理(仅站长)
|
||||
)
|
||||
|
||||
// Actor 当前请求操作者的实时权限快照(每次后台请求从 DB 现取,
|
||||
@@ -37,6 +38,8 @@ func (a *Actor) HasPerm(p string) bool {
|
||||
return false
|
||||
}
|
||||
switch p {
|
||||
case PermBoards:
|
||||
return a.Role == model.RoleOwner
|
||||
case PermUsers, PermSettings:
|
||||
return a.Role == model.RoleSuperAdmin || a.Role == model.RoleOwner
|
||||
case PermAnnouncements:
|
||||
|
||||
@@ -576,7 +576,7 @@ func (s *AuthService) ChangePassword(userID uint, oldPassword, newPassword strin
|
||||
|
||||
// UpdateProfile 更新昵称/邮箱/签名。头像只能通过上传接口(/api/upload/avatar)
|
||||
// 或历史头像选用接口(/api/avatar/use)修改,这里不接收头像字段,防止写入任意外链。
|
||||
// - nickname 非空且不超过 64 字符
|
||||
// - nickname 非空、不超过 64 字符,且全站唯一(忽略大小写,排除自身;注册时昵称=用户名,用户名本身唯一)
|
||||
// - email 可为空;非空时需符合邮箱格式且不与他人重复
|
||||
// - signature 不超过 255 字符
|
||||
func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature string) (*model.User, error) {
|
||||
@@ -590,6 +590,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
|
||||
if len(nickname) > 64 {
|
||||
return nil, errors.New("昵称不能超过 64 个字符")
|
||||
}
|
||||
// 昵称全站唯一(排除自身):与用户名同级,保证 @提及 昵称→用户名 映射无歧义
|
||||
var nickCount int64
|
||||
s.db.Model(&model.User{}).
|
||||
Where("LOWER(nickname) = LOWER(?) AND id <> ?", nickname, userID).
|
||||
Count(&nickCount)
|
||||
if nickCount > 0 {
|
||||
return nil, errors.New("该昵称已被使用")
|
||||
}
|
||||
email = strings.TrimSpace(email)
|
||||
if email != "" {
|
||||
if !strings.Contains(email, "@") || len(email) > 128 {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
@@ -85,6 +87,304 @@ func (s *BoardService) Get(id uint) (*model.Board, error) {
|
||||
return &board, nil
|
||||
}
|
||||
|
||||
// ===== 板块管理(仅站长;handler 通过 PermBoards 兜底) =====
|
||||
|
||||
// BoardInput 板块新建/更新入参;service 层做归一与长度约束
|
||||
type BoardInput struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
ColorIndex *int `json:"color_index"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
PostPolicy string `json:"post_policy"`
|
||||
Visible *bool `json:"visible"`
|
||||
}
|
||||
|
||||
// 板块错误(handler 映射为 4xx)
|
||||
var (
|
||||
ErrBoardNameRequired = errors.New("板块名称不能为空")
|
||||
ErrBoardInUse = errors.New("该板块下仍有帖子,无法删除")
|
||||
)
|
||||
|
||||
const (
|
||||
boardNameMaxRunes = 64
|
||||
boardDescMaxRunes = 512
|
||||
boardIconMaxRunes = 64
|
||||
)
|
||||
|
||||
// normalizeBoardInput 归一化并校验入参;ColorIndex/SortOrder/Visible 为 nil 时保持原值
|
||||
func normalizeBoardInput(in *BoardInput) error {
|
||||
in.Name = strings.TrimSpace(in.Name)
|
||||
in.Description = strings.TrimSpace(in.Description)
|
||||
in.Icon = strings.TrimSpace(in.Icon)
|
||||
in.PostPolicy = model.NormalizeBoardPostPolicy(in.PostPolicy)
|
||||
if in.Name == "" {
|
||||
return ErrBoardNameRequired
|
||||
}
|
||||
if len([]rune(in.Name)) > boardNameMaxRunes {
|
||||
in.Name = string([]rune(in.Name)[:boardNameMaxRunes])
|
||||
}
|
||||
if len([]rune(in.Description)) > boardDescMaxRunes {
|
||||
in.Description = string([]rune(in.Description)[:boardDescMaxRunes])
|
||||
}
|
||||
if len([]rune(in.Icon)) > boardIconMaxRunes {
|
||||
in.Icon = string([]rune(in.Icon)[:boardIconMaxRunes])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyInput 把入参写入 board(用于 Create 与 Update;nil 字段保留旧值)
|
||||
func applyInput(b *model.Board, in *BoardInput) {
|
||||
b.Name = in.Name
|
||||
b.Description = in.Description
|
||||
b.Icon = in.Icon
|
||||
b.PostPolicy = in.PostPolicy
|
||||
if in.ColorIndex != nil {
|
||||
b.ColorIndex = *in.ColorIndex
|
||||
}
|
||||
if in.SortOrder != nil {
|
||||
b.SortOrder = *in.SortOrder
|
||||
}
|
||||
if in.Visible != nil {
|
||||
b.Visible = *in.Visible
|
||||
}
|
||||
}
|
||||
|
||||
// Create 创建板块
|
||||
func (s *BoardService) Create(in BoardInput) (*model.Board, error) {
|
||||
if err := normalizeBoardInput(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := model.Board{
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
Icon: in.Icon,
|
||||
ColorIndex: -1,
|
||||
SortOrder: 0,
|
||||
PostPolicy: in.PostPolicy,
|
||||
Visible: true,
|
||||
}
|
||||
applyInput(&b, &in)
|
||||
if err := s.db.Create(&b).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Update 更新板块;不存在返回 gorm.ErrRecordNotFound
|
||||
func (s *BoardService) Update(id uint, in BoardInput) (*model.Board, error) {
|
||||
if err := normalizeBoardInput(&in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var b model.Board
|
||||
if err := s.db.First(&b, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyInput(&b, &in)
|
||||
if err := s.db.Save(&b).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// Delete 软删板块;若仍有未删除帖子则拒绝,避免内容悬空
|
||||
func (s *BoardService) Delete(id uint) error {
|
||||
var cnt int64
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Where("board_id = ? AND deleted_at IS NULL", id).
|
||||
Count(&cnt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt > 0 {
|
||||
return ErrBoardInUse
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("board_id = ?", id).Delete(&model.UserBoard{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&model.Board{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Reorder 批量设置 sort_order;ids 顺序即展示顺序
|
||||
func (s *BoardService) Reorder(ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.Board{}).Where("id = ?", id).
|
||||
Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// BoardModeratorMember 板块版主列表项(id/username/nickname/avatar/role 透出)
|
||||
type BoardModeratorMember struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
Email string `json:"email"`
|
||||
Banned bool `json:"banned"`
|
||||
}
|
||||
|
||||
// ListModerators 列出该板块的版主(role=board_admin 且授权本板)
|
||||
func (s *BoardService) ListModerators(boardID uint) ([]BoardModeratorMember, error) {
|
||||
var mods []model.User
|
||||
if err := s.db.Table("users").
|
||||
Select("users.id, users.username, users.nickname, users.avatar, users.email, users.banned").
|
||||
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
|
||||
Where("users.role = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin).
|
||||
Order("users.id ASC").
|
||||
Find(&mods).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]BoardModeratorMember, 0, len(mods))
|
||||
for _, u := range mods {
|
||||
out = append(out, BoardModeratorMember{
|
||||
ID: u.ID, Username: u.Username, Nickname: u.Nickname,
|
||||
Avatar: u.Avatar, Email: u.Email, Banned: u.Banned,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddModerator 将用户设为该板块版主:
|
||||
// - 目标用户必须存在且非站长;站长身份不可被授予
|
||||
// - 若其当前非 board_admin,则升级为 board_admin 并授权本板(强制下线)
|
||||
// - 若已是 board_admin,仅追加本板授权(不动角色与登录态)
|
||||
// 操作者权限由 handler 通过 PermBoards 兜底(仅站长可调用)
|
||||
func (s *BoardService) AddModerator(boardID, userID uint) error {
|
||||
var board model.Board
|
||||
if err := s.db.First(&board, boardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var u model.User
|
||||
if err := tx.First(&u, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role == model.RoleOwner {
|
||||
return ErrProtectedOwner
|
||||
}
|
||||
// 已授权则幂等返回
|
||||
var cnt int64
|
||||
if err := tx.Model(&model.UserBoard{}).
|
||||
Where("user_id = ? AND board_id = ?", userID, boardID).Count(&cnt).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cnt > 0 {
|
||||
return nil
|
||||
}
|
||||
// 角色升级:非 board_admin 升为 board_admin 并强制下线
|
||||
roleChanged := u.Role != model.RoleBoardAdmin
|
||||
if roleChanged {
|
||||
if err := tx.Model(&u).Update("role", model.RoleBoardAdmin).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := invalidateUserSessions(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.UserBoard{UserID: userID, BoardID: boardID}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveModerator 取消该用户在本板的版主授权;若无其他板块授权则降级为普通用户
|
||||
func (s *BoardService) RemoveModerator(boardID, userID uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("user_id = ? AND board_id = ?", userID, boardID).
|
||||
Delete(&model.UserBoard{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var rest int64
|
||||
if err := tx.Model(&model.UserBoard{}).
|
||||
Where("user_id = ?", userID).Count(&rest).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if rest == 0 {
|
||||
var u model.User
|
||||
if err := tx.Select("id, role").First(&u, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if u.Role == model.RoleBoardAdmin {
|
||||
if err := tx.Model(&u).Update("role", model.RoleUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := invalidateUserSessions(tx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// viewerPostInfo 发帖/可见性判定所需的访问者信息
|
||||
type viewerPostInfo struct {
|
||||
Role model.Role
|
||||
BoardIDs []uint
|
||||
}
|
||||
|
||||
// loadViewerPostInfo 读取用户角色与其被授权的板块(用于发帖策略与可见性判定)
|
||||
func (s *BoardService) loadViewerPostInfo(userID uint) (*viewerPostInfo, error) {
|
||||
var u model.User
|
||||
if err := s.db.Select("id, role").First(&u, userID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := &viewerPostInfo{Role: u.Role, BoardIDs: []uint{}}
|
||||
if u.Role == model.RoleBoardAdmin {
|
||||
if err := s.db.Model(&model.UserBoard{}).
|
||||
Where("user_id = ?", userID).Pluck("board_id", &info.BoardIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CanPostToBoard 判定 userID 是否可在 board 发帖。
|
||||
// policy=staff 时:仅 admin 及以上,或管辖该板的 board_admin 允许;否则拒绝。
|
||||
func (s *BoardService) CanPostToBoard(userID uint, board *model.Board) (bool, error) {
|
||||
if board == nil || board.PostPolicy != model.BoardPostPolicyStaff {
|
||||
return true, nil
|
||||
}
|
||||
info, err := s.loadViewerPostInfo(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if model.RoleLevel(info.Role) >= model.RoleLevel(model.RoleAdmin) {
|
||||
return true, nil
|
||||
}
|
||||
if info.Role == model.RoleBoardAdmin {
|
||||
for _, id := range info.BoardIDs {
|
||||
if id == board.ID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// CanViewBoardEntry 判定访问者是否可见该板块入口(列表/导航)。
|
||||
// visible=false 的板块仅 staff 可见;普通用户不可见。
|
||||
func (s *BoardService) CanViewBoardEntry(userID uint, board *model.Board) (bool, error) {
|
||||
if board == nil || board.Visible {
|
||||
return true, nil
|
||||
}
|
||||
if userID == 0 {
|
||||
return false, nil
|
||||
}
|
||||
info, err := s.loadViewerPostInfo(userID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return model.IsStaff(info.Role), nil
|
||||
}
|
||||
|
||||
// Sidebar 首页板块右栏:管理员 + 概况统计 + 本板热门/活跃
|
||||
func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
|
||||
board, err := s.Get(boardID)
|
||||
|
||||
@@ -587,7 +587,16 @@ func (s *ChatService) InviteMembers(operatorID, roomID uint, userIDs []uint, ove
|
||||
func (s *ChatService) EnsureDefaultMembership(userID uint) error {
|
||||
var room model.ChatRoom
|
||||
if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil {
|
||||
return nil // 尚无大厅则跳过
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
// 空库首次部署时启动播种因无用户跳过,这里就地补建大厅(自愈,不依赖重启)
|
||||
if err := model.EnsureDefaultChatRoom(s.db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.db.Where("is_default = ?", true).First(&room).Error; err != nil {
|
||||
return nil // 仍无大厅(尚无任何用户)则跳过
|
||||
}
|
||||
}
|
||||
if _, err := s.membership(s.db, room.ID, userID); err == nil {
|
||||
return nil
|
||||
|
||||
738
backend/service/legacyimport.go
Normal file
738
backend/service/legacyimport.go
Normal file
@@ -0,0 +1,738 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
)
|
||||
|
||||
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包,
|
||||
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
|
||||
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
|
||||
// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。
|
||||
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
|
||||
|
||||
const (
|
||||
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
|
||||
LegacyDBMaxBytes = 64 << 20
|
||||
// LegacyZipMaxBytes 头像压缩包上传上限
|
||||
LegacyZipMaxBytes = 128 << 20
|
||||
// legacyAvatarFileMax 单个头像解压上限
|
||||
legacyAvatarFileMax = 8 << 20
|
||||
// legacySignatureMaxRunes 新站 Signature 列上限
|
||||
legacySignatureMaxRunes = 255
|
||||
// legacyEmailMaxRunes 新站 Email 列上限
|
||||
legacyEmailMaxRunes = 128
|
||||
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
|
||||
legacyAvatarPrefix = "/uploads/avatars/"
|
||||
// legacyDetailLimit 报告明细条数上限,防止超长响应
|
||||
legacyDetailLimit = 50
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
|
||||
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
|
||||
)
|
||||
|
||||
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
|
||||
func LegacyImportSource(source string) bool {
|
||||
return source == "jiang13"
|
||||
}
|
||||
|
||||
// LegacyImportOptions 导入选项
|
||||
type LegacyImportOptions struct {
|
||||
WithContent bool // 同时导入板块 / 帖子 / 评论
|
||||
DryRun bool // 预检:只统计不写库
|
||||
}
|
||||
|
||||
// LegacyImportReport 导入结果摘要(JSON 返回给前端)
|
||||
type LegacyImportReport struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Users LegacyUserReport `json:"users"`
|
||||
Boards *LegacyBoardReport `json:"boards,omitempty"`
|
||||
Posts *LegacyPostReport `json:"posts,omitempty"`
|
||||
Comments *LegacyCommentReport `json:"comments,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyUserReport 用户导入明细
|
||||
type LegacyUserReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
AvatarWritten int `json:"avatar_written"`
|
||||
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
|
||||
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyBoardReport 板块导入明细
|
||||
type LegacyBoardReport struct {
|
||||
Created int `json:"created"` // 新建(预检时为「将新建」)
|
||||
Reused int `json:"reused"` // 复用同名已有板块
|
||||
Names []string `json:"names,omitempty"` // 新建板块名
|
||||
}
|
||||
|
||||
// LegacyPostReport 帖子导入明细
|
||||
type LegacyPostReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"` // 已导入过(去重记录)
|
||||
PollsSkipped int `json:"polls_skipped"`
|
||||
PollTitles []string `json:"poll_titles,omitempty"`
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// LegacyCommentReport 评论导入明细
|
||||
type LegacyCommentReport struct {
|
||||
Total int `json:"total"`
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
|
||||
OwnerFallback []string `json:"owner_fallback,omitempty"`
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
}
|
||||
|
||||
// ===== 旧库行结构(只映射所需列) =====
|
||||
|
||||
type legacyUserRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Username string `gorm:"column:username"`
|
||||
Email string `gorm:"column:email"`
|
||||
Password string `gorm:"column:password"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
Signature string `gorm:"column:signature"`
|
||||
Avatar string `gorm:"column:avatar"`
|
||||
Role string `gorm:"column:role"`
|
||||
Banned bool `gorm:"column:banned"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` // 软删行由 GORM 自动过滤
|
||||
}
|
||||
|
||||
func (legacyUserRow) TableName() string { return "users" }
|
||||
|
||||
type legacyBoardRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Description string `gorm:"column:description"`
|
||||
Icon string `gorm:"column:icon"`
|
||||
ColorIndex int `gorm:"column:color_index"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (legacyBoardRow) TableName() string { return "boards" }
|
||||
|
||||
type legacyPostRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
BoardID uint `gorm:"column:board_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
Title string `gorm:"column:title"`
|
||||
Content string `gorm:"column:content"`
|
||||
Tags string `gorm:"column:tags"`
|
||||
Pinned int `gorm:"column:pinned"`
|
||||
Featured bool `gorm:"column:featured"`
|
||||
Status string `gorm:"column:status"`
|
||||
PostType string `gorm:"column:post_type"`
|
||||
LikeCount int `gorm:"column:like_count"`
|
||||
ViewCount int `gorm:"column:view_count"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (legacyPostRow) TableName() string { return "posts" }
|
||||
|
||||
type legacyCommentRow struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
PostID uint `gorm:"column:post_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
ReplyTo *uint `gorm:"column:reply_to"`
|
||||
Content string `gorm:"column:content"`
|
||||
Status string `gorm:"column:status"`
|
||||
LikeCount int `gorm:"column:like_count"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||
}
|
||||
|
||||
func (legacyCommentRow) TableName() string { return "comments" }
|
||||
|
||||
// LegacyImportService 管理后台「数据导入」
|
||||
type LegacyImportService struct {
|
||||
db *gorm.DB
|
||||
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars
|
||||
md *htmltomarkdown.Converter
|
||||
}
|
||||
|
||||
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
|
||||
return &LegacyImportService{
|
||||
db: db,
|
||||
uploadsDir: uploadsDir,
|
||||
// 空域名:相对链接保持原样;CommonMark 风格输出
|
||||
md: htmltomarkdown.NewConverter("", true, nil),
|
||||
}
|
||||
}
|
||||
|
||||
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath 为可选头像 zip(空串表示未上传)。
|
||||
// operatorID 为执行导入的账号(站长),作者缺失的帖子/评论归属到该账号。
|
||||
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
|
||||
if err := validateSQLiteMagic(dbPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
oldDB, err := gorm.Open(sqlite.Open("file:"+filepath.ToSlash(dbPath)+"?mode=ro"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrLegacyInvalidSQLite, err)
|
||||
}
|
||||
|
||||
rep := &LegacyImportReport{DryRun: opts.DryRun}
|
||||
rep.Users = s.importUsers(oldDB, zipPath, opts)
|
||||
if !opts.WithContent {
|
||||
return rep, nil
|
||||
}
|
||||
boardMap, boardRep := s.importBoards(oldDB, opts)
|
||||
rep.Boards = boardRep
|
||||
rep.Posts = s.importPosts(oldDB, boardMap, operatorID, opts, rep)
|
||||
rep.Comments = s.importComments(oldDB, operatorID, opts, rep)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// ===== 用户 =====
|
||||
|
||||
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts LegacyImportOptions) LegacyUserReport {
|
||||
var rows []legacyUserRow
|
||||
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}}
|
||||
}
|
||||
|
||||
// 头像包索引:文件名 → 条目。
|
||||
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
|
||||
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
|
||||
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
|
||||
avatars := map[string]*zip.File{}
|
||||
if zipPath != "" {
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}}
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ReplaceAll(f.Name, "\\", "/")
|
||||
parts := strings.Split(name, "/")
|
||||
var key string
|
||||
switch {
|
||||
case len(parts) == 1:
|
||||
key = parts[0]
|
||||
case len(parts) == 2 && parts[0] != "" && parts[0] != "." && parts[0] != "..":
|
||||
key = parts[1]
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if key == "" || key == "." || key == ".." {
|
||||
continue
|
||||
}
|
||||
if _, exists := avatars[key]; !exists {
|
||||
avatars[key] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rep := LegacyUserReport{Total: len(rows)}
|
||||
avatarDir := filepath.Join(s.uploadsDir, "avatars")
|
||||
for _, u := range rows {
|
||||
username := strings.TrimSpace(u.Username)
|
||||
if username == "" || u.Password == "" {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID))
|
||||
continue
|
||||
}
|
||||
// 用户名列是硬唯一索引,软删行仍占用 → Unscoped 查重,存在即跳过(幂等)
|
||||
var n int64
|
||||
if err := s.db.Unscoped().Model(&model.User{}).Where("username = ?", username).Count(&n).Error; err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (查重失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
rep.Skipped++
|
||||
rep.Conflicts = append(rep.Conflicts, username)
|
||||
continue
|
||||
}
|
||||
|
||||
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun)
|
||||
if err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
if wrote {
|
||||
rep.AvatarWritten++
|
||||
}
|
||||
if u.Avatar != "" && avatarURL == "" {
|
||||
rep.AvatarMissing = append(rep.AvatarMissing, fmt.Sprintf("%s: %s", username, u.Avatar))
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
rep.Imported++
|
||||
continue
|
||||
}
|
||||
user := model.User{
|
||||
Username: username,
|
||||
Email: truncateRunesN(u.Email, legacyEmailMaxRunes),
|
||||
Password: u.Password, // bcrypt 哈希原样复制
|
||||
Nickname: s.uniqueNickname(u.Nickname, username, u.ID),
|
||||
Signature: truncateRunesN(u.Signature, legacySignatureMaxRunes),
|
||||
Avatar: avatarURL,
|
||||
Role: model.RoleUser, // 导入者即新站站长,旧角色一律降为普通用户
|
||||
Banned: u.Banned,
|
||||
CreatedAt: u.CreatedAt, // 保留原注册时间
|
||||
}
|
||||
if err := s.db.Create(&user).Error; err != nil {
|
||||
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
|
||||
continue
|
||||
}
|
||||
rep.Imported++
|
||||
}
|
||||
return rep
|
||||
}
|
||||
|
||||
// uniqueNickname 旧昵称与本站已有昵称(忽略大小写;软删不占用,与唯一索引语义一致)冲突时降级:
|
||||
// 旧昵称 → 用户名 → 用户名-旧ID,保证满足 LOWER(nickname) 唯一索引且不为空。
|
||||
func (s *LegacyImportService) uniqueNickname(nickname, username string, oldID uint) string {
|
||||
candidates := []string{strings.TrimSpace(nickname), username, fmt.Sprintf("%s-%d", username, oldID)}
|
||||
last := candidates[len(candidates)-1]
|
||||
for _, cand := range candidates {
|
||||
var n int64
|
||||
if err := s.db.Model(&model.User{}).Where("LOWER(nickname) = LOWER(?)", cand).Count(&n).Error; err != nil || n == 0 {
|
||||
return cand // 查重失败按可用处理,Create 时仍有唯一索引兜底
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
// resolveAvatar 从头像包解出旧头像文件到新站 avatars 目录(文件名原样保留,URL 即旧值)。
|
||||
// 返回新 Avatar 字段值(空串=无法迁移)与是否实际写入了文件。dryRun 时只判断不解压。
|
||||
func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*zip.File, avatarDir string, dryRun bool) (string, bool, error) {
|
||||
if oldAvatar == "" || len(oldAvatar) <= len(legacyAvatarPrefix) {
|
||||
return "", false, nil
|
||||
}
|
||||
name := oldAvatar[len(legacyAvatarPrefix):]
|
||||
if name != path.Base(name) || strings.ContainsRune(name, '\\') {
|
||||
return "", false, nil // 不符合旧站 URL 约定(如外部链接),无法迁移
|
||||
}
|
||||
f, ok := pack[name]
|
||||
if !ok {
|
||||
return "", false, nil // 包内缺失
|
||||
}
|
||||
dst := filepath.Join(avatarDir, name)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
|
||||
}
|
||||
if dryRun {
|
||||
return legacyAvatarPrefix + name, true, nil
|
||||
}
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer src.Close()
|
||||
// 先写临时文件再 rename,避免中断留下半截文件
|
||||
out, err := os.CreateTemp(avatarDir, ".legacy-*")
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
tmpName := out.Name()
|
||||
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
|
||||
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
|
||||
if closeErr := out.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if written > legacyAvatarFileMax {
|
||||
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20)
|
||||
}
|
||||
if err := os.Rename(tmpName, dst); err != nil {
|
||||
// Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
|
||||
if _, statErr := os.Stat(dst); statErr == nil {
|
||||
return legacyAvatarPrefix + name, false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return legacyAvatarPrefix + name, true, nil
|
||||
}
|
||||
|
||||
// ===== 板块 =====
|
||||
|
||||
// importBoards 按旧板块名自动建新板块(同名忽略大小写复用),返回 旧板块ID → 新板块ID 映射。
|
||||
func (s *LegacyImportService) importBoards(oldDB *gorm.DB, opts LegacyImportOptions) (map[uint]uint, *LegacyBoardReport) {
|
||||
rep := &LegacyBoardReport{}
|
||||
m := map[uint]uint{}
|
||||
var rows []legacyBoardRow
|
||||
if err := oldDB.Order("sort_order ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
return m, rep // 板块读取失败:帖子逐条按 board_id 缺失进 failed
|
||||
}
|
||||
for _, b := range rows {
|
||||
name := strings.TrimSpace(b.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
var existing model.Board
|
||||
if err := s.db.Where("LOWER(name) = LOWER(?)", name).First(&existing).Error; err == nil {
|
||||
m[b.ID] = existing.ID
|
||||
rep.Reused++
|
||||
continue
|
||||
}
|
||||
rep.Created++
|
||||
rep.Names = append(rep.Names, name)
|
||||
if opts.DryRun {
|
||||
// 预检不写库;占位映射(0 不会用于真实写入)让帖子预检仍按「将创建」计数
|
||||
m[b.ID] = 0
|
||||
continue
|
||||
}
|
||||
board := model.Board{
|
||||
Name: name,
|
||||
Description: b.Description,
|
||||
Icon: b.Icon,
|
||||
ColorIndex: b.ColorIndex,
|
||||
SortOrder: b.SortOrder,
|
||||
}
|
||||
if err := s.db.Create(&board).Error; err != nil {
|
||||
continue // 创建失败:对应帖子进 failed
|
||||
}
|
||||
s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "board", SourceID: strconv.FormatUint(uint64(b.ID), 10), NewID: board.ID})
|
||||
m[b.ID] = board.ID
|
||||
}
|
||||
return m, rep
|
||||
}
|
||||
|
||||
// ===== 帖子 / 评论 =====
|
||||
|
||||
// mapLegacyUsers 旧用户ID → 本站用户ID。先按用户名映射(含软删本站用户),
|
||||
// 找不到回退到 operatorID(站长,由调用方处理并记录明细)。
|
||||
func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
|
||||
type oldRef struct {
|
||||
ID uint
|
||||
Username string
|
||||
}
|
||||
var olds []oldRef
|
||||
// 含软删旧用户:其内容仍需归属。
|
||||
// oldRef 是包内匿名结构体,必须显式指定表名,否则 GORM 会推断成 old_refs
|
||||
if err := oldDB.Unscoped().Table("users").Select("id", "username").Find(&olds).Error; err != nil {
|
||||
return map[uint]uint{}
|
||||
}
|
||||
var news []model.User
|
||||
// Unscoped:软删本站账号也参与映射(内容归属不变)
|
||||
if err := s.db.Unscoped().Select("id", "username").Find(&news).Error; err != nil {
|
||||
return map[uint]uint{}
|
||||
}
|
||||
byName := make(map[string]uint, len(news))
|
||||
for _, u := range news {
|
||||
byName[strings.ToLower(u.Username)] = u.ID
|
||||
}
|
||||
m := make(map[uint]uint, len(olds))
|
||||
for _, o := range olds {
|
||||
name := strings.ToLower(strings.TrimSpace(o.Username))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if id, ok := byName[name]; ok {
|
||||
m[o.ID] = id
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
|
||||
out := &LegacyPostReport{}
|
||||
if boardMap == nil || len(boardMap) == 0 {
|
||||
out.Failed = append(out.Failed, "无可用板块映射,帖子未导入")
|
||||
return out
|
||||
}
|
||||
var rows []legacyPostRow
|
||||
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
|
||||
out.Failed = append(out.Failed, "读取 posts 表失败: "+err.Error())
|
||||
return out
|
||||
}
|
||||
out.Total = len(rows)
|
||||
|
||||
userMap := s.mapLegacyUsers(oldDB)
|
||||
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
|
||||
convFailed := 0
|
||||
|
||||
for _, p := range rows {
|
||||
title := strings.TrimSpace(p.Title)
|
||||
if title == "" {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
|
||||
continue
|
||||
}
|
||||
if p.PostType == "poll" {
|
||||
out.PollsSkipped++
|
||||
out.PollTitles = append(out.PollTitles, title)
|
||||
continue
|
||||
}
|
||||
if p.Status != "published" {
|
||||
out.Skipped++
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (旧状态 %s,跳过)", title, p.Status))
|
||||
continue
|
||||
}
|
||||
boardID, ok := boardMap[p.BoardID]
|
||||
if !ok {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (旧板块 #%d 无法映射)", title, p.BoardID))
|
||||
continue
|
||||
}
|
||||
// 幂等:去重记录已存在即跳过
|
||||
if !opts.DryRun {
|
||||
var n int64
|
||||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(p.ID), 10)).Count(&n)
|
||||
if n > 0 {
|
||||
out.Skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 作者映射,缺失归站长
|
||||
authorID := operatorID
|
||||
if id, ok := userMap[p.UserID]; ok {
|
||||
authorID = id
|
||||
} else if !missingAuthors[p.UserID] {
|
||||
missingAuthors[p.UserID] = true
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID))
|
||||
}
|
||||
|
||||
// HTML → Markdown;失败保留原文(正文不丢)
|
||||
content := p.Content
|
||||
if strings.Contains(strings.ToLower(content), "<") {
|
||||
if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" {
|
||||
content = md
|
||||
} else {
|
||||
convFailed++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
out.Imported++
|
||||
continue
|
||||
}
|
||||
post := model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: authorID,
|
||||
Title: truncateRunesN(title, 256),
|
||||
Content: content,
|
||||
Tags: p.Tags,
|
||||
PostType: model.NormalizePostType(p.PostType), // question→question,normal→discussion
|
||||
Pinned: p.Pinned,
|
||||
Recommended: p.Featured,
|
||||
Status: model.ContentStatusPublished,
|
||||
LikeCount: p.LikeCount,
|
||||
ViewCount: p.ViewCount,
|
||||
CreatedAt: p.CreatedAt,
|
||||
UpdatedAt: p.UpdatedAt,
|
||||
}
|
||||
if err := s.db.Create(&post).Error; err != nil {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("%s (写入失败: %v)", title, err))
|
||||
continue
|
||||
}
|
||||
s.db.Create(&model.ImportRecord{Source: "jiang13", Kind: "post", SourceID: strconv.FormatUint(uint64(p.ID), 10), NewID: post.ID})
|
||||
out.Imported++
|
||||
}
|
||||
if convFailed > 0 {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed))
|
||||
}
|
||||
out.OwnerFallback = capList(out.OwnerFallback)
|
||||
out.Failed = capList(out.Failed)
|
||||
out.PollTitles = capList(out.PollTitles)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyCommentReport {
|
||||
out := &LegacyCommentReport{}
|
||||
var rows []legacyCommentRow
|
||||
if err := oldDB.Order("post_id ASC, id ASC").Find(&rows).Error; err != nil {
|
||||
out.Failed = append(out.Failed, "读取 comments 表失败: "+err.Error())
|
||||
return out
|
||||
}
|
||||
out.Total = len(rows)
|
||||
|
||||
var posts []legacyPostRow
|
||||
if err := oldDB.Unscoped().Select("id", "title").Find(&posts).Error; err != nil {
|
||||
posts = nil
|
||||
}
|
||||
postTitles := map[uint]string{}
|
||||
for _, p := range posts {
|
||||
postTitles[p.ID] = p.Title
|
||||
}
|
||||
|
||||
userMap := s.mapLegacyUsers(oldDB)
|
||||
missingAuthors := map[uint]bool{}
|
||||
convFailed := 0
|
||||
|
||||
for _, cm := range rows {
|
||||
if cm.Status != "published" {
|
||||
out.Skipped++
|
||||
out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status))
|
||||
continue
|
||||
}
|
||||
// 幂等:去重记录已存在即跳过
|
||||
if !opts.DryRun {
|
||||
var n int64
|
||||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "comment", strconv.FormatUint(uint64(cm.ID), 10)).Count(&n)
|
||||
if n > 0 {
|
||||
out.Skipped++
|
||||
continue
|
||||
}
|
||||
// 所属帖子必须已导入(否则评论无落点)
|
||||
var pn int64
|
||||
s.db.Model(&model.ImportRecord{}).Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).Count(&pn)
|
||||
if pn == 0 {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (所属帖子未导入)", postTitles[cm.PostID], cm.ID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
authorID := operatorID
|
||||
if id, ok := userMap[cm.UserID]; ok {
|
||||
authorID = id
|
||||
} else if !missingAuthors[cm.UserID] {
|
||||
missingAuthors[cm.UserID] = true
|
||||
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID))
|
||||
}
|
||||
|
||||
// HTML → Markdown;失败保留原文(内容不丢)
|
||||
content := cm.Content
|
||||
if strings.Contains(strings.ToLower(content), "<") {
|
||||
if md, err := s.md.ConvertString(content); err == nil && strings.TrimSpace(md) != "" {
|
||||
content = md
|
||||
} else {
|
||||
convFailed++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.DryRun {
|
||||
out.Imported++
|
||||
continue
|
||||
}
|
||||
if err := s.importCommentOne(cm, authorID, content); err != nil {
|
||||
out.Failed = append(out.Failed, fmt.Sprintf("《%s》#%d (写入失败: %v)", postTitles[cm.PostID], cm.ID, err))
|
||||
continue
|
||||
}
|
||||
out.Imported++
|
||||
}
|
||||
if convFailed > 0 {
|
||||
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed))
|
||||
}
|
||||
out.SkippedDetail = capList(out.SkippedDetail)
|
||||
out.OwnerFallback = capList(out.OwnerFallback)
|
||||
out.Failed = capList(out.Failed)
|
||||
return out
|
||||
}
|
||||
|
||||
// importCommentOne 写入单条评论并处理回复链挂接与去重记录。
|
||||
// 源 ID 顺序保证父评论先于子评论处理;reply_to 指向的父评论通过去重记录取回新 ID。
|
||||
func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uint, content string) error {
|
||||
// 源帖子 → 新帖子 ID
|
||||
var postRec model.ImportRecord
|
||||
if err := s.db.Where("source = ? AND kind = ? AND source_id = ?", "jiang13", "post", strconv.FormatUint(uint64(cm.PostID), 10)).First(&postRec).Error; err != nil {
|
||||
return errors.New("找不到源帖子记录")
|
||||
}
|
||||
|
||||
comment := model.Comment{
|
||||
PostID: postRec.NewID,
|
||||
UserID: authorID,
|
||||
Content: content,
|
||||
Status: model.ContentStatusPublished,
|
||||
CreatedAt: cm.CreatedAt,
|
||||
UpdatedAt: cm.UpdatedAt,
|
||||
}
|
||||
|
||||
// 回复挂接:reply_to 指向同帖已有评论;找不到父评论(被删/跨帖)→ 落为主评论
|
||||
if cm.ReplyTo != nil && *cm.ReplyTo > 0 {
|
||||
var parent model.Comment
|
||||
err := s.db.Where("comments.post_id = ?", postRec.NewID).
|
||||
Joins("JOIN import_records ir ON ir.kind = 'comment' AND ir.new_id = comments.id").
|
||||
Where("ir.source = ? AND ir.source_id = ?", "jiang13", strconv.FormatUint(uint64(*cm.ReplyTo), 10)).
|
||||
First(&parent).Error
|
||||
if err == nil {
|
||||
comment.ParentID = &parent.ID
|
||||
comment.Depth = parent.Depth + 1
|
||||
if parent.RootID != nil {
|
||||
root := *parent.RootID
|
||||
comment.RootID = &root
|
||||
} else {
|
||||
comment.RootID = &parent.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 评论 / 计数 / 去重记录同一事务,避免重试导致重复评论
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 帖子评论计数 +1(UpdateColumn 不触碰 updated_at)
|
||||
if err := tx.Model(&model.Post{}).Where("id = ?", postRec.NewID).
|
||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&model.ImportRecord{
|
||||
Source: "jiang13", Kind: "comment",
|
||||
SourceID: strconv.FormatUint(uint64(cm.ID), 10), NewID: comment.ID,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 工具 =====
|
||||
|
||||
// capList 明细条数截断,超出部分以「…等 N 条」收尾
|
||||
func capList(list []string) []string {
|
||||
if len(list) <= legacyDetailLimit {
|
||||
return list
|
||||
}
|
||||
out := append([]string{}, list[:legacyDetailLimit]...)
|
||||
out = append(out, fmt.Sprintf("…等共 %d 条", len(list)))
|
||||
return out
|
||||
}
|
||||
|
||||
// validateSQLiteMagic 校验文件以 SQLite 3 魔数开头,避免把任意文件喂给解析器
|
||||
func validateSQLiteMagic(p string) error {
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
magic := make([]byte, 16)
|
||||
if _, err := io.ReadFull(f, magic); err != nil {
|
||||
return ErrLegacyInvalidSQLite
|
||||
}
|
||||
if string(magic) != "SQLite format 3\x00" {
|
||||
return ErrLegacyInvalidSQLite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// truncateRunesN 按 rune 数截断字符串到 max
|
||||
func truncateRunesN(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
@@ -577,3 +577,16 @@ func randomID() string {
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randomCode 生成 6 位数字邮箱验证码,供用户阅读输入。
|
||||
func randomCode() string {
|
||||
const n = 6
|
||||
b := make([]byte, n)
|
||||
if _, e := io.ReadFull(rand.Reader, b); e != nil {
|
||||
panic(e)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = '0' + b[i]%10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
@@ -125,6 +126,13 @@ func MatchFilter(c FilterConfig, scope, input string) FilterResult {
|
||||
return result
|
||||
}
|
||||
func (o *Operations) Filter(scope, input string, actor uint) error {
|
||||
if actor > 0 {
|
||||
var role model.Role
|
||||
if err := o.db.Model(&model.User{}).Where("id = ?", actor).
|
||||
Select("role").Scan(&role).Error; err == nil && model.IsStaff(role) {
|
||||
return nil // 管理角色不受内容过滤限制
|
||||
}
|
||||
}
|
||||
v, _, e := o.read(o.db, "filter")
|
||||
if e != nil {
|
||||
return errors.New("内容过滤暂不可用,请稍后重试")
|
||||
|
||||
@@ -392,7 +392,7 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
code := randomID()
|
||||
code := randomCode()
|
||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||
if e := tx.Create(&model.EmailChallenge{Hash: hash, Email: email, Purpose: purpose, ExpiresAt: time.Now().Add(15 * time.Minute)}).Error; e != nil {
|
||||
@@ -402,17 +402,60 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
||||
})
|
||||
return 0, e
|
||||
}
|
||||
const (
|
||||
codeVerifyMaxAttempts = 5
|
||||
codeVerifyWindow = 15 * 60 // 秒,与验证码有效期一致
|
||||
)
|
||||
|
||||
// CodeThrottleError 表示验证码校验失败次数超限,携带需等待的秒数。
|
||||
type CodeThrottleError struct{ Wait int }
|
||||
|
||||
func (e *CodeThrottleError) Error() string {
|
||||
return fmt.Sprintf("验证码错误次数过多,请 %d 秒后重试", e.Wait)
|
||||
}
|
||||
|
||||
func (o *Operations) ConsumeCode(email, purpose, code string) error {
|
||||
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(strings.ToLower(strings.TrimSpace(email))+":"+purpose+":"+code), time.Now()).Update("used", true)
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
failKey := "verifyfail:" + purpose + ":" + email
|
||||
|
||||
// 失败次数限流:窗口内错误次数超限则拒绝,防止 6 位数字码被暴力枚举。
|
||||
if wait, e := o.verifyFailWait(failKey); e != nil {
|
||||
return e
|
||||
} else if wait > 0 {
|
||||
return &CodeThrottleError{Wait: wait}
|
||||
}
|
||||
|
||||
r := o.db.Model(&model.EmailChallenge{}).Where("hash = ? AND used = false AND expires_at > ?", counterKey(email+":"+purpose+":"+code), time.Now()).Update("used", true)
|
||||
if r.Error != nil {
|
||||
return r.Error
|
||||
}
|
||||
if r.RowsAffected != 1 {
|
||||
if _, e := o.Quota(failKey, codeVerifyMaxAttempts, codeVerifyWindow); e != nil {
|
||||
return e
|
||||
}
|
||||
return fmt.Errorf("验证码无效或已过期")
|
||||
}
|
||||
// 校验成功,清除失败计数。
|
||||
o.db.Delete(&model.ActionCounter{}, "key = ?", counterKey(failKey))
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifyFailWait 读取当前失败计数,若已达上限返回需等待的秒数,不修改计数。
|
||||
func (o *Operations) verifyFailWait(key string) (int, error) {
|
||||
var c model.ActionCounter
|
||||
e := o.db.First(&c, "key = ?", counterKey(key)).Error
|
||||
if e != nil {
|
||||
if errors.Is(e, gorm.ErrRecordNotFound) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, e
|
||||
}
|
||||
if time.Now().Before(c.ExpiresAt) && c.Count >= codeVerifyMaxAttempts {
|
||||
return int(time.Until(c.ExpiresAt).Seconds()) + 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Probe merged drafts so partial API updates cannot bypass activation validation.
|
||||
func (o *Operations) ProbeBeforeSave(ctx context.Context, name string, raw json.RawMessage, clear []string) error {
|
||||
v, _, _, e := o.draft(o.db, name, raw, clear)
|
||||
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
type PostService struct {
|
||||
db *gorm.DB
|
||||
setting *SettingService
|
||||
board *BoardService
|
||||
devMode bool
|
||||
}
|
||||
|
||||
@@ -36,6 +37,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService {
|
||||
return s
|
||||
}
|
||||
|
||||
// WithBoard 注入板块服务(发帖策略校验用);可链式调用
|
||||
func (s *PostService) WithBoard(board *BoardService) *PostService {
|
||||
s.board = board
|
||||
return s
|
||||
}
|
||||
|
||||
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
|
||||
func (s *PostService) WithDevMode(dev bool) *PostService {
|
||||
s.devMode = dev
|
||||
@@ -744,11 +751,12 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
typeMeta, _ = encodeMeta(qm)
|
||||
}
|
||||
|
||||
// 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限)
|
||||
// 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限);管理角色豁免防刷
|
||||
var bountyPts int
|
||||
if postType == model.PostTypeBounty {
|
||||
bm, _ := parseBountyMeta(typeMeta)
|
||||
bountyPts = bm.Points
|
||||
if !s.isStaffUser(in.UserID) {
|
||||
var bountyPosts []model.Post
|
||||
s.db.Select("type_meta").
|
||||
Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty).
|
||||
@@ -774,6 +782,7 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
if todayN >= 5 {
|
||||
return nil, errors.New("今日悬赏发帖已达上限(5)")
|
||||
}
|
||||
}
|
||||
bm.Escrowed = true
|
||||
bm.Refunded = false
|
||||
bm.Expired = false
|
||||
@@ -1051,7 +1060,7 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭)
|
||||
// checkNewUserCooldown 新用户发帖冷静期(小时数取自站点设置,0=关闭);管理角色豁免
|
||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
hours := DefaultCooldownHours
|
||||
if s.setting != nil {
|
||||
@@ -1066,12 +1075,25 @@ func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if model.IsStaff(user.Role) { // 管理角色不受发帖冷静期限制
|
||||
return nil
|
||||
}
|
||||
if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour {
|
||||
return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isStaffUser 作者是否管理角色(悬赏防刷等限制豁免用)
|
||||
func (s *PostService) isStaffUser(userID uint) bool {
|
||||
var role model.Role
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).
|
||||
Select("role").Scan(&role).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return model.IsStaff(role)
|
||||
}
|
||||
|
||||
// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值
|
||||
type DeletePostOpts struct {
|
||||
DeleteType string
|
||||
|
||||
@@ -828,7 +828,11 @@ func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error)
|
||||
Group("c.user_id")
|
||||
if hours > 0 {
|
||||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||||
q = q.Where("u.created_at <= ?", cutoff)
|
||||
// 管理角色不受新号冷静期限制
|
||||
q = q.Where("u.created_at <= ? OR u.role IN ?", cutoff, []string{
|
||||
string(model.RoleBoardAdmin), string(model.RoleAdmin),
|
||||
string(model.RoleSuperAdmin), string(model.RoleOwner),
|
||||
})
|
||||
}
|
||||
var ids []uint
|
||||
if err := q.Pluck("c.user_id", &ids).Error; err != nil {
|
||||
|
||||
@@ -17,6 +17,8 @@ const (
|
||||
SettingKeyAccent = "theme_accent"
|
||||
// SettingKeyTrustReviewedPublish 已过审用户后续发帖/评论免审;缺行视为开启(默认开)
|
||||
SettingKeyTrustReviewedPublish = "trust_reviewed_publish"
|
||||
// SettingKeyPublishWithoutReview 全站免审:所有人发帖/评论直接公开,无需过审记录;缺行视为关闭(默认关)
|
||||
SettingKeyPublishWithoutReview = "publish_without_review"
|
||||
SettingKeySiteName = "site_name"
|
||||
SettingKeySiteDescription = "site_description"
|
||||
SettingKeyAllowRegister = "allow_register"
|
||||
@@ -99,6 +101,7 @@ var accentHexRe = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
type PublicSiteSettings struct {
|
||||
Accent string `json:"accent"`
|
||||
TrustReviewedPublish bool `json:"trust_reviewed_publish"`
|
||||
PublishWithoutReview bool `json:"publish_without_review"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteDescription string `json:"site_description"`
|
||||
AllowRegister bool `json:"allow_register"`
|
||||
@@ -210,6 +213,7 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
SiteName: DefaultSiteName,
|
||||
SiteDescription: DefaultSiteDescription,
|
||||
TrustReviewedPublish: true,
|
||||
PublishWithoutReview: false,
|
||||
AllowRegister: true,
|
||||
AllowComments: true,
|
||||
CommentsRequireLogin: false,
|
||||
@@ -247,6 +251,12 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
||||
}
|
||||
out.TrustReviewedPublish = trust
|
||||
|
||||
withoutReview, err := s.PublishWithoutReview()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.PublishWithoutReview = withoutReview
|
||||
|
||||
name, found, err := s.getValue(SettingKeySiteName)
|
||||
if err != nil {
|
||||
return out, err
|
||||
@@ -429,6 +439,20 @@ func (s *SettingService) SetTrustReviewedPublish(on bool) error {
|
||||
return s.setBoolDefaultTrue(SettingKeyTrustReviewedPublish, on)
|
||||
}
|
||||
|
||||
// PublishWithoutReview 全站免审:所有人发布内容直接公开(优先于过审免审)。缺行视为关闭。
|
||||
func (s *SettingService) PublishWithoutReview() (bool, error) {
|
||||
v, found, err := s.getValue(SettingKeyPublishWithoutReview)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return parseBoolDefaultFalse(v, found), nil
|
||||
}
|
||||
|
||||
// SetPublishWithoutReview 持久化全站免审开关;关闭时删键保持「缺行=默认关」。
|
||||
func (s *SettingService) SetPublishWithoutReview(on bool) error {
|
||||
return s.setBoolDefaultFalse(SettingKeyPublishWithoutReview, on)
|
||||
}
|
||||
|
||||
func (s *SettingService) SetSiteName(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || name == DefaultSiteName {
|
||||
|
||||
146
backend/service/staff_exempt_test.go
Normal file
146
backend/service/staff_exempt_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/config"
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// staffTestDB 独立 schema 隔离的测试库(同 testOps 模式;未配置 DSN 则跳过)
|
||||
func staffTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("OPS_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set OPS_TEST_DATABASE_URL to an isolated PostgreSQL database")
|
||||
}
|
||||
u, e := url.Parse(dsn)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
db, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
schema := "staff_exempt_" + fmt.Sprint(time.Now().UnixNano())
|
||||
if e = db.Exec("CREATE SCHEMA " + schema).Error; e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("search_path", schema)
|
||||
u.RawQuery = q.Encode()
|
||||
scoped, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
sql, _ := scoped.DB()
|
||||
_ = sql.Close()
|
||||
db.Exec("DROP SCHEMA " + schema + " CASCADE")
|
||||
sql, _ = db.DB()
|
||||
_ = sql.Close()
|
||||
})
|
||||
if e = scoped.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.SiteSetting{},
|
||||
&model.PostLotteryEntry{}, &model.ModuleConfig{}, &model.SettingsAudit{}); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return scoped
|
||||
}
|
||||
|
||||
func mustUser(t *testing.T, db *gorm.DB, username string, role model.Role, createdAt time.Time) model.User {
|
||||
t.Helper()
|
||||
u := model.User{Username: username, Password: "x", Role: role, CreatedAt: createdAt, UpdatedAt: createdAt}
|
||||
if err := db.Create(&u).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func TestCheckNewUserCooldownStaffExempt(t *testing.T) {
|
||||
db := staffTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
plain := mustUser(t, db, "plain", model.RoleUser, now)
|
||||
owner := mustUser(t, db, "owner", model.RoleOwner, now)
|
||||
badmin := mustUser(t, db, "badmin", model.RoleBoardAdmin, now)
|
||||
|
||||
ps := NewPostService(db).WithSetting(NewSettingService(db))
|
||||
if err := ps.checkNewUserCooldown(plain.ID); err == nil {
|
||||
t.Fatal("new plain user should be blocked by cooldown")
|
||||
}
|
||||
for _, u := range []model.User{owner, badmin} {
|
||||
if err := ps.checkNewUserCooldown(u.ID); err != nil {
|
||||
t.Fatalf("staff role %s should bypass cooldown: %v", u.Role, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterStaffExempt(t *testing.T) {
|
||||
db := staffTestDB(t)
|
||||
b, _ := json.Marshal(FilterConfig{Enabled: true, Rules: []FilterRule{{
|
||||
ID: "r1", Word: "bad", Scopes: []string{"body"}, Action: "block", Enabled: true,
|
||||
}}})
|
||||
if err := db.Create(&model.ModuleConfig{Name: "filter", Version: 1, Data: string(b)}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain := mustUser(t, db, "plain", model.RoleUser, time.Now().UTC())
|
||||
owner := mustUser(t, db, "owner", model.RoleOwner, time.Now().UTC())
|
||||
|
||||
o := NewOperations(db, &config.Config{DevMode: true})
|
||||
if err := o.Filter("body", "a bad word", plain.ID); err == nil {
|
||||
t.Fatal("plain user should be blocked by filter")
|
||||
}
|
||||
if err := o.Filter("body", "a bad word", owner.ID); err != nil {
|
||||
t.Fatalf("owner should bypass filter: %v", err)
|
||||
}
|
||||
if err := o.Filter("body", "a bad word", 0); err == nil {
|
||||
t.Fatal("anonymous should be blocked by filter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLotteryEligibleStaffBypassesCooldown(t *testing.T) {
|
||||
db := staffTestDB(t)
|
||||
now := time.Now().UTC()
|
||||
author := mustUser(t, db, "author", model.RoleUser, now)
|
||||
plain := mustUser(t, db, "plain", model.RoleUser, now)
|
||||
owner := mustUser(t, db, "owner", model.RoleOwner, now)
|
||||
|
||||
board := model.Board{Name: "test"}
|
||||
if err := db.Create(&board).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
post := model.Post{BoardID: board.ID, UserID: author.ID, Title: "lottery", Content: "draw",
|
||||
PostType: model.PostTypeLottery, Status: model.ContentStatusPublished}
|
||||
if err := db.Create(&post).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, uid := range []uint{plain.ID, owner.ID} {
|
||||
c := model.Comment{PostID: post.ID, UserID: uid, Content: "join", Status: model.ContentStatusPublished}
|
||||
if err := db.Create(&c).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
ps := NewPostService(db).WithSetting(NewSettingService(db))
|
||||
ids, err := ps.lotteryEligibleIDs(post.ID, author.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[uint]bool{}
|
||||
for _, id := range ids {
|
||||
got[id] = true
|
||||
}
|
||||
if got[plain.ID] {
|
||||
t.Fatal("new plain user should be excluded from lottery pool")
|
||||
}
|
||||
if !got[owner.ID] {
|
||||
t.Fatal("staff new account should stay in lottery pool")
|
||||
}
|
||||
}
|
||||
66
docs/about.md
Normal file
66
docs/about.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# 姜十三论坛 介绍
|
||||
|
||||
## 这是什么
|
||||
|
||||
**姜十三论坛(jiang13-bbs)** 是一个现代化的社区论坛系统,致力于提供流畅、安全、高质量的在线讨论体验。它面向需要自建社区的个人与团队,开箱即用,同时具备完整的内容、互动、通知与管理能力。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 📝 内容创作与讨论
|
||||
- **发帖 / 编辑 / 删除**:支持普通帖与问答帖两种类型,作者与管理员均可管理。
|
||||
- **Markdown 渲染**:完整支持 Markdown 语法,包含代码高亮、引用、列表等,技术内容排版美观。
|
||||
- **楼层化评论**:主评论占楼层、子评论不占楼层,最多 10 层嵌套,讨论层次分明。
|
||||
- **点赞互动**:帖子点赞带防重复机制,让优质内容被看见。
|
||||
- **置顶与推荐**:管理员可对帖子进行置顶、推荐,引导社区焦点。
|
||||
|
||||
### 🔔 实时通知
|
||||
- 评论、回复、点赞等互动实时推送,未读红点随页面首屏直出。
|
||||
- 基于 WebSocket 的实时通道,回前台自动校准,消息不遗漏。
|
||||
- 支持单条 / 全部标记已读。
|
||||
|
||||
### 👤 会员体系
|
||||
- 注册即登录,免二次跳转。
|
||||
- 个人资料:昵称、头像、邮箱、个性签名自由编辑。
|
||||
- 每日签到获取积分,连续签到有加成。
|
||||
- 个人主页聚合你的发帖、评论与签到记录。
|
||||
|
||||
### 🛠️ 管理后台
|
||||
- 仪表盘总览社区数据。
|
||||
- 公告管理:支持草稿与发布,标签预设色,重要信息精准触达。
|
||||
- 用户、内容、设置等全方位管理能力。
|
||||
|
||||
### 🔍 SEO 友好
|
||||
- 全站 SSR / ISR,搜索引擎可见的关键内容都在初始 HTML 中。
|
||||
- 帖子详情输出结构化数据(JSON-LD),利于搜索收录与展示。
|
||||
- 自动生成 sitemap 与 robots。
|
||||
|
||||
### 🔒 安全可靠
|
||||
- HttpOnly Cookie + Refresh Token 一次性轮转认证,令牌不落地浏览器存储。
|
||||
- CSRF 双提交防护、速率限制(登录 / 注册 / 发帖冷静期)。
|
||||
- 完备的安全响应头(CSP / HSTS 等)。
|
||||
- 生产环境强制 HTTPS,使用 `__Host-` 前缀 Cookie。
|
||||
|
||||
## 技术架构
|
||||
|
||||
| 层级 | 技术选型 |
|
||||
|------|----------|
|
||||
| 后端 | Go 1.27 · Gin · GORM · PostgreSQL 17 |
|
||||
| 前端 | Next.js 16 (App Router) · TypeScript · Tailwind CSS v4 |
|
||||
| 认证 | JWT(HttpOnly Cookie)+ Refresh Token 轮转 + CSRF 双提交 + Token Version 撤销 |
|
||||
| 实时 | WebSocket |
|
||||
| 部署 | Docker(Next SSR + Go + Postgres,反代自备) |
|
||||
|
||||
系统采用前后端分离架构,前端负责服务端渲染与交互,后端负责业务逻辑与数据,二者职责清晰、可独立伸缩。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 个人 / 团队自建社区、技术论坛
|
||||
- 产品用户交流与反馈社区
|
||||
- 兴趣小组、垂直领域讨论站
|
||||
- 需要高质量 SEO 与安全性的内容社区
|
||||
|
||||
## 许可与联系方式
|
||||
|
||||
本项目为**专有软件**,源码可见但不构成开源授权,未经书面许可不得使用、部署或分发。
|
||||
|
||||
联系方式:aarbbs@88.com
|
||||
37
docs/announcement.md
Normal file
37
docs/announcement.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# 姜十三论坛正式上线公告
|
||||
|
||||
各位社区成员:
|
||||
|
||||
经过一段时间的开发与打磨,**姜十三论坛**今天正式与大家见面了!🎉
|
||||
|
||||
这里是一个全新的社区交流空间,我们希望它能成为一个内容优质、氛围友好、体验流畅的讨论家园。无论你是来分享技术、交流想法,还是结识同好,都欢迎在这里扎根。
|
||||
|
||||
## 本站亮点
|
||||
|
||||
- **极致的阅读体验**:全站服务端渲染(SSR),首屏秒开,关键内容对搜索引擎友好。
|
||||
- **Markdown 原生支持**:发帖、评论全面支持 Markdown,代码高亮、引用、列表一应俱全。
|
||||
- **楼层化评论**:主评论按楼层排列,子评论最多嵌套 10 层,讨论结构清晰不混乱。
|
||||
- **实时通知**:评论、回复、点赞第一时间通过站内通知提醒你,不错过任何互动。
|
||||
- **每日签到**:每天签到领积分,连续签到还有额外加成,记录你与社区的陪伴。
|
||||
- **个人主页**:你的发帖、评论、签到积分一目了然,打造属于你的社区名片。
|
||||
- **问答帖子**:遇到问题可以发问答帖,获得解答后标记「采纳」,让好答案沉淀下来。
|
||||
|
||||
## 给新成员的几句话
|
||||
|
||||
1. **第一个注册的账号将自动成为站长**,欢迎你接手管理、建设这个社区。
|
||||
2. 发帖前建议先浏览各板块,遵守社区氛围,友善讨论、理性表达。
|
||||
3. 遇到任何问题或有建议,欢迎在反馈板块留言,或直接联系管理员。
|
||||
|
||||
## 关于安全与隐私
|
||||
|
||||
- 全站采用 HttpOnly Cookie + Refresh Token 轮转认证,不把令牌存进浏览器本地存储,更安全。
|
||||
- 密码修改、账号异常等操作会及时让旧登录失效,保护你的账号安全。
|
||||
- 生产环境强制 HTTPS,登录凭据加密传输。
|
||||
|
||||
---
|
||||
|
||||
社区的好坏,取决于每一位成员。期待与你一起,把姜十三论坛建设成一个有价值、有温度的地方。
|
||||
|
||||
如有问题或建议,可通过邮件联系:aarbbs@88.com
|
||||
|
||||
—— 姜十三论坛 团队
|
||||
183
docs/deploy-1panel.md
Normal file
183
docs/deploy-1panel.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 姜十三论坛(jiang13-bbs)Docker 部署教程
|
||||
|
||||
> 前置条件:应用镜像(`jiang13-api`、`jiang13-web`)已推送到你的私有镜像仓库。本教程只讲如何在服务器上用 1Panel 部署、反代与运维。
|
||||
>
|
||||
> 项目架构:Next.js 前端(web)+ Go 后端(api)+ PostgreSQL 17,三者为独立容器。
|
||||
|
||||
---
|
||||
|
||||
## 一、镜像说明
|
||||
|
||||
| 服务 | 镜像 | 来源 |
|
||||
|---|---|---|
|
||||
| api | `<你的用户名>/jiang13-api` | 你的私有仓库 |
|
||||
| web | `<你的用户名>/jiang13-web` | 你的私有仓库 |
|
||||
| postgres | `postgres:17-alpine` | Docker Hub 官方公共镜像,直接拉取 |
|
||||
|
||||
> 安全说明:api / web 镜像为多阶段构建产物,**最终镜像不含源码**,密钥运行时注入,不烤进镜像。部署前请确认这两个仓库在 Docker Hub 上已设为 **Private**。
|
||||
|
||||
---
|
||||
|
||||
## 二、1Panel 部署
|
||||
|
||||
### 1. 配置私有仓库认证(关键)
|
||||
私有镜像必须先认证,否则拉取报 `denied`。
|
||||
|
||||
1Panel 左侧 **容器 → 「仓库」标签 → 创建**:
|
||||
- 仓库地址:`docker.io`
|
||||
- 用户名:你的 Docker Hub 用户名
|
||||
- 密码:Docker Hub 密码或 Access Token(推荐 Token)
|
||||
|
||||
### 2. 用编排一键部署
|
||||
**容器 → 「编排」标签 → 创建编排**,名称 `jiang13`,粘贴以下内容:
|
||||
|
||||
```yaml
|
||||
name: jiang13
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: jiang13
|
||||
POSTGRES_PASSWORD: 改成你的强密码
|
||||
POSTGRES_DB: jiang13
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U jiang13 -d jiang13"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
image: <你的用户名>/jiang13-api:0.1.0
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
HTTP_PORT: "3001"
|
||||
DEV_MODE: "false"
|
||||
JIANG13_WORK_PATH: /var/lib/jiang13
|
||||
DATA_DIR: /var/lib/jiang13/data
|
||||
DB_DSN: postgres://jiang13:改成上面同一个密码@postgres:5432/jiang13?sslmode=disable
|
||||
JWT_SECRET: ""
|
||||
SITE_URL: https://改成你的域名
|
||||
SETTINGS_MASTER_KEY: ""
|
||||
volumes:
|
||||
- appdata:/var/lib/jiang13/data
|
||||
|
||||
web:
|
||||
image: <你的用户名>/jiang13-web:0.1.0
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BACKEND_URL: http://api:3001
|
||||
SITE_URL: https://改成你的域名
|
||||
NODE_ENV: production
|
||||
DEV_MODE: "false"
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
ports:
|
||||
- "13000:3000"
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
appdata:
|
||||
```
|
||||
|
||||
**只需改 3 处**:
|
||||
1. `POSTGRES_PASSWORD` 的值
|
||||
2. `DB_DSN` 里的密码(**必须与第 1 处完全一致**)
|
||||
3. 两处 `SITE_URL` 的域名
|
||||
|
||||
保存后,在编排列表点 **▶ 启动**。会自动:拉镜像 → 起 postgres → 健康后起 api → 健康后起 web。
|
||||
|
||||
> 容器名固定为 `postgres` / `api`,因为服务间通过容器名互访(`@postgres:5432`、`http://api:3001`)。
|
||||
|
||||
### 3. 验证启动
|
||||
「容器」标签确认三个容器状态:
|
||||
- postgres Up (healthy)
|
||||
- api Up (healthy) ← 健康才说明连库成功
|
||||
- web Up
|
||||
|
||||
接口检查:
|
||||
```bash
|
||||
curl http://127.0.0.1:13000/healthz # web,返回 ok
|
||||
docker exec jiang13-api-1 wget -qO- http://127.0.0.1:3001/health # api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Nginx 反向代理 + HTTPS
|
||||
|
||||
`DEV_MODE=false` 启用了 `__Host-` 前缀 cookie,**必须 HTTPS**。
|
||||
|
||||
1Panel **网站 → 创建网站 → 反向代理**:
|
||||
- 目标地址:`http://127.0.0.1:13000`
|
||||
- 申请并开启 SSL(1Panel 可一键 Let's Encrypt)
|
||||
- 域名 DNS 解析到服务器
|
||||
|
||||
访问 `https://你的域名`,空库下**第一个注册的账号自动成为站长(管理员)**。
|
||||
|
||||
---
|
||||
|
||||
## 四、配置说明
|
||||
|
||||
### 环境变量
|
||||
| 变量 | 说明 | 建议 |
|
||||
|---|---|---|
|
||||
| `POSTGRES_PASSWORD` | 数据库密码 | 强密码,与 DB_DSN 一致 |
|
||||
| `DB_DSN` | 后端连库串 | 密码须与上者一致 |
|
||||
| `JWT_SECRET` | 登录 token 签名密钥 | **留空**,自动生成并持久化到 data/.jwt_secret;自填须 ≥32 字符强随机 |
|
||||
| `SETTINGS_MASTER_KEY` | 加密邮件/S3 凭据(32字节Base64) | **留空**=禁用保存凭据功能;需要时用 `openssl rand -base64 32` 生成 |
|
||||
| `SITE_URL` | 对外公开 origin | 前后端一致,与浏览器地址栏完全相同(含 https、无尾斜杠)。运行时读取,可后改重启生效 |
|
||||
|
||||
> `JWT_SECRET` 与 `SETTINGS_MASTER_KEY` **不要填同一个值**(密钥复用是安全大忌,且两者格式/生命周期不同)。
|
||||
|
||||
### 端口被占用怎么办
|
||||
- 容器内端口(3000/3001)写死不能改;只改映射的**宿主机端口**(冒号左边),如 `"13000:3000"`。
|
||||
- 用 Nginx 反代时 **api 无需暴露端口**(web 通过容器名内部访问)。
|
||||
- 查占用:`ss -tlnp | grep -E ':3000|:3001'`
|
||||
|
||||
### 数据卷
|
||||
- `pgdata`:数据库数据
|
||||
- `appdata`:上传文件 / `.jwt_secret`
|
||||
|
||||
删除编排时**不要勾选删除数据卷**,否则数据丢失。若换过数据库密码,需先删除旧 `pgdata` 卷重建(Postgres 只在空数据目录初始化密码)。
|
||||
|
||||
---
|
||||
|
||||
## 五、更新与运维
|
||||
|
||||
### 更新版本
|
||||
修改编排中镜像的 tag(如 `:0.1.0` → `:0.2.0`),或使用 `:latest` 标签后点「重新拉取镜像」再重启容器:
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
> `:latest` 不会自动更新,需手动重新拉取才生效;建议同时保留版本号标签以便回滚。
|
||||
|
||||
### 常用命令
|
||||
```bash
|
||||
docker compose ps # 查看状态
|
||||
docker compose logs -f # 看日志
|
||||
docker compose logs api --tail 50
|
||||
docker compose down # 停止(保留数据)
|
||||
docker compose down -v # 停止并清空数据(慎用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、排障速查
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|---|---|---|
|
||||
| api 反复 restart,日志 `password authentication failed` | 密码不一致 / pgdata 残留旧密码 | 统一两处密码;删 pgdata 卷重建 |
|
||||
| api 日志 `JWT_SECRET 过弱` | 填了 change-me 或过短 | 留空自动生成,或换 ≥32 字符强随机值 |
|
||||
| 拉私有镜像 `denied` | 未配置仓库认证 | 1Panel 仓库里添加 Docker Hub 凭证 |
|
||||
| web 不启动 | 等不到 api healthy | 先把 api 弄健康(解决上面问题) |
|
||||
| 能打开页面但登录异常 | HTTP 下 __Host- cookie 不生效 | 必须走 HTTPS 反代 |
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2, Megaphone } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateAnnouncement,
|
||||
apiAdminUpdateAnnouncement,
|
||||
@@ -246,34 +246,68 @@ export default function AnnouncementComposeClient({
|
||||
|
||||
return (
|
||||
<div className="admin-doc-page admin-doc-editor">
|
||||
<header className="admin-doc-head">
|
||||
<div className="min-w-0">
|
||||
<Link href={backHref} className="admin-doc-back">
|
||||
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||
<header className="admin-doc-sticky">
|
||||
<div className="admin-doc-sticky-inner">
|
||||
<div className="admin-doc-sticky-title">
|
||||
<Link href={backHref} className="admin-doc-back" aria-label="返回公告列表">
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
<span className="admin-doc-back-text">返回列表</span>
|
||||
</Link>
|
||||
<div className="admin-doc-head-title">
|
||||
<span className="admin-doc-sticky-icon" aria-hidden>
|
||||
<Megaphone size={15} />
|
||||
</span>
|
||||
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
|
||||
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
|
||||
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
|
||||
</AdminStatusChip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-doc-sticky-actions">
|
||||
{canViewOnline ? (
|
||||
<a
|
||||
href={publicPath}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line"
|
||||
className="btn btn-line btn-sm"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 查看线上页面
|
||||
<ExternalLink size={14} aria-hidden /> 线上页面
|
||||
</a>
|
||||
) : null}
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "发布" },
|
||||
]}
|
||||
/>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
aria-busy={saving || undefined}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-doc-split">
|
||||
<div className="admin-doc-main">
|
||||
<AdminField
|
||||
className="admin-doc-title"
|
||||
id="anno-title"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
@@ -328,7 +362,12 @@ export default function AnnouncementComposeClient({
|
||||
/>
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">标签颜色</p>
|
||||
<p className="admin-doc-label">
|
||||
标签颜色
|
||||
<span className="admin-doc-label-value">
|
||||
{ANNOUNCEMENT_COLORS.find((c) => c.value === form.tag_color)?.label}
|
||||
</span>
|
||||
</p>
|
||||
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
|
||||
{ANNOUNCEMENT_COLORS.map((c) => (
|
||||
<button
|
||||
@@ -343,7 +382,12 @@ export default function AnnouncementComposeClient({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
|
||||
{/* 与前台同一套 j13-anno-tag 标记,所见即所得 */}
|
||||
<div className="admin-doc-tagpreview" data-note="前台胶囊效果">
|
||||
<span className={`j13-anno-tag is-${form.tag_color}`}>
|
||||
{form.tag.trim() || "公告"}
|
||||
</span>
|
||||
</div>
|
||||
{errors.tag_color ? (
|
||||
<p className="admin-settings-row-error" role="alert">
|
||||
{errors.tag_color}
|
||||
@@ -366,9 +410,10 @@ export default function AnnouncementComposeClient({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEdit && saved ? (
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">访问地址</p>
|
||||
{isEdit && saved ? (
|
||||
<>
|
||||
<p className="admin-doc-hint">
|
||||
{publicPath}
|
||||
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
|
||||
@@ -377,54 +422,13 @@ export default function AnnouncementComposeClient({
|
||||
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
|
||||
<Copy size={14} aria-hidden /> 复制地址
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
|
||||
<p className="admin-doc-hint">保存后按公告编号生成 /announcement/编号;草稿没有公开入口。</p>
|
||||
)}
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">保存后状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "已发布" },
|
||||
]}
|
||||
/>
|
||||
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="admin-doc-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-doc-actions-btns">
|
||||
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
<p className="admin-doc-hint">一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
|
||||
{canViewOnline ? (
|
||||
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
|
||||
) : (
|
||||
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal file
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal file
@@ -0,0 +1,739 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Users as UsersIcon,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
EyeOff,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiAdminAddBoardModerator,
|
||||
apiAdminCreateBoard,
|
||||
apiAdminDeleteBoard,
|
||||
apiAdminListBoardModerators,
|
||||
apiAdminListBoards,
|
||||
apiAdminReorderBoards,
|
||||
apiAdminRemoveBoardModerator,
|
||||
apiAdminUpdateBoard,
|
||||
BOARD_POST_POLICY,
|
||||
type AdminBoard,
|
||||
type AdminBoardInput,
|
||||
type AdminBoardModerator,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
AdminEmpty,
|
||||
AdminPageHeader,
|
||||
AdminStatusChip,
|
||||
} from "@/components/admin";
|
||||
import Modal from "@/components/Modal";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import BoardIcon, {
|
||||
BOARD_ICONS,
|
||||
BOARD_COLOR_COUNT,
|
||||
boardColor,
|
||||
} from "@/components/BoardIcon";
|
||||
|
||||
const POLICY_LABEL: Record<string, string> = {
|
||||
[BOARD_POST_POLICY.ALL]: "全员可发",
|
||||
[BOARD_POST_POLICY.STAFF]: "仅管理员",
|
||||
};
|
||||
|
||||
const POST_POLICY_OPTIONS: { value: string; label: string; desc: string }[] = [
|
||||
{
|
||||
value: BOARD_POST_POLICY.ALL,
|
||||
label: "全员可发",
|
||||
desc: "所有登录用户均可在此板块发帖",
|
||||
},
|
||||
{
|
||||
value: BOARD_POST_POLICY.STAFF,
|
||||
label: "仅管理员及以上",
|
||||
desc: "仅版主、超级管理员、站长可发帖",
|
||||
},
|
||||
];
|
||||
|
||||
function emptyInput(): AdminBoardInput {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
icon: "message-circle",
|
||||
color_index: -1,
|
||||
sort_order: 0,
|
||||
post_policy: BOARD_POST_POLICY.ALL,
|
||||
visible: true,
|
||||
};
|
||||
}
|
||||
|
||||
function toInput(b: AdminBoard): AdminBoardInput {
|
||||
return {
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
icon: b.icon,
|
||||
color_index: b.color_index,
|
||||
sort_order: b.sort_order,
|
||||
post_policy: b.post_policy,
|
||||
visible: b.visible,
|
||||
};
|
||||
}
|
||||
|
||||
function BoardEditModal({
|
||||
open,
|
||||
initial,
|
||||
title,
|
||||
busy,
|
||||
editTarget,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: AdminBoardInput;
|
||||
title: string;
|
||||
busy: boolean;
|
||||
/** 编辑中的板块(新建为 null);自动配色预览按其 ID 轮换 */
|
||||
editTarget: AdminBoard | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (input: AdminBoardInput) => void;
|
||||
}) {
|
||||
const [form, setForm] = useState<AdminBoardInput>(initial);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setForm(initial);
|
||||
}, [open, initial]);
|
||||
|
||||
const update = (patch: Partial<AdminBoardInput>) => setForm((f) => ({ ...f, ...patch }));
|
||||
|
||||
// 预览配色:固定(>0)按存值取模;自动(-1/0)按已有板块 ID 轮换,新板块用 accent 占位
|
||||
const previewColor =
|
||||
(form.color_index ?? 0) > 0
|
||||
? (form.color_index ?? 0) % BOARD_COLOR_COUNT
|
||||
: editTarget
|
||||
? editTarget.id % BOARD_COLOR_COUNT
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => onSubmit(form)}
|
||||
disabled={busy || !form.name.trim()}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
板块名称 <span style={{ color: "var(--danger)" }}>*</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
maxLength={64}
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
className="field"
|
||||
placeholder="如:综合讨论"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 外观:实时预览 + 图标 + 颜色,联动更新 */}
|
||||
<div
|
||||
className="flex flex-col gap-3.5 rounded-xl p-3.5"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
|
||||
previewColor >= 0 ? `av-${previewColor}` : ""
|
||||
}`}
|
||||
style={
|
||||
previewColor < 0
|
||||
? { background: "var(--accent-soft)", color: "var(--accent)" }
|
||||
: undefined
|
||||
}
|
||||
aria-hidden
|
||||
>
|
||||
<BoardIcon icon={form.icon} name={form.name || "板"} size={20} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{form.name.trim() || "板块名称预览"}
|
||||
</p>
|
||||
<p className="meta text-[12px] truncate">
|
||||
{form.description?.trim() || "描述将显示在这里"}
|
||||
</p>
|
||||
</div>
|
||||
<span className="ml-auto meta text-[11px] shrink-0 tracking-wide">前台预览</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
图标
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="板块图标">
|
||||
{BOARD_ICONS.map((o) => {
|
||||
const active = form.icon === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
data-tip={o.label}
|
||||
aria-label={o.label}
|
||||
onClick={() => update({ icon: o.value })}
|
||||
className="w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
<BoardIcon icon={o.value} name="" size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
图标颜色
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="图标颜色">
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={(form.color_index ?? 0) <= 0}
|
||||
data-tip="自动:按板块编号轮换配色"
|
||||
aria-label="自动配色"
|
||||
onClick={() => update({ color_index: -1 })}
|
||||
className="h-9 px-2.5 rounded-[10px] flex items-center justify-center text-[11.5px] font-medium transition-colors"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: `1.5px dashed ${(form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--line)"}`,
|
||||
color: (form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--ink-3)",
|
||||
boxShadow:
|
||||
(form.color_index ?? 0) <= 0 ? "0 0 0 3px var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
{/* 存值 1..16 → 渲染 av-1..av-15、av-0;0 与 -1 均视为自动 */}
|
||||
{Array.from({ length: BOARD_COLOR_COUNT }, (_, k) => k + 1).map((v) => {
|
||||
const active = (form.color_index ?? 0) === v;
|
||||
return (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
data-tip={`颜色 ${v}`}
|
||||
aria-label={`颜色 ${v}`}
|
||||
onClick={() => update({ color_index: v })}
|
||||
className={`av-${v % BOARD_COLOR_COUNT} w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors`}
|
||||
style={{
|
||||
border: `1.5px solid ${active ? "currentColor" : "transparent"}`,
|
||||
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
{active && <Check size={14} strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="meta text-[11.5px] mt-1.5">
|
||||
{(form.color_index ?? 0) <= 0
|
||||
? "自动模式:前台按板块编号分配配色,各页面保持一致。"
|
||||
: "指定固定配色,前台所有位置保持一致。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
描述
|
||||
</span>
|
||||
<textarea
|
||||
value={form.description}
|
||||
maxLength={512}
|
||||
rows={3}
|
||||
onChange={(e) => update({ description: e.target.value })}
|
||||
className="field"
|
||||
placeholder="板块简介,可选"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
排序权重
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={form.sort_order ?? 0}
|
||||
onChange={(e) => update({ sort_order: parseInt(e.target.value || "0", 10) })}
|
||||
className="field"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
|
||||
发帖策略
|
||||
</span>
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2"
|
||||
role="radiogroup"
|
||||
aria-label="发帖策略"
|
||||
>
|
||||
{POST_POLICY_OPTIONS.map((o) => {
|
||||
const active = (form.post_policy || BOARD_POST_POLICY.ALL) === o.value;
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={active}
|
||||
onClick={() => update({ post_policy: o.value })}
|
||||
className="flex flex-col gap-0.5 rounded-[10px] px-3 py-2.5 text-left transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="text-[13px] font-semibold"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
{o.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-[11.5px] leading-snug"
|
||||
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
|
||||
>
|
||||
{o.desc}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<label className="inline-flex items-center gap-2 text-[13px]" style={{ color: "var(--ink)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.visible}
|
||||
onChange={(e) => update({ visible: e.target.checked })}
|
||||
className="accent-[var(--accent)]"
|
||||
/>
|
||||
前台可见(关闭后仅管理团队可见入口)
|
||||
</label>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeratorsModal({
|
||||
open,
|
||||
board,
|
||||
busy,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
open: boolean;
|
||||
board: AdminBoard | null;
|
||||
busy: boolean;
|
||||
onClose: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const [mods, setMods] = useState<AdminBoardModerator[]>([]);
|
||||
const [username, setUsername] = useState("");
|
||||
const [loadErr, setLoadErr] = useState("");
|
||||
const [actErr, setActErr] = useState("");
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!board) return;
|
||||
setLoadErr("");
|
||||
try {
|
||||
const r = await apiAdminListBoardModerators(board.id);
|
||||
setMods(r.moderators ?? []);
|
||||
} catch (e) {
|
||||
setLoadErr(e instanceof Error ? e.message : "获取版主失败");
|
||||
}
|
||||
}, [board]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && board) {
|
||||
setUsername("");
|
||||
setActErr("");
|
||||
reload();
|
||||
}
|
||||
}, [open, board, reload]);
|
||||
|
||||
const add = async () => {
|
||||
if (!board) return;
|
||||
const u = username.trim();
|
||||
if (!u) return;
|
||||
setActErr("");
|
||||
try {
|
||||
await apiAdminAddBoardModerator(board.id, u);
|
||||
setUsername("");
|
||||
await reload();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setActErr(e instanceof Error ? e.message : "添加版主失败");
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (uid: number) => {
|
||||
if (!board) return;
|
||||
setActErr("");
|
||||
try {
|
||||
await apiAdminRemoveBoardModerator(board.id, uid);
|
||||
await reload();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
setActErr(e instanceof Error ? e.message : "移除版主失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={`版主管理 · ${board?.name ?? ""}`}
|
||||
description="版主需为 board_admin 角色。新加入用户若已非该角色将被自动升级并强制下线;移除最后一块授权时自动降级为普通用户。"
|
||||
onClose={onClose}
|
||||
busy={busy}
|
||||
maxWidthClass="max-w-lg"
|
||||
footer={
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
关闭
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="按用户名添加版主"
|
||||
className="field flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
add();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm shrink-0"
|
||||
onClick={add}
|
||||
disabled={busy || !username.trim()}
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
{actErr && <p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{actErr}</p>}
|
||||
{loadErr ? (
|
||||
<p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{loadErr}</p>
|
||||
) : mods.length === 0 ? (
|
||||
<p className="meta text-[12.5px] py-4 text-center">暂无版主</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{mods.map((m) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-3 px-2 py-2 rounded-[var(--r-sm)]"
|
||||
style={{ background: "var(--panel-2)" }}
|
||||
>
|
||||
<Avatar name={m.nickname || m.username} src={m.avatar || undefined} size={30} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
|
||||
{m.nickname || m.username}
|
||||
</p>
|
||||
<p className="meta text-[11.5px] truncate">@{m.username}</p>
|
||||
</div>
|
||||
{m.banned && (
|
||||
<span className="admin-status is-muted" style={{ color: "var(--danger)" }}>
|
||||
已封禁
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
onClick={() => remove(m.id)}
|
||||
disabled={busy}
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={13} /> 移除
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardsAdmin({
|
||||
initial,
|
||||
initError,
|
||||
}: {
|
||||
initial: AdminBoard[] | null;
|
||||
initError: string;
|
||||
}) {
|
||||
const [boards, setBoards] = useState<AdminBoard[]>(initial ?? []);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(initError);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<AdminBoard | null>(null);
|
||||
const [editInitial, setEditInitial] = useState<AdminBoardInput>(emptyInput());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [modsOpen, setModsOpen] = useState(false);
|
||||
const [modsTarget, setModsTarget] = useState<AdminBoard | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const r = await apiAdminListBoards();
|
||||
setBoards(r.boards ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "获取板块列表失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditTarget(null);
|
||||
setEditInitial(emptyInput());
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (b: AdminBoard) => {
|
||||
setEditTarget(b);
|
||||
setEditInitial(toInput(b));
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const submitEdit = async (input: AdminBoardInput) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (editTarget) {
|
||||
const r = await apiAdminUpdateBoard(editTarget.id, input);
|
||||
setBoards((prev) => prev.map((b) => (b.id === r.board.id ? { ...b, ...r.board } : b)));
|
||||
} else {
|
||||
const r = await apiAdminCreateBoard(input);
|
||||
setBoards((prev) => [...prev, r.board]);
|
||||
}
|
||||
setEditOpen(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (b: AdminBoard) => {
|
||||
if (!window.confirm(`确认删除板块「${b.name}」?该操作不可恢复。`)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await apiAdminDeleteBoard(b.id);
|
||||
setBoards((prev) => prev.filter((x) => x.id !== b.id));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "删除失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const move = async (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir;
|
||||
if (target < 0 || target >= boards.length) return;
|
||||
const next = [...boards];
|
||||
const tmp = next[idx];
|
||||
next[idx] = next[target];
|
||||
next[target] = tmp;
|
||||
// 乐观更新:失败回滚
|
||||
const prev = boards;
|
||||
setBoards(next);
|
||||
try {
|
||||
await apiAdminReorderBoards(next.map((b) => b.id));
|
||||
} catch (e) {
|
||||
setBoards(prev);
|
||||
setError(e instanceof Error ? e.message : "排序失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openMods = (b: AdminBoard) => {
|
||||
setModsTarget(b);
|
||||
setModsOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={FolderOpen}
|
||||
title="板块管理"
|
||||
description="新建、编辑、删除论坛板块,配置发帖策略与可见性,并指派版主。仅站长可操作。"
|
||||
actions={
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建板块
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{boards.length === 0 ? (
|
||||
<AdminEmpty
|
||||
icon={<FolderOpen size={22} />}
|
||||
title="暂无板块"
|
||||
description="点击右上角「新建板块」创建第一个板块。"
|
||||
/>
|
||||
) : (
|
||||
<div className="panel overflow-hidden boards-panel">
|
||||
<div className="boards-head">
|
||||
<span>排序</span>
|
||||
<span>板块</span>
|
||||
<span className="boards-actions">操作</span>
|
||||
</div>
|
||||
{boards.map((b, i) => (
|
||||
<div key={b.id} className="boards-row">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
onClick={() => move(i, -1)}
|
||||
disabled={i === 0 || busy}
|
||||
aria-label="上移"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-icon-btn"
|
||||
onClick={() => move(i, 1)}
|
||||
disabled={i === boards.length - 1 || busy}
|
||||
aria-label="下移"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0 flex items-center gap-2.5">
|
||||
<span
|
||||
className={`av-${boardColor(b)} w-8 h-8 rounded-[10px] flex items-center justify-center shrink-0`}
|
||||
aria-hidden
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{b.name}
|
||||
</p>
|
||||
{b.description && (
|
||||
<p className="meta text-[12px] truncate">{b.description}</p>
|
||||
)}
|
||||
{/* 低信息密度列表:策略/状态/帖数并入元信息行,替代三列 */}
|
||||
<div className="boards-meta">
|
||||
<AdminStatusChip tone="muted">
|
||||
{POLICY_LABEL[b.post_policy] ?? b.post_policy}
|
||||
</AdminStatusChip>
|
||||
{b.visible ? (
|
||||
<AdminStatusChip tone="ok">可见</AdminStatusChip>
|
||||
) : (
|
||||
<AdminStatusChip tone="muted">
|
||||
<EyeOff size={11} /> 隐藏
|
||||
</AdminStatusChip>
|
||||
)}
|
||||
<span className="boards-meta-posts">{b.post_count ?? 0} 帖</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap boards-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => openEdit(b)}
|
||||
disabled={busy}
|
||||
>
|
||||
<Pencil size={13} /> 编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => openMods(b)}
|
||||
disabled={busy}
|
||||
>
|
||||
<UsersIcon size={13} /> 版主
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => remove(b)}
|
||||
disabled={busy}
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<p className="meta text-[12px] mt-3 text-center">加载中…</p>
|
||||
)}
|
||||
|
||||
<BoardEditModal
|
||||
open={editOpen}
|
||||
initial={editInitial}
|
||||
title={editTarget ? `编辑板块 · ${editTarget.name}` : "新建板块"}
|
||||
busy={busy}
|
||||
editTarget={editTarget}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={submitEdit}
|
||||
/>
|
||||
|
||||
<ModeratorsModal
|
||||
open={modsOpen}
|
||||
board={modsTarget}
|
||||
busy={busy}
|
||||
onClose={() => setModsOpen(false)}
|
||||
onChanged={reload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
frontend/app/admin/boards/page.tsx
Normal file
33
frontend/app/admin/boards/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies } from "next/headers";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminBoards, type AdminBoard } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import BoardsAdmin from "./BoardsAdmin";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "板块管理",
|
||||
};
|
||||
|
||||
export default async function AdminBoardsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
|
||||
// 仅站长可管理板块
|
||||
if (!me.user || !isOwner(me.user.role)) {
|
||||
return <Forbidden text="板块管理仅站长可操作。" />;
|
||||
}
|
||||
|
||||
let initial: AdminBoard[] | null = null;
|
||||
let initError = "";
|
||||
try {
|
||||
const r = await fetchAdminBoards(cookie || undefined);
|
||||
initial = r.boards ?? [];
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "获取板块列表失败";
|
||||
}
|
||||
|
||||
return <BoardsAdmin initial={initial} initError={initError} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
|
||||
import { ArrowLeft, Copy, ExternalLink, FileText, Loader2 } from "lucide-react";
|
||||
import {
|
||||
apiAdminCreateSitePage,
|
||||
apiAdminUpdateSitePage,
|
||||
@@ -283,34 +283,68 @@ export default function PageComposeClient({
|
||||
|
||||
return (
|
||||
<div className="admin-doc-page admin-doc-editor">
|
||||
<header className="admin-doc-head">
|
||||
<div className="min-w-0">
|
||||
<Link href={backHref} className="admin-doc-back">
|
||||
<ArrowLeft size={14} aria-hidden /> 返回列表
|
||||
<header className="admin-doc-sticky">
|
||||
<div className="admin-doc-sticky-inner">
|
||||
<div className="admin-doc-sticky-title">
|
||||
<Link href={backHref} className="admin-doc-back" aria-label="返回单页列表">
|
||||
<ArrowLeft size={15} aria-hidden />
|
||||
<span className="admin-doc-back-text">返回列表</span>
|
||||
</Link>
|
||||
<div className="admin-doc-head-title">
|
||||
<span className="admin-doc-sticky-icon" aria-hidden>
|
||||
<FileText size={15} />
|
||||
</span>
|
||||
<h1>{isEdit ? "编辑单页" : "新建单页"}</h1>
|
||||
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
|
||||
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
|
||||
</AdminStatusChip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-doc-sticky-actions">
|
||||
{canViewOnline && savedPage ? (
|
||||
<a
|
||||
href={sitePagePublicPath(savedPage.slug)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-line"
|
||||
className="btn btn-line btn-sm"
|
||||
title="打开已发布内容。未保存的修改不会出现在这里。"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden /> 查看线上页面
|
||||
<ExternalLink size={14} aria-hidden /> 线上页面
|
||||
</a>
|
||||
) : null}
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "发布" },
|
||||
]}
|
||||
/>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
aria-busy={saving || undefined}
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-doc-split">
|
||||
<div className="admin-doc-main">
|
||||
<AdminField
|
||||
className="admin-doc-title"
|
||||
id="page-title"
|
||||
label="标题"
|
||||
maxLength={200}
|
||||
@@ -437,50 +471,8 @@ export default function PageComposeClient({
|
||||
/>
|
||||
<p className="admin-doc-hint">数字越小越靠前,可以为负数。</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-doc-field">
|
||||
<p className="admin-doc-label">保存后状态</p>
|
||||
<AdminSegmented<PublishKey>
|
||||
role="group"
|
||||
ariaLabel="保存后状态"
|
||||
size="sm"
|
||||
value={form.published ? "published" : "draft"}
|
||||
onChange={(key) => patchForm({ published: key === "published" })}
|
||||
options={[
|
||||
{ key: "draft", label: "草稿" },
|
||||
{ key: "published", label: "已发布" },
|
||||
]}
|
||||
/>
|
||||
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="admin-doc-actions" aria-busy={saving || undefined}>
|
||||
<div className="admin-doc-actions-btns">
|
||||
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!dirty || saving}
|
||||
onClick={requestSave}
|
||||
>
|
||||
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
|
||||
{saving ? "保存中…" : saveLabel}
|
||||
</button>
|
||||
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
|
||||
{statusText}
|
||||
</p>
|
||||
</div>
|
||||
<p className="admin-doc-hint">一次保存会写入标题、正文和页面设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
|
||||
{canViewOnline ? (
|
||||
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
|
||||
) : (
|
||||
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
|
||||
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AdminSettingsGroup,
|
||||
@@ -27,6 +27,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
|
||||
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
|
||||
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
|
||||
const [withoutReviewOn, setWithoutReviewOn] = useState(initial.publish_without_review === true);
|
||||
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
|
||||
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
|
||||
const [togglingComments, setTogglingComments] = useState(false);
|
||||
@@ -34,6 +35,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
const [togglingMessages, setTogglingMessages] = useState(false);
|
||||
const [togglingLink, setTogglingLink] = useState(false);
|
||||
const [togglingTrust, setTogglingTrust] = useState(false);
|
||||
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
|
||||
const [savingCooldown, setSavingCooldown] = useState(false);
|
||||
const [moduleDirty, setModuleDirty] = useState(false);
|
||||
const leave = useUnsavedGuard(moduleDirty);
|
||||
@@ -103,6 +105,25 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWithoutReview = async () => {
|
||||
if (togglingWithoutReview) return;
|
||||
setTogglingWithoutReview(true);
|
||||
try {
|
||||
const res = await apiUpdateSiteSettings({ publish_without_review: !withoutReviewOn });
|
||||
setWithoutReviewOn(res.publish_without_review === true);
|
||||
toast(
|
||||
res.publish_without_review
|
||||
? "已开启全站免审(发帖与评论直接公开,无需过审记录)"
|
||||
: "已关闭全站免审(将按过审免审规则决定是否待审)",
|
||||
"ok",
|
||||
);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
} finally {
|
||||
setTogglingWithoutReview(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTrust = async () => {
|
||||
if (togglingTrust) return;
|
||||
setTogglingTrust(true);
|
||||
@@ -218,18 +239,40 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
|
||||
<InstantNote saving={togglingLink} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldOff size={15} /> 全站免审
|
||||
</span>
|
||||
}
|
||||
hintBelow="所有人发帖与评论直接公开,无需有过审记录;开启后优先生效。"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={withoutReviewOn}
|
||||
disabled={togglingWithoutReview}
|
||||
onChange={() => void toggleWithoutReview()}
|
||||
label="全站免审"
|
||||
/>
|
||||
<InstantNote saving={togglingWithoutReview} />
|
||||
</div>
|
||||
</AdminSettingsRow>
|
||||
<AdminSettingsRow
|
||||
label={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<ShieldCheck size={15} /> 过审免审
|
||||
</span>
|
||||
}
|
||||
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
|
||||
hintBelow={
|
||||
withoutReviewOn
|
||||
? "全站免审已开启,所有内容直接公开,本开关暂不参与判定。"
|
||||
: "用户曾有过审内容后,后续发帖与评论直接公开。"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<AdminSwitch
|
||||
checked={trustOn}
|
||||
disabled={togglingTrust}
|
||||
disabled={withoutReviewOn || togglingTrust}
|
||||
onChange={() => void toggleTrust()}
|
||||
label="过审免审"
|
||||
/>
|
||||
|
||||
@@ -16,6 +16,7 @@ const NAV: { href: string; label: string }[] = [
|
||||
{ href: "/admin/settings/mail", label: "邮件服务" },
|
||||
{ href: "/admin/settings/storage", label: "文件与存储" },
|
||||
{ href: "/admin/settings/filter", label: "内容过滤" },
|
||||
{ href: "/admin/settings/import", label: "数据导入" },
|
||||
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
|
||||
];
|
||||
|
||||
|
||||
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
ClipboardCheck,
|
||||
Database,
|
||||
FileUp,
|
||||
ListChecks,
|
||||
ListX,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
ScanLine,
|
||||
Upload,
|
||||
Users,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AdminSettingsGroup,
|
||||
AdminSwitch,
|
||||
} from "@/components/admin";
|
||||
import {
|
||||
apiAdminImportLegacy,
|
||||
type LegacyCommentReport,
|
||||
type LegacyImportReport,
|
||||
type LegacyPostReport,
|
||||
} from "@/lib/api";
|
||||
|
||||
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
|
||||
const SOURCES = [
|
||||
{ key: "jiang13", label: "姜十三论坛(SQLite)", enabled: true },
|
||||
{ key: "wordpress", label: "WordPress", enabled: false },
|
||||
{ key: "typecho", label: "Typecho", enabled: false },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 站点设置 → 数据导入:
|
||||
* 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。
|
||||
* 全程幂等:已导入对象自动跳过,可安全重复执行。
|
||||
*/
|
||||
export default function ImportPanel() {
|
||||
const [source, setSource] = useState<string>("jiang13");
|
||||
const [dbFile, setDbFile] = useState<File | null>(null);
|
||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||
const [withContent, setWithContent] = useState(true);
|
||||
const [busy, setBusy] = useState<"check" | "import" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [report, setReport] = useState<LegacyImportReport | null>(null);
|
||||
const dbInputRef = useRef<HTMLInputElement>(null);
|
||||
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
|
||||
const [preflighted, setPreflighted] = useState(false);
|
||||
|
||||
const resetResult = () => {
|
||||
setPreflighted(false);
|
||||
setReport(null);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const pickDb = (f: File | null) => {
|
||||
setDbFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const pickZip = (f: File | null) => {
|
||||
setZipFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const toggleContent = () => {
|
||||
setWithContent((v) => !v);
|
||||
resetResult();
|
||||
};
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (!dbFile || busy) return;
|
||||
setBusy(dryRun ? "check" : "import");
|
||||
setError("");
|
||||
try {
|
||||
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
|
||||
withContent,
|
||||
dryRun,
|
||||
});
|
||||
setReport(rep);
|
||||
if (dryRun) setPreflighted(true);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "导入失败");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminSettingsGroup
|
||||
title="数据源"
|
||||
description="选择要导入的站点程序类型;其他平台适配将陆续提供"
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{SOURCES.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
disabled={!s.enabled}
|
||||
onClick={() => {
|
||||
setSource(s.key);
|
||||
resetResult();
|
||||
}}
|
||||
className={`btn btn-sm ${source === s.key && s.enabled ? "btn-primary" : "btn-line"} ${!s.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||
aria-pressed={source === s.key}
|
||||
title={s.enabled ? undefined : "计划中,敬请期待"}
|
||||
>
|
||||
<Database size={14} aria-hidden />
|
||||
{s.label}
|
||||
{!s.enabled && (
|
||||
<span className="text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
|
||||
计划中
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="导入文件"
|
||||
description="第一步:从旧站服务器或本地备份中取得以下文件"
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<FilePickRow
|
||||
label="站点数据库(必填)"
|
||||
hint="旧站 data 目录下的 jiang13.db,最大 64MB"
|
||||
fileName={dbFile?.name}
|
||||
inputRef={dbInputRef}
|
||||
accept=".db,.sqlite,.sqlite3,application/octet-stream"
|
||||
onPick={pickDb}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
<FilePickRow
|
||||
label="头像压缩包(可选)"
|
||||
hint="把旧站 data/uploads/avatars 目录压缩为 zip(直接右键压缩该文件夹即可),最大 128MB"
|
||||
fileName={zipFile?.name}
|
||||
inputRef={zipInputRef}
|
||||
accept=".zip,application/zip"
|
||||
onPick={pickZip}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="导入范围"
|
||||
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
|
||||
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
|
||||
同时导入帖子与评论
|
||||
</span>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="预检与导入"
|
||||
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!dbFile || busy !== null}
|
||||
onClick={() => void run(true)}
|
||||
>
|
||||
{busy === "check" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ScanLine size={14} aria-hidden />}
|
||||
{busy === "check" ? "预检中…" : "预检(不写入)"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={!dbFile || busy !== null || !preflighted}
|
||||
onClick={() => void run(false)}
|
||||
>
|
||||
{busy === "import" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ClipboardCheck size={14} aria-hidden />}
|
||||
{busy === "import" ? "导入中…" : "确认正式导入"}
|
||||
</button>
|
||||
<span className="meta text-[12px]" aria-live="polite">
|
||||
{busy
|
||||
? null
|
||||
: !dbFile
|
||||
? "请先选择站点数据库"
|
||||
: !preflighted
|
||||
? "请先运行预检"
|
||||
: "预检通过,可以正式导入"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--danger)" }} role="alert">
|
||||
<AlertTriangle size={14} aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{report && <ReportView report={report} />}
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilePickRow({
|
||||
label,
|
||||
hint,
|
||||
fileName,
|
||||
inputRef,
|
||||
accept,
|
||||
onPick,
|
||||
busy,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
fileName?: string;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
accept: string;
|
||||
onPick: (f: File | null) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl px-3.5 py-3 flex items-center justify-between gap-3 flex-wrap"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="meta text-[12px] mt-0.5 truncate">{fileName || hint}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
<Upload size={14} aria-hidden />
|
||||
选择文件
|
||||
</button>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
onPick(e.target.files?.[0] ?? null);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg px-2.5 py-1.5 inline-flex items-center gap-1.5 text-[12.5px]"
|
||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<span style={{ color: ok === false ? "var(--ink-3)" : "var(--accent)" }}>{icon}</span>
|
||||
<span style={{ color: "var(--ink-3)" }}>{label}</span>
|
||||
<strong style={{ color: "var(--ink)" }}>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
const isDry = report.dry_run;
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
<p
|
||||
className="flex items-center gap-1.5 text-[13px] font-semibold"
|
||||
style={{ color: isDry ? "var(--ink-3)" : "var(--ok)" }}
|
||||
>
|
||||
{isDry ? (
|
||||
<>
|
||||
<ScanLine size={15} aria-hidden />
|
||||
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={15} aria-hidden />
|
||||
导入完成。重复执行时已导入的内容会自动跳过
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<ReportCard icon={<Users size={14} aria-hidden />} title="用户">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧库用户" value={report.users.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.users.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已存在跳过" value={report.users.skipped} ok={false} />
|
||||
{report.users.avatar_written > 0 && (
|
||||
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
|
||||
)}
|
||||
<DetailList title="已存在(跳过)" items={report.users.conflicts} />
|
||||
<DetailList title="头像源缺失(字段置空)" items={report.users.avatar_missing} color="var(--danger)" />
|
||||
<DetailList title="失败" items={report.users.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
|
||||
{report.boards && (
|
||||
<ReportCard icon={<Database size={14} aria-hidden />} title="板块">
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将新建" : "已新建"} value={report.boards.created} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="复用同名" value={report.boards.reused} ok={false} />
|
||||
<DetailList title={isDry ? "将新建板块" : "新建板块"} items={report.boards.names} />
|
||||
</ReportCard>
|
||||
)}
|
||||
|
||||
{report.posts && <PostsView report={report.posts} isDry={isDry} />}
|
||||
{report.comments && <CommentsView report={report.comments} isDry={isDry} />}
|
||||
|
||||
{report.notes && report.notes.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl px-3.5 py-3 flex flex-col gap-1 text-[12.5px]"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
{report.notes.map((n, i) => (
|
||||
<p key={i} className="flex items-start gap-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
|
||||
{n}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) {
|
||||
return (
|
||||
<ReportCard icon={<ListChecks size={14} aria-hidden />} title="帖子">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧帖" value={report.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
|
||||
{report.polls_skipped > 0 && (
|
||||
<StatChip icon={<XCircle size={13} aria-hidden />} label="投票帖未迁移" value={report.polls_skipped} ok={false} />
|
||||
)}
|
||||
<DetailList title="投票帖(未迁移)" items={report.poll_titles} color="var(--danger)" />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsView({ report, isDry }: { report: LegacyCommentReport; isDry: boolean }) {
|
||||
return (
|
||||
<ReportCard icon={<MessageSquare size={14} aria-hidden />} title="评论">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧评论" value={report.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
|
||||
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportCard({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl px-3.5 py-3 flex flex-col gap-2"
|
||||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{icon}
|
||||
{title}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailList({
|
||||
title,
|
||||
items,
|
||||
color,
|
||||
}: {
|
||||
title: string;
|
||||
items?: string[];
|
||||
color?: string;
|
||||
}) {
|
||||
if (!items || items.length === 0) return null;
|
||||
return (
|
||||
<details className="w-full">
|
||||
<summary
|
||||
className="flex items-center gap-1.5 cursor-pointer select-none text-[12.5px]"
|
||||
style={{ color: color || "var(--ink-3)" }}
|
||||
>
|
||||
<ListX size={13} aria-hidden />
|
||||
{title}:{items.length}
|
||||
</summary>
|
||||
<ul className="mt-1 ml-5 list-disc max-h-40 overflow-y-auto text-[12.5px]" style={{ color: "var(--ink-3)" }}>
|
||||
{items.map((it, i) => (
|
||||
<li key={i} className="break-all">
|
||||
{it}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
10
frontend/app/admin/settings/import/page.tsx
Normal file
10
frontend/app/admin/settings/import/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ImportPanel from "./ImportPanel";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "数据导入",
|
||||
};
|
||||
|
||||
export default function ImportSettingsPage() {
|
||||
return <ImportPanel />;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Undo2,
|
||||
Reply,
|
||||
Copy,
|
||||
AtSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiListChatMessages,
|
||||
@@ -53,6 +52,7 @@ import Avatar from "@/components/Avatar";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
||||
|
||||
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
|
||||
const MARK_READ_GAP_MS = 2_000;
|
||||
@@ -137,7 +137,6 @@ function RecallNotice({
|
||||
href={`/u/${uid}`}
|
||||
className="transition-opacity hover:opacity-80"
|
||||
style={nameStyle}
|
||||
data-tip={`查看 ${name} 的主页`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
@@ -206,10 +205,11 @@ function formatTime(dateStr: string) {
|
||||
return `${md} ${hm}`;
|
||||
}
|
||||
|
||||
function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
const byUsername = new Map(
|
||||
members.map((m) => [m.user.username.toLowerCase(), m.user])
|
||||
);
|
||||
function highlightMentions(
|
||||
content: string,
|
||||
users: Map<string, User>,
|
||||
onOpenCard: (u: User) => void
|
||||
) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let last = 0;
|
||||
@@ -217,19 +217,21 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
let i = 0;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(content.slice(last, m.index));
|
||||
const user = byUsername.get(m[1].toLowerCase());
|
||||
const user = users.get(m[1].toLowerCase());
|
||||
parts.push(
|
||||
user ? (
|
||||
<Link
|
||||
<button
|
||||
key={`m-${i}`}
|
||||
href={`/u/${user.id}`}
|
||||
type="button"
|
||||
className="j13-mention-token"
|
||||
data-tip={`查看 ${user.nickname || user.username} 的主页`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCard(user);
|
||||
}}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{m[1]}
|
||||
</Link>
|
||||
@{user.nickname || user.username}
|
||||
</button>
|
||||
) : (
|
||||
<span key={`m-${i}`}>@{m[1]}</span>
|
||||
)
|
||||
@@ -242,67 +244,154 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
}
|
||||
|
||||
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
|
||||
function renderChatContent(content: string, members: ChatRoomMember[]) {
|
||||
function renderChatContent(
|
||||
content: string,
|
||||
users: Map<string, User>,
|
||||
onOpenCard: (u: User) => void
|
||||
) {
|
||||
return (
|
||||
<AutolinkText
|
||||
text={content}
|
||||
forceNewTab
|
||||
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
|
||||
renderText={(seg) => <>{highlightMentions(seg, users, onOpenCard)}</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
|
||||
function renderComposerMirror(content: string, tokens: Set<string>) {
|
||||
if (!content) return null;
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(content.slice(last, m.index));
|
||||
const key = m[1].toLowerCase();
|
||||
parts.push(
|
||||
tokens.has(key) ? (
|
||||
<span key={`t-${i}`} className="j13-mention-token">
|
||||
@{m[1]}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`t-${i}`}>@{m[1]}</span>
|
||||
)
|
||||
);
|
||||
last = m.index + m[0].length;
|
||||
i++;
|
||||
/**
|
||||
* 编辑框提及方案:内容直接存「@昵称」(宽度即昵称宽度,光标/选区天然对齐),
|
||||
* mentionTokens 记录 昵称→用户名 映射,发送时再翻译回 @用户名(后端按用户名解析提醒)。
|
||||
* 昵称可含空格/@ 等任意字符,因此按字面匹配扫描(最长优先 + 边界校验 + 占位去重)。
|
||||
*/
|
||||
function collectMentionSpans(
|
||||
text: string,
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number; key: string }[] {
|
||||
const spans: { start: number; end: number; key: string }[] = [];
|
||||
if (!text || tokens.size === 0) return spans;
|
||||
const keys = [...tokens.keys()].sort((a, b) => b.length - a.length);
|
||||
const occupied = new Array<boolean>(text.length).fill(false);
|
||||
for (const key of keys) {
|
||||
if (!key) continue;
|
||||
const needle = `@${key}`;
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = text.indexOf(needle, from);
|
||||
if (idx < 0) break;
|
||||
from = idx + 1;
|
||||
const end = idx + needle.length;
|
||||
// @ 前需为行首/空白,昵称后需为空白/行尾(与插入时的「@昵称 」格式一致)
|
||||
const prevCh = idx > 0 ? text[idx - 1] : "";
|
||||
if (prevCh && !/\s/.test(prevCh)) continue;
|
||||
const nextCh = end < text.length ? text[end] : "";
|
||||
if (nextCh && !/\s/.test(nextCh)) continue;
|
||||
let clash = false;
|
||||
for (let i = idx; i < end; i++) {
|
||||
if (occupied[i]) {
|
||||
clash = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clash) continue;
|
||||
for (let i = idx; i < end; i++) occupied[i] = true;
|
||||
spans.push({ start: idx, end, key });
|
||||
}
|
||||
}
|
||||
return spans.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 以蓝色昵称呈现(胶囊样式在发出后的气泡里) */
|
||||
function renderComposerMirror(content: string, tokens: Map<string, string>) {
|
||||
if (!content) return null;
|
||||
const spans = collectMentionSpans(content, tokens);
|
||||
const parts: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
spans.forEach((s, i) => {
|
||||
if (s.start > last) parts.push(content.slice(last, s.start));
|
||||
parts.push(
|
||||
<span key={`t-${i}`} className="j13-mention-token">
|
||||
@{s.key}
|
||||
</span>
|
||||
);
|
||||
last = s.end;
|
||||
});
|
||||
if (last < content.length) parts.push(content.slice(last));
|
||||
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
|
||||
if (content.endsWith("\n")) parts.push("\n");
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
|
||||
function translateMentionsOut(text: string, tokens: Map<string, string>): string {
|
||||
if (tokens.size === 0) return text;
|
||||
const spans = collectMentionSpans(text, tokens);
|
||||
if (spans.length === 0) return text;
|
||||
let out = "";
|
||||
let last = 0;
|
||||
for (const s of spans) {
|
||||
out += text.slice(last, s.start) + `@${tokens.get(s.key) || s.key}`;
|
||||
last = s.end;
|
||||
}
|
||||
return out + text.slice(last);
|
||||
}
|
||||
|
||||
/** 服务端文本(@用户名)→ 编辑框文本(@昵称),并为可识别的提及建立 token(重新编辑草稿用) */
|
||||
function hydrateMentionsIn(
|
||||
text: string,
|
||||
userIndex: Map<string, User>
|
||||
): { text: string; tokens: Map<string, string> } {
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
const replaces: { start: number; end: number; display: string; username: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const u = userIndex.get(m[1].toLowerCase());
|
||||
if (!u) continue;
|
||||
const display = u.nickname || u.username;
|
||||
if (!display || display === m[1]) continue;
|
||||
replaces.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
display,
|
||||
username: m[1],
|
||||
});
|
||||
}
|
||||
const tokens = new Map<string, string>();
|
||||
let out = text;
|
||||
for (let i = replaces.length - 1; i >= 0; i--) {
|
||||
const r = replaces[i];
|
||||
out = out.slice(0, r.start) + `@${r.display}` + out.slice(r.end);
|
||||
tokens.set(r.display, r.username);
|
||||
}
|
||||
return { text: out, tokens };
|
||||
}
|
||||
|
||||
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
|
||||
function mentionTokenBefore(
|
||||
text: string,
|
||||
cursor: number,
|
||||
tokens: Set<string>
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number } | null {
|
||||
const before = text.slice(0, cursor);
|
||||
const m = before.match(/@([A-Za-z0-9_-]{3,32})( ?)$/);
|
||||
if (!m || !tokens.has(m[1].toLowerCase())) return null;
|
||||
return { start: cursor - m[0].length, end: cursor };
|
||||
for (const s of collectMentionSpans(text, tokens)) {
|
||||
if (s.end === cursor) return { start: s.start, end: cursor };
|
||||
if (s.end + 1 === cursor && text[s.end] === " ") {
|
||||
return { start: s.start, end: cursor };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
|
||||
function mentionTokenAfter(
|
||||
text: string,
|
||||
cursor: number,
|
||||
tokens: Set<string>
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number } | null {
|
||||
const after = text.slice(cursor);
|
||||
const m = after.match(/^@([A-Za-z0-9_-]{3,32})( ?)/);
|
||||
if (!m || !tokens.has(m[1].toLowerCase())) return null;
|
||||
return { start: cursor, end: cursor + m[0].length };
|
||||
for (const s of collectMentionSpans(text, tokens)) {
|
||||
if (s.start !== cursor) continue;
|
||||
const end = s.end < text.length && text[s.end] === " " ? s.end + 1 : s.end;
|
||||
return { start: cursor, end };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ChatRoomClient({
|
||||
@@ -344,8 +433,12 @@ export default function ChatRoomClient({
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
|
||||
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
|
||||
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
|
||||
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
/** 用户资料卡(头像/@昵称点击弹出) */
|
||||
const [userCard, setUserCard] = useState<ChatUserCardTarget | null>(null);
|
||||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||||
const [reEditDrafts, setReEditDrafts] = useState<
|
||||
Record<number, { text: string; until: number }>
|
||||
@@ -369,6 +462,25 @@ export default function ChatRoomClient({
|
||||
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
|
||||
const canKickMembers = isOwner || isRoomAdmin || oversee;
|
||||
|
||||
/** @解析索引:成员表 + 消息发送者(成员未加载/已退群仍能显示昵称) */
|
||||
const userIndex = useMemo(() => {
|
||||
const idx = new Map<string, User>();
|
||||
const add = (u?: User | null) => {
|
||||
if (u?.username) idx.set(u.username.toLowerCase(), u);
|
||||
};
|
||||
members.forEach((m) => add(m.user));
|
||||
messages.forEach((m) => add(m.sender));
|
||||
return idx;
|
||||
}, [members, messages]);
|
||||
|
||||
const openUserCard = useCallback(
|
||||
(u: User) => {
|
||||
const mem = members.find((m) => m.user_id === u.id);
|
||||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
|
||||
},
|
||||
[members]
|
||||
);
|
||||
|
||||
const rememberReEdit = useCallback((id: number, text: string) => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
@@ -578,12 +690,17 @@ export default function ChatRoomClient({
|
||||
const prevTokens = mentionTokens;
|
||||
const replyId = replyTo?.id || 0;
|
||||
setContent("");
|
||||
setMentionTokens(new Set());
|
||||
setMentionTokens(new Map());
|
||||
setReplyTo(null);
|
||||
setMentionOpen(false);
|
||||
requestAnimationFrame(() => syncComposerHeight());
|
||||
try {
|
||||
const { message } = await apiSendChatMessage(roomId, prevContent, replyId);
|
||||
// 编辑框内存「@昵称」,发出前翻译回 @用户名(后端按用户名解析提及)
|
||||
const { message } = await apiSendChatMessage(
|
||||
roomId,
|
||||
translateMentionsOut(prevContent, prevTokens),
|
||||
replyId
|
||||
);
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message];
|
||||
@@ -624,42 +741,53 @@ export default function ChatRoomClient({
|
||||
.slice(0, 8);
|
||||
})();
|
||||
|
||||
const insertMention = (username: string) => {
|
||||
/**
|
||||
* 插入 @提及:
|
||||
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
|
||||
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
|
||||
*/
|
||||
const insertMention = (
|
||||
username: string,
|
||||
nickname: string,
|
||||
opts?: { replaceTrigger?: boolean }
|
||||
) => {
|
||||
const el = inputRef.current;
|
||||
const val = content;
|
||||
const cursor = el?.selectionStart ?? val.length;
|
||||
const before = val.slice(0, cursor);
|
||||
let before = val.slice(0, cursor);
|
||||
const after = val.slice(cursor);
|
||||
if (opts?.replaceTrigger) {
|
||||
const at = before.lastIndexOf("@");
|
||||
const head = at >= 0 ? before.slice(0, at) : before;
|
||||
const next = `${head}@${username} ${after}`;
|
||||
if (at >= 0) before = before.slice(0, at);
|
||||
} else if (before && !/\s$/.test(before)) {
|
||||
before = `${before} `;
|
||||
}
|
||||
// 编辑框直接写入昵称(宽度即昵称宽度),映射表记录昵称→用户名
|
||||
const display = nickname || username;
|
||||
const next = `${before}@${display} ${after}`;
|
||||
setContent(next);
|
||||
setMentionTokens((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(username.toLowerCase());
|
||||
return n;
|
||||
});
|
||||
setMentionTokens((prev) => new Map(prev).set(display, username));
|
||||
setMentionOpen(false);
|
||||
setMentionFilter("");
|
||||
requestAnimationFrame(() => {
|
||||
const pos = head.length + username.length + 2;
|
||||
const pos = before.length + display.length + 2;
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos, pos);
|
||||
syncComposerHeight();
|
||||
});
|
||||
};
|
||||
|
||||
const pruneMentionTokens = (text: string, tokens: Set<string>) => {
|
||||
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
|
||||
if (tokens.size === 0) return tokens;
|
||||
const present = new Set<string>();
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
if (tokens.has(key)) present.add(key);
|
||||
}
|
||||
const present = new Set(
|
||||
collectMentionSpans(text, tokens).map((s) => s.key)
|
||||
);
|
||||
if (present.size === tokens.size) return tokens;
|
||||
return present;
|
||||
const next: Map<string, string> = new Map();
|
||||
tokens.forEach((username, key) => {
|
||||
if (present.has(key)) next.set(key, username);
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
const onComposerChange = (raw: string) => {
|
||||
@@ -668,7 +796,7 @@ export default function ChatRoomClient({
|
||||
const el = inputRef.current;
|
||||
const cursor = el?.selectionStart ?? raw.length;
|
||||
const before = raw.slice(0, cursor);
|
||||
const m = before.match(/@([A-Za-z0-9_-]{0,32})$/);
|
||||
const m = before.match(/@([^\s@]{0,32})$/);
|
||||
if (m && isRealMember && !isDirect) {
|
||||
setMentionOpen(true);
|
||||
setMentionFilter(m[1] || "");
|
||||
@@ -696,7 +824,13 @@ export default function ChatRoomClient({
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const pick = mentionCandidates[mentionIndex];
|
||||
if (pick) insertMention(pick.user.username);
|
||||
if (pick) {
|
||||
insertMention(
|
||||
pick.user.username,
|
||||
pick.user.nickname || pick.user.username,
|
||||
{ replaceTrigger: true }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -762,6 +896,21 @@ export default function ChatRoomClient({
|
||||
}
|
||||
};
|
||||
|
||||
/** 光标落点在已确认 @token 内部时吸附到 token 末尾(token 原子化,飞书体验) */
|
||||
const onComposerSelect = () => {
|
||||
const el = inputRef.current;
|
||||
if (!el || mentionTokens.size === 0) return;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
if (start !== end) return;
|
||||
for (const s of collectMentionSpans(content, mentionTokens)) {
|
||||
if (start > s.start && start < s.end) {
|
||||
el.setSelectionRange(s.end, s.end);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||||
if (m.recalled_at) return;
|
||||
setCtxMenu({ x, y, message: m });
|
||||
@@ -928,13 +1077,16 @@ export default function ChatRoomClient({
|
||||
toast("重新编辑已过期");
|
||||
return;
|
||||
}
|
||||
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
|
||||
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
|
||||
setReplyTo(null);
|
||||
setContent(text);
|
||||
setContent(display);
|
||||
setMentionTokens(tokens);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(text.length, text.length);
|
||||
el.setSelectionRange(display.length, display.length);
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
});
|
||||
@@ -972,13 +1124,10 @@ export default function ChatRoomClient({
|
||||
];
|
||||
if (!isDirect && isRealMember && m.sender_id !== meId) {
|
||||
items.push({
|
||||
key: "at",
|
||||
label: `提及 @${m.sender.username}`,
|
||||
icon: <AtSign size={14} />,
|
||||
onClick: () => {
|
||||
setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
key: "profile",
|
||||
label: "查看资料卡",
|
||||
icon: <Users size={14} />,
|
||||
onClick: () => openUserCard(m.sender),
|
||||
});
|
||||
}
|
||||
if (canRecallMsg(m)) {
|
||||
@@ -1030,6 +1179,25 @@ export default function ChatRoomClient({
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isDirect && peer && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() =>
|
||||
openUserCard({
|
||||
...peer,
|
||||
role: "",
|
||||
})
|
||||
}
|
||||
aria-label={`查看 ${peer.nickname || peer.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={peer.nickname || peer.username}
|
||||
src={peer.avatar || undefined}
|
||||
size={24}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<h1
|
||||
className="font-extrabold text-[16px] truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
@@ -1222,11 +1390,20 @@ export default function ChatRoomClient({
|
||||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
{!grouped && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => openUserCard(m.sender)}
|
||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||||
@@ -1267,7 +1444,7 @@ export default function ChatRoomClient({
|
||||
已撤回
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
{renderChatContent(m.content, members)}
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1286,11 +1463,18 @@ export default function ChatRoomClient({
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
{!grouped && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => openUserCard(m.sender)}
|
||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -1303,12 +1487,34 @@ export default function ChatRoomClient({
|
||||
{!isDirect && m.sender_id === ownerId && (
|
||||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||||
)}
|
||||
<span
|
||||
className="text-[12px] truncate"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
<button
|
||||
type="button"
|
||||
className={`text-[12px] truncate transition-colors ${
|
||||
!isDirect && isRealMember
|
||||
? "group/nm cursor-pointer text-[color:var(--ink-2)] hover:text-[color:var(--accent)]"
|
||||
: "cursor-default text-[color:var(--ink-2)]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isDirect && isRealMember) {
|
||||
insertMention(
|
||||
m.sender.username,
|
||||
m.sender.nickname || m.sender.username
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isDirect || !isRealMember}
|
||||
>
|
||||
{m.sender.nickname}
|
||||
{/* 悬浮时名字前出现 @,整个名字成为可 @ 点击对象 */}
|
||||
{!isDirect && isRealMember && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="hidden group-hover/nm:inline font-semibold text-[color:var(--accent)]"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
)}
|
||||
{m.sender.nickname}
|
||||
</button>
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
@@ -1362,7 +1568,7 @@ export default function ChatRoomClient({
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{renderChatContent(m.content, members)}
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1428,7 +1634,11 @@ export default function ChatRoomClient({
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(m.user.username);
|
||||
insertMention(
|
||||
m.user.username,
|
||||
m.user.nickname || m.user.username,
|
||||
{ replaceTrigger: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
@@ -1476,6 +1686,7 @@ export default function ChatRoomClient({
|
||||
value={content}
|
||||
onChange={(e) => onComposerChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onComposerSelect}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) {
|
||||
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
@@ -1587,6 +1798,13 @@ export default function ChatRoomClient({
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
onOpenCard={(mem) =>
|
||||
setUserCard({
|
||||
user: mem.user,
|
||||
roomRole: mem.role,
|
||||
muted: mem.muted,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{rightPanel === "invite" && (
|
||||
@@ -1628,6 +1846,13 @@ export default function ChatRoomClient({
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
onOpenCard={(mem) =>
|
||||
setUserCard({
|
||||
user: mem.user,
|
||||
roomRole: mem.role,
|
||||
muted: mem.muted,
|
||||
})
|
||||
}
|
||||
showTitle
|
||||
/>
|
||||
</aside>
|
||||
@@ -1642,6 +1867,14 @@ export default function ChatRoomClient({
|
||||
/>
|
||||
)}
|
||||
|
||||
{userCard && (
|
||||
<ChatUserCard
|
||||
target={userCard}
|
||||
meId={meId}
|
||||
onClose={() => setUserCard(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
title={confirm?.title || ""}
|
||||
@@ -1700,6 +1933,7 @@ function MembersPanel({
|
||||
onKick,
|
||||
onMute,
|
||||
onSetAdmin,
|
||||
onOpenCard,
|
||||
showTitle = false,
|
||||
}: {
|
||||
members: ChatRoomMember[];
|
||||
@@ -1711,6 +1945,7 @@ function MembersPanel({
|
||||
onKick: (uid: number, nickname: string) => void;
|
||||
onMute: (uid: number, nickname: string, muted: boolean) => void;
|
||||
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
|
||||
onOpenCard: (m: ChatRoomMember) => void;
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -1755,8 +1990,15 @@ function MembersPanel({
|
||||
<li
|
||||
key={m.id}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => onOpenCard(m)}
|
||||
aria-label={`查看 ${name} 的资料卡`}
|
||||
>
|
||||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/u/${m.user.id}`}
|
||||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||||
|
||||
@@ -3167,6 +3167,7 @@ body:has([data-admin-viewport]) {
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1rem;
|
||||
/* 半透明 + 毛玻璃:后台画布镂空透出全站画布层,顶栏与画布融为一体 */
|
||||
background: color-mix(in srgb, var(--bg) 88%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
@@ -3253,6 +3254,11 @@ body:has([data-admin-viewport]) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 公告/单页编辑器吸顶操作栏:同上,必须留在未分层区域才能压过 .admin-canvas 的媒体查询内边距 */
|
||||
.admin-canvas:has(.admin-doc-editor) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 移动端导航抽屉 */
|
||||
.admin-drawer {
|
||||
position: fixed;
|
||||
@@ -3441,6 +3447,12 @@ select:focus-visible {
|
||||
color: transparent;
|
||||
caret-color: var(--ink);
|
||||
}
|
||||
/* 框选时 ::selection 前景色会盖过透明字色,露出底层真实文本(@用户名);
|
||||
这里保持字形透明 + 半透明高亮,让镜像层的 @昵称 透出(宽度已补偿对齐) */
|
||||
.j13-chat-composer-input.has-value::selection {
|
||||
background: color-mix(in srgb, var(--accent) 24%, transparent);
|
||||
color: transparent;
|
||||
}
|
||||
.j13-chat-composer-input.has-value::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
@@ -3525,20 +3537,18 @@ a.j13-mention-token:hover {
|
||||
background: var(--accent);
|
||||
color: var(--accent-on) !important;
|
||||
}
|
||||
/* 输入镜像层:不加粗/不加 padding,避免与透明 textarea 字宽错位 */
|
||||
/* 输入镜像层:token 仅蓝色文字(胶囊样式只在发出后的气泡里显示);
|
||||
基础胶囊色带 !important,此处需同权重覆盖 */
|
||||
.j13-chat-composer-mirror .j13-mention-token {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: inherit;
|
||||
font-weight: inherit;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: color-mix(in srgb, var(--accent) 82%, #000);
|
||||
}
|
||||
.dark .j13-chat-composer-mirror .j13-mention-token {
|
||||
background: var(--accent);
|
||||
color: var(--accent-on) !important;
|
||||
background: transparent !important;
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
@@ -3772,10 +3782,7 @@ a.j13-mention-token:hover {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* 站点设置:限宽左对齐,不写入后台公共主容器 */
|
||||
.admin-canvas:has(.admin-settings-page) {
|
||||
background: var(--bg);
|
||||
}
|
||||
/* 站点设置:限宽左对齐,不写入后台公共主容器(画布保持镂空,与其他管理页一致) */
|
||||
@media (min-width: 1920px) {
|
||||
.admin-canvas:has(.admin-settings-page) {
|
||||
padding-left: 3rem; /* 宽屏距主导航最多约 48px;1024+ 默认 2rem */
|
||||
@@ -3803,9 +3810,11 @@ a.j13-mention-token:hover {
|
||||
top: 0;
|
||||
align-self: start;
|
||||
z-index: 5;
|
||||
/* 滚动时盖住右侧内容,避免透出 */
|
||||
background: var(--bg);
|
||||
padding-block: 0.15rem 0.5rem;
|
||||
/* 画布镂空后导航改白色卡片:盖住滚动内容,并与右侧设置组卡片同语言 */
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
padding: 0.5rem 0.4rem;
|
||||
margin-block: -0.15rem;
|
||||
}
|
||||
.admin-settings-nav {
|
||||
@@ -4773,10 +4782,7 @@ a.j13-mention-token:hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 单页管理:列表与编辑各自限宽,不套用设置页 1120px */
|
||||
.admin-canvas:has(.admin-doc-page) {
|
||||
background: var(--bg);
|
||||
}
|
||||
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
|
||||
.admin-doc-page {
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
@@ -4785,56 +4791,126 @@ a.j13-mention-token:hover {
|
||||
.admin-doc-list {
|
||||
max-width: 1240px;
|
||||
}
|
||||
/* 编辑器本身通栏:吸顶栏背景/分隔线才能横贯整个画布,不在 1440px 处被切断 */
|
||||
.admin-doc-editor {
|
||||
max-width: 1440px;
|
||||
container-type: inline-size;
|
||||
container-name: doc-editor;
|
||||
}
|
||||
.admin-doc-head {
|
||||
/* 编辑器吸顶操作栏:返回/标题/状态 + 发布状态/撤销/保存,滚动时始终可见。
|
||||
画布镂空透出全站画布层,操作栏用白色块(同用户管理吸顶栏),滚动时干净盖住内容 */
|
||||
.admin-doc-sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
margin-inline: -1rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.admin-doc-sticky {
|
||||
margin-inline: -1.5rem;
|
||||
padding-inline: 1.5rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.admin-doc-sticky {
|
||||
margin-inline: -2rem;
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
}
|
||||
/* 栏内内容与下方 1440px 内容区对齐(编辑器已通栏) */
|
||||
.admin-doc-sticky-inner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
gap: 0.5rem 1rem;
|
||||
flex-wrap: wrap;
|
||||
max-width: 1440px;
|
||||
margin-right: auto;
|
||||
}
|
||||
.admin-doc-sticky-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-doc-sticky-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.65rem;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-doc-sticky-title h1 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
line-height: 1.3;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.admin-doc-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
min-width: 2rem;
|
||||
min-height: 2rem;
|
||||
font-size: 13px;
|
||||
padding: 0 0.6rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-2);
|
||||
text-decoration: none;
|
||||
flex-shrink: 0;
|
||||
transition: border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.admin-doc-back:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.admin-doc-back:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
.admin-doc-head-title {
|
||||
@media (max-width: 420px) {
|
||||
.admin-doc-back-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.admin-doc-sticky-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
gap: 0.45rem 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
.admin-doc-head-title h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--ink);
|
||||
.admin-doc-sticky-actions .btn {
|
||||
min-height: 2.25rem;
|
||||
}
|
||||
.admin-doc-status {
|
||||
margin: 0 0 0 0.15rem;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-doc-status.is-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.admin-doc-split {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
max-width: 1440px;
|
||||
}
|
||||
.admin-doc-main {
|
||||
min-width: 0;
|
||||
@@ -4850,6 +4926,18 @@ a.j13-mention-token:hover {
|
||||
line-height: 1.4;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.admin-doc-label-value {
|
||||
margin-left: 0.4rem;
|
||||
font-weight: 600;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
/* 主栏标题输入:正文中最大的输入项,突出「先起标题」 */
|
||||
.admin-doc-title .admin-field-input {
|
||||
font-size: 16.5px;
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.01em;
|
||||
min-height: 2.9rem;
|
||||
}
|
||||
.admin-doc-hint {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 12.5px;
|
||||
@@ -4857,6 +4945,75 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink-3);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 编辑页主栏整块铺纯白(含标题与正文编辑区):画布镂空后编辑区域需要干净的白底,
|
||||
右栏设置卡同为白卡,两卡之间露出纹饰画布 */
|
||||
.admin-doc-editor .admin-doc-main {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 1.1rem 1.1rem 1.2rem;
|
||||
}
|
||||
/* 编辑器自带白卡边框在纯白主栏内会形成双层描边,扁平化融入主卡 */
|
||||
.admin-doc-editor .admin-doc-main .j13-md-editor:not(.is-fullscreen) {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
/* 板块管理:低信息密度列表 —— 策略/状态/帖数并入板块元信息行,6 列并 3 列并限宽,
|
||||
避免宽屏下名称列吞掉全部富余空间而显得空旷 */
|
||||
.boards-panel {
|
||||
max-width: 60rem;
|
||||
}
|
||||
.boards-head,
|
||||
.boards-row {
|
||||
display: grid;
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
.boards-head {
|
||||
padding-block: 0.65rem;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.boards-row {
|
||||
padding-block: 0.8rem;
|
||||
}
|
||||
.boards-row + .boards-row {
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.boards-actions {
|
||||
justify-self: end;
|
||||
}
|
||||
.boards-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.boards-meta-posts {
|
||||
font-size: 11.5px;
|
||||
color: var(--ink-3);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.boards-head {
|
||||
display: none;
|
||||
}
|
||||
.boards-row {
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
}
|
||||
.boards-actions {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
.admin-doc-md .j13-md-editor:not(.is-fullscreen) {
|
||||
height: clamp(16rem, calc(100dvh - 22rem), 40rem);
|
||||
min-height: 0;
|
||||
@@ -4875,7 +5032,7 @@ a.j13-mention-token:hover {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel);
|
||||
padding: 0.85rem 0.9rem 1rem;
|
||||
padding: 0.9rem 1rem 1.05rem;
|
||||
}
|
||||
.admin-doc-rail > summary {
|
||||
cursor: pointer;
|
||||
@@ -4886,15 +5043,39 @@ a.j13-mention-token:hover {
|
||||
min-height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
padding-right: 1.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
.admin-doc-rail > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
/* 折叠箭头:桌面双栏时 summary 隐藏,仅移动端单栏可见 */
|
||||
.admin-doc-rail > summary::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
top: 50%;
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-right: 1.5px solid var(--ink-3);
|
||||
border-bottom: 1.5px solid var(--ink-3);
|
||||
transform: translateY(-70%) rotate(45deg);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.admin-doc-rail[open] > summary::after {
|
||||
transform: translateY(-30%) rotate(-135deg);
|
||||
}
|
||||
.admin-doc-rail-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-top: 0.35rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
/* 设置项分组:细分隔线代替卡片套卡片 */
|
||||
.admin-doc-rail-body > * + * {
|
||||
margin-top: 1.05rem;
|
||||
padding-top: 1.05rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.admin-doc-field {
|
||||
min-width: 0;
|
||||
@@ -4949,6 +5130,21 @@ a.j13-mention-token:hover {
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
/* 标签胶囊实时预览:色块下方一条安静的展示条,与前台同款 j13-anno-tag */
|
||||
.admin-doc-tagpreview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.7rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.7rem;
|
||||
background: var(--bg);
|
||||
}
|
||||
.admin-doc-tagpreview::after {
|
||||
content: attr(data-note);
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-doc-colors .j13-anno-dot {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -4963,26 +5159,6 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-doc-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
.admin-doc-actions-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem 0.75rem;
|
||||
}
|
||||
.admin-doc-actions .btn {
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
.admin-doc-status {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-doc-status.is-error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.admin-doc-table-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -5129,11 +5305,11 @@ a.j13-mention-token:hover {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@container doc-editor (min-width: 64rem) {
|
||||
/* 双栏阈值取 72rem:保证吸顶操作栏单行放下,右侧设置栏再入列 */
|
||||
@container doc-editor (min-width: 72rem) {
|
||||
.admin-doc-split {
|
||||
grid-template-columns: minmax(0, 1fr) 19rem;
|
||||
column-gap: 1.5rem;
|
||||
row-gap: 0.85rem;
|
||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||
column-gap: 1.75rem;
|
||||
}
|
||||
.admin-doc-main {
|
||||
grid-column: 1;
|
||||
@@ -5141,10 +5317,12 @@ a.j13-mention-token:hover {
|
||||
}
|
||||
.admin-doc-rail {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
grid-row: 1;
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
max-height: calc(100dvh - 6rem);
|
||||
/* 吸顶操作栏(约 3.7rem)之下留出呼吸空隙 */
|
||||
top: 4.5rem;
|
||||
max-height: calc(100dvh - 10rem);
|
||||
overflow: auto;
|
||||
}
|
||||
.admin-doc-rail > summary {
|
||||
@@ -5153,9 +5331,10 @@ a.j13-mention-token:hover {
|
||||
.admin-doc-rail:not([open]) > .admin-doc-rail-body {
|
||||
display: flex;
|
||||
}
|
||||
.admin-doc-actions {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
/* 现代浏览器(Chrome 131+)对关闭的 details 用 content-visibility 隐藏内容,
|
||||
上一条 display 规则救不回来;桌面双栏强制可见,防止移动端折叠后拉宽窗口设置凭空消失 */
|
||||
.admin-doc-rail:not([open])::details-content {
|
||||
content-visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6118,7 +6297,8 @@ a.j13-mention-token:hover {
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.j13-anno-dot:hover { transform: scale(1.12); }
|
||||
.j13-anno-dot[aria-pressed="true"] {
|
||||
.j13-anno-dot[aria-pressed="true"],
|
||||
.j13-anno-dot[aria-checked="true"] {
|
||||
border-color: var(--panel);
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
@@ -8499,7 +8679,7 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 头像配色(浅/暗各自保证可读) */
|
||||
/* 头像/板块配色(浅/暗各自保证可读) */
|
||||
.av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; }
|
||||
.av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; }
|
||||
.av-2 { background: rgba(109, 71, 192, 0.12); color: #6d47c0; }
|
||||
@@ -8508,6 +8688,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
.av-5 { background: rgba(171, 56, 93, 0.12); color: #ab385d; }
|
||||
.av-6 { background: rgba(133, 88, 0, 0.14); color: #855800; }
|
||||
.av-7 { background: rgba(82, 96, 122, 0.13); color: #52607a; }
|
||||
.av-8 { background: rgba(5, 150, 105, 0.13); color: #059669; }
|
||||
.av-9 { background: rgba(2, 132, 199, 0.13); color: #0284c7; }
|
||||
.av-10 { background: rgba(67, 56, 202, 0.13); color: #4338ca; }
|
||||
.av-11 { background: rgba(190, 24, 93, 0.13); color: #be185d; }
|
||||
.av-12 { background: rgba(180, 83, 9, 0.14); color: #b45309; }
|
||||
.av-13 { background: rgba(77, 124, 3, 0.14); color: #4d7c03; }
|
||||
.av-14 { background: rgba(157, 23, 158, 0.13); color: #9d179e; }
|
||||
.av-15 { background: rgba(159, 0, 51, 0.13); color: #9f0033; }
|
||||
.dark .av-0 { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; }
|
||||
.dark .av-1 { background: rgba(92, 201, 224, 0.18); color: #5cc9e0; }
|
||||
.dark .av-2 { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; }
|
||||
@@ -8516,6 +8704,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
.dark .av-5 { background: rgba(240, 147, 181, 0.18); color: #f093b5; }
|
||||
.dark .av-6 { background: rgba(224, 178, 89, 0.18); color: #e0b259; }
|
||||
.dark .av-7 { background: rgba(170, 182, 207, 0.18); color: #aab6cf; }
|
||||
.dark .av-8 { background: rgba(110, 231, 183, 0.18); color: #6ee7b7; }
|
||||
.dark .av-9 { background: rgba(125, 211, 252, 0.18); color: #7dd3fc; }
|
||||
.dark .av-10 { background: rgba(165, 180, 252, 0.18); color: #a5b4fc; }
|
||||
.dark .av-11 { background: rgba(251, 113, 133, 0.18); color: #fb7185; }
|
||||
.dark .av-12 { background: rgba(252, 211, 77, 0.18); color: #fcd34d; }
|
||||
.dark .av-13 { background: rgba(190, 242, 100, 0.18); color: #bef264; }
|
||||
.dark .av-14 { background: rgba(232, 121, 249, 0.18); color: #e879f9; }
|
||||
.dark .av-15 { background: rgba(248, 113, 113, 0.18); color: #f87171; }
|
||||
|
||||
/* ============================================================
|
||||
微交互
|
||||
|
||||
@@ -38,6 +38,14 @@ export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [retryAfter, setRetryAfter] = useState(0);
|
||||
|
||||
// 登录限流倒计时:窗口期内禁用提交,避免无意义请求与提示条消失重现的闪烁
|
||||
useEffect(() => {
|
||||
if (retryAfter <= 0) return;
|
||||
const t = setTimeout(() => setRetryAfter((s) => s - 1), 1000);
|
||||
return () => clearTimeout(t);
|
||||
}, [retryAfter]);
|
||||
|
||||
// 已登录用户访问登录页:直接回落到安全的跳转目标
|
||||
useEffect(() => {
|
||||
@@ -52,7 +60,7 @@ export default function LoginPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
// 提交不清空 error:提示条消失又重现会造成表单跳动(闪烁),输入时再清除
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiLogin(username, password);
|
||||
@@ -63,6 +71,9 @@ export default function LoginPage() {
|
||||
router.refresh();
|
||||
} else {
|
||||
setError(res.error || "登录失败");
|
||||
if (typeof res.retry_after === "number" && res.retry_after > 0) {
|
||||
setRetryAfter(res.retry_after);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -96,7 +107,10 @@ export default function LoginPage() {
|
||||
id="login-username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
@@ -110,15 +124,18 @@ export default function LoginPage() {
|
||||
id="login-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="field"
|
||||
placeholder="输入密码"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
|
||||
{loading ? "登录中..." : "登录"}
|
||||
<button type="submit" disabled={loading || retryAfter > 0} className="btn btn-primary w-full justify-center mt-2">
|
||||
{loading ? "登录中..." : retryAfter > 0 ? `${retryAfter} 秒后可重试` : "登录"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ type Props = {
|
||||
boardName: string;
|
||||
boardId: number;
|
||||
boardColorIndex?: number;
|
||||
boardIcon?: string;
|
||||
};
|
||||
|
||||
export default function EditPostClient({
|
||||
@@ -31,6 +32,7 @@ export default function EditPostClient({
|
||||
boardName,
|
||||
boardId,
|
||||
boardColorIndex = 0,
|
||||
boardIcon,
|
||||
}: Props) {
|
||||
return (
|
||||
<PostComposer
|
||||
@@ -47,6 +49,7 @@ export default function EditPostClient({
|
||||
boardName={boardName}
|
||||
boardId={boardId}
|
||||
boardColorIndex={boardColorIndex}
|
||||
boardIcon={boardIcon}
|
||||
cancelHref={`/post/${postId}`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -47,6 +47,7 @@ export default async function EditPostPage({ params }: PageProps) {
|
||||
boardName={post.board?.name || "未知板块"}
|
||||
boardId={post.board_id}
|
||||
boardColorIndex={post.board?.color_index ?? 0}
|
||||
boardIcon={post.board?.icon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type Board,
|
||||
type CommentsResponse,
|
||||
} from "@/lib/api";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
import { postViewerCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||
import CommentSection from "@/components/CommentSection";
|
||||
@@ -109,7 +110,7 @@ function formatDateTime(dateStr: string) {
|
||||
============================================================ */
|
||||
|
||||
function BoardCard({ board }: { board: Board }) {
|
||||
const colorIdx = Math.max(0, board.color_index ?? 0) % 8;
|
||||
const colorIdx = boardColor(board);
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
|
||||
<p className="j13-module-title mb-3" id="board-card-title">
|
||||
@@ -117,9 +118,9 @@ function BoardCard({ board }: { board: Board }) {
|
||||
</p>
|
||||
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
|
||||
<span
|
||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
|
||||
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
|
||||
>
|
||||
{board.name.slice(0, 1)}
|
||||
<BoardIcon icon={board.icon} name={board.name} size={20} />
|
||||
</span>
|
||||
<span
|
||||
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
|
||||
119
frontend/components/BoardIcon.tsx
Normal file
119
frontend/components/BoardIcon.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Bookmark,
|
||||
Book,
|
||||
Briefcase,
|
||||
Bell,
|
||||
Camera,
|
||||
Code,
|
||||
Coffee,
|
||||
Cpu,
|
||||
Film,
|
||||
Flame,
|
||||
FlaskConical,
|
||||
Gamepad2,
|
||||
Globe,
|
||||
Hash,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
Music,
|
||||
Newspaper,
|
||||
Palette,
|
||||
Pencil,
|
||||
Rocket,
|
||||
Sparkles,
|
||||
Star,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
/** 板块图标库:icon 字段值 → lucide 图标;与后端种子数据保持一致 */
|
||||
const BOARD_ICON_MAP: Record<string, LucideIcon> = {
|
||||
"message-circle": MessageCircle,
|
||||
code: Code,
|
||||
"help-circle": HelpCircle,
|
||||
coffee: Coffee,
|
||||
sparkles: Sparkles,
|
||||
star: Star,
|
||||
heart: Heart,
|
||||
bookmark: Bookmark,
|
||||
hash: Hash,
|
||||
flame: Flame,
|
||||
bell: Bell,
|
||||
megaphone: Megaphone,
|
||||
newspaper: Newspaper,
|
||||
book: Book,
|
||||
briefcase: Briefcase,
|
||||
camera: Camera,
|
||||
music: Music,
|
||||
film: Film,
|
||||
"gamepad-2": Gamepad2,
|
||||
rocket: Rocket,
|
||||
globe: Globe,
|
||||
pencil: Pencil,
|
||||
palette: Palette,
|
||||
cpu: Cpu,
|
||||
"flask-conical": FlaskConical,
|
||||
};
|
||||
|
||||
/** 管理端图标选项:value 即 icon 字段值,label 为中文描述 */
|
||||
export const BOARD_ICONS: { value: string; label: string }[] = [
|
||||
{ value: "message-circle", label: "综合" },
|
||||
{ value: "hash", label: "话题" },
|
||||
{ value: "code", label: "代码" },
|
||||
{ value: "help-circle", label: "求助" },
|
||||
{ value: "coffee", label: "闲聊" },
|
||||
{ value: "sparkles", label: "闪光" },
|
||||
{ value: "star", label: "星标" },
|
||||
{ value: "heart", label: "心形" },
|
||||
{ value: "bookmark", label: "书签" },
|
||||
{ value: "flame", label: "热门" },
|
||||
{ value: "bell", label: "通知" },
|
||||
{ value: "megaphone", label: "公告" },
|
||||
{ value: "newspaper", label: "资讯" },
|
||||
{ value: "book", label: "学习" },
|
||||
{ value: "briefcase", label: "职场" },
|
||||
{ value: "camera", label: "摄影" },
|
||||
{ value: "music", label: "音乐" },
|
||||
{ value: "film", label: "影视" },
|
||||
{ value: "gamepad-2", label: "游戏" },
|
||||
{ value: "rocket", label: "创业" },
|
||||
{ value: "globe", label: "国际" },
|
||||
{ value: "pencil", label: "写作" },
|
||||
{ value: "palette", label: "设计" },
|
||||
{ value: "cpu", label: "硬件" },
|
||||
{ value: "flask-conical", label: "科学" },
|
||||
];
|
||||
|
||||
/** 配色总数:av-0 ~ av-(N-1),与 globals.css 保持一致 */
|
||||
export const BOARD_COLOR_COUNT = 16;
|
||||
|
||||
/**
|
||||
* 解析板块配色下标(全站统一规则):
|
||||
* - color_index > 0:固定配色,按 N 取模(存 1..16 渲染 av-0..av-15)
|
||||
* - 其余(0 = 旧数据未设置 / -1 = 自动):按板块 ID 轮换,任何页面位置都一致
|
||||
*/
|
||||
export function boardColor(b: { id: number; color_index?: number | null }): number {
|
||||
const ci = b.color_index ?? 0;
|
||||
if (ci > 0) return ci % BOARD_COLOR_COUNT;
|
||||
return b.id % BOARD_COLOR_COUNT;
|
||||
}
|
||||
|
||||
/** 板块图标:icon 匹配 lucide 图标名;未知/缺失时回退为板块名首字 */
|
||||
export default function BoardIcon({
|
||||
icon,
|
||||
name,
|
||||
size = 15,
|
||||
}: {
|
||||
icon?: string | null;
|
||||
name: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const I = icon ? BOARD_ICON_MAP[icon] : undefined;
|
||||
if (I) return <I size={size} aria-hidden="true" />;
|
||||
return (
|
||||
<span aria-hidden="true" style={{ fontSize: Math.max(10, size * 0.78), lineHeight: 1 }}>
|
||||
{name.slice(0, 1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import type { Board } from "@/lib/api";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
|
||||
type BoardPickerProps = {
|
||||
boards: Board[];
|
||||
@@ -15,10 +16,6 @@ type BoardPickerProps = {
|
||||
id?: string;
|
||||
};
|
||||
|
||||
function boardColorIndex(b: Board, fallbackIndex: number) {
|
||||
return Math.max(0, b.color_index ?? fallbackIndex) % 8;
|
||||
}
|
||||
|
||||
export default function BoardPicker({
|
||||
boards,
|
||||
value,
|
||||
@@ -104,9 +101,7 @@ export default function BoardPicker({
|
||||
? "暂无可用板块"
|
||||
: "请选择板块";
|
||||
|
||||
const colorIdx = selected
|
||||
? boardColorIndex(selected, selectedIdx)
|
||||
: 0;
|
||||
const colorIdx = selected ? boardColor(selected) : 0;
|
||||
|
||||
return (
|
||||
<div className="j13-board-picker" ref={rootRef}>
|
||||
@@ -126,7 +121,7 @@ export default function BoardPicker({
|
||||
className={`j13-board-trigger-ico${selected ? ` av-${colorIdx}` : " is-placeholder"}`}
|
||||
aria-hidden
|
||||
>
|
||||
{(selected?.name || "板").slice(0, 1)}
|
||||
<BoardIcon icon={selected?.icon} name={selected?.name || "板"} size={16} />
|
||||
</span>
|
||||
<span className="j13-board-trigger-copy">
|
||||
<span className="j13-board-trigger-name">{label}</span>
|
||||
@@ -160,7 +155,6 @@ export default function BoardPicker({
|
||||
>
|
||||
{boards.map((b, i) => {
|
||||
const selectedHere = b.id === value;
|
||||
const c = boardColorIndex(b, i);
|
||||
return (
|
||||
<li key={b.id} role="presentation">
|
||||
<button
|
||||
@@ -173,8 +167,8 @@ export default function BoardPicker({
|
||||
onMouseEnter={() => setActiveIndex(i)}
|
||||
onClick={() => pick(b.id)}
|
||||
>
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{b.name.slice(0, 1)}
|
||||
<span className={`j13-board-trigger-ico av-${boardColor(b)}`} aria-hidden>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={16} />
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 truncate">{b.name}</span>
|
||||
{selectedHere && <Check size={15} className="shrink-0" />}
|
||||
@@ -188,19 +182,23 @@ export default function BoardPicker({
|
||||
);
|
||||
}
|
||||
|
||||
/** 编辑态只读板块徽章(与触发器同高) */
|
||||
/** 编辑态只读板块徽章(与触发器同高;boardId + 原始 color_index,内部按全站规则解析配色) */
|
||||
export function BoardBadge({
|
||||
name,
|
||||
boardId,
|
||||
colorIndex = 0,
|
||||
icon,
|
||||
}: {
|
||||
name: string;
|
||||
boardId: number;
|
||||
colorIndex?: number;
|
||||
icon?: string;
|
||||
}) {
|
||||
const c = Math.max(0, colorIndex) % 8;
|
||||
const c = boardColor({ id: boardId, color_index: colorIndex });
|
||||
return (
|
||||
<div className="j13-board-badge">
|
||||
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
|
||||
{name.slice(0, 1)}
|
||||
<BoardIcon icon={icon} name={name} size={16} />
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</div>
|
||||
|
||||
218
frontend/components/ChatUserCard.tsx
Normal file
218
frontend/components/ChatUserCard.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Calendar, Crown, MessageSquare, VolumeX } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import ImageLightbox from "@/components/markdown/ImageLightbox";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
export type ChatUserCardTarget = {
|
||||
user: User;
|
||||
/** 群内身份(可从成员表查到时携带) */
|
||||
roomRole?: "owner" | "admin" | "member";
|
||||
muted?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 聊天用户资料卡:头像(可进图箱)+ 基本信息 + 主页/私聊入口。
|
||||
* 公开资料拉取失败时降级为消息内已有的基础字段,不阻塞弹层。
|
||||
*/
|
||||
export default function ChatUserCard({
|
||||
target,
|
||||
meId,
|
||||
onClose,
|
||||
}: {
|
||||
target: ChatUserCardTarget;
|
||||
meId: number;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const allowMessages = useAllowMessages();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [dmBusy, setDmBusy] = useState(false);
|
||||
const [avatarOpen, setAvatarOpen] = useState(false);
|
||||
|
||||
const { user } = target;
|
||||
const isSelf = user.id === meId;
|
||||
const name = user.nickname || user.username;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setProfile(null);
|
||||
apiGetUserProfile(user.id)
|
||||
.then((p) => {
|
||||
if (alive) setProfile(p);
|
||||
})
|
||||
.catch(() => {
|
||||
// 静默:资料卡仍展示消息携带的基础字段
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [user.id]);
|
||||
|
||||
const startDM = async () => {
|
||||
setDmBusy(true);
|
||||
try {
|
||||
const { room } = await apiOpenDirectChat(user.id);
|
||||
onClose();
|
||||
router.push(`/chat/${room.id}`);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "无法发起私聊");
|
||||
} finally {
|
||||
setDmBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sig = (profile?.user.signature || "").trim();
|
||||
const canPreviewAvatar = !!user.avatar;
|
||||
const stats = profile?.stats;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open
|
||||
title="用户资料"
|
||||
ariaLabel={`${name} 的资料卡`}
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-sm"
|
||||
>
|
||||
{/* 头部:头像 + 昵称/角色 */}
|
||||
<div className="flex items-center gap-3.5">
|
||||
{canPreviewAvatar ? (
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-zoom-in rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
|
||||
aria-label="查看头像"
|
||||
onClick={() => setAvatarOpen(true)}
|
||||
>
|
||||
<Avatar name={name} src={user.avatar || undefined} size={64} />
|
||||
</button>
|
||||
) : (
|
||||
<Avatar name={name} src={user.avatar || undefined} size={64} />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<p
|
||||
className="text-[16px] font-extrabold tracking-tight truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{user.role && user.role !== "user" && (
|
||||
<RoleBadge role={user.role} boardIds={user.board_ids} />
|
||||
)}
|
||||
{profile?.user.banned === true && (
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
|
||||
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
|
||||
>
|
||||
已封禁
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="meta text-[12.5px] mt-0.5 truncate">@{user.username}</p>
|
||||
{target.roomRole === "owner" && (
|
||||
<p
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium"
|
||||
style={{ color: "var(--gold)" }}
|
||||
>
|
||||
<Crown size={12} /> 群主
|
||||
</p>
|
||||
)}
|
||||
{target.roomRole === "admin" && (
|
||||
<p
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
<Crown size={12} /> 群管理员
|
||||
</p>
|
||||
)}
|
||||
{target.muted && (
|
||||
<p
|
||||
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
<VolumeX size={12} /> 禁言中
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 签名 */}
|
||||
{sig && (
|
||||
<p
|
||||
className="mt-3 text-[13px] leading-relaxed"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{sig}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 基本信息行 */}
|
||||
<div
|
||||
className="mt-3.5 pt-3 flex items-center gap-x-3 gap-y-1.5 flex-wrap text-[12.5px]"
|
||||
style={{ borderTop: "1px solid var(--line)", color: "var(--ink-2)" }}
|
||||
>
|
||||
<span>
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{stats ? stats.post_count : "–"}
|
||||
</span>{" "}
|
||||
帖子
|
||||
</span>
|
||||
<span>
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{stats ? stats.comment_count : "–"}
|
||||
</span>{" "}
|
||||
评论
|
||||
</span>
|
||||
<span>
|
||||
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{stats ? stats.points : "–"}
|
||||
</span>{" "}
|
||||
积分
|
||||
</span>
|
||||
{profile?.user.created_at && (
|
||||
<span className="inline-flex items-center gap-1 meta">
|
||||
<Calendar size={12} />
|
||||
{new Date(profile.user.created_at).toLocaleDateString("zh-CN")} 加入
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 操作区 */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<Link href={`/u/${user.id}`} className="btn btn-line btn-sm flex-1 justify-center">
|
||||
用户主页
|
||||
</Link>
|
||||
{allowMessages && !isSelf && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startDM}
|
||||
disabled={dmBusy}
|
||||
className="btn btn-accent btn-sm flex-1 justify-center gap-1.5"
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
{dmBusy ? "打开中…" : "发私信"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{avatarOpen && canPreviewAvatar && (
|
||||
<ImageLightbox
|
||||
items={[{ src: user.avatar, alt: `${name} 的头像` }]}
|
||||
index={0}
|
||||
onClose={() => setAvatarOpen(false)}
|
||||
onIndexChange={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -169,6 +169,7 @@ type EditProps = {
|
||||
boardName?: string;
|
||||
boardId?: number;
|
||||
boardColorIndex?: number;
|
||||
boardIcon?: string;
|
||||
cancelHref: string;
|
||||
};
|
||||
|
||||
@@ -1397,7 +1398,9 @@ export default function PostComposer(props: PostComposerProps) {
|
||||
) : (
|
||||
<BoardBadge
|
||||
name={props.boardName || "未知板块"}
|
||||
boardId={props.boardId || 0}
|
||||
colorIndex={props.boardColorIndex ?? 0}
|
||||
icon={props.boardIcon}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { toast } from "@/lib/toast";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import Modal from "@/components/Modal";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import BoardIcon from "@/components/BoardIcon";
|
||||
|
||||
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
|
||||
export interface RoleAccountTarget {
|
||||
@@ -281,6 +282,7 @@ export default function RoleAccountModal({
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
<span className="truncate">{b.name}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ChevronDown,
|
||||
FileText,
|
||||
CircleDollarSign,
|
||||
Columns3,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
canModerateAny,
|
||||
canAccessAdminMessages,
|
||||
isAdminOrAbove,
|
||||
isOwner,
|
||||
isStaff,
|
||||
isSuperOrOwner,
|
||||
type RoleUserLike,
|
||||
@@ -117,6 +119,17 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "板块",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/boards",
|
||||
label: "板块管理",
|
||||
icon: Columns3,
|
||||
visible: (u) => isOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "站点",
|
||||
items: [
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
|
||||
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
|
||||
function boardHref(id: number) {
|
||||
@@ -52,7 +53,7 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
|
||||
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b, i) => (
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
@@ -60,8 +61,8 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
|
||||
{b.name.slice(0, 1)}
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
@@ -126,6 +127,7 @@ export function WorkspaceNavMobile({
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -82,8 +82,47 @@ export interface Board {
|
||||
icon: string;
|
||||
color_index?: number;
|
||||
sort_order: number;
|
||||
post_policy?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
// 管理端板块:列表附带已发布帖子数
|
||||
export interface AdminBoard {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
color_index: number;
|
||||
sort_order: number;
|
||||
post_policy: string;
|
||||
visible: boolean;
|
||||
post_count?: number;
|
||||
}
|
||||
|
||||
export interface AdminBoardModerator {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
email: string;
|
||||
banned: boolean;
|
||||
}
|
||||
|
||||
export type AdminBoardInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
color_index?: number | null;
|
||||
sort_order?: number | null;
|
||||
post_policy?: string;
|
||||
visible?: boolean | null;
|
||||
};
|
||||
|
||||
export const BOARD_POST_POLICY = {
|
||||
ALL: "all",
|
||||
STAFF: "staff",
|
||||
} as const;
|
||||
|
||||
// 首页聚合接口
|
||||
export interface OverviewStats {
|
||||
posts: number;
|
||||
@@ -695,10 +734,12 @@ export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
|
||||
}
|
||||
|
||||
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认);
|
||||
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true)
|
||||
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true);
|
||||
// publish_without_review 为全站免审,无需过审记录直接发布(缺省/未配置视为 false)
|
||||
export interface PublicSettings {
|
||||
accent: string;
|
||||
trust_reviewed_publish: boolean;
|
||||
publish_without_review: boolean;
|
||||
site_name: string;
|
||||
site_description: string;
|
||||
/** 无 Logo 时页眉字标;空则回退显示名称 */
|
||||
@@ -772,6 +813,7 @@ export const DEFAULT_ATTACHMENT_EXTS: string[] = [
|
||||
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
accent: "",
|
||||
trust_reviewed_publish: true,
|
||||
publish_without_review: false,
|
||||
site_name: "姜十三论坛",
|
||||
site_description: "姜十三论坛 - 技术分享与讨论社区",
|
||||
site_wordmark: "",
|
||||
@@ -842,6 +884,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
return {
|
||||
accent: data?.accent ?? "",
|
||||
trust_reviewed_publish: data?.trust_reviewed_publish !== false,
|
||||
publish_without_review: data?.publish_without_review === true,
|
||||
site_name: (data?.site_name || "").trim() || DEFAULT_PUBLIC_SETTINGS.site_name,
|
||||
site_description:
|
||||
data?.site_description !== undefined
|
||||
@@ -2142,6 +2185,187 @@ export async function apiAdminSetUserBan(
|
||||
return data as { user: AdminUser };
|
||||
}
|
||||
|
||||
// ===== 数据导入(超管/站长) =====
|
||||
|
||||
export type LegacyUserReport = {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
avatar_written: number;
|
||||
conflicts?: string[];
|
||||
avatar_missing?: string[];
|
||||
failed?: string[];
|
||||
};
|
||||
|
||||
export type LegacyBoardReport = {
|
||||
created: number;
|
||||
reused: number;
|
||||
names?: string[];
|
||||
};
|
||||
|
||||
export type LegacyPostReport = {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
polls_skipped: number;
|
||||
poll_titles?: string[];
|
||||
owner_fallback?: string[];
|
||||
failed?: string[];
|
||||
};
|
||||
|
||||
export type LegacyCommentReport = {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
skipped_detail?: string[];
|
||||
owner_fallback?: string[];
|
||||
failed?: string[];
|
||||
};
|
||||
|
||||
export type LegacyImportReport = {
|
||||
dry_run: boolean;
|
||||
users: LegacyUserReport;
|
||||
boards?: LegacyBoardReport;
|
||||
posts?: LegacyPostReport;
|
||||
comments?: LegacyCommentReport;
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export type LegacyImportOptions = {
|
||||
withContent: boolean;
|
||||
dryRun: boolean;
|
||||
};
|
||||
|
||||
/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */
|
||||
export async function apiAdminImportLegacy(
|
||||
source: string,
|
||||
dbFile: File,
|
||||
zipFile: File | null,
|
||||
opts: LegacyImportOptions
|
||||
): Promise<LegacyImportReport> {
|
||||
const form = new FormData();
|
||||
form.append("db_file", dbFile, dbFile.name || "jiang13.db");
|
||||
if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip");
|
||||
form.append("with_content", String(opts.withContent));
|
||||
form.append("dry_run", String(opts.dryRun));
|
||||
const res = await fetchWithRefresh(`/api/admin/import/${source}`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "导入失败");
|
||||
return (data.report || {}) as LegacyImportReport;
|
||||
}
|
||||
|
||||
// ===== 板块管理(仅站长) =====
|
||||
|
||||
export async function fetchAdminBoards(
|
||||
cookieHeader?: string
|
||||
): Promise<{ boards: AdminBoard[] }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/admin/boards`,
|
||||
ssrInit(cookieHeaders(cookieHeader))
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取板块列表失败");
|
||||
return data as { boards: AdminBoard[] };
|
||||
}
|
||||
|
||||
export async function apiAdminListBoards(): Promise<{ boards: AdminBoard[] }> {
|
||||
const res = await fetchWithRefresh("/api/admin/boards", {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取板块列表失败");
|
||||
return data as { boards: AdminBoard[] };
|
||||
}
|
||||
|
||||
export async function apiAdminCreateBoard(
|
||||
input: AdminBoardInput
|
||||
): Promise<{ board: AdminBoard }> {
|
||||
const res = await fetchWithRefresh("/api/admin/boards", {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "创建板块失败");
|
||||
return data as { board: AdminBoard };
|
||||
}
|
||||
|
||||
export async function apiAdminUpdateBoard(
|
||||
id: number,
|
||||
input: AdminBoardInput
|
||||
): Promise<{ board: AdminBoard }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/boards/${id}`, {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "更新板块失败");
|
||||
return data as { board: AdminBoard };
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteBoard(id: number): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/boards/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "删除板块失败");
|
||||
}
|
||||
|
||||
export async function apiAdminReorderBoards(ids: number[]): Promise<void> {
|
||||
const res = await fetchWithRefresh("/api/admin/boards/reorder", {
|
||||
method: "PUT",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "排序失败");
|
||||
}
|
||||
|
||||
export async function apiAdminListBoardModerators(
|
||||
id: number
|
||||
): Promise<{ moderators: AdminBoardModerator[] }> {
|
||||
const res = await fetchWithRefresh(`/api/admin/boards/${id}/moderators`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取版主失败");
|
||||
return data as { moderators: AdminBoardModerator[] };
|
||||
}
|
||||
|
||||
export async function apiAdminAddBoardModerator(
|
||||
boardId: number,
|
||||
username: string
|
||||
): Promise<void> {
|
||||
const res = await fetchWithRefresh(`/api/admin/boards/${boardId}/moderators`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "添加版主失败");
|
||||
}
|
||||
|
||||
export async function apiAdminRemoveBoardModerator(
|
||||
boardId: number,
|
||||
uid: number
|
||||
): Promise<void> {
|
||||
const res = await fetchWithRefresh(
|
||||
`/api/admin/boards/${boardId}/moderators/${uid}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: clientHeaders(),
|
||||
}
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "移除版主失败");
|
||||
}
|
||||
|
||||
// ===== 站长用户内容档案 =====
|
||||
|
||||
export interface AdminUserAuditProfile {
|
||||
@@ -2277,6 +2501,7 @@ export async function apiGetSettings(): Promise<PublicSettings> {
|
||||
export type UpdateSiteSettingsBody = {
|
||||
accent?: string;
|
||||
trust_reviewed_publish?: boolean;
|
||||
publish_without_review?: boolean;
|
||||
site_name?: string;
|
||||
site_description?: string;
|
||||
site_wordmark?: string;
|
||||
@@ -2790,6 +3015,16 @@ export async function apiOpenDirectChat(
|
||||
return data as { room: ChatRoomView };
|
||||
}
|
||||
|
||||
// 客户端:公开用户资料(资料卡用;只取第一页减小体积)
|
||||
export async function apiGetUserProfile(id: number): Promise<UserProfile> {
|
||||
const res = await fetchWithRefresh(`/api/users/${id}?page=1&size=1`, {
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || "获取用户资料失败");
|
||||
return data as UserProfile;
|
||||
}
|
||||
|
||||
// 客户端:搜索用户以发起私聊
|
||||
export async function apiSearchChatUsers(
|
||||
q: string
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface RtPresenceData {
|
||||
export interface RtSettingsData {
|
||||
accent: string;
|
||||
trust_reviewed_publish?: boolean;
|
||||
publish_without_review?: boolean;
|
||||
site_name?: string;
|
||||
site_description?: string;
|
||||
site_wordmark?: string;
|
||||
|
||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
import "./.next/dev/types/root-params.d.ts";
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/types/root-params.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
Reference in New Issue
Block a user