feat: 站点媒体库与移动端底部导航,服务测试补强

- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
2026-09-27 02:35:38 +08:00
parent 734afbc39f
commit 1d862a0dd3
61 changed files with 4757 additions and 1129 deletions

View File

@@ -3,14 +3,18 @@ module github.com/freefire/jiang13-bbs
go 1.27.0
require (
github.com/JohannesKaufmann/html-to-markdown v1.6.0
github.com/gen2brain/webp v0.6.4
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/gorilla/websocket v1.5.3
github.com/minio/minio-go/v7 v7.0.83
github.com/yuin/goldmark v1.7.8
golang.org/x/crypto v0.43.0
golang.org/x/image v0.46.0
golang.org/x/net v0.45.0
golang.org/x/text v0.42.0
gopkg.in/ini.v1 v1.67.0
gorm.io/driver/postgres v1.5.9
@@ -18,7 +22,6 @@ 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
@@ -26,10 +29,10 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.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
@@ -57,7 +60,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/sys v0.48.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect

View File

@@ -18,8 +18,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gen2brain/webp v0.6.4 h1:SUDdmxADOAiPQ+5ylNmuHhuYf2dOi0KgKZHL5vpVCNU=
github.com/gen2brain/webp v0.6.4/go.mod h1:iGWMaCSw7t3I/Cv9llzEKmpnR36S8lS8VL/ZVjxU0JE=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -47,6 +51,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
@@ -104,8 +110,10 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
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 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
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 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
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=
@@ -194,8 +202,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -205,6 +213,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
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=

View File

@@ -2,11 +2,13 @@ package handler
import (
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/service"
@@ -18,8 +20,14 @@ import (
// POST /api/admin/import/:source multipart:
// - db_file 旧站 SQLite 库(必填)
// - avatars_zip 头像压缩包(可选)
// - images_zip 帖子图片压缩包(可选,旧站 data/uploads/posts 打包)
// - with_content "true"/"false",是否同时导入板块/帖子/评论(默认 true)
// - dry_run "true"/"false",预检模式不写库(默认 false)
// - dry_run "true"/"false",预检模式不写库并返回勾选清单(默认 false)
// - skip_users 逗号分隔旧用户ID,不创建账号(内容仍按归属规则落位)
// - operator_users 逗号分隔旧用户ID,内容归到站长
// - user_map "旧用户ID:本站用户名" 逗号分隔,内容归到指定已有账号
// - skip_posts 逗号分隔旧帖ID,排除(其评论自动跳过)
// - skip_comments 逗号分隔旧评论ID,排除
//
// source 走白名单(当前仅 jiang13),为 WordPress / Typecho 等来源预留扩展。
@@ -32,7 +40,7 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
}
const overhead = 64 << 10 // multipart 边界与表单开销
limit := int64(service.LegacyDBMaxBytes) + int64(service.LegacyZipMaxBytes) + overhead
limit := int64(service.LegacyDBMaxBytes) + 2*int64(service.LegacyZipMaxBytes) + overhead
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
claims := middleware.CurrentUser(c)
@@ -50,25 +58,25 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
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, "读取头像压缩包失败")
zipPath, ok := optionalLegacyZip(c, "avatars_zip", "legacy-avatars-*.zip", "头像压缩包")
if !ok {
return
}
if zipPath != "" {
defer os.Remove(zipPath)
} else if ferr != nil {
// 表单整体解析失败(超限等);「字段不存在」直接忽略
var maxErr *http.MaxBytesError
if errors.As(ferr, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
}
imagesZipPath, ok := optionalLegacyZip(c, "images_zip", "legacy-images-*.zip", "帖子图片压缩包")
if !ok {
return
}
if imagesZipPath != "" {
defer os.Remove(imagesZipPath)
}
userMap, err := parseUserTargetMap(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dbPath, err := saveMultipartToTemp(dbFH, "legacy-*.db")
@@ -78,12 +86,17 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
}
defer os.Remove(dbPath)
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, service.LegacyImportOptions{
rep, err := h.LegacyImport.ImportFromFiles(dbPath, zipPath, imagesZipPath, service.LegacyImportOptions{
WithContent: formBool(c, "with_content", true),
DryRun: formBool(c, "dry_run", false),
SkipUserIDs: parseIDSet(c, "skip_users"),
OperatorUsers: parseIDSet(c, "operator_users"),
UserTargetNames: userMap,
SkipPostIDs: parseIDSet(c, "skip_posts"),
SkipCommentIDs: parseIDSet(c, "skip_comments"),
}, claims.ID)
if err != nil {
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) {
if errors.Is(err, service.ErrLegacyInvalidSQLite) || errors.Is(err, service.ErrLegacyBadZip) || errors.Is(err, service.ErrLegacyBadTarget) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -93,6 +106,75 @@ func (h *Handlers) AdminImportLegacy(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"report": rep})
}
// optionalLegacyZip 读取可选 zip 表单项并落临时文件;字段不存在返回空串。
// 出错时已写响应,返回 false 中止请求。
func optionalLegacyZip(c *gin.Context, field, pattern, label string) (string, bool) {
fh, err := c.FormFile(field)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "上传内容过大"})
return "", false
}
return "", true // 字段不存在,视为未上传
}
if fh.Size > service.LegacyZipMaxBytes {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": label+"不能超过 128MB"})
return "", false
}
path, err := saveMultipartToTemp(fh, pattern)
if err != nil {
respondLegacyUploadError(c, err, "读取"+label+"失败")
return "", false
}
return path, true
}
// parseIDSet 解析逗号分隔的旧对象 ID 列表(如 "3,7,12")
func parseIDSet(c *gin.Context, key string) map[uint]bool {
v := strings.TrimSpace(c.PostForm(key))
if v == "" {
return nil
}
out := map[uint]bool{}
for _, part := range strings.Split(v, ",") {
id, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
if err == nil && id > 0 {
out[uint(id)] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
// parseUserTargetMap 解析 "旧用户ID:本站用户名" 列表(如 "5:bob,7:alice");
// 目标账号由 service 在导入时校验并解析为账号 ID。
func parseUserTargetMap(c *gin.Context) (map[uint]string, error) {
v := strings.TrimSpace(c.PostForm("user_map"))
if v == "" {
return nil, nil
}
out := map[uint]string{}
for _, pair := range strings.Split(v, ",") {
kv := strings.SplitN(strings.TrimSpace(pair), ":", 2)
if len(kv) != 2 {
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
}
oldID, err := strconv.ParseUint(strings.TrimSpace(kv[0]), 10, 64)
name := strings.TrimSpace(kv[1])
if err != nil || oldID == 0 || name == "" {
return nil, fmt.Errorf("user_map 格式错误: %s", pair)
}
out[uint(oldID)] = name
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func formBool(c *gin.Context, key string, def bool) bool {
v := c.PostForm(key)
if v == "" {

View File

@@ -213,8 +213,9 @@ func (h *Handlers) Login(c *gin.Context) {
// dev 模式不设 Secure,生产环境需 HTTPS
h.Ops.ClearFailure(req.Username)
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
// 真·单会话:本次登录顶掉了所有旧会话,即时广播告知被踢设备(旧浏览器 WS 在线则
// 当场弹"登录已失效",无需等 access token 过期或硬刷新才发现)
// 多会话并存:新登录不踢旧设备;仅当本次登录超出会话上限、淘汰了最旧
// 会话时广播告知(被淘汰设备 WS 在线则当场弹"登录已失效",无需等
// access token 过期或硬刷新才发现)
if kicked > 0 {
h.Hub.BroadcastUser(user.ID, realtime.Envelope{
Type: realtime.EventSessionReplaced,
@@ -309,7 +310,7 @@ func (h *Handlers) MyLoginDevices(c *gin.Context) {
}
familyID := claims.FamilyID
if refreshToken, err := c.Cookie(service.RefreshCookieName); err == nil {
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, ip, ua, claims.ID); fid != 0 {
if fid := h.Auth.TouchDeviceFromRefresh(refreshToken, claims.ID); fid != 0 {
familyID = fid
}
}

View File

@@ -49,7 +49,7 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
}
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP)
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP / GIF;除 GIF 外自动转存 WebP)
func (h *Handlers) UploadImage(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -311,6 +311,27 @@ func (h *Handlers) UploadBrandFromMedia(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"url": url})
}
// AdminMediaLibrary 管理后台媒体库:全站图片盘点(磁盘五类目录 + 附件元数据)
func (h *Handlers) AdminMediaLibrary(c *gin.Context) {
items, counts, err := h.Upload.AdminMediaLibrary()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取媒体库失败"})
return
}
c.JSON(http.StatusOK, gin.H{"items": nonNilSlice(items), "counts": counts, "total": len(items)})
}
// MediaLibraryThumb 媒体库网格缩略图(?u=/uploads/images/xx.webp),失败回退由前端处理
func (h *Handlers) MediaLibraryThumb(c *gin.Context) {
data, err := h.Upload.MediaThumb(c.Query("u"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "缩略图不可用"})
return
}
c.Header("Cache-Control", "private, max-age=86400")
c.Data(http.StatusOK, "image/webp", data)
}
func brandTooLarge(slot string) string {
if slot == service.BrandSlotFavicon {
return "Favicon 不能超过 512KB"

View File

@@ -3,6 +3,7 @@ package router
import (
"context"
"log"
"net"
"os"
"path/filepath"
"strings"
@@ -18,6 +19,30 @@ import (
"github.com/gin-gonic/gin"
)
// parseTrustedProxies 解析 TRUSTED_PROXIES(逗号分隔的 IP/CIDR/localhost)。
// 容忍条目两侧空白与空项,无法解析的条目跳过并告警:编排环境变量多一个空格
// 不应让整个服务拒绝启动(真实事故:尾随空格致 invalid CIDR 秒退)。
// 全部非法或未配置时返回 nil,退化为「ClientIP 记 TCP 对端」并触发启动警告。
func parseTrustedProxies(v string) []string {
var out []string
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if part == "localhost" {
out = append(out, part)
continue
}
if _, _, err := net.ParseCIDR(part); err != nil && net.ParseIP(part) == nil {
log.Printf("警告: TRUSTED_PROXIES 条目 %q 无法解析,已忽略", part)
continue
}
out = append(out, part)
}
return out
}
// Setup 初始化路由
func Setup(cfg *config.Config) (*gin.Engine, error) {
if cfg.DevMode {
@@ -27,12 +52,10 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}
r := gin.New()
var proxies []string
if v := os.Getenv("TRUSTED_PROXIES"); v != "" {
proxies = strings.Split(v, ",")
} else {
proxies := parseTrustedProxies(os.Getenv("TRUSTED_PROXIES"))
if len(proxies) == 0 {
// 未配置信任代理时 ClientIP 退化为 TCP 直连对端:反代/容器部署会记录内网地址
log.Println("警告: TRUSTED_PROXIES 未配置,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
log.Println("警告: TRUSTED_PROXIES 未配置或无有效条目,ClientIP 将记录反代/容器内网地址(如 172.19.0.4),请设置为实际反代网段,如 172.16.0.0/12")
}
if err := r.SetTrustedProxies(proxies); err != nil {
return nil, err
@@ -55,7 +78,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
}))
// 服务
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret).WithDevMode(cfg.DevMode)
boardSvc := service.NewBoardService(model.DB)
settingSvc := service.NewSettingService(model.DB)
// 等级体系快照随进程启动加载(后台保存时经 SetLevels 刷新,无需重启)
@@ -82,6 +105,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
legacyImportSvc := service.NewLegacyImportService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
moderationSvc := service.NewModerationService(model.DB, notifSvc)
chatSvc := service.NewChatService(model.DB, notifSvc)
// 导入建号绕过注册链路,显式接线:导入用户同样加入默认全站大厅
legacyImportSvc.WithHallMembership(chatSvc.EnsureDefaultMembership)
badgeSvc := service.NewBadgeService(model.DB).WithNotification(notifSvc)
visitSvc := service.NewVisitStatsService(model.DB, settingSvc)
analyticsSvc := service.NewAnalyticsService(model.DB)
@@ -391,6 +416,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia)
staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand)
staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia)
// 媒体库:全站图片盘点(只读展示)+ 网格缩略图
staffAPI.GET("/media-library", authMW.RequirePerm(service.PermSettings), h.AdminMediaLibrary)
staffAPI.GET("/media-library/thumb", authMW.RequirePerm(service.PermSettings), h.MediaLibraryThumb)
// 用户与权限管理(超级管理员/站长):列表、角色授权、封禁、登录历史
usersAPI := staffAPI.Group("", authMW.RequirePerm(service.PermUsers))

View File

@@ -0,0 +1,33 @@
package router
import (
"reflect"
"testing"
)
// TestParseTrustedProxies 覆盖:合法 CIDR/IP/localhost、空白容忍、空项、非法条目跳过
func TestParseTrustedProxies(t *testing.T) {
cases := []struct {
name string
in string
want []string
}{
{"未配置", "", nil},
{"单个网段", "172.19.0.0/16", []string{"172.19.0.0/16"}},
// 真实事故:尾随空格曾导致 invalid CIDR 整进程退出
{"尾随空格", "172.19.0.0/16 ", []string{"172.19.0.0/16"}},
{"两侧空白与空项", " 172.19.0.0/16 , 10.0.0.5 ,", []string{"172.19.0.0/16", "10.0.0.5"}},
{"单个 IP", "10.0.0.5", []string{"10.0.0.5"}},
{"localhost", "localhost", []string{"localhost"}},
{"非法条目跳过", "172.19.0.0/16,bad-cidr", []string{"172.19.0.0/16"}},
{"全部非法", "bad,,worse", nil},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := parseTrustedProxies(c.in)
if !reflect.DeepEqual(got, c.want) {
t.Errorf("parseTrustedProxies(%q) = %v, want %v", c.in, got, c.want)
}
})
}
}

View File

@@ -38,11 +38,12 @@ var (
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
)
// AdDurationOption 可购时长档位
// AdDurationOption 可购时长档位(图片 / 文字广告分别计价)
type AdDurationOption struct {
Days int `json:"days"`
Label string `json:"label"`
PriceHint string `json:"price_hint"`
PriceHint string `json:"price_hint"` // 图片广告价格
PriceHintText string `json:"price_hint_text"` // 文字广告价格
}
// AdPaymentOption 收款方式(展示二维码,人工确认)
@@ -68,11 +69,11 @@ func DefaultAdConfig() AdConfig {
Enabled: true,
PanelTitle: AdDefaultTitle,
Durations: []AdDurationOption{
{Days: 30, Label: "1 个月", PriceHint: ""},
{Days: 60, Label: "2 个月", PriceHint: ""},
{Days: 90, Label: "3 个月", PriceHint: ""},
{Days: 180, Label: "6 个月", PriceHint: ""},
{Days: 365, Label: "12 个月", PriceHint: ""},
{Days: 30, Label: "1 个月", PriceHint: "", PriceHintText: ""},
{Days: 60, Label: "2 个月", PriceHint: "", PriceHintText: ""},
{Days: 90, Label: "3 个月", PriceHint: "", PriceHintText: ""},
{Days: 180, Label: "6 个月", PriceHint: "", PriceHintText: ""},
{Days: 365, Label: "12 个月", PriceHint: "", PriceHintText: ""},
},
Payments: []AdPaymentOption{},
}
@@ -116,6 +117,9 @@ func (s *AdService) loadConfig() (AdConfig, error) {
if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" {
cfg.Durations[i].PriceHint = ""
}
if strings.TrimSpace(cfg.Durations[i].PriceHintText) == "面议" {
cfg.Durations[i].PriceHintText = ""
}
}
return cfg, nil
}
@@ -195,7 +199,14 @@ func normalizeAdConfig(in AdConfig) (AdConfig, error) {
if utf8.RuneCountInString(price) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price})
priceText := strings.TrimSpace(d.PriceHintText)
if priceText == "面议" {
priceText = ""
}
if utf8.RuneCountInString(priceText) > 32 {
return AdConfig{}, ErrAdInvalid
}
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price, PriceHintText: priceText})
}
out.Durations = durs

View File

@@ -73,6 +73,7 @@ type AuthService struct {
db *gorm.DB
jwtSecret []byte
encKey []byte // refresh token 落库密文的 AES-GCM 密钥(由 JWT 密钥派生)
devMode bool
}
func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
@@ -81,6 +82,21 @@ func NewAuthService(db *gorm.DB, jwtSecret string) *AuthService {
return &AuthService{db: db, jwtSecret: []byte(jwtSecret), encKey: keySum[:]}
}
// WithDevMode dev 模式放宽轮转宽限期:开发中 go run / next dev 重启频繁,
// 轮换响应丢失后的重放往往远超 60s 才回来,同链重放不应升级为盗用判定
func (s *AuthService) WithDevMode(dev bool) *AuthService {
s.devMode = dev
return s
}
// rotationGrace 轮转宽限期:dev 放宽(见 RefreshRotationGrace 注释)
func (s *AuthService) rotationGrace() time.Duration {
if s.devMode {
return 15 * time.Minute
}
return RefreshRotationGrace
}
// dummyPasswordHash 用户不存在时也执行一次 bcrypt 比较,避免通过响应耗时枚举用户名
var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
@@ -133,7 +149,8 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
}
// Login 用户登录,返回 access token + refresh token + user;
// kicked 为本次登录顶掉的旧会话数(真·单会话:任何新登录踢掉所有旧设备;>0 供上层广播告知)
// kicked 为本次登录因超出会话上限被淘汰的最旧会话数(多会话并存:
// 新登录不踢旧设备,仅超过 maxActiveSessions 时裁剪;>0 供上层广播告知)
func (s *AuthService) Login(username, password, ip, ua string) (string, string, *model.User, int, error) {
var user model.User
if err := s.db.Where("username = ?", username).First(&user).Error; err != nil {
@@ -317,7 +334,8 @@ func (s *AuthService) decryptToken(encoded string) (string, error) {
}
// CreateRefreshToken 创建并存储 refresh token(存哈希 + 密文 + 设备信息)。
// 返回值 kicked 为被顶掉的旧会话行数(真·单会话:踢掉所有旧设备)。
// 多会话并存:各设备登录互不影响,新会话构成独立的轮转家族(family_id);
// 返回值 kicked 为因超出会话上限被淘汰的最旧会话行数。
func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, uint, int, error) {
token := generateRandomToken()
cipherText, err := s.encryptToken(token)
@@ -342,7 +360,7 @@ func (s *AuthService) CreateRefreshToken(userID uint, ip, ua string) (string, ui
return "", 0, 0, err
}
rt.FamilyID = rt.ID
kicked := s.revokeAllSessionsExcept(userID, rt.FamilyID)
kicked := s.enforceSessionCap(userID)
return token, rt.FamilyID, kicked, nil
}
@@ -410,7 +428,7 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
if rt.Revoked {
// 宽限期内、且轮转链指向的新 token 仍健康:并发重放,返回同一对
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= RefreshRotationGrace {
if rt.RotatedTo != 0 && rt.RotatedAt != nil && now.Sub(*rt.RotatedAt) <= s.rotationGrace() {
var next model.RefreshToken
if e := tx.First(&next, rt.RotatedTo).Error; e == nil &&
next.UserID == rt.UserID && !next.Revoked && now.Before(next.ExpiresAt) &&
@@ -423,13 +441,16 @@ func (s *AuthService) RotateRefreshToken(oldToken, ip, ua string) (string, strin
}
}
}
// 其他吊销后重放:判定为盗用,撤销该用户整个 refresh token 家族
// 其他吊销后重放:判定为盗用,仅撤销该轮转链所在家族(OAuth BCP:
// 被盗的是这台设备的链,不牵连用户其余登录设备——否则开发期
// middleware/浏览器丢轮换的竞态会把所有设备一齐踢下线)
fam := sessionFamilyID(rt)
if err := tx.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ?", rt.UserID, false).
Where("(family_id = ? OR id = ?) AND revoked = ?", fam, fam, false).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now}).Error; err != nil {
return err
}
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 user=%d 的全部 refresh token", rt.UserID)
log.Printf("[auth] 检测到 refresh token 吊销后重放,已撤销 family=%d 的 refresh token", fam)
outErr = ErrRefreshReused
return nil
}

View File

@@ -24,12 +24,14 @@ type BoardWithCount struct {
PostCount int64 `json:"post_count"`
}
// BoardModerator 板块管理员公开资料
// BoardModerator 板块管理员公开资料(含等级与颁发的徽章)
type BoardModerator struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
Badges []model.UserBadge `json:"badges,omitempty"` // 管理员颁发的徽章(Preload("Badges.Badge") 填充)
}
// BoardSidebarStats 板级统计(不含在线)
@@ -432,13 +434,14 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
return nil, err
}
// 板块管理员:仅 board_admin 且授权本板、未封禁
// 板块管理员:仅 board_admin 且授权本板、未封禁(等级由 AfterFind 算,徽章随 Preload 带)
var mods []model.User
if err := s.db.Table("users").
Select("users.id, users.username, users.nickname, users.avatar").
if err := s.db.Model(&model.User{}).
Select("users.id, users.username, users.nickname, users.avatar, users.total_points").
Joins("JOIN user_boards ON user_boards.user_id = users.id AND user_boards.board_id = ?", boardID).
Where("users.role = ? AND users.banned = ? AND users.deleted_at IS NULL", model.RoleBoardAdmin, false).
Order("users.id ASC").
Preload("Badges.Badge").
Find(&mods).Error; err != nil {
return nil, err
}
@@ -448,6 +451,8 @@ func (s *BoardService) Sidebar(boardID uint) (*BoardSidebarData, error) {
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
Level: u.Level,
Badges: u.Badges,
})
}

View File

@@ -0,0 +1,69 @@
package service
import (
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
// 板块右栏聚合:管理员应携带等级(AfterFind 按累计积分计算)与颁发的徽章
func TestBoardSidebarModeratorsCarryLevelAndBadges(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.Board{}, &model.UserBoard{},
&model.Post{}, &model.Comment{}, &model.Badge{}, &model.UserBadge{}); err != nil {
t.Fatalf("migrate: %v", err)
}
board := model.Board{Name: "综合讨论"}
if err := db.Create(&board).Error; err != nil {
t.Fatalf("seed board: %v", err)
}
// 高积分管理员 + 低积分管理员 + 普通用户(不入列)
modHigh := model.User{Username: "mod_high", Password: "x", Role: model.RoleBoardAdmin, TotalPoints: 10000}
modLow := model.User{Username: "mod_low", Password: "x", Role: model.RoleBoardAdmin}
plain := model.User{Username: "plain", Password: "x"}
for _, u := range []*model.User{&modHigh, &modLow, &plain} {
if err := db.Create(u).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
}
for _, uid := range []uint{modHigh.ID, modLow.ID, plain.ID} {
if err := db.Create(&model.UserBoard{UserID: uid, BoardID: board.ID}).Error; err != nil {
t.Fatalf("seed user_board: %v", err)
}
}
badge := model.Badge{Name: "骨灰玩家", Icon: "medal", Color: "gold"}
if err := db.Create(&badge).Error; err != nil {
t.Fatalf("seed badge: %v", err)
}
if err := db.Create(&model.UserBadge{BadgeID: badge.ID, UserID: modHigh.ID, AwardedBy: 1}).Error; err != nil {
t.Fatalf("seed user_badge: %v", err)
}
s := NewBoardService(db)
data, err := s.Sidebar(board.ID)
if err != nil {
t.Fatalf("Sidebar: %v", err)
}
if len(data.Moderators) != 2 {
t.Fatalf("应只含 2 名 board_admin,got %d", len(data.Moderators))
}
first := data.Moderators[0]
if first.Username != "mod_high" {
t.Fatalf("排序错位: %+v", first)
}
if first.Level <= 1 {
t.Fatalf("高积分管理员等级应 >1,got %d", first.Level)
}
if len(first.Badges) != 1 || first.Badges[0].Badge.Name != "骨灰玩家" {
t.Fatalf("徽章未随行加载: %+v", first.Badges)
}
if data.Moderators[1].Level != 1 || len(data.Moderators[1].Badges) != 0 {
t.Fatalf("低积分管理员应为 Lv.1 无徽章: %+v", data.Moderators[1])
}
}

View File

@@ -397,7 +397,9 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
var existing model.ChatRoom
err := s.db.Where("room_type = ? AND direct_key = ?", model.ChatRoomTypeDirect, key).First(&existing).Error
if err == nil {
_ = s.ensureMember(existing.ID, meID)
// 关闭私聊会硬删成员行、丢失已读水位;主动重开视为已读历史,
// 否则重建行 last_read=0 会把全部历史消息重算成未读(列表徽标闪现)
_ = s.ensureMember(existing.ID, meID, existing.LastMessageID)
_ = s.ensureMember(existing.ID, peerID)
return s.RoomDetail(meID, existing.ID, false)
}
@@ -431,7 +433,8 @@ func (s *ChatService) GetOrCreateDM(meID, peerID uint) (*RoomView, error) {
return s.RoomDetail(meID, room.ID, false)
}
func (s *ChatService) ensureMember(roomID, userID uint) error {
// ensureMember 保证成员行存在;重建(如私聊关闭后重开)时可选恢复已读水位
func (s *ChatService) ensureMember(roomID, userID uint, readWatermark ...uint) error {
_, err := s.membership(s.db, roomID, userID)
if err == nil {
return nil
@@ -440,6 +443,9 @@ func (s *ChatService) ensureMember(roomID, userID uint) error {
return err
}
m := model.ChatRoomMember{RoomID: roomID, UserID: userID, Role: model.ChatRoleMember}
if len(readWatermark) > 0 {
m.LastReadMessageID = readWatermark[0]
}
if e := s.db.Create(&m).Error; e != nil {
return e
}

View File

@@ -8,6 +8,8 @@ import (
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -20,43 +22,64 @@ import (
"github.com/freefire/jiang13-bbs/model"
)
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包,
// 旧站(jiang13-forum,SQLite)数据导入:管理后台上传旧库 + 可选头像包 / 帖子图片包,
// 导入用户账号(用户名 / bcrypt 密码 / 昵称 / 签名 / 邮箱 / 头像)与板块、帖子、评论。
// 两站密码同为 bcrypt,哈希原样复制,老用户用原密码即可登录。
// 支持预检(dry-run,不写库);ImportRecord 去重保证幂等可重复导入。
// 支持预检(dry-run,不写库,返回用户 / 帖子 / 评论清单供勾选);ImportRecord 去重保证幂等可重复导入。
// 支持选择性导入:按旧用户勾选是否建号、内容归属(指定已有账号 / 站长)、按帖子 / 评论排除。
// 帖子图片:正文引用的旧站 /uploads/posts/<名> 从图片包落盘到新站 uploads/images 并改写 URL。
// 来源参数化(source),为 WordPress / Typecho 等外部数据源预留扩展位。
const (
// LegacyDBMaxBytes 旧库上传上限(实测旧站整库约 2MB,留足余量)
LegacyDBMaxBytes = 64 << 20
// LegacyZipMaxBytes 头像压缩包上传上限
// LegacyZipMaxBytes 头像 / 帖子图片压缩包上传上限
LegacyZipMaxBytes = 128 << 20
// legacyAvatarFileMax 单个头像解压上限
legacyAvatarFileMax = 8 << 20
// legacyImageFileMax 单张帖子图片解压上限
legacyImageFileMax = 20 << 20
// legacySignatureMaxRunes 新站 Signature 列上限
legacySignatureMaxRunes = 255
// legacyEmailMaxRunes 新站 Email 列上限
legacyEmailMaxRunes = 128
// legacyAvatarPrefix 旧站头像 URL 约定前缀(与旧站静态服务规范一致)
legacyAvatarPrefix = "/uploads/avatars/"
// legacyPostImagePrefix 旧站帖子图片 URL 约定前缀
legacyPostImagePrefix = "/uploads/posts/"
// legacyImagePrefix 新站帖子图片落盘后的 URL 前缀
legacyImagePrefix = "/uploads/images/"
// legacyPreviewLimit 预检清单条数上限,防止超大库拖垮响应
legacyPreviewLimit = 2000
// legacyDetailLimit 报告明细条数上限,防止超长响应
legacyDetailLimit = 50
)
var (
ErrLegacyInvalidSQLite = errors.New("文件不是有效的旧站 SQLite 数据库")
ErrLegacyBadZip = errors.New("头像压缩包无法读取")
ErrLegacyBadZip = errors.New("压缩包无法读取")
ErrLegacyBadTarget = errors.New("内容归属目标账号不存在")
)
// legacyPostImageRe 匹配正文中对旧站帖子图片的引用(相对路径或任意域名绝对 URL),
// 捕获组为纯文件名(字符集不含路径分隔符,杜绝目录穿越)。
var legacyPostImageRe = regexp.MustCompile(`(?:https?://[^()\[\]\s]+)?/uploads/posts/([A-Za-z0-9._\-]+)`)
// LegacyImportSource 受支持的导入来源(handler 按此做路由白名单)
func LegacyImportSource(source string) bool {
return source == "jiang13"
}
// LegacyImportOptions 导入选项
// LegacyImportOptions 导入选项(skip / 归属参数由 handler 从表单解析)
type LegacyImportOptions struct {
WithContent bool // 同时导入板块 / 帖子 / 评论
DryRun bool // 预检:只统计不写库
DryRun bool // 预检:只统计不写库,并返回勾选清单
SkipUserIDs map[uint]bool // 不创建账号的旧用户(内容仍按归属规则落位)
OperatorUsers map[uint]bool // 内容明确归到站长的旧用户
UserTargetNames map[uint]string // 旧用户ID → 指定已有账号用户名(内容归属)
UserTargetMap map[uint]uint // (内部)UserTargetNames 解析后的目标账号 ID
SkipPostIDs map[uint]bool // 排除的旧帖(其评论自动跳过)
SkipCommentIDs map[uint]bool // 排除的旧评论
}
// LegacyImportReport 导入结果摘要(JSON 返回给前端)
@@ -66,14 +89,51 @@ type LegacyImportReport struct {
Boards *LegacyBoardReport `json:"boards,omitempty"`
Posts *LegacyPostReport `json:"posts,omitempty"`
Comments *LegacyCommentReport `json:"comments,omitempty"`
UserList []LegacyUserPreview `json:"user_list,omitempty"` // 仅 dry-run:勾选清单
PostList []LegacyPostPreview `json:"post_list,omitempty"` // 仅 dry-run
CommentList []LegacyCommentPreview `json:"comment_list,omitempty"` // 仅 dry-run
Notes []string `json:"notes,omitempty"`
}
// LegacyUserPreview 预检清单:旧用户(供勾选是否建号 / 内容归属)
type LegacyUserPreview struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Posts int `json:"posts"`
Comments int `json:"comments"`
Exists bool `json:"exists"` // 本站已有同名账号(导入时自动跳过建号)
HasAvatar bool `json:"has_avatar"`
}
// LegacyPostPreview 预检清单:旧帖(取消勾选即排除)
type LegacyPostPreview struct {
ID uint `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
Board string `json:"board"`
CreatedAt time.Time `json:"created_at"`
Poll bool `json:"poll"` // 投票帖不可迁移
Published bool `json:"published"` // 非公开帖导入时自动跳过
}
// LegacyCommentPreview 预检清单:旧评论(取消勾选即排除)
type LegacyCommentPreview struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Author string `json:"author"`
Excerpt string `json:"excerpt"`
CreatedAt time.Time `json:"created_at"`
Published bool `json:"published"`
}
// LegacyUserReport 用户导入明细
type LegacyUserReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Excluded int `json:"excluded"` // 手动排除建号
AvatarWritten int `json:"avatar_written"`
Conflicts []string `json:"conflicts,omitempty"` // 用户名已存在
AvatarMissing []string `json:"avatar_missing,omitempty"` // 旧头像字段有值但包内缺失
@@ -92,9 +152,13 @@ type LegacyPostReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"` // 已导入过(去重记录)
Excluded int `json:"excluded"` // 手动排除
ExcludedDetail []string `json:"excluded_detail,omitempty"`
PollsSkipped int `json:"polls_skipped"`
PollTitles []string `json:"poll_titles,omitempty"`
OwnerFallback []string `json:"owner_fallback,omitempty"` // 作者缺失归到站长
ImagesWritten int `json:"images_written"` // 正文图片迁移落盘数
ImagesMissing []string `json:"images_missing,omitempty"` // 包内缺失 / 落盘失败,保留原链接
Failed []string `json:"failed,omitempty"`
}
@@ -103,8 +167,12 @@ type LegacyCommentReport struct {
Total int `json:"total"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Excluded int `json:"excluded"` // 手动排除(含所属帖子被排除)
ExcludedDetail []string `json:"excluded_detail,omitempty"`
SkippedDetail []string `json:"skipped_detail,omitempty"` // 非公开评论等
OwnerFallback []string `json:"owner_fallback,omitempty"`
ImagesWritten int `json:"images_written"`
ImagesMissing []string `json:"images_missing,omitempty"`
Failed []string `json:"failed,omitempty"`
}
@@ -176,8 +244,16 @@ func (legacyCommentRow) TableName() string { return "comments" }
// LegacyImportService 管理后台「数据导入」
type LegacyImportService struct {
db *gorm.DB
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars
uploadsDir string // {DataDir}/uploads,头像写入 uploads/avatars,帖子图片写入 uploads/images
md *htmltomarkdown.Converter
ensureHall func(userID uint) error // 建号后加入默认大厅的钩子(可空;与注册链路一致)
}
// WithHallMembership 注入「加入默认全站大厅」回调(chatSvc.EnsureDefaultMembership)。
// 导入建号绕过注册链路,需显式接线保持行为一致;错误静默,幂等可补齐。
func (s *LegacyImportService) WithHallMembership(fn func(userID uint) error) *LegacyImportService {
s.ensureHall = fn
return s
}
func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService {
@@ -189,50 +265,24 @@ func NewLegacyImportService(db *gorm.DB, uploadsDir string) *LegacyImportService
}
}
// 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()}}
}
// 头像包索引:文件名 → 条目。
// zipPack 旧站文件包(头像 / 帖子图片)索引:纯文件名 → 条目。
// 兼容两种打包方式:文件平铺在 zip 根目录,或统一放在单个顶层文件夹下
// (Windows 右键压缩文件夹的产物)。zip-slip 防护:只取纯文件名作键,
// 更深层嵌套与 .. 一律忽略,落盘路径永远由键拼接。
avatars := map[string]*zip.File{}
if zipPath != "" {
type zipPack struct {
idx map[string]*zip.File
close func()
}
func openZipPack(zipPath string) (*zipPack, error) {
if zipPath == "" {
return &zipPack{idx: map[string]*zip.File{}, close: func() {}}, nil
}
zr, err := zip.OpenReader(zipPath)
if err != nil {
return LegacyUserReport{Failed: []string{ErrLegacyBadZip.Error()}}
return nil, ErrLegacyBadZip
}
defer zr.Close()
p := &zipPack{idx: make(map[string]*zip.File, len(zr.File)), close: func() { _ = zr.Close() }}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
@@ -251,15 +301,91 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
if key == "" || key == "." || key == ".." {
continue
}
if _, exists := avatars[key]; !exists {
avatars[key] = f
if _, exists := p.idx[key]; !exists {
p.idx[key] = f
}
}
return p, nil
}
// ImportFromFiles dbPath 为旧站 SQLite 库路径(调用方先落临时文件),zipPath / imagesZipPath
// 为可选头像包与帖子图片包(空串表示未上传)。operatorID 为执行导入的账号(站长),
// 作者缺失的帖子/评论归属到该账号。
func (s *LegacyImportService) ImportFromFiles(dbPath, zipPath, imagesZipPath string, opts LegacyImportOptions, operatorID uint) (*LegacyImportReport, error) {
if err := validateSQLiteMagic(dbPath); err != nil {
return nil, err
}
// 归属目标:用户名 → 本站已有账号 ID(含软删账号,内容归属不变)
if len(opts.UserTargetNames) > 0 {
resolved := make(map[uint]uint, len(opts.UserTargetNames))
for oldID, name := range opts.UserTargetNames {
var u model.User
if err := s.db.Unscoped().Where("username = ?", name).First(&u).Error; err != nil {
return nil, fmt.Errorf("%w: %s", ErrLegacyBadTarget, name)
}
resolved[oldID] = u.ID
}
opts.UserTargetNames = nil
opts.UserTargetMap = resolved
}
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)
}
// 导入结束释放 SQLite 句柄,避免 Windows 上文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
defer func() { _ = sqlDB.Close() }()
}
avatars, err := openZipPack(zipPath)
if err != nil {
return nil, err
}
defer avatars.close()
images, err := openZipPack(imagesZipPath)
if err != nil {
return nil, err
}
defer images.close()
if !opts.DryRun {
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "avatars"), 0o755)
_ = os.MkdirAll(filepath.Join(s.uploadsDir, "images"), 0o755)
}
rep := &LegacyImportReport{DryRun: opts.DryRun}
if opts.DryRun {
s.buildPreview(oldDB, rep)
}
rep.Users = s.importUsers(oldDB, avatars, opts)
if !opts.WithContent {
return rep, nil
}
boardMap, boardRep := s.importBoards(oldDB, opts)
rep.Boards = boardRep
rep.Posts = s.importPosts(oldDB, boardMap, images, operatorID, opts, rep)
rep.Comments = s.importComments(oldDB, images, operatorID, opts, rep)
return rep, nil
}
// ===== 用户 =====
func (s *LegacyImportService) importUsers(oldDB *gorm.DB, avatars *zipPack, opts LegacyImportOptions) LegacyUserReport {
var rows []legacyUserRow
if err := oldDB.Order("id ASC").Find(&rows).Error; err != nil {
return LegacyUserReport{Failed: []string{"读取 users 表失败: " + err.Error()}}
}
rep := LegacyUserReport{Total: len(rows)}
avatarDir := filepath.Join(s.uploadsDir, "avatars")
for _, u := range rows {
// 手动排除:不创建账号,其内容按归属规则落位(见 resolveAuthor)
if opts.SkipUserIDs[u.ID] {
rep.Excluded++
continue
}
username := strings.TrimSpace(u.Username)
if username == "" || u.Password == "" {
rep.Failed = append(rep.Failed, fmt.Sprintf("#%d (用户名或密码为空)", u.ID))
@@ -277,7 +403,7 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
continue
}
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars, avatarDir, opts.DryRun)
avatarURL, wrote, err := s.resolveAvatar(u.Avatar, avatars.idx, avatarDir, opts.DryRun)
if err != nil {
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (头像写入失败: %v)", username, err))
continue
@@ -308,6 +434,9 @@ func (s *LegacyImportService) importUsers(oldDB *gorm.DB, zipPath string, opts L
rep.Failed = append(rep.Failed, fmt.Sprintf("%s (写入失败: %v)", username, err))
continue
}
if s.ensureHall != nil {
_ = s.ensureHall(user.ID) // 加入默认全站大厅(幂等,失败不阻断导入)
}
rep.Imported++
}
return rep
@@ -342,42 +471,111 @@ func (s *LegacyImportService) resolveAvatar(oldAvatar string, pack map[string]*z
return "", false, nil // 包内缺失
}
dst := filepath.Join(avatarDir, name)
if dryRun {
if _, err := os.Stat(dst); err == nil {
return legacyAvatarPrefix + name, false, nil // 已存在,幂等跳过
}
if dryRun {
return legacyAvatarPrefix + name, true, nil
}
wrote, err := extractZipEntry(f, dst, legacyAvatarFileMax)
if err != nil {
return "", false, err
}
return legacyAvatarPrefix + name, wrote, nil
}
// extractZipEntry 把 zip 条目解到 dst(先写临时文件再 rename,避免中断留下半截文件)。
// 目标已存在时不写入直接返回 false(幂等;兼容 Windows 上目标已存在时 Rename 失败的语义)。
func extractZipEntry(f *zip.File, dst string, max int64) (bool, error) {
if _, err := os.Stat(dst); err == nil {
return false, nil
}
src, err := f.Open()
if err != nil {
return "", false, err
return false, err
}
defer src.Close()
// 先写临时文件再 rename,避免中断留下半截文件
out, err := os.CreateTemp(avatarDir, ".legacy-*")
out, err := os.CreateTemp(filepath.Dir(dst), ".legacy-*")
if err != nil {
return "", false, err
return false, err
}
tmpName := out.Name()
defer os.Remove(tmpName) // rename 成功后此处 Remove 必失败,忽略
written, err := io.Copy(out, io.LimitReader(src, legacyAvatarFileMax+1))
written, err := io.Copy(out, io.LimitReader(src, max+1))
if closeErr := out.Close(); err == nil {
err = closeErr
}
if err != nil {
return "", false, err
return false, err
}
if written > legacyAvatarFileMax {
return "", false, fmt.Errorf("头像文件超过 %dMB 上限", legacyAvatarFileMax>>20)
if written > max {
return false, fmt.Errorf("文件超过 %dMB 上限", max>>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, nil // Windows 上目标已存在时 Rename 失败;视为已存在(幂等)
}
return "", false, err
return false, err
}
return legacyAvatarPrefix + name, true, nil
return true, nil
}
// ===== 帖子 / 评论图片迁移 =====
// legacyImageStat 单次导入的图片迁移统计(跨帖子/评论累计)
type legacyImageStat struct {
written int
missing map[string]bool
}
func newImageStat() *legacyImageStat {
return &legacyImageStat{missing: map[string]bool{}}
}
// names 返回缺失文件名(排序去重)
func (st *legacyImageStat) names() []string {
out := make([]string, 0, len(st.missing))
for k := range st.missing {
out = append(out, k)
}
sort.Strings(out)
return out
}
// migrateContentImages 把内容里引用的旧站帖子图片(/uploads/posts/<名>,含旧域名绝对 URL)
// 从图片包落盘到新站 uploads/images(文件名原样保留),并把 URL 改写为新站相对路径。
// 包内缺失或落盘失败的引用保留原链接并计入 missing。dryRun 只统计不落盘。
func (s *LegacyImportService) migrateContentImages(content string, pack *zipPack, dryRun bool, stat *legacyImageStat) string {
if !strings.Contains(content, legacyPostImagePrefix) {
return content
}
return legacyPostImageRe.ReplaceAllStringFunc(content, func(m string) string {
name := legacyPostImageRe.FindStringSubmatch(m)[1]
if name == "." || name == ".." || name != path.Base(name) {
return m
}
f, ok := pack.idx[name]
if !ok {
stat.missing[name] = true
return m
}
dst := filepath.Join(s.uploadsDir, "images", name)
if dryRun {
if _, err := os.Stat(dst); err != nil {
stat.written++
}
return legacyImagePrefix + name
}
wrote, err := extractZipEntry(f, dst, legacyImageFileMax)
if err != nil {
stat.missing[name] = true
return m
}
if wrote {
stat.written++
}
return legacyImagePrefix + name
})
}
// ===== 板块 =====
@@ -461,7 +659,22 @@ func (s *LegacyImportService) mapLegacyUsers(oldDB *gorm.DB) map[uint]uint {
return m
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
// resolveAuthor 内容归属:管理员显式指定 > 旧用户名匹配 > 站长兜底。
// 第二个返回值表示是否因找不到作者而兜底到站长(显式指定不算,用于报告明细)。
func resolveAuthor(oldUserID uint, opts LegacyImportOptions, userMap map[uint]uint, operatorID uint) (uint, bool) {
if id, ok := opts.UserTargetMap[oldUserID]; ok && id > 0 {
return id, false
}
if opts.OperatorUsers[oldUserID] {
return operatorID, false
}
if id, ok := userMap[oldUserID]; ok {
return id, false
}
return operatorID, true
}
func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint, images *zipPack, operatorID uint, opts LegacyImportOptions, rep *LegacyImportReport) *LegacyPostReport {
out := &LegacyPostReport{}
if boardMap == nil || len(boardMap) == 0 {
out.Failed = append(out.Failed, "无可用板块映射,帖子未导入")
@@ -477,6 +690,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
userMap := s.mapLegacyUsers(oldDB)
missingAuthors := map[uint]bool{} // 旧用户ID → 已记录过 fallback
convFailed := 0
imgStat := newImageStat()
for _, p := range rows {
title := strings.TrimSpace(p.Title)
@@ -484,6 +698,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
out.Failed = append(out.Failed, fmt.Sprintf("#%d (标题为空)", p.ID))
continue
}
if opts.SkipPostIDs[p.ID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("%s (手动排除)", title))
continue
}
if p.PostType == "poll" {
out.PollsSkipped++
out.PollTitles = append(out.PollTitles, title)
@@ -509,13 +728,11 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
}
}
// 作者映射,缺失归站长
authorID := operatorID
if id, ok := userMap[p.UserID]; ok {
authorID = id
} else if !missingAuthors[p.UserID] {
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
authorID, fellBack := resolveAuthor(p.UserID, opts, userMap, operatorID)
if fellBack && !missingAuthors[p.UserID] {
missingAuthors[p.UserID] = true
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未找到,归到站长)", p.UserID))
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分内容(作者未匹配到账号,归到站长)", p.UserID))
}
// HTML → Markdown;失败保留原文(正文不丢)
@@ -527,6 +744,7 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
convFailed++
}
}
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
if opts.DryRun {
out.Imported++
@@ -557,13 +775,16 @@ func (s *LegacyImportService) importPosts(oldDB *gorm.DB, boardMap map[uint]uint
if convFailed > 0 {
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条内容 HTML 转 Markdown 失败,已保留原始内容", convFailed))
}
out.ImagesWritten = imgStat.written
out.ImagesMissing = capList(imgStat.names())
out.OwnerFallback = capList(out.OwnerFallback)
out.ExcludedDetail = capList(out.ExcludedDetail)
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 {
func (s *LegacyImportService) importComments(oldDB *gorm.DB, images *zipPack, 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 {
@@ -584,8 +805,20 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
userMap := s.mapLegacyUsers(oldDB)
missingAuthors := map[uint]bool{}
convFailed := 0
imgStat := newImageStat()
for _, cm := range rows {
// 所属帖子被排除 → 评论自动跳过(无落点)
if opts.SkipPostIDs[cm.PostID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (所属帖子被排除)", postTitles[cm.PostID], cm.ID))
continue
}
if opts.SkipCommentIDs[cm.ID] {
out.Excluded++
out.ExcludedDetail = append(out.ExcludedDetail, fmt.Sprintf("《%s》#%d (手动排除)", postTitles[cm.PostID], cm.ID))
continue
}
if cm.Status != "published" {
out.Skipped++
out.SkippedDetail = append(out.SkippedDetail, fmt.Sprintf("《%s》#%d (旧状态 %s)", postTitles[cm.PostID], cm.ID, cm.Status))
@@ -608,12 +841,11 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
}
}
authorID := operatorID
if id, ok := userMap[cm.UserID]; ok {
authorID = id
} else if !missingAuthors[cm.UserID] {
// 作者归属:显式指定 > 用户名匹配 > 站长兜底
authorID, fellBack := resolveAuthor(cm.UserID, opts, userMap, operatorID)
if fellBack && !missingAuthors[cm.UserID] {
missingAuthors[cm.UserID] = true
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未找到,归到站长)", cm.UserID))
out.OwnerFallback = append(out.OwnerFallback, fmt.Sprintf("#%d 的部分评论(作者未匹配到账号,归到站长)", cm.UserID))
}
// HTML → Markdown;失败保留原文(内容不丢)
@@ -625,6 +857,7 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
convFailed++
}
}
content = s.migrateContentImages(content, images, opts.DryRun, imgStat)
if opts.DryRun {
out.Imported++
@@ -639,7 +872,10 @@ func (s *LegacyImportService) importComments(oldDB *gorm.DB, operatorID uint, op
if convFailed > 0 {
rep.Notes = append(rep.Notes, fmt.Sprintf("%d 条评论 HTML 转 Markdown 失败,已保留原始内容", convFailed))
}
out.ImagesWritten = imgStat.written
out.ImagesMissing = capList(imgStat.names())
out.SkippedDetail = capList(out.SkippedDetail)
out.ExcludedDetail = capList(out.ExcludedDetail)
out.OwnerFallback = capList(out.OwnerFallback)
out.Failed = capList(out.Failed)
return out
@@ -699,6 +935,117 @@ func (s *LegacyImportService) importCommentOne(cm legacyCommentRow, authorID uin
})
}
// ===== 预检清单 =====
// buildPreview 汇总旧用户 / 帖子 / 评论清单供管理员勾选导入范围,仅 dry-run 调用。
// 超出 legacyPreviewLimit 的清单截断并记入 Notes。
func (s *LegacyImportService) buildPreview(oldDB *gorm.DB, rep *LegacyImportReport) {
var users []legacyUserRow
if err := oldDB.Unscoped().Order("id ASC").Find(&users).Error; err != nil {
return
}
var boards []legacyBoardRow
// 与 importBoards/importPosts/importComments 保持一致:不含软删行(用户除外,软删用户内容仍需归属)
_ = oldDB.Order("id ASC").Find(&boards).Error
boardNames := map[uint]string{}
for _, b := range boards {
boardNames[b.ID] = b.Name
}
var posts []legacyPostRow
_ = oldDB.Order("id ASC").Find(&posts).Error
var comments []legacyCommentRow
_ = oldDB.Order("post_id ASC, id ASC").Find(&comments).Error
authorName := map[uint]string{}
postCount := map[uint]int{}
commentCount := map[uint]int{}
for _, u := range users {
name := strings.TrimSpace(u.Nickname)
if name == "" {
name = strings.TrimSpace(u.Username)
}
authorName[u.ID] = name
}
postTitles := map[uint]string{}
for _, p := range posts {
postCount[p.UserID]++
postTitles[p.ID] = p.Title
}
for _, cm := range comments {
commentCount[cm.UserID]++
}
existing := s.existingUsernames()
for _, u := range users {
rep.UserList = append(rep.UserList, LegacyUserPreview{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Posts: postCount[u.ID],
Comments: commentCount[u.ID],
Exists: existing[strings.ToLower(strings.TrimSpace(u.Username))],
HasAvatar: u.Avatar != "",
})
}
for _, p := range posts {
rep.PostList = append(rep.PostList, LegacyPostPreview{
ID: p.ID,
Title: p.Title,
Author: authorName[p.UserID],
Board: boardNames[p.BoardID],
CreatedAt: p.CreatedAt,
Poll: p.PostType == "poll",
Published: p.Status == "published",
})
}
for _, cm := range comments {
rep.CommentList = append(rep.CommentList, LegacyCommentPreview{
ID: cm.ID,
PostID: cm.PostID,
PostTitle: postTitles[cm.PostID],
Author: authorName[cm.UserID],
Excerpt: legacyExcerpt(cm.Content, 60),
CreatedAt: cm.CreatedAt,
Published: cm.Status == "published",
})
}
if len(rep.UserList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("用户清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.UserList = rep.UserList[:legacyPreviewLimit]
}
if len(rep.PostList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("帖子清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.PostList = rep.PostList[:legacyPreviewLimit]
}
if len(rep.CommentList) > legacyPreviewLimit {
rep.Notes = append(rep.Notes, fmt.Sprintf("评论清单超过 %d 条,仅展示前 %d 条", legacyPreviewLimit, legacyPreviewLimit))
rep.CommentList = rep.CommentList[:legacyPreviewLimit]
}
}
// existingUsernames 本站已有用户名集合(含软删,忽略大小写)
func (s *LegacyImportService) existingUsernames() map[string]bool {
var rows []model.User
if err := s.db.Unscoped().Select("username").Find(&rows).Error; err != nil {
return map[string]bool{}
}
set := make(map[string]bool, len(rows))
for _, u := range rows {
set[strings.ToLower(u.Username)] = true
}
return set
}
var legacyHTMLTagRe = regexp.MustCompile(`<[^>]*>`)
// legacyExcerpt 内容摘要:去 HTML 标签、压平空白后按 rune 截断(预检清单展示用)
func legacyExcerpt(s string, max int) string {
s = legacyHTMLTagRe.ReplaceAllString(s, " ")
s = strings.Join(strings.Fields(s), " ")
return truncateRunesN(s, max)
}
// ===== 工具 =====
// capList 明细条数截断,超出部分以「…等 N 条」收尾

View File

@@ -0,0 +1,281 @@
package service
import (
"archive/zip"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
// newLegacyTestEnv 建新站内存库 + 旧站 SQLite 文件 + 两个 zip 包,并灌入固定种子数据:
// 用户 alice(#1)/bob(#2)/carol(#3)/dave(#4),板块 闲聊(#10),
// 帖子 #100(alice)/#101(bob)/#102(carol 草稿)/#103(alice 投票)/#104(dave),
// 评论 #200(bob→100)/#201(carol→100)/#202(alice→101)。
func newLegacyTestEnv(t *testing.T) (*LegacyImportService, string, string, string, uint, map[uint]bool) {
t.Helper()
tmp := t.TempDir()
newDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open new db: %v", err)
}
if err := newDB.AutoMigrate(&model.User{}, &model.Board{}, &model.Post{}, &model.Comment{}, &model.ImportRecord{}); err != nil {
t.Fatalf("migrate new db: %v", err)
}
admin := model.User{Username: "admin", Password: "x", Nickname: "站长", Role: model.RoleOwner}
if err := newDB.Create(&admin).Error; err != nil {
t.Fatalf("seed admin: %v", err)
}
oldPath := filepath.Join(tmp, "old.db")
oldDB, err := gorm.Open(sqlite.Open(oldPath), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatalf("open old db: %v", err)
}
if err := oldDB.AutoMigrate(&legacyUserRow{}, &legacyBoardRow{}, &legacyPostRow{}, &legacyCommentRow{}); err != nil {
t.Fatalf("migrate old db: %v", err)
}
seed := []interface{}{
&legacyUserRow{ID: 1, Username: "alice", Password: "h1", Nickname: "爱丽丝", Avatar: "/uploads/avatars/a1.jpg"},
&legacyUserRow{ID: 2, Username: "bob", Password: "h2", Nickname: "鲍勃"},
&legacyUserRow{ID: 3, Username: "carol", Password: "h3", Nickname: "卡罗尔"},
&legacyUserRow{ID: 4, Username: "dave", Password: "h4"},
&legacyBoardRow{ID: 10, Name: "闲聊"},
&legacyPostRow{ID: 100, BoardID: 10, UserID: 1, Title: "看图帖", Status: "published", PostType: "normal",
Content: `<p>看图</p><p><img src="/uploads/posts/img1.jpg"></p>`},
&legacyPostRow{ID: 101, BoardID: 10, UserID: 2, Title: "普通帖", Status: "published", PostType: "normal", Content: `<p>正文</p>`},
&legacyPostRow{ID: 102, BoardID: 10, UserID: 3, Title: "草稿帖", Status: "draft", PostType: "normal", Content: `<p>草稿</p>`},
&legacyPostRow{ID: 103, BoardID: 10, UserID: 1, Title: "投票帖", Status: "published", PostType: "poll", Content: `<p>投票</p>`},
&legacyPostRow{ID: 104, BoardID: 10, UserID: 4, Title: "缺图帖", Status: "published", PostType: "normal",
Content: `<p>缺图</p><p><img src="/uploads/posts/missing.jpg"></p>`},
&legacyCommentRow{ID: 200, PostID: 100, UserID: 2, Status: "published",
Content: `<p>补一张</p><p><img src="https://old.example.com/uploads/posts/img1.jpg"></p>`},
&legacyCommentRow{ID: 201, PostID: 100, UserID: 3, Status: "published", Content: `<p>顶</p>`},
&legacyCommentRow{ID: 202, PostID: 101, UserID: 1, Status: "published", Content: `<p>前排</p>`},
}
for _, row := range seed {
if err := oldDB.Create(row).Error; err != nil {
t.Fatalf("seed old db: %v", err)
}
}
// seed 完成即释放句柄,避免 TempDir 清理时文件被占用
if sqlDB, err := oldDB.DB(); err == nil {
_ = sqlDB.Close()
}
avatarZip := filepath.Join(tmp, "avatars.zip")
writeTestZip(t, avatarZip, map[string]string{"a1.jpg": "fake-avatar"})
imagesZip := filepath.Join(tmp, "images.zip")
writeTestZip(t, imagesZip, map[string]string{"img1.jpg": "fake-image"})
svc := NewLegacyImportService(newDB, filepath.Join(tmp, "uploads"))
// 入厅钩子桩:生产为 PostgreSQL,ChatService.EnsureDefaultMembership 里的
// GREATEST 表达式 SQLite 不支持,故用等价桩验证「建号即触发钩子」的接线
hallMembers := map[uint]bool{}
svc.WithHallMembership(func(id uint) error {
hallMembers[id] = true
return nil
})
return svc, oldPath, avatarZip, imagesZip, admin.ID, hallMembers
}
func writeTestZip(t *testing.T, path string, files map[string]string) {
t.Helper()
f, err := os.Create(path)
if err != nil {
t.Fatalf("create zip: %v", err)
}
w := zip.NewWriter(f)
for name, content := range files {
fw, err := w.Create(name)
if err != nil {
t.Fatalf("zip entry: %v", err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatalf("zip write: %v", err)
}
}
if err := w.Close(); err != nil {
t.Fatalf("zip close: %v", err)
}
if err := f.Close(); err != nil {
t.Fatalf("zip file close: %v", err)
}
}
func TestLegacyImportPreviewLists(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, _, _ := newLegacyTestEnv(t)
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, LegacyImportOptions{WithContent: true, DryRun: true}, 1)
if err != nil {
t.Fatalf("dry run: %v", err)
}
if len(rep.UserList) != 4 || len(rep.PostList) != 5 || len(rep.CommentList) != 3 {
t.Fatalf("预检清单数量不符: users=%d posts=%d comments=%d", len(rep.UserList), len(rep.PostList), len(rep.CommentList))
}
if rep.UserList[0].Username != "alice" || !rep.UserList[0].HasAvatar {
t.Fatal("alice 预检项不符")
}
// 将导入:#100/#101/#104(草稿、投票排除)
if rep.Posts.Imported != 3 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("预检帖子统计不符: %+v", rep.Posts)
}
// dry-run 不落盘
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); !os.IsNotExist(err) {
t.Fatal("预检不应写文件")
}
}
func TestLegacyImportSelectiveRun(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, hallMembers := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipUserIDs: map[uint]bool{2: true, 3: true}, // bob / carol 不建号
UserTargetNames: map[uint]string{2: "admin"}, // bob 的内容归到已有账号 admin
SkipCommentIDs: map[uint]bool{202: true}, // 排除 alice 在 #101 的评论
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
// 用户:alice / dave 建号,bob / carol 排除
if rep.Users.Imported != 2 || rep.Users.Excluded != 2 {
t.Fatalf("用户统计不符: %+v", rep.Users)
}
// 帖子:#100/#101/#104 导入,#102 草稿跳过,#103 投票
if rep.Posts.Imported != 3 || rep.Posts.Excluded != 0 || rep.Posts.PollsSkipped != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
// 评论:#200/#201 导入,#202 排除
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符: %+v", rep.Comments)
}
// 图片:#100 与 #200 各引用 img1.jpg,只落盘一次
if rep.Posts.ImagesWritten != 1 || len(rep.Comments.ImagesMissing) != 0 {
t.Fatalf("图片统计不符: posts=%+v comments=%+v", rep.Posts.ImagesWritten, rep.Comments.ImagesMissing)
}
if len(rep.Posts.ImagesMissing) != 1 || rep.Posts.ImagesMissing[0] != "missing.jpg" {
t.Fatalf("缺失图片应记录: %v", rep.Posts.ImagesMissing)
}
// 内容归属
var post100, post101 model.Post
if err := svc.db.Where("title = ?", "看图帖").First(&post100).Error; err != nil {
t.Fatalf("post100: %v", err)
}
if err := svc.db.Where("title = ?", "普通帖").First(&post101).Error; err != nil {
t.Fatalf("post101: %v", err)
}
var alice model.User
if err := svc.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("alice: %v", err)
}
if post100.UserID != alice.ID {
t.Fatalf("#100 应归 alice: got %d", post100.UserID)
}
if post101.UserID != adminID {
t.Fatalf("#101 应按 user_map 归 admin: got %d", post101.UserID)
}
// bob 的评论 #200 也归 admin;carol 的评论 #201 兜底归 admin
var cm200, cm201 model.Comment
if err := svc.db.Where("content LIKE ?", "%补一张%").First(&cm200).Error; err != nil {
t.Fatalf("cm200: %v", err)
}
if err := svc.db.Where("content LIKE ?", "%顶%").First(&cm201).Error; err != nil {
t.Fatalf("cm201: %v", err)
}
if cm200.UserID != adminID || cm201.UserID != adminID {
t.Fatalf("评论归属不符: cm200=%d cm201=%d", cm200.UserID, cm201.UserID)
}
// 图片 URL 改写 + 文件落盘
if !strings.Contains(post100.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(post100.Content, legacyPostImagePrefix) {
t.Fatalf("#100 图片 URL 未改写: %s", post100.Content)
}
if !strings.Contains(cm200.Content, legacyImagePrefix+"img1.jpg") || strings.Contains(cm200.Content, "old.example.com") {
t.Fatalf("#200 图片 URL 未改写: %s", cm200.Content)
}
// 缺图帖:包内缺失的引用保留原链接,不改写
p104 := post104Content(t, svc)
if !strings.Contains(p104, legacyPostImagePrefix+"missing.jpg") || strings.Contains(p104, legacyImagePrefix) {
t.Fatalf("缺图引用应保留原链接: %s", p104)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "images", "img1.jpg")); err != nil {
t.Fatalf("img1.jpg 未落盘: %v", err)
}
if _, err := os.Stat(filepath.Join(svc.uploadsDir, "avatars", "a1.jpg")); err != nil {
t.Fatalf("头像未落盘: %v", err)
}
// 导入建号的用户触发入厅钩子;被排除建号的 bob/carol 不触发
var dave model.User
if err := svc.db.Where("username = ?", "dave").First(&dave).Error; err != nil {
t.Fatalf("dave: %v", err)
}
if len(hallMembers) != 2 || !hallMembers[alice.ID] || !hallMembers[dave.ID] {
t.Fatalf("入厅钩子应只对 alice/dave 触发: %v", hallMembers)
}
// 幂等:重复导入全部跳过
rep2, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("re-import: %v", err)
}
if rep2.Users.Imported != 0 || rep2.Posts.Imported != 0 || rep2.Comments.Imported != 0 {
t.Fatalf("重复导入应全部跳过: %+v", rep2)
}
if rep2.Users.Excluded != 2 || rep2.Posts.Skipped != 4 || rep2.Comments.Skipped != 2 {
t.Fatalf("重复导入统计不符: users=%+v posts=%+v comments=%+v", rep2.Users, rep2.Posts, rep2.Comments)
}
}
// TestLegacyImportSkipPostCascades 排除帖子时其评论自动跳过
func TestLegacyImportSkipPostCascades(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{
WithContent: true,
SkipPostIDs: map[uint]bool{101: true}, // #101 排除 → 其评论 #202 自动跳过
}
rep, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if err != nil {
t.Fatalf("import: %v", err)
}
if rep.Posts.Imported != 2 || rep.Posts.Excluded != 1 {
t.Fatalf("帖子统计不符: %+v", rep.Posts)
}
if rep.Comments.Imported != 2 || rep.Comments.Excluded != 1 {
t.Fatalf("评论统计不符(#202 应随 #101 排除): %+v", rep.Comments)
}
var n int64
svc.db.Model(&model.Post{}).Where("title = ?", "普通帖").Count(&n)
if n != 0 {
t.Fatal("#101 不应导入")
}
}
// TestLegacyImportBadTarget 归属目标账号不存在时报错
func TestLegacyImportBadTarget(t *testing.T) {
svc, oldPath, avatarZip, imagesZip, adminID, _ := newLegacyTestEnv(t)
opts := LegacyImportOptions{WithContent: true, UserTargetNames: map[uint]string{2: "ghost"}}
_, err := svc.ImportFromFiles(oldPath, avatarZip, imagesZip, opts, adminID)
if !errors.Is(err, ErrLegacyBadTarget) {
t.Fatalf("应返回 ErrLegacyBadTarget: %v", err)
}
}
func post104Content(t *testing.T, svc *LegacyImportService) string {
t.Helper()
var p model.Post
if err := svc.db.Where("title = ?", "缺图帖").First(&p).Error; err != nil {
t.Fatalf("post104: %v", err)
}
return p.Content
}

View File

@@ -163,8 +163,10 @@ func (s *AuthService) LookupFamilyByRefreshPlain(plain string, userID uint) uint
return sessionFamilyID(rt)
}
// TouchDeviceFromRefresh 用当前请求的 IP/UA 校准本会话,并回填尚未写入的 family_id。
func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint) uint {
// TouchDeviceFromRefresh 刷新本会话活跃时间,并回填尚未写入的 family_id。
// 会话的 IP/UA 以登录时记录为准,不随访问端环境漂移,
// 否则设备指纹会跟着请求变,设备列表在 UA 模拟/代理下会来回跳。
func (s *AuthService) TouchDeviceFromRefresh(plain string, userID uint) uint {
if plain == "" {
return 0
}
@@ -177,12 +179,6 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
}
now := time.Now()
upd := map[string]any{"last_used_at": now, "updated_at": now}
if v := truncateStr(ip, 45); v != "" {
upd["ip"] = v
}
if v := truncateStr(ua, 500); v != "" {
upd["user_agent"] = v
}
fid := sessionFamilyID(rt)
if rt.FamilyID == 0 {
upd["family_id"] = fid
@@ -191,26 +187,29 @@ func (s *AuthService) TouchDeviceFromRefresh(plain, ip, ua string, userID uint)
return fid
}
// revokeAllSessionsExcept 真·单会话:登录时吊销该用户除新会话外的全部
// 有效会话(任何新登录都踢掉所有旧设备);返回吊销行数供上层广播告知。
func (s *AuthService) revokeAllSessionsExcept(userID, keepFamily uint) int {
// maxActiveSessions 每个用户允许并存的活跃会话(登录设备)上限,
// 与设备列表展示上限 loginDeviceLimit 一致;超出时淘汰最久未使用的会话。
const maxActiveSessions = 20
// enforceSessionCap 会话上限守护:多会话并存下新登录不踢旧设备,仅当该用户
// 活跃会话超过 maxActiveSessions 时,按 last_used_at 淘汰最旧的会话腾位;
// 返回淘汰行数供上层广播告知(新会话 last_used_at 最新,不会被淘汰)。
func (s *AuthService) enforceSessionCap(userID uint) int {
now := time.Now()
var actives []model.RefreshToken
if err := s.db.Select("id", "family_id").
if err := s.db.Select("id").
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, now).
Order("last_used_at DESC, created_at DESC, id DESC").
Find(&actives).Error; err != nil {
return 0
}
ids := make([]uint, 0)
for _, t := range actives {
if sessionFamilyID(t) == keepFamily {
continue
}
ids = append(ids, t.ID)
}
if len(ids) == 0 {
if len(actives) <= maxActiveSessions {
return 0
}
ids := make([]uint, 0, len(actives)-maxActiveSessions)
for _, t := range actives[maxActiveSessions:] {
ids = append(ids, t.ID)
}
res := s.db.Model(&model.RefreshToken{}).Where("id IN ?", ids).
Updates(map[string]any{"revoked": true, "token_cipher": "", "updated_at": now})
if res.Error != nil {

View File

@@ -0,0 +1,276 @@
package service
import (
"errors"
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func newLoginSessionTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.RefreshToken{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func newLoginSessionUser(t *testing.T, db *gorm.DB, username string) *model.User {
t.Helper()
hashed, err := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
user := model.User{Username: username, Password: string(hashed)}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("seed user: %v", err)
}
return &user
}
func countActiveSessions(t *testing.T, db *gorm.DB, userID uint) int64 {
t.Helper()
var n int64
if err := db.Model(&model.RefreshToken{}).
Where("user_id = ? AND revoked = ? AND expires_at > ?", userID, false, time.Now()).
Count(&n).Error; err != nil {
t.Fatalf("count active sessions: %v", err)
}
return n
}
// TestLoginMultiSessionCoexist 多会话并存:不同设备先后登录互不顶号,
// 各会话的 access token(family_id)均持续有效。
func TestLoginMultiSessionCoexist(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "alice")
uas := []string{"Chrome-Windows", "Edge-Windows", "iPhone-Safari"}
families := make([]uint, 0, len(uas))
for i, ua := range uas {
access, refresh, _, kicked, err := s.Login("alice", "password123", "1.2.3.4", ua)
if err != nil {
t.Fatalf("login #%d: %v", i+1, err)
}
if kicked != 0 {
t.Fatalf("login #%d 不应踢掉旧会话,kicked=%d", i+1, kicked)
}
if _, err := s.ValidateRefreshToken(refresh); err != nil {
t.Fatalf("login #%d refresh 校验失败: %v", i+1, err)
}
claims, err := s.ParseToken(access)
if err != nil {
t.Fatalf("login #%d 解析 access token: %v", i+1, err)
}
if _, err := s.ValidateClaims(claims); err != nil {
t.Fatalf("login #%d 会话应有效: %v", i+1, err)
}
families = append(families, claims.FamilyID)
}
if n := countActiveSessions(t, db, user.ID); n != int64(len(uas)) {
t.Fatalf("应共存 %d 个活跃会话,实际 %d", len(uas), n)
}
for _, fam := range families {
if !s.familySessionActive(user.ID, fam) {
t.Fatalf("family=%d 会话应仍有效", fam)
}
}
}
// TestSessionCapEvictsOldest 会话上限守护:活跃会话超过 maxActiveSessions 时,
// 按 last_used_at 淘汰最旧会话,其余会话不受影响。
func TestSessionCapEvictsOldest(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "bob")
families := make([]uint, 0, maxActiveSessions)
for i := 0; i < maxActiveSessions; i++ {
_, family, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device")
if err != nil {
t.Fatalf("create #%d: %v", i+1, err)
}
if kicked != 0 {
t.Fatalf("create #%d 不应淘汰会话,kicked=%d", i+1, kicked)
}
families = append(families, family)
}
// 人为拉开 last_used_at,保证淘汰顺序确定(第 1 个最旧)
base := time.Now().Add(-2 * time.Hour)
for i, fam := range families {
if err := db.Model(&model.RefreshToken{}).Where("family_id = ?", fam).
Update("last_used_at", base.Add(time.Duration(i)*time.Minute)).Error; err != nil {
t.Fatalf("调整 last_used_at: %v", err)
}
}
_, _, kicked, err := s.CreateRefreshToken(user.ID, "1.2.3.4", "device-new")
if err != nil {
t.Fatalf("create #%d: %v", maxActiveSessions+1, err)
}
if kicked != 1 {
t.Fatalf("超出上限应淘汰 1 个最旧会话,kicked=%d", kicked)
}
if n := countActiveSessions(t, db, user.ID); n != maxActiveSessions {
t.Fatalf("淘汰后应剩 %d 个活跃会话,实际 %d", maxActiveSessions, n)
}
if s.familySessionActive(user.ID, families[0]) {
t.Fatal("最旧会话应已被淘汰")
}
for _, fam := range families[1:] {
if !s.familySessionActive(user.ID, fam) {
t.Fatalf("family=%d 会话不应被淘汰", fam)
}
}
}
// TestRevokeLoginDeviceWithMultiSessions 多会话下剔除设备:仅目标会话失效,
// 当前会话不受影响(沿用既有剔除语义的回归保障)。
func TestRevokeLoginDeviceWithMultiSessions(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "carol")
access1, refresh1, _, _, err := s.Login("carol", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login 1: %v", err)
}
claims1, err := s.ParseToken(access1)
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
family1 := claims1.FamilyID
access2, _, _, _, err := s.Login("carol", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
claims2, err := s.ParseToken(access2)
if err != nil {
t.Fatalf("parse access 2: %v", err)
}
family2 := claims2.FamilyID
devices, err := s.ListLoginDevices(user.ID, family1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("list devices: %v", err)
}
if len(devices) != 2 {
t.Fatalf("应列出 2 台设备,实际 %d", len(devices))
}
var targetID uint
for _, d := range devices {
if !d.Current {
targetID = d.ID
}
}
if targetID == 0 {
t.Fatal("未找到非当前设备")
}
if err := s.RevokeLoginDevice(user.ID, family1, targetID); err != nil {
t.Fatalf("revoke device: %v", err)
}
if s.familySessionActive(user.ID, family2) {
t.Fatal("被剔除设备的会话应已失效")
}
if _, err := s.ValidateRefreshToken(refresh1); err != nil {
t.Fatalf("当前设备会话不应受影响: %v", err)
}
}
// TestRefreshReuseRevokesFamilyOnly 宽限期外的重放判定盗用时,仅吊销该轮转链
// 所在家族,不牵连同用户其他登录设备(此前误撤全用户会话:开发期 middleware
// /浏览器丢轮换的竞态会把 Chrome 等所有设备一齐踢下线)。
func TestRefreshReuseRevokesFamilyOnly(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret")
user := newLoginSessionUser(t, db, "dave")
access1, refresh1, _, _, err := s.Login("dave", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login 1: %v", err)
}
claims1, err := s.ParseToken(access1)
if err != nil {
t.Fatalf("parse access 1: %v", err)
}
access2, refresh2, _, _, err := s.Login("dave", "password123", "2.2.2.2", "iPhone-Mobile")
if err != nil {
t.Fatalf("login 2: %v", err)
}
claims2, err := s.ParseToken(access2)
if err != nil {
t.Fatalf("parse access 2: %v", err)
}
// 家族1 正常轮转一次:旧行吊销并链接到新行
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); err != nil {
t.Fatalf("rotate 1: %v", err)
}
// 把旧行 rotated_at 拨回宽限期之外,模拟「轮换响应丢失后的延迟重放」
if err := db.Model(&model.RefreshToken{}).
Where("token_hash = ?", hashRefreshToken(refresh1)).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
// 宽限期外重放:判定盗用,仅家族1 被整体吊销
if _, _, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home"); !errors.Is(err, ErrRefreshReused) {
t.Fatalf("期望 ErrRefreshReused,实际 %v", err)
}
if s.familySessionActive(user.ID, claims1.FamilyID) {
t.Fatal("重放所在家族应被整体吊销")
}
if !s.familySessionActive(user.ID, claims2.FamilyID) {
t.Fatal("其他登录设备(家族)不应被牵连")
}
if n := countActiveSessions(t, db, user.ID); n != 1 {
t.Fatalf("应仅剩家族2 的 1 个活跃会话,实际 %d", n)
}
if _, err := s.ValidateRefreshToken(refresh2); err != nil {
t.Fatalf("其他设备的 refresh 不应受影响: %v", err)
}
}
// TestRefreshReuseDevGraceReturnsSamePair dev 模式宽限期放宽:轮换后远超
// 生产宽限窗的同链重放(dev 重启丢响应的常态)仍返回同一对,不升级为盗用判定。
func TestRefreshReuseDevGraceReturnsSamePair(t *testing.T) {
db := newLoginSessionTestDB(t)
s := NewAuthService(db, "test-secret").WithDevMode(true)
newLoginSessionUser(t, db, "eve")
_, refresh1, _, _, err := s.Login("eve", "password123", "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("login: %v", err)
}
_, newRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("rotate: %v", err)
}
// rotated_at 拨回生产宽限期之外(仍在 dev 宽限内)
if err := db.Model(&model.RefreshToken{}).
Where("token_hash = ?", hashRefreshToken(refresh1)).
Update("rotated_at", time.Now().Add(-2*time.Minute)).Error; err != nil {
t.Fatalf("backdate rotated_at: %v", err)
}
_, replayRefresh, _, err := s.RotateRefreshToken(refresh1, "1.1.1.1", "Chrome-Home")
if err != nil {
t.Fatalf("dev 宽限期内重放不应报错: %v", err)
}
if replayRefresh != newRefresh {
t.Fatal("dev 宽限期内重放应返回同一对新 token")
}
}

View File

@@ -0,0 +1,388 @@
package service
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
)
// 媒体库(管理后台):盘点站点内全部图片资源。
// 数据源 = uploads 五个分类目录的磁盘扫描 + attachments 表元数据合并:
// - 旧站导入图 / 背景图 / 广告素材 / 品牌 Logo 落盘但无附件记录,只有磁盘扫描能覆盖
// - 附件记录(头像、帖子插图)补充上传者、创建时间等元数据;远程存储对象(/api/media/)仅存在于库中
// 同名(去扩展名)同时存在原图与 WebP 时只展示 WebP,原图 URL 挂在 OriginalURL 供查看。
// 媒体库分类:目录名即分类键,前端 tab 直接使用
var mediaLibraryCategories = []struct {
Key string // 分类键(image/avatar/background/ads/brand)
Dir string // uploads 下的子目录
Name string // 展示名
}{
{"image", "images", "帖子插图"},
{"avatar", "avatars", "用户头像"},
{"background", "backgrounds", "站点背景"},
{"ads", "ads", "广告素材"},
{"brand", "brand", "品牌资源"},
}
// MediaLibraryItem 媒体库条目
type MediaLibraryItem struct {
URL string `json:"url"` // 展示地址(有 WebP 时为 WebP)
OriginalURL string `json:"original_url,omitempty"` // 同名原图(仅当原图与 WebP 并存)
Category string `json:"category"` // image/avatar/background/ads/brand
CategoryName string `json:"category_name"`
Name string `json:"name"` // 文件名(含扩展名)
MIME string `json:"mime"`
Size int64 `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
}
// AdminMediaLibrary 全站图片盘点:磁盘扫描 + 附件元数据合并,按时间倒序。
// 返回条目列表与各分类计数(含全部计数由前端按列表累加)。
func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int, error) {
// 附件元数据索引:URL → 记录(联出上传者昵称)
type attMeta struct {
Kind string
URL string
MIME string
Size int
Width int
Height int
Nickname string
Username string
CreatedAt time.Time
}
var rows []attMeta
if err := s.db.Table("attachments").
Select("attachments.kind, attachments.url, attachments.mime, attachments.size, attachments.width, attachments.height, attachments.created_at, users.nickname, users.username").
Joins("LEFT JOIN users ON users.id = attachments.user_id").
Scan(&rows).Error; err != nil {
return nil, nil, err
}
metaByURL := make(map[string]attMeta, len(rows))
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
items := make([]MediaLibraryItem, 0, len(rows))
categoryName := func(key string) string {
for _, cat := range mediaLibraryCategories {
if cat.Key == key {
return cat.Name
}
}
return key
}
for _, r := range rows {
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
if !strings.HasPrefix(r.URL, "/api/media/") {
continue
}
uploader := r.Nickname
if uploader == "" {
uploader = r.Username
}
cat := r.Kind
if cat != model.AttachmentKindImage && cat != model.AttachmentKindAvatar {
continue // 未知 kind 不进媒体库
}
ca := r.CreatedAt
items = append(items, MediaLibraryItem{
URL: r.URL, Category: cat, CategoryName: categoryName(cat),
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
Size: int64(r.Size), Width: r.Width, Height: r.Height,
Uploader: uploader, UploadedAt: &ca,
})
}
// 磁盘扫描五类目录;同名(去扩展名)成组,WebP 优先展示、原图挂 OriginalURL
type group struct {
webp *MediaLibraryItem
original *MediaLibraryItem
}
for _, cat := range mediaLibraryCategories {
dir := filepath.Join(s.dir, cat.Dir)
entries, err := os.ReadDir(dir)
if err != nil {
continue // 目录不存在视为空
}
groups := map[string]*group{}
for _, e := range entries {
if e.IsDir() || strings.HasSuffix(e.Name(), ".partial") {
continue
}
ext := strings.ToLower(filepath.Ext(e.Name()))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
continue
}
url := "/uploads/" + cat.Dir + "/" + e.Name()
item := MediaLibraryItem{
URL: url, Category: cat.Key, CategoryName: cat.Name,
Name: e.Name(),
MIME: map[string]string{
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
".webp": "image/webp", ".gif": "image/gif",
}[ext],
}
if info, err := e.Info(); err == nil {
item.Size = info.Size()
mt := info.ModTime()
item.UploadedAt = &mt
}
if m, ok := metaByURL[url]; ok {
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
uploader := m.Nickname
if uploader == "" {
uploader = m.Username
}
ca := m.CreatedAt
item.Uploader = uploader
item.UploadedAt = &ca
if m.Size > 0 {
item.Size = int64(m.Size)
}
if m.Width > 0 {
item.Width, item.Height = m.Width, m.Height
}
}
// 无尺寸记录时读图片头解析宽高(失败不阻断,保持 0)
if item.Width == 0 && item.Size > 0 && item.Size <= 20<<20 {
if f, err := os.Open(filepath.Join(dir, e.Name())); err == nil {
w, h, derr := decodeImageSizeReader(f, item.MIME)
_ = f.Close()
if derr == nil {
item.Width, item.Height = w, h
}
}
}
stem := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name()))
g := groups[stem]
if g == nil {
g = &group{}
groups[stem] = g
}
if ext == ".webp" {
g.webp = &item
} else if g.original == nil {
g.original = &item
}
}
for _, g := range groups {
switch {
case g.webp != nil && g.original != nil:
// 原图与 WebP 并存:只展示 WebP,原图作 original_url
g.webp.OriginalURL = g.original.URL
if g.webp.Size == 0 {
g.webp.Size = g.original.Size
}
items = append(items, *g.webp)
case g.webp != nil:
items = append(items, *g.webp)
default:
items = append(items, *g.original)
}
}
}
sort.Slice(items, func(i, j int) bool {
ti, tj := items[i].UploadedAt, items[j].UploadedAt
switch {
case ti == nil && tj == nil:
return items[i].URL < items[j].URL
case ti == nil:
return false
case tj == nil:
return true
}
return ti.After(*tj)
})
counts := map[string]int{}
for _, it := range items {
counts[it.Category]++
}
s.attachMediaSources(items)
return items, counts, nil
}
// attachMediaSources 为条目补来源回链:
// - 头像:users.avatar 精确匹配 → 用户主页
// - 其余:分块 LIKE 扫描帖子/评论正文(展示 URL 与同名原图都参与匹配),帖子优先于评论
//
// 内容 LIKE 无索引,纯顺序扫描,但仅管理端低频调用;分块 OR 控制往返次数。
func (s *UploadService) attachMediaSources(items []MediaLibraryItem) {
urlSet := map[string]bool{}
for _, it := range items {
for _, u := range []string{it.URL, it.OriginalURL} {
if strings.HasPrefix(u, "/uploads/") {
urlSet[u] = true
}
}
}
if len(urlSet) == 0 {
return
}
urls := make([]string, 0, len(urlSet))
for u := range urlSet {
urls = append(urls, u)
}
type postRef struct {
id uint
title string
}
postByURL := map[string]postRef{}
type commentRef struct {
id, postID uint
}
commentByURL := map[string]commentRef{}
for i := 0; i < len(urls); i += 40 {
end := i + 40
if end > len(urls) {
end = len(urls)
}
chunk := urls[i:end]
conds := make([]string, len(chunk))
args := make([]any, len(chunk))
for j, u := range chunk {
conds[j] = "content LIKE ?"
args[j] = "%" + u + "%"
}
where := strings.Join(conds, " OR ")
var prows []struct {
ID uint
Title string
Content string
}
if err := s.db.Model(&model.Post{}).Select("id, title, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&prows).Error; err != nil {
return
}
for _, p := range prows {
for _, u := range chunk {
if strings.Contains(p.Content, u) {
if _, ok := postByURL[u]; !ok {
postByURL[u] = postRef{id: p.ID, title: p.Title}
}
}
}
}
var crows []struct {
ID uint
PostID uint
Content string
}
if err := s.db.Model(&model.Comment{}).Select("id, post_id, content").
Where(where, args...).Order("id ASC").Limit(600).Scan(&crows).Error; err != nil {
return
}
for _, cm := range crows {
for _, u := range chunk {
if strings.Contains(cm.Content, u) {
if _, ok := commentByURL[u]; !ok {
commentByURL[u] = commentRef{id: cm.ID, postID: cm.PostID}
}
}
}
}
}
// 评论来源要带宿主帖子标题,一次性补齐
postTitle := func(id uint) string {
var p model.Post
if err := s.db.Select("id, title").First(&p, id).Error; err != nil {
return ""
}
return p.Title
}
commentPostTitle := map[uint]string{}
for _, cm := range commentByURL {
if _, ok := commentPostTitle[cm.postID]; !ok {
commentPostTitle[cm.postID] = postTitle(cm.postID)
}
}
// 头像使用者:users.avatar 精确匹配(同一头像多人使用时取最早的用户)
userByAvatar := map[string]struct {
ID uint
Nickname, Username string
}{}
var urows []struct {
ID uint
Nickname string
Username string
Avatar string
}
if err := s.db.Model(&model.User{}).Select("id, nickname, username, avatar").
Where("avatar IN ?", urls).Order("id ASC").Find(&urows).Error; err == nil {
for _, u := range urows {
if _, ok := userByAvatar[u.Avatar]; !ok {
userByAvatar[u.Avatar] = struct {
ID uint
Nickname, Username string
}{ID: u.ID, Nickname: u.Nickname, Username: u.Username}
}
}
}
truncate := func(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
postLabel := func(p postRef) string {
title := p.title
if title == "" {
title = fmt.Sprintf("#%d", p.id)
}
return "帖子《" + truncate(title, 40) + "》"
}
for i := range items {
it := &items[i]
// 头像使用者优先(头像正文引用场景极少)
if u, ok := userByAvatar[it.URL]; ok {
name := u.Nickname
if name == "" {
name = u.Username
}
it.SourceURL = fmt.Sprintf("/u/%d", u.ID)
it.SourceLabel = "@" + name + " 的头像"
continue
}
for _, u := range []string{it.URL, it.OriginalURL} {
if u == "" || it.SourceURL != "" {
continue
}
if p, ok := postByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d", p.id)
it.SourceLabel = postLabel(p)
break
}
if cm, ok := commentByURL[u]; ok {
it.SourceURL = fmt.Sprintf("/post/%d#comment-%d", cm.postID, cm.id)
title := commentPostTitle[cm.postID]
if title != "" {
it.SourceLabel = "帖子《" + truncate(title, 40) + "》下的评论"
} else {
it.SourceLabel = "帖子评论"
}
break
}
}
}
}

View File

@@ -0,0 +1,312 @@
package service
import (
"bytes"
"fmt"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"time"
xwebp "golang.org/x/image/webp"
"github.com/freefire/jiang13-bbs/model"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func newMediaLibraryService(t *testing.T) (*UploadService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func writeMediaFile(t *testing.T, dir, rel string, data []byte) {
t.Helper()
full := filepath.Join(dir, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(full, data, 0o644); err != nil {
t.Fatalf("write %s: %v", rel, err)
}
}
func tinyPNG() []byte {
src := image.NewRGBA(image.Rect(0, 0, 12, 8))
src.Set(0, 0, color.RGBA{1, 2, 3, 255})
var buf bytes.Buffer
_ = png.Encode(&buf, src)
return buf.Bytes()
}
func TestAdminMediaLibraryWebpPreferredWithOriginalURL(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "images/pair.jpg", []byte("fake-jpeg-bytes"))
writeMediaFile(t, dir, "images/pair.webp", []byte("fake-webp-bytes"))
writeMediaFile(t, dir, "images/solo.png", tinyPNG())
writeMediaFile(t, dir, "images/trash.txt", []byte("not an image"))
writeMediaFile(t, dir, "images/partial.jpg.partial", []byte("temp"))
writeMediaFile(t, dir, "ads/qr.gif", []byte("GIF89a-fake"))
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 同名原图 + WebP 并存:只出 WebP 条目,原图挂 original_url
pair, ok := byURL["/uploads/images/pair.webp"]
if !ok {
t.Fatalf("pair.webp missing; items=%v", items)
}
if pair.OriginalURL != "/uploads/images/pair.jpg" {
t.Fatalf("want original pair.jpg, got %q", pair.OriginalURL)
}
if _, exists := byURL["/uploads/images/pair.jpg"]; exists {
t.Fatal("pair.jpg should be hidden behind webp")
}
// 无配对的单文件:保留原格式并解析出宽高
solo, ok := byURL["/uploads/images/solo.png"]
if !ok {
t.Fatal("solo.png missing")
}
if solo.MIME != "image/png" || solo.Width != 12 || solo.Height != 8 {
t.Fatalf("solo.png meta wrong: %+v", solo)
}
// 非图片与 .partial 被过滤
if _, exists := byURL["/uploads/images/trash.txt"]; exists {
t.Fatal("trash.txt should be excluded")
}
if _, exists := byURL["/uploads/images/partial.jpg.partial"]; exists {
t.Fatal(".partial should be excluded")
}
// 广告素材目录计入 ads 分类
if qr, ok := byURL["/uploads/ads/qr.gif"]; !ok || qr.Category != "ads" {
t.Fatalf("ads/qr.gif missing or wrong category: %+v", qr)
}
if counts["image"] != 2 || counts["ads"] != 1 {
t.Fatalf("counts wrong: %v", counts)
}
}
func TestAdminMediaLibraryMergesAttachmentMetaAndRemoteObjects(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 附件记录引用的磁盘文件必须真实存在,否则该记录会被跳过(见 SkipsLocalMissingFiles)
writeMediaFile(t, dir, "images/abc.webp", []byte("fake-webp-bytes"))
created := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
if err := s.db.Create(&model.User{Username: "alice", Nickname: "爱丽丝"}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
var alice model.User
if err := s.db.Where("username = ?", "alice").First(&alice).Error; err != nil {
t.Fatalf("load user: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindImage,
URL: "/uploads/images/abc.webp", MIME: "image/webp",
Size: 4567, Width: 320, Height: 240, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
if err := s.db.Create(&model.Attachment{
UserID: alice.ID, Kind: model.AttachmentKindAvatar,
URL: "/api/media/remote01", MIME: "image/webp",
Size: 999, Width: 128, Height: 128, CreatedAt: created,
}).Error; err != nil {
t.Fatalf("create remote attachment: %v", err)
}
items, counts, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
// 磁盘文件与附件记录合并:元数据以记录为准(上传者/尺寸/创建时间)
disk, ok := byURL["/uploads/images/abc.webp"]
if !ok {
t.Fatalf("attachment-backed local file missing: %+v", items)
}
if disk.Uploader != "爱丽丝" || disk.Size != 4567 || disk.Width != 320 || disk.Height != 240 {
t.Fatalf("meta not merged: %+v", disk)
}
// 远程存储对象:不在磁盘上也必须出现在媒体库
remote, ok := byURL["/api/media/remote01"]
if !ok {
t.Fatal("remote object missing")
}
if remote.Category != "avatar" || remote.Uploader != "爱丽丝" {
t.Fatalf("remote meta wrong: %+v", remote)
}
if counts["avatar"] != 1 {
t.Fatalf("avatar count wrong: %v", counts)
}
}
func TestAdminMediaLibrarySkipsLocalMissingFiles(t *testing.T) {
s, _ := newMediaLibraryService(t)
if err := s.db.Create(&model.Attachment{
UserID: 1, Kind: model.AttachmentKindImage,
URL: "/uploads/images/ghost.webp", MIME: "image/webp", Size: 1,
}).Error; err != nil {
t.Fatalf("create attachment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
for _, it := range items {
if it.URL == "/uploads/images/ghost.webp" {
t.Fatal("local-missing file should not be listed")
}
}
}
func TestAdminMediaLibraryAttachSources(t *testing.T) {
s, dir := newMediaLibraryService(t)
writeMediaFile(t, dir, "avatars/av1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/p1.webp", []byte("fake-webp"))
writeMediaFile(t, dir, "images/c1.jpg", []byte("fake-jpeg"))
// 头像被 bob 使用
if err := s.db.Create(&model.User{
Username: "bob", Nickname: "阿Bob", Avatar: "/uploads/avatars/av1.webp",
}).Error; err != nil {
t.Fatalf("create user: %v", err)
}
// 帖子正文引用 p1
post := model.Post{UserID: 1, Title: "图片来源测试", Content: "看图 ![a](/uploads/images/p1.webp)"}
if err := s.db.Create(&post).Error; err != nil {
t.Fatalf("create post: %v", err)
}
// 评论引用 c1
if err := s.db.Create(&model.Comment{
PostID: post.ID, UserID: 1, Content: "评论里也有 /uploads/images/c1.jpg",
}).Error; err != nil {
t.Fatalf("create comment: %v", err)
}
items, _, err := s.AdminMediaLibrary()
if err != nil {
t.Fatalf("AdminMediaLibrary: %v", err)
}
byURL := map[string]MediaLibraryItem{}
for _, it := range items {
byURL[it.URL] = it
}
av, ok := byURL["/uploads/avatars/av1.webp"]
if !ok {
t.Fatal("avatar item missing")
}
if av.SourceURL != "/u/1" || av.SourceLabel != "@阿Bob 的头像" {
t.Fatalf("avatar source wrong: %+v", av)
}
pimg, ok := byURL["/uploads/images/p1.webp"]
if !ok {
t.Fatal("post image missing")
}
if pimg.SourceURL != fmt.Sprintf("/post/%d", post.ID) ||
!strings.Contains(pimg.SourceLabel, "图片来源测试") {
t.Fatalf("post source wrong: %+v", pimg)
}
cimg, ok := byURL["/uploads/images/c1.jpg"]
if !ok {
t.Fatal("comment image missing")
}
if cimg.SourceURL != fmt.Sprintf("/post/%d#comment-", post.ID) &&
!strings.HasPrefix(cimg.SourceURL, fmt.Sprintf("/post/%d#comment-", post.ID)) {
t.Fatalf("comment source url wrong: %+v", cimg)
}
if !strings.Contains(cimg.SourceLabel, "下的评论") {
t.Fatalf("comment source label wrong: %+v", cimg)
}
}
func TestMediaThumb(t *testing.T) {
s, dir := newMediaLibraryService(t)
// 960×640 PNG → 缩到 480×320 的 WebP
big := image.NewRGBA(image.Rect(0, 0, 960, 640))
var bigBuf bytes.Buffer
if err := png.Encode(&bigBuf, big); err != nil {
t.Fatalf("encode png: %v", err)
}
writeMediaFile(t, dir, "images/big.png", bigBuf.Bytes())
// 小图(≤480)直接回原图
writeMediaFile(t, dir, "images/small.png", tinyPNG())
bigThumb, err := s.MediaThumb("/uploads/images/big.png")
if err != nil {
t.Fatalf("MediaThumb big: %v", err)
}
img, err := xwebp.Decode(bytes.NewReader(bigThumb))
if err != nil {
t.Fatalf("thumb not webp: %v", err)
}
if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 320 {
t.Fatalf("thumb size wrong: %v", img.Bounds())
}
small, err := s.MediaThumb("/uploads/images/small.png")
if err != nil {
t.Fatalf("MediaThumb small: %v", err)
}
if !bytes.Equal(small, tinyPNG()) {
t.Fatal("small image should be returned as-is")
}
// 缓存命中:第二次调用结果一致
again, err := s.MediaThumb("/uploads/images/big.png")
if err != nil || !bytes.Equal(again, bigThumb) {
t.Fatalf("cache miss/mismatch: %v", err)
}
// 非法与越界路径
if _, err := s.MediaThumb("/api/media/xxx"); err == nil {
t.Fatal("remote url should be rejected")
}
if _, err := s.MediaThumb("/uploads/../secrets/x.png"); err == nil {
t.Fatal("traversal should be rejected")
}
if _, err := s.MediaThumb("/uploads/other/x.png"); err == nil {
t.Fatal("unknown category should be rejected")
}
}

View File

@@ -0,0 +1,114 @@
package service
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strings"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/draw"
xwebp "golang.org/x/image/webp"
)
// 媒体库缩略图:管理后台网格不再直连原图(全量图片一次加载网络压力大)。
// 服务端按需生成最长边 480px 的 WebP 缩略图,落盘 .thumbs/(按 URL 哈希命名)缓存,
// 源文件更新后(mtime 更新)自动重建。仅覆盖本地 /uploads/ 图片;
// 远程存储对象与解码失败(如动图 WebP)由调用方回退原图。
// MediaThumbMaxSide 缩略图最长边(网格单元 ~200px,2x DPR 足够)
const MediaThumbMaxSide = 480
// MediaThumb 返回指定 /uploads/ 图片的缩略图字节。
func (s *UploadService) MediaThumb(url string) ([]byte, error) {
if !strings.HasPrefix(url, "/uploads/") || len(url) > 512 {
return nil, errors.New("无效地址")
}
rel := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(url, "/uploads/")))
if filepath.IsAbs(rel) || rel == "." || strings.HasPrefix(rel, "..") {
return nil, errors.New("无效地址")
}
dir := filepath.Dir(rel)
ok := false
for _, cat := range mediaLibraryCategories {
if cat.Dir == dir {
ok = true
break
}
}
if !ok {
return nil, errors.New("无效地址")
}
ext := strings.ToLower(filepath.Ext(rel))
switch ext {
case ".jpg", ".jpeg", ".png", ".webp", ".gif":
default:
return nil, errors.New("非图片")
}
src := filepath.Join(s.dir, rel)
info, err := os.Stat(src)
if err != nil || info.IsDir() {
return nil, errors.New("文件不存在")
}
// 磁盘缓存:哈希命名防跨分类文件名冲突;mtime 旧于源文件则重建
sum := sha1.Sum([]byte(url))
thumbPath := filepath.Join(s.dir, ".thumbs", hex.EncodeToString(sum[:])+".webp")
if ti, err := os.Stat(thumbPath); err == nil && !ti.IsDir() && !ti.ModTime().Before(info.ModTime()) {
return os.ReadFile(thumbPath)
}
data, err := os.ReadFile(src)
if err != nil {
return nil, err
}
var img image.Image
if ext == ".webp" {
img, err = xwebp.Decode(bytes.NewReader(data))
} else {
img, _, err = image.Decode(bytes.NewReader(data))
}
if err != nil {
return nil, err // 如动图 WebP,调用方回退原图
}
b := img.Bounds()
w, h := b.Dx(), b.Dy()
if w <= MediaThumbMaxSide && h <= MediaThumbMaxSide {
// 小图无需缩放,直接回原图省 CPU
return data, nil
}
nw, nh := w, h
if w >= h {
nw = MediaThumbMaxSide
nh = max(1, h*MediaThumbMaxSide/w)
} else {
nh = MediaThumbMaxSide
nw = max(1, w*MediaThumbMaxSide/h)
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Src, nil)
var buf bytes.Buffer
if err := webpenc.Encode(&buf, dst, webpenc.Options{Quality: 78}); err != nil {
return nil, err
}
// 原子落缓存(失败仅影响下次重复生成,不阻断响应)
if err := os.MkdirAll(filepath.Dir(thumbPath), 0o755); err == nil {
tmp := thumbPath + ".partial"
if err := os.WriteFile(tmp, buf.Bytes(), 0o644); err == nil {
if err := os.Rename(tmp, thumbPath); err != nil {
_ = os.Remove(tmp)
}
}
}
return buf.Bytes(), nil
}

View File

@@ -19,6 +19,7 @@ import (
"time"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"golang.org/x/image/webp"
"gorm.io/gorm"
)
@@ -29,10 +30,13 @@ const (
AvatarMinDim = 64
AvatarMaxDim = 512
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
ImageMaxBytes = 5 << 20 // 5 MiB
// 帖子插图:JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
ImageMaxBytes = 5 << 20 // 5 MiB(对原始上传文件生效)
ImageMaxDim = 4096
// JPEG 转 WebP 的有损质量;PNG 走无损,不受此参数影响
ImageWebPQuality = 80
// 站点背景图(前台/后台墙纸)
BackgroundMaxBytes = 8 << 20 // 8 MiB
BackgroundMaxDim = 8192
@@ -190,7 +194,50 @@ func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
return cfg.Width, cfg.Height, nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
// transcodeImageToWebP 把校验通过的 JPEG/PNG 临时文件解码后转码为 WebP:
// JPEG 走有损 q80,PNG 走无损(保留透明通道与文字锐度)。
// 成功返回新文件名/MIME/大小并清理原临时文件;失败返回 error(调用方保留原格式落盘)。
func (s *UploadService) transcodeImageToWebP(tmp, name, ext string) (string, string, int64, error) {
webpName := strings.TrimSuffix(name, ext) + ".webp"
webpPath := filepath.Join(s.dir, "images", webpName)
webpTmp := webpPath + ".partial"
if s.ops != nil {
release, e := s.ops.BeginTemporary(webpTmp)
if e != nil {
return "", "", 0, e
}
defer release()
}
f, err := os.Open(tmp)
if err != nil {
return "", "", 0, err
}
img, _, err := image.Decode(f) // 仅 JPEG/PNG 会走到这里,解码器已在文件头注册
_ = f.Close()
if err != nil {
return "", "", 0, err
}
var buf bytes.Buffer
if err := webpenc.Encode(&buf, img, webpenc.Options{
Quality: ImageWebPQuality,
Lossless: ext == ".png",
}); err != nil {
return "", "", 0, err
}
if err := os.WriteFile(webpTmp, buf.Bytes(), 0o644); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
if err := os.Rename(webpTmp, webpPath); err != nil {
_ = os.Remove(webpTmp)
return "", "", 0, err
}
_ = os.Remove(tmp)
return webpName, "image/webp", int64(buf.Len()), nil
}
// SaveImage 流式保存帖子插图:校验格式/大小/尺寸 → JPEG/PNG 转 WebP → 落盘 → 写 attachments(kind=image)
func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment, error) {
if src == nil {
return nil, errors.New("文件为空")
@@ -284,33 +331,48 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader) (*model.Attachment
_ = os.Remove(tmp)
return nil, errors.New("图片边长不能超过 4096px")
}
// JPEG/PNG 统一转存 WebP;GIF(动图)与已是 WebP 的原样保留
storeName, storeMime, storeSize := filename, format.mime, total
storePath := fullPath
if format.mime == "image/jpeg" || format.mime == "image/png" {
name, mime, size, err := s.transcodeImageToWebP(tmp, filename, format.ext)
if err != nil {
log.Printf("[upload] 插图转 WebP 失败,保留原格式 %s: %v", filename, err)
} else {
storeName, storeMime, storeSize = name, mime, size
storePath = filepath.Join(s.dir, "images", name)
}
}
if storePath == fullPath {
if err := os.Rename(tmp, fullPath); err != nil {
_ = os.Remove(tmp)
return nil, err
}
}
att := &model.Attachment{
UserID: userID,
Kind: model.AttachmentKindImage,
URL: "/uploads/images/" + filename,
MIME: format.mime,
Size: int(total),
URL: "/uploads/images/" + storeName,
MIME: storeMime,
Size: int(storeSize),
Width: w,
Height: h,
}
if s.ops != nil {
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
id, e := s.ops.StoreFile(storePath, att.MIME, true)
if e != nil {
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
return nil, e
}
if id != "" {
att.URL = "/api/media/" + id
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
}
}
if err := s.db.Create(att).Error; err != nil {
_ = os.Remove(fullPath)
_ = os.Remove(storePath)
return nil, err
}
return att, nil

View File

@@ -0,0 +1,172 @@
package service
import (
"bytes"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
"github.com/freefire/jiang13-bbs/model"
webpenc "github.com/gen2brain/webp"
"github.com/glebarez/sqlite"
xwebp "golang.org/x/image/webp"
"gorm.io/gorm"
)
func newUploadTestService(t *testing.T) (*UploadService, string) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.Attachment{}); err != nil {
t.Fatalf("migrate: %v", err)
}
dir := t.TempDir()
s := NewUploadService(db, dir)
if err := s.EnsureDir(); err != nil {
t.Fatalf("ensure dir: %v", err)
}
return s, dir
}
func storedUploadPath(t *testing.T, dir, url string) []byte {
t.Helper()
rel := strings.TrimPrefix(url, "/uploads/")
data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(rel)))
if err != nil {
t.Fatalf("read stored file %s: %v", url, err)
}
return data
}
func TestSaveImageTranscodesJPEGToWebP(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
for y := 0; y < 36; y++ {
for x := 0; x < 48; x++ {
src.Set(x, y, color.RGBA{uint8(x * 5), uint8(y * 7), 128, 255})
}
}
var in bytes.Buffer
if err := jpeg.Encode(&in, src, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
if att.Width != 48 || att.Height != 36 {
t.Fatalf("dims changed: %dx%d", att.Width, att.Height)
}
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
if err != nil {
t.Fatalf("decode stored webp: %v", err)
}
if got := img.Bounds(); got.Dx() != 48 || got.Dy() != 36 {
t.Fatalf("stored webp dims: %v", got)
}
}
func TestSaveImageTranscodesPNGLosslessKeepsAlpha(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewNRGBA(image.Rect(0, 0, 24, 24))
for y := 0; y < 24; y++ {
for x := 0; x < 24; x++ {
if y < 12 {
src.SetNRGBA(x, y, color.NRGBA{0, 0, 0, 0}) // 上半透明
} else {
src.SetNRGBA(x, y, color.NRGBA{12, 34, 56, 255})
}
}
}
var in bytes.Buffer
if err := png.Encode(&in, src); err != nil {
t.Fatalf("encode png: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
img, err := xwebp.Decode(bytes.NewReader(storedUploadPath(t, dir, att.URL)))
if err != nil {
t.Fatalf("decode stored webp: %v", err)
}
if _, _, _, a := img.At(0, 0).RGBA(); a != 0 {
t.Fatalf("alpha lost at transparent pixel: %d", a)
}
r, g, b, _ := img.At(5, 20).RGBA()
if r>>8 != 12 || g>>8 != 34 || b>>8 != 56 {
t.Fatalf("opaque color changed: %d,%d,%d", r>>8, g>>8, b>>8)
}
}
func TestSaveImageKeepsGIFAsIs(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 16, 16))
for y := 0; y < 16; y++ {
for x := 0; x < 16; x++ {
src.Set(x, y, color.RGBA{uint8(x * 16), uint8(y * 16), 0, 255})
}
}
var in bytes.Buffer
if err := gif.Encode(&in, src, nil); err != nil {
t.Fatalf("encode gif: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".gif") || att.MIME != "image/gif" {
t.Fatalf("gif should be kept as-is, got url=%s mime=%s", att.URL, att.MIME)
}
data := storedUploadPath(t, dir, att.URL)
magic := string(data[:6])
if magic != "GIF89a" && magic != "GIF87a" {
t.Fatalf("stored gif magic invalid: %q", magic)
}
}
func TestSaveImageKeepsWebPAsIs(t *testing.T) {
s, dir := newUploadTestService(t)
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
var in bytes.Buffer
if err := webpenc.Encode(&in, src, webpenc.Options{Quality: ImageWebPQuality}); err != nil {
t.Fatalf("encode webp: %v", err)
}
att, err := s.SaveImage(1, bytes.NewReader(in.Bytes()))
if err != nil {
t.Fatalf("SaveImage: %v", err)
}
if !strings.HasSuffix(att.URL, ".webp") || att.MIME != "image/webp" {
t.Fatalf("want webp attachment, got url=%s mime=%s", att.URL, att.MIME)
}
if !bytes.Equal(storedUploadPath(t, dir, att.URL), in.Bytes()) {
t.Fatal("webp input should be stored byte-for-byte")
}
if att.Size != in.Len() {
t.Fatalf("size mismatch: att=%d input=%d", att.Size, in.Len())
}
}

View File

@@ -0,0 +1,36 @@
# 旧站迁移公告:账号数据已迁入,旧站将全面搬家
各位社区成员:
**旧网站将在不久后全面迁移到姜十三论坛**,以后我们就在新家见面。
## 目前已迁移:用户账号
旧站账号数据已完成迁移,包括用户名、昵称、个性签名、邮箱和头像。
**无需重新注册**,直接用旧站的**用户名 + 原密码**登录即可——密码是原样迁移的,不需要重置。登录后建议在「账号设置」里核对一下资料。
![【图片占位】新站登录页截图](docs/screenshots/migration/login.png)
## 暂未迁移:帖子与评论
旧站的帖子与评论**暂时不会搬过来**。因为直接迁移会丢失旧帖里的图片,迁移方案还在完善中。等完成后我们会发布公告,把大家的帖子完整搬进来,请耐心等待。
在此之前,新内容请直接发在新站。
## 常见问题
**Q:需要重新注册吗?**
A:不需要,用旧站用户名和密码直接登录。
**Q:密码忘了怎么办?**
A:联系管理员,核对身份后协助重置。
**Q:旧帖会丢失吗?**
A:不会。帖子与评论等迁移方案完善后会完整搬入,届时另行公告。
---
如有问题或建议,可通过邮件联系:aarbbs@88.com
—— 姜十三论坛 团队

View File

@@ -68,7 +68,7 @@
- cdn:默认空,可选 HTTPS 基址。只用于已声明公开图片,部署方必须配置其桶/前缀权限。私有文件仍走鉴权下载;CDN 不是将私有桶设为公开的指令。
- image_max_mb:默认 5 MiB,范围 1–50;attachment_max_mb:默认 20 MiB,范围 1–100;attachment_max_count:默认每帖 10,范围 1–20。原 API 每次上传一个文件,前端批量仍受每帖上限约束。
- attachment_ext_limit:默认 true;attachment_exts:继承原业务白名单,1–80 个小写字母数字扩展名。原站点支持软件安装包和技术文本;这些仍只作为强制下载附件,不能内联执行。
- 正文图片复用 JPEG/PNG/WebP 解码检查与最大 4096px;头像保持 2 MiB、64–512px WebP;SVG 仅保留管理员品牌上传能力。附件复用真实图片头校验,伪装/活跃内容降为二进制并强制 attachment/nosniff。
- 正文图片复用 JPEG/PNG/WebP/GIF 解码检查与最大 4096px,其中 JPEG/PNG 落盘前统一转存 WebP(JPEG 有损 q80、PNG 无损;GIF 动图与已是 WebP 的原样保留,转码失败降级为保留原格式);头像保持 2 MiB、64–512px WebP;SVG 仅保留管理员品牌上传能力。附件复用真实图片头校验,伪装/活跃内容降为二进制并强制 attachment/nosniff。
- 1 MiB=1,048,576 字节;仓库 Nginx 示例与 Next 代理缓冲均为 512 MiB,仍需确认实际部署入口没有更低限制。
- 保存 S3 前验证合并后的草稿,在专用 tests/ 前缀上传、读回并删除一个随机测试对象,不扫描桶。失败不更新配置;不会自动切回其他存储。
- 每个远程对象保存不可变 storage-N 配置引用与对象键。切换后原本地 URL、历史远程配置继续可读;界面显示历史引用数,不提供删除历史配置按钮。

View File

@@ -16,7 +16,6 @@ import {
SiteDocShell,
SiteDocIdentity,
SiteDocAnnoNav,
SiteDocBreadcrumb,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
@@ -109,13 +108,6 @@ export default async function AboutIndexPage() {
className="px-5 sm:px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<SiteDocBreadcrumb
className="mb-3"
items={[
{ href: "/", label: "首页" },
{ label: "关于本站" },
]}
/>
<div className="flex items-end justify-between gap-3">
<div>
<h1

View File

@@ -88,11 +88,11 @@ export default function AdsAdmin({
enabled: true,
panel_title: "自助推广",
durations: [
{ days: 30, label: "1 个月", price_hint: "" },
{ days: 60, label: "2 个月", price_hint: "" },
{ days: 90, label: "3 个月", price_hint: "" },
{ days: 180, label: "6 个月", price_hint: "" },
{ days: 365, label: "12 个月", price_hint: "" },
{ days: 30, label: "1 个月", price_hint: "", price_hint_text: "" },
{ days: 60, label: "2 个月", price_hint: "", price_hint_text: "" },
{ days: 90, label: "3 个月", price_hint: "", price_hint_text: "" },
{ days: 180, label: "6 个月", price_hint: "", price_hint_text: "" },
{ days: 365, label: "12 个月", price_hint: "", price_hint_text: "" },
],
payments: [],
}
@@ -489,7 +489,10 @@ export default function AdsAdmin({
onClick={() =>
setCfg({
...cfg,
durations: [...cfg.durations, { days: 14, label: "14 天", price_hint: "" }],
durations: [
...cfg.durations,
{ days: 14, label: "14 天", price_hint: "", price_hint_text: "" },
],
})
}
>
@@ -500,9 +503,21 @@ export default function AdsAdmin({
{cfg.durations.length === 0 ? (
<p className="meta text-[13px]">至少保留一个时长档位。</p>
) : (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: "var(--line)" }}>
<table className="w-full min-w-[600px] text-left text-[13px]">
<thead>
<tr className="meta text-[12px]" style={{ background: "var(--surface)" }}>
<th className="px-3 py-2 font-medium">天数</th>
<th className="px-3 py-2 font-medium">标签</th>
<th className="px-3 py-2 font-medium">图片价格</th>
<th className="px-3 py-2 font-medium">文字价格</th>
<th className="px-3 py-2 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{cfg.durations.map((d, i) => (
<div key={i} className="j13-ads-row">
<tr key={i} style={{ borderTop: "1px solid var(--line)" }}>
<td className="px-3 py-2">
<input
type="number"
className="field w-20"
@@ -515,16 +530,48 @@ export default function AdsAdmin({
}}
aria-label="天数"
/>
</td>
<td className="px-3 py-2">
<input
className="field min-w-0 flex-1"
className="field w-full min-w-0"
value={d.label}
placeholder="标签,如 1 个月"
placeholder="如 1 个月"
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, label: e.target.value };
setCfg({ ...cfg, durations });
}}
/>
</td>
<td className="px-3 py-2">
<input
className="field w-full min-w-0"
value={d.price_hint}
maxLength={32}
placeholder="如 ¥299,可空"
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, price_hint: e.target.value };
setCfg({ ...cfg, durations });
}}
aria-label="图片广告价格"
/>
</td>
<td className="px-3 py-2">
<input
className="field w-full min-w-0"
value={d.price_hint_text}
maxLength={32}
placeholder="如 ¥99,可空"
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, price_hint_text: e.target.value };
setCfg({ ...cfg, durations });
}}
aria-label="文字广告价格"
/>
</td>
<td className="px-3 py-2 text-right">
<button
type="button"
className="btn btn-line btn-sm"
@@ -535,8 +582,11 @@ export default function AdsAdmin({
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SubBlock>

View File

@@ -1,11 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Pencil, Plus, Trash2, Users } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Award, ChevronRight, Plus, Trash2, Users } from "lucide-react";
import Avatar from "@/components/Avatar";
import BadgeChip from "@/components/BadgeChip";
import ConfirmDialog from "@/components/ConfirmDialog";
import Modal from "@/components/Modal";
import AdminEmpty from "@/components/admin/AdminEmpty";
import AdminPageHeader from "@/components/admin/AdminPageHeader";
import { BADGE_ICONS } from "@/lib/badgeIcons";
import { toast } from "@/lib/toast";
import {
@@ -32,8 +33,8 @@ export default function BadgesClient() {
const [badges, setBadges] = useState<BadgeWithHolders[]>([]);
const [loading, setLoading] = useState(true);
// 编辑/新建表单
const [editing, setEditing] = useState<BadgeWithHolders | null>(null);
// 主从布局:左侧列表选中项 / 右侧详情(编辑表单 + 持有者)
const [selectedId, setSelectedId] = useState<number | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState<BadgeInput>({ name: "", icon: "medal", color: "accent" });
const [saving, setSaving] = useState(false);
@@ -43,7 +44,6 @@ export default function BadgesClient() {
const [deletingBusy, setDeletingBusy] = useState(false);
// 持有者面板
const [holdersFor, setHoldersFor] = useState<BadgeWithHolders | null>(null);
const [holders, setHolders] = useState<BadgeHolder[]>([]);
const [holdersLoading, setHoldersLoading] = useState(false);
const [userQuery, setUserQuery] = useState("");
@@ -51,6 +51,8 @@ export default function BadgesClient() {
const [searching, setSearching] = useState(false);
const [grantingId, setGrantingId] = useState<number | null>(null);
const detailRef = useRef<HTMLDivElement>(null);
const reload = useCallback(async () => {
try {
const res = await apiAdminListBadges();
@@ -66,22 +68,85 @@ export default function BadgesClient() {
void reload();
}, [reload]);
const selected = useMemo(
() => badges.find((b) => b.id === selectedId) ?? null,
[badges, selectedId],
);
const openCreate = () => {
setForm({ name: "", icon: "medal", color: "accent" });
setEditing(null);
setCreating(true);
setSelectedId(null);
};
const openEdit = (b: BadgeWithHolders) => {
setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" });
const selectBadge = (b: BadgeWithHolders, scroll = true) => {
setCreating(false);
setEditing(b);
setSelectedId(b.id);
// 窄屏单列时,点选后滚动到右侧详情
if (scroll && window.matchMedia("(max-width: 1023px)").matches) {
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const closeForm = () => {
setCreating(false);
setEditing(null);
// 首次加载完成后自动选中第一个徽章(删除刷新期间跳过,避免选中已删除项)
useEffect(() => {
if (loading || creating || deletingBusy || selectedId != null) return;
if (badges.length > 0) selectBadge(badges[0], false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, badges, creating, deletingBusy, selectedId]);
// 选中项被删除时回落到第一个(或清空)
useEffect(() => {
if (creating || loading || selectedId == null) return;
if (!badges.some((b) => b.id === selectedId)) {
setSelectedId(badges.length > 0 ? badges[0].id : null);
}
}, [badges, selectedId, creating, loading]);
// 选中变化:同步表单草稿并重置搜索(badges 刻意不作为依赖,避免授予/撤销刷新时清掉搜索结果与未保存草稿)
useEffect(() => {
if (creating || selectedId == null) return;
const sel = badges.find((b) => b.id === selectedId);
if (!sel) return;
setForm({ name: sel.name, icon: sel.icon || "medal", color: sel.color || "accent" });
setUserQuery("");
setSearchResults([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId, creating]);
// 持有者加载:选中变化或徽章列表刷新(授予/撤销后)重新拉取
useEffect(() => {
if (creating || selectedId == null) {
setHolders([]);
setHoldersLoading(false);
return;
}
let alive = true;
setHoldersLoading(true);
apiAdminBadgeHolders(selectedId)
.then((res) => {
if (alive) setHolders(res.holders ?? []);
})
.catch((e) => {
if (alive) {
toast(e instanceof Error ? e.message : "获取持有者失败");
setHolders([]);
}
})
.finally(() => {
if (alive) setHoldersLoading(false);
});
return () => {
alive = false;
};
}, [selectedId, creating, badges]);
const formDirty =
creating ||
(selected != null &&
(form.name !== selected.name ||
form.icon !== (selected.icon || "medal") ||
form.color !== (selected.color || "accent")));
const saveForm = async () => {
if (saving) return;
@@ -91,14 +156,15 @@ export default function BadgesClient() {
}
setSaving(true);
try {
if (editing) {
await apiAdminUpdateBadge(editing.id, form);
toast("徽章已更新", "ok");
} else {
await apiAdminCreateBadge(form);
if (creating) {
const created = await apiAdminCreateBadge(form);
toast("徽章已创建", "ok");
setCreating(false);
setSelectedId(created.id);
} else if (selected) {
await apiAdminUpdateBadge(selected.id, form);
toast("徽章已更新", "ok");
}
closeForm();
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
@@ -114,7 +180,7 @@ export default function BadgesClient() {
await apiAdminDeleteBadge(deleting.id);
toast("徽章已删除", "ok");
setDeleting(null);
if (holdersFor?.id === deleting.id) setHoldersFor(null);
if (selectedId === deleting.id) setSelectedId(null);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败");
@@ -123,22 +189,6 @@ export default function BadgesClient() {
}
};
const openHolders = async (b: BadgeWithHolders) => {
setHoldersFor(b);
setUserQuery("");
setSearchResults([]);
setHoldersLoading(true);
try {
const res = await apiAdminBadgeHolders(b.id);
setHolders(res.holders ?? []);
} catch (e) {
toast(e instanceof Error ? e.message : "获取持有者失败");
setHolders([]);
} finally {
setHoldersLoading(false);
}
};
const searchUsers = async () => {
const q = userQuery.trim();
if (!q || searching) return;
@@ -161,12 +211,11 @@ export default function BadgesClient() {
};
const grant = async (userId: number) => {
if (!holdersFor || grantingId != null) return;
if (!selected || grantingId != null) return;
setGrantingId(userId);
try {
await apiAdminGrantBadge(holdersFor.id, userId);
await apiAdminGrantBadge(selected.id, userId);
toast("已授予", "ok");
await openHolders(holdersFor);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "授予失败");
@@ -176,169 +225,125 @@ export default function BadgesClient() {
};
const revoke = async (userId: number) => {
if (!holdersFor) return;
if (!selected) return;
try {
await apiAdminRevokeBadge(holdersFor.id, userId);
await apiAdminRevokeBadge(selected.id, userId);
toast("已撤销", "ok");
await openHolders(holdersFor);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "撤销失败");
}
};
// 授予/撤销后 reload 刷新持有数;这里再同步持有者列表
useEffect(() => {
if (selectedId == null || creating) return;
setHoldersLoading(true);
apiAdminBadgeHolders(selectedId)
.then((res) => setHolders(res.holders ?? []))
.catch(() => setHolders([]))
.finally(() => setHoldersLoading(false));
}, [badges, selectedId, creating]);
const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]);
const nameOk = runeCount(form.name.trim()) >= 1 && runeCount(form.name.trim()) <= MAX_NAME_RUNES;
return (
<div className="admin-settings-stack">
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
徽章管理
</h2>
<p className="meta text-sm mt-1">
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
</p>
</div>
<AdminPageHeader
icon={Award}
title="徽章管理"
description="管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。"
actions={
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建徽章
</button>
}
/>
<div className="grid gap-5 items-start lg:grid-cols-2">
{/* 左半屏:徽章列表 */}
<section className="panel p-0 overflow-hidden" aria-label="徽章列表">
<div
className="flex items-center justify-between gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<p className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
全部徽章
</p>
{!loading && badges.length > 0 && (
<span className="meta text-xs tabular-nums">{badges.length} 枚</span>
)}
</div>
{loading ? (
<p className="meta px-1 py-6">加载徽章列表…</p>
<p className="meta px-4 py-6">加载徽章列表…</p>
) : badges.length === 0 ? (
<div className="panel text-center py-12">
<div className="px-4 py-12 text-center">
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
</div>
) : (
<div className="panel p-0 overflow-hidden">
{badges.map((b, i) => (
<div
key={b.id}
className="flex items-center gap-3 px-4 py-3 flex-wrap"
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
>
<BadgeChip badge={b} />
<span className="meta text-xs">
{b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有
</span>
<span className="flex-1" />
<ul>
{badges.map((b, i) => {
const active = !creating && b.id === selectedId;
return (
<li key={b.id}>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => void openHolders(b)}
onClick={() => selectBadge(b)}
aria-current={active ? "true" : undefined}
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
background: active ? "var(--accent-soft)" : undefined,
}}
>
<Users size={13} /> 持有者
</button>
<button type="button" className="btn btn-line btn-sm" onClick={() => openEdit(b)}>
<Pencil size={13} /> 编辑
<BadgeChip badge={b} />
<span className="meta text-xs">{b.holders} 人持有</span>
<span className="flex-1" />
<ChevronRight
size={14}
className="shrink-0 transition-transform duration-200"
style={{
color: active ? "var(--accent)" : "var(--ink-3)",
transform: active ? "translateX(2px)" : "none",
}}
aria-hidden
/>
</button>
</li>
);
})}
</ul>
)}
</section>
{/* 右半屏:编辑表单 + 持有者 */}
<div ref={detailRef} className="flex flex-col gap-5 min-w-0">
{creating || selected ? (
<>
{/* 编辑/新建表单 */}
<section className="panel overflow-hidden">
<div
className="flex items-center justify-between gap-3 px-4 py-3.5 sm:px-5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
{creating ? "新建徽章" : "编辑徽章"}
</p>
{!creating && selected && (
<button
type="button"
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
onClick={() => setDeleting(b)}
onClick={() => setDeleting(selected)}
>
<Trash2 size={13} /> 删除
</button>
</div>
))}
</div>
)}
{/* 持有者面板 */}
<Modal
open={holdersFor != null}
title={`持有者 · ${holdersFor?.name ?? ""}`}
onClose={() => setHoldersFor(null)}
maxWidthClass="max-w-lg"
>
<div className="flex gap-2 mb-4">
<input
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
placeholder="输入用户名或昵称搜索"
className="flex-1 min-w-0"
/>
<button
type="button"
className="btn btn-line btn-sm shrink-0"
disabled={searching || !userQuery.trim()}
onClick={() => void searchUsers()}
>
{searching ? "搜索中…" : "搜索"}
</button>
</div>
{searchResults.length > 0 && (
<div className="mb-4">
<p className="meta text-xs mb-1.5">搜索结果</p>
<div className="flex flex-col gap-1">
{searchResults.map((u) => (
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
{u.nickname || u.username}
<span className="meta ml-1">@{u.username}</span>
</span>
<span className="flex-1" />
{holdersHeldIds.has(u.id) ? (
<span className="meta text-xs">已持有</span>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={grantingId === u.id}
onClick={() => void grant(u.id)}
>
{grantingId === u.id ? "授予中…" : "授予"}
</button>
)}
</div>
))}
</div>
</div>
)}
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
{holdersLoading ? (
<p className="meta text-sm py-3">加载持有者…</p>
) : holders.length === 0 ? (
<p className="meta text-sm py-3">暂无人持有。</p>
) : (
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
{holders.map((h) => (
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
{h.nickname || h.username}
<span className="meta ml-1">@{h.username}</span>
</span>
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
<span className="flex-1" />
<button
type="button"
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
onClick={() => void revoke(h.user_id)}
>
撤销
</button>
</div>
))}
</div>
)}
</Modal>
{/* 新建/编辑表单 */}
<Modal
open={creating || editing != null}
title={editing ? "编辑徽章" : "新建徽章"}
onClose={closeForm}
maxWidthClass="max-w-md"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 px-4 py-4 sm:px-5">
<div>
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
名称(1–{MAX_NAME_RUNES} 字)
@@ -410,20 +415,145 @@ export default function BadgesClient() {
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
</div>
</div>
<div className="flex justify-end gap-2 mt-5">
<button type="button" className="btn btn-line btn-sm" onClick={closeForm}>
<div
className="flex justify-end gap-2 px-4 py-3 sm:px-5"
style={{ borderTop: "1px solid var(--line)" }}
>
{creating ? (
<button type="button" className="btn btn-line btn-sm" onClick={() => setCreating(false)}>
取消
</button>
) : (
<button
type="button"
className="btn btn-line btn-sm"
disabled={!formDirty || saving}
onClick={() => selected && setForm({ name: selected.name, icon: selected.icon || "medal", color: selected.color || "accent" })}
>
重置
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saving || runeCount(form.name.trim()) === 0}
disabled={saving || !nameOk || (!creating && !formDirty)}
onClick={() => void saveForm()}
>
{saving ? "保存中…" : "保存"}
{saving ? "保存中…" : creating ? "创建" : "保存修改"}
</button>
</div>
</Modal>
</section>
{/* 持有者管理 */}
{!creating && selected && (
<section className="panel overflow-hidden">
<div
className="flex items-center gap-2.5 px-4 py-3.5 sm:px-5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Users size={16} />
</span>
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
持有者
<span className="meta ml-2 text-[12px] font-normal tabular-nums">{holders.length} 人</span>
</p>
</div>
<div className="px-4 py-4 sm:px-5">
<div className="flex gap-2 mb-4">
<input
value={userQuery}
onChange={(e) => setUserQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
placeholder="输入用户名或昵称搜索"
className="flex-1 min-w-0"
/>
<button
type="button"
className="btn btn-line btn-sm shrink-0"
disabled={searching || !userQuery.trim()}
onClick={() => void searchUsers()}
>
{searching ? "搜索中…" : "搜索"}
</button>
</div>
{searchResults.length > 0 && (
<div className="mb-4">
<p className="meta text-xs mb-1.5">搜索结果</p>
<div className="flex flex-col gap-1">
{searchResults.map((u) => (
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
{u.nickname || u.username}
<span className="meta ml-1">@{u.username}</span>
</span>
<span className="flex-1" />
{holdersHeldIds.has(u.id) ? (
<span className="meta text-xs">已持有</span>
) : (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={grantingId === u.id}
onClick={() => void grant(u.id)}
>
{grantingId === u.id ? "授予中…" : "授予"}
</button>
)}
</div>
))}
</div>
</div>
)}
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
{holdersLoading ? (
<p className="meta text-sm py-3">加载持有者…</p>
) : holders.length === 0 ? (
<p className="meta text-sm py-3">暂无人持有。</p>
) : (
<div className="flex flex-col gap-1 max-h-72 overflow-y-auto">
{holders.map((h) => (
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
{h.nickname || h.username}
<span className="meta ml-1">@{h.username}</span>
</span>
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
<span className="flex-1" />
<button
type="button"
className="btn btn-line btn-sm"
style={{ color: "var(--danger)" }}
onClick={() => void revoke(h.user_id)}
>
撤销
</button>
</div>
))}
</div>
)}
</div>
</section>
)}
</>
) : (
<section className="panel">
<AdminEmpty
icon={<Award size={22} />}
title={loading ? "加载中…" : "从左侧选择一个徽章"}
description={loading ? undefined : "选中后可在此编辑名称、图标与颜色,管理持有者"}
/>
</section>
)}
</div>
</div>
{/* 删除确认 */}
<ConfirmDialog

View File

@@ -0,0 +1,274 @@
"use client";
import { useMemo, useState } from "react";
import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
import Modal from "@/components/Modal";
import AdminEmpty from "@/components/admin/AdminEmpty";
import type { MediaLibraryItem } from "@/lib/api";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
function formatTime(iso?: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function formatDims(item: MediaLibraryItem): string {
if (item.width > 0 && item.height > 0) return `${item.width} × ${item.height}`;
return "—";
}
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
function thumbSrc(item: MediaLibraryItem): string {
if (item.url.startsWith("/uploads/")) {
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
}
return item.url;
}
/** 管理后台媒体库:全站图片网格 + 分类筛选 + 详情弹窗(WebP 优先,可查看原图) */
export default function MediaLibrary({
items,
counts,
total,
initError,
}: {
items: MediaLibraryItem[];
counts: Record<string, number>;
total: number;
initError?: string;
}) {
const [category, setCategory] = useState<string>("all");
const [active, setActive] = useState<MediaLibraryItem | null>(null);
const [showOriginal, setShowOriginal] = useState(false);
const [copied, setCopied] = useState(false);
const filtered = useMemo(
() => (category === "all" ? items : items.filter((i) => i.category === category)),
[items, category]
);
const tabs = useMemo(() => {
const list: { key: string; label: string; count: number }[] = [
{ key: "all", label: "全部", count: total },
];
// 按现有条目实际分类生成 tab,保持稳定顺序
const seen = new Map<string, string>();
for (const i of items) seen.set(i.category, i.category_name);
for (const [key, label] of seen) {
list.push({ key, label, count: counts[key] ?? 0 });
}
return list;
}, [items, counts, total]);
const openItem = (item: MediaLibraryItem) => {
setActive(item);
setShowOriginal(false);
setCopied(false);
};
const copyLink = async (item: MediaLibraryItem) => {
try {
await navigator.clipboard.writeText(`${window.location.origin}${item.url}`);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* 剪贴板不可用静默 */
}
};
if (initError) {
return (
<AdminEmpty
icon={<Images size={20} />}
title="媒体库加载失败"
description={initError}
/>
);
}
if (total === 0) {
return (
<AdminEmpty
icon={<Images size={20} />}
title="暂无图片资源"
description="站点内还没有任何上传图片"
/>
);
}
return (
<div>
{/* 分类 tab */}
<div className="flex items-center gap-1.5 flex-wrap mb-4">
{tabs.map((t) => {
const on = category === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setCategory(t.key)}
aria-pressed={on}
className="h-8 px-3 rounded-full text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
style={{
border: `1px solid ${on ? "var(--accent)" : "var(--line)"}`,
background: on ? "var(--accent-soft)" : "var(--panel)",
color: on ? "var(--accent)" : "var(--ink-2)",
}}
>
{t.label}
<span className="tabular-nums opacity-70">{t.count}</span>
</button>
);
})}
</div>
{/* 图片网格 */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
{filtered.map((item) => (
<button
key={item.url}
type="button"
onClick={() => openItem(item)}
className="group text-left rounded-xl overflow-hidden transition-transform hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
title={item.name}
>
<span className="block relative aspect-square overflow-hidden">
<img
src={thumbSrc(item)}
alt={item.name}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover"
/>
{item.original_url && (
<span
className="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded text-[10px] font-semibold"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
WebP
</span>
)}
</span>
<span className="block px-2.5 py-2">
<span
className="block text-[12px] font-medium truncate"
style={{ color: "var(--ink)" }}
>
{item.name}
</span>
<span className="block meta text-[11px] mt-0.5">
{formatDims(item)} · {formatBytes(item.size)}
</span>
</span>
</button>
))}
</div>
{/* 详情弹窗 */}
<Modal
open={active !== null}
onClose={() => setActive(null)}
title={active?.name ?? ""}
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
maxWidthClass="max-w-2xl"
dismissOnOverlay
bodyClassName=""
>
{active && (
<div>
<div
className="rounded-xl overflow-hidden flex items-center justify-center"
style={{
background:
"repeating-conic-gradient(var(--panel-2) 0% 25%, var(--panel) 0% 50%) 50% / 20px 20px",
maxHeight: "52vh",
}}
>
<img
src={showOriginal && active.original_url ? active.original_url : active.url}
alt={active.name}
className="max-w-full max-h-[52vh] w-auto h-auto object-contain"
/>
</div>
{active.original_url && (
<p className="meta text-[12px] mt-2.5">
{showOriginal
? "当前显示原图(原格式,体积更大)"
: "当前显示 WebP 压缩版;站点内容引用的均为 WebP"}
</p>
)}
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-[12.5px]">
{[
["分类", active.category_name],
["尺寸", formatDims(active)],
["大小", formatBytes(active.size)],
["格式", active.mime],
["上传者", active.uploader || "—"],
["时间", formatTime(active.uploaded_at)],
].map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2 min-w-0">
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
<dd className="truncate" style={{ color: "var(--ink)" }} title={v}>
{v}
</dd>
</div>
))}
{active.source_url && (
<div className="flex items-baseline gap-2 min-w-0 col-span-2">
<dt className="meta shrink-0 w-11 text-right">来源</dt>
<dd className="truncate">
<a
href={active.source_url}
target="_blank"
rel="noreferrer"
title={active.source_label || active.source_url}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
{active.source_label || active.source_url}
</a>
</dd>
</div>
)}
</dl>
<div className="mt-5 flex items-center justify-end gap-2 flex-wrap">
{active.original_url && (
<button
type="button"
className="btn btn-sm"
onClick={() => setShowOriginal((v) => !v)}
>
<Eye size={14} aria-hidden />
{showOriginal ? "看 WebP" : "查看原图"}
</button>
)}
<button type="button" className="btn btn-sm" onClick={() => copyLink(active)}>
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
{copied ? "已复制" : "复制链接"}
</button>
<a
className="btn btn-sm"
href={active.original_url && showOriginal ? active.original_url : active.url}
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14} aria-hidden />
新窗口打开
</a>
</div>
</div>
)}
</Modal>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { cookies } from "next/headers";
import MediaLibrary from "./MediaLibrary";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminMediaLibrary, type MediaLibraryItem } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isSuperOrOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import AdminPageHeader from "@/components/admin/AdminPageHeader";
import { Images } from "lucide-react";
export const metadata: Metadata = {
title: "媒体库",
};
export default async function AdminMediaPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isSuperOrOwner(me.user.role)) {
return <Forbidden text="媒体库仅超级管理员及以上角色可查看。" />;
}
let items: MediaLibraryItem[] = [];
let counts: Record<string, number> = {};
let total = 0;
let initError = "";
try {
const res = await fetchAdminMediaLibrary(cookie || undefined);
items = res.items ?? [];
counts = res.counts ?? {};
total = res.total ?? 0;
} catch (e) {
initError = e instanceof Error ? e.message : "加载失败";
}
return (
<div>
<AdminPageHeader
icon={Images}
title="媒体库"
description={`站点内全部图片资源盘点(共 ${total} 张)。同名图片同时存在原图与 WebP 时仅展示 WebP,点开可查看原图。`}
/>
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
<MediaLibrary items={items} counts={counts} total={total} initError={initError} />
</Suspense>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import {
ClipboardCheck,
Database,
FileUp,
Images,
ListChecks,
ListX,
Loader2,
@@ -22,9 +23,8 @@ import {
} from "@/components/admin";
import {
apiAdminImportLegacy,
type LegacyCommentReport,
type LegacyImportReport,
type LegacyPostReport,
type LegacyUserPreview,
} from "@/lib/api";
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
@@ -34,27 +34,40 @@ const SOURCES = [
{ key: "typecho", label: "Typecho", enabled: false },
] as const;
/** 单个旧用户的导入选择:是否建号 + 内容归属 */
type UserSel = { create: boolean; owner: "auto" | "operator" | "custom"; target: string };
/**
* 站点设置 → 数据导入:
* 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。
* 上传旧站数据库(+可选头像包 / 帖子图片包)→ 预检(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 [imagesZipFile, setImagesZipFile] = 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 [preview, setPreview] = useState<LegacyImportReport | null>(null); // 预检清单
const [report, setReport] = useState<LegacyImportReport | null>(null); // 最近一次执行结果
const dbInputRef = useRef<HTMLInputElement>(null);
const zipInputRef = useRef<HTMLInputElement>(null);
const imagesZipInputRef = useRef<HTMLInputElement>(null);
// 勾选状态(预检后按清单初始化,默认全部导入)
const [userSel, setUserSel] = useState<Record<number, UserSel>>({});
const [postChecked, setPostChecked] = useState<Set<number>>(new Set());
const [commentChecked, setCommentChecked] = useState<Set<number>>(new Set());
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
const [preflighted, setPreflighted] = useState(false);
const resetResult = () => {
setPreflighted(false);
setPreview(null);
setReport(null);
setError("");
};
@@ -67,22 +80,73 @@ export default function ImportPanel() {
setZipFile(f);
resetResult();
};
const pickImagesZip = (f: File | null) => {
setImagesZipFile(f);
resetResult();
};
const toggleContent = () => {
setWithContent((v) => !v);
resetResult();
};
const initSelections = (rep: LegacyImportReport) => {
const users: Record<number, UserSel> = {};
(rep.user_list ?? []).forEach((u) => {
users[u.id] = { create: true, owner: "auto", target: "" };
});
setUserSel(users);
setPostChecked(new Set((rep.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id)));
setCommentChecked(new Set((rep.comment_list ?? []).filter((c) => c.published).map((c) => c.id)));
};
// 由勾选状态构造请求参数(相对预检清单求差集)
const buildOptions = () => {
const skipUsers: number[] = [];
const operatorUsers: number[] = [];
const userMap: { oldId: number; username: string }[] = [];
Object.entries(userSel).forEach(([idStr, sel]) => {
const id = Number(idStr);
if (sel.owner === "operator") {
skipUsers.push(id);
operatorUsers.push(id);
} else if (sel.owner === "custom" && sel.target.trim()) {
skipUsers.push(id);
userMap.push({ oldId: id, username: sel.target.trim() });
} else if (!sel.create) {
skipUsers.push(id);
}
});
return {
withContent,
dryRun: false,
skipUsers,
operatorUsers,
userMap,
skipPosts: (preview?.post_list ?? [])
.filter((p) => p.published && !p.poll && !postChecked.has(p.id))
.map((p) => p.id),
skipComments: (preview?.comment_list ?? [])
.filter((c) => c.published && !commentChecked.has(c.id))
.map((c) => c.id),
};
};
const run = async (dryRun: boolean) => {
if (!dbFile || busy) return;
setBusy(dryRun ? "check" : "import");
setError("");
try {
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
withContent,
const opts = dryRun ? { withContent, dryRun } : buildOptions();
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, imagesZipFile, {
...opts,
dryRun,
});
setReport(rep);
if (dryRun) setPreflighted(true);
if (dryRun) {
setPreview(rep);
initSelections(rep);
setPreflighted(true);
}
} catch (e) {
setError(e instanceof Error ? e.message : "导入失败");
} finally {
@@ -90,6 +154,25 @@ export default function ImportPanel() {
}
};
const setUser = (id: number, patch: Partial<UserSel>) => {
setUserSel((m) => {
const cur = m[id] ?? { create: true, owner: "auto" as const, target: "" };
const next = { ...cur, ...patch };
// 归属指定已有账号 / 站长时,账号不再新建
if (next.owner !== "auto") next.create = false;
return { ...m, [id]: next };
});
};
const toggleSet = (set: Set<number>, id: number) => {
const next = new Set(set);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
};
const hasLists = withContent && !!preview?.post_list?.length;
return (
<div className="flex flex-col gap-4">
<AdminSettingsGroup
@@ -145,12 +228,21 @@ export default function ImportPanel() {
onPick={pickZip}
busy={busy !== null}
/>
<FilePickRow
label="帖子图片压缩包(可选)"
hint="把旧站 data/uploads/posts 目录压缩为 zip,最大 128MB;帖子/评论正文里的旧站图片会迁移到新站并改写链接"
fileName={imagesZipFile?.name}
inputRef={imagesZipInputRef}
accept=".zip,application/zip"
onPick={pickImagesZip}
busy={busy !== null}
/>
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="导入范围"
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
description="第二步:确认是否导入内容。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数。投票帖暂不迁移。"
>
<div className="flex items-center gap-3">
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
@@ -162,7 +254,7 @@ export default function ImportPanel() {
<AdminSettingsGroup
title="预检与导入"
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
description="第三步:先预检生成内容清单并勾选导入范围,再正式写入。可重复执行,已导入的内容自动跳过。"
>
<div className="flex flex-wrap items-center gap-2">
<button
@@ -190,7 +282,7 @@ export default function ImportPanel() {
? "请先选择站点数据库"
: !preflighted
? "请先运行预检"
: "预检通过,可以正式导入"}
: "已生成清单,确认勾选后可正式导入"}
</span>
</div>
@@ -201,6 +293,38 @@ export default function ImportPanel() {
</p>
)}
{preflighted && preview?.user_list && preview.user_list.length > 0 && (
<UserSelectList list={preview.user_list} sel={userSel} onChange={setUser} />
)}
{hasLists && preview && (
<PostSelectList
list={preview.post_list ?? []}
checked={postChecked}
onToggle={(id) => setPostChecked((s) => toggleSet(s, id))}
onAll={(on) =>
setPostChecked(
on
? new Set((preview.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id))
: new Set()
)
}
/>
)}
{hasLists && preview && !!preview.comment_list?.length && (
<CommentSelectList
list={preview.comment_list}
checked={commentChecked}
onToggle={(id) => setCommentChecked((s) => toggleSet(s, id))}
onAll={(on) =>
setCommentChecked(
on
? new Set(preview.comment_list!.filter((c) => c.published).map((c) => c.id))
: new Set()
)
}
/>
)}
{report && <ReportView report={report} />}
</AdminSettingsGroup>
</div>
@@ -258,6 +382,222 @@ function FilePickRow({
);
}
// ===== 预检后勾选清单 =====
const fieldStyle = { background: "var(--panel)", border: "1px solid var(--line)" } as const;
function UserSelectList({
list,
sel,
onChange,
}: {
list: LegacyUserPreview[];
sel: Record<number, UserSel>;
onChange: (id: number, patch: Partial<UserSel>) => void;
}) {
return (
<div className="mt-2 flex flex-col gap-2">
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
<Users size={14} aria-hidden />
用户({list.length})——取消勾选则不创建账号,其内容按右侧归属落位
</p>
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
{list.map((u) => {
const s = sel[u.id] ?? { create: true, owner: "auto" as const, target: "" };
return (
<div
key={u.id}
className="rounded-lg px-3 py-2 flex items-center gap-2.5 flex-wrap"
style={fieldStyle}
>
<label className="inline-flex items-center gap-2 text-[12.5px] shrink-0" style={{ color: "var(--ink)" }}>
<input
type="checkbox"
className="accent-[var(--accent)]"
checked={s.create}
disabled={s.owner !== "auto"}
onChange={(e) => onChange(u.id, { create: e.target.checked })}
/>
建号
</label>
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 flex-wrap">
<span className="text-[13px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{u.username}
</span>
{u.nickname && u.nickname !== u.username && (
<span className="meta text-[12px] truncate">{u.nickname}</span>
)}
{u.exists && (
<span className="text-[11px] font-semibold" style={{ color: "var(--warn, #b45309)" }}>
同名已存在·导入时跳过建号
</span>
)}
</div>
<span className="meta text-[12px] shrink-0">
帖 {u.posts} · 评 {u.comments}
</span>
<select
value={s.owner}
onChange={(e) => onChange(u.id, { owner: e.target.value as UserSel["owner"] })}
className="rounded-lg px-2 py-1 text-[12.5px] shrink-0"
style={fieldStyle}
>
<option value="auto">归属:跟随默认</option>
<option value="operator">归到站长</option>
<option value="custom">指定已有账号…</option>
</select>
{s.owner === "custom" && (
<input
value={s.target}
placeholder="本站用户名"
onChange={(e) => onChange(u.id, { target: e.target.value })}
className="rounded-lg px-2 py-1 text-[12.5px] w-36 shrink-0"
style={fieldStyle}
/>
)}
</div>
);
})}
</div>
</div>
);
}
function BulkBar({ label, onAll }: { label: string; onAll: (on: boolean) => void }) {
return (
<span className="inline-flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
{label}
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(true)}>
全选
</button>
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(false)}>
全不选
</button>
</span>
);
}
function PostSelectList({
list,
checked,
onToggle,
onAll,
}: {
list: { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean }[];
checked: Set<number>;
onToggle: (id: number) => void;
onAll: (on: boolean) => void;
}) {
const selectable = list.filter((p) => p.published && !p.poll).length;
return (
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center gap-3 flex-wrap">
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
<ListChecks size={14} aria-hidden />
帖子({selectable}/{list.length} 可导入)——取消勾选即排除,其评论一并跳过
</p>
<BulkBar label="批量:" onAll={onAll} />
</div>
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
{list.map((p) => {
const disabled = !p.published || p.poll;
return (
<label
key={p.id}
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${disabled ? "opacity-55" : "cursor-pointer"}`}
style={fieldStyle}
>
<input
type="checkbox"
className="accent-[var(--accent)] shrink-0"
checked={!disabled && checked.has(p.id)}
disabled={disabled}
onChange={() => onToggle(p.id)}
/>
<span className="text-[13px] truncate flex-1" style={{ color: "var(--ink)" }}>
{p.title}
</span>
{p.poll && (
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
投票帖·不可迁移
</span>
)}
{!p.published && (
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
非公开
</span>
)}
<span className="meta text-[12px] shrink-0 truncate max-w-[180px]">
{p.author} · {p.board} · {fmtDate(p.created_at)}
</span>
</label>
);
})}
</div>
</div>
);
}
function CommentSelectList({
list,
checked,
onToggle,
onAll,
}: {
list: { id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean }[];
checked: Set<number>;
onToggle: (id: number) => void;
onAll: (on: boolean) => void;
}) {
const selectable = list.filter((c) => c.published).length;
return (
<details className="mt-2 flex flex-col gap-2">
<summary className="flex items-center gap-2 flex-wrap cursor-pointer select-none">
<span className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
<MessageSquare size={14} aria-hidden />
评论({selectable}/{list.length} 可导入)
</span>
<BulkBar label="批量:" onAll={onAll} />
</summary>
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
{list.map((c) => (
<label
key={c.id}
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${!c.published ? "opacity-55" : "cursor-pointer"}`}
style={fieldStyle}
>
<input
type="checkbox"
className="accent-[var(--accent)] shrink-0"
checked={c.published && checked.has(c.id)}
disabled={!c.published}
onChange={() => onToggle(c.id)}
/>
<span className="text-[12.5px] truncate flex-1" style={{ color: "var(--ink)" }}>
{c.excerpt || "(无文字)"}
</span>
{!c.published && (
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
非公开
</span>
)}
<span className="meta text-[12px] shrink-0 truncate max-w-[240px]">
《{c.post_title}》{c.author} · {fmtDate(c.created_at)}
</span>
</label>
))}
</div>
</details>
);
}
function fmtDate(s: string) {
const d = new Date(s);
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("zh-CN");
}
// ===== 结果报告 =====
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
return (
<div
@@ -282,7 +622,7 @@ function ReportView({ report }: { report: LegacyImportReport }) {
{isDry ? (
<>
<ScanLine size={15} aria-hidden />
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
预检结果(未写入任何数据)——请在上方勾选导入范围,确认后点击「确认正式导入」
</>
) : (
<>
@@ -296,6 +636,9 @@ function ReportView({ report }: { report: LegacyImportReport }) {
<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.excluded > 0 && (
<StatChip icon={<ListX size={13} aria-hidden />} label="不建号" value={report.users.excluded} ok={false} />
)}
{report.users.avatar_written > 0 && (
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
)}
@@ -332,29 +675,45 @@ function ReportView({ report }: { report: LegacyImportReport }) {
);
}
function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) {
function PostsView({ report, isDry }: { report: NonNullable<LegacyImportReport["posts"]>; 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.excluded > 0 && (
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
)}
{report.images_written > 0 && (
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
)}
{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.excluded_detail} />
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} 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 }) {
function CommentsView({ report, isDry }: { report: NonNullable<LegacyImportReport["comments"]>; 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} />
{report.excluded > 0 && (
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
)}
{report.images_written > 0 && (
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
)}
<DetailList title="手动排除" items={report.excluded_detail} />
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
<DetailList title="失败" items={report.failed} color="var(--danger)" />
</ReportCard>

View File

@@ -180,6 +180,16 @@ export default function SidebarAdmin({
}
};
// 放弃未保存修改:回到最近一次保存的布局
const discardChanges = () => {
if (busy) return;
try {
setConfig(JSON.parse(savedJsonRef.current));
} catch {
/* 忽略:快照损坏时保持现状 */
}
};
/* ---------- 自定义工具 ---------- */
const openCreate = () => {
@@ -304,16 +314,41 @@ export default function SidebarAdmin({
</nav>
);
// 页头与浮动保存条共用的未保存提示 + 操作按钮
const dirtyDot = (
<span
className="inline-block w-2 h-2 rounded-full animate-pulse shrink-0"
style={{ background: "var(--gold)" }}
aria-hidden
/>
);
const saveControls = dirty ? (
<div className="flex items-center gap-2">
<span className="meta flex items-center gap-1.5 text-[12px]">
{dirtyDot}
布局有未保存的修改
</span>
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
撤销修改
</button>
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
{busy ? "保存中…" : "保存布局"}
</button>
</div>
) : undefined;
return (
<AdminSettingsPage>
<AdminPageHeader
icon={PanelLeft}
title="侧栏管理"
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
actions={saveControls}
/>
<AdminSettingsWork nav={leftNav}>
<div className="space-y-5 pb-24">
<>
<div className="space-y-5 pb-4">
{SIDES.map(({ scope, icon, desc, sides }) => {
const sc = config.scopes[scope] ?? {};
const left = sc.left ?? [];
@@ -413,27 +448,34 @@ export default function SidebarAdmin({
</div>
</section>
</div>
</AdminSettingsWork>
{/* 吸底保存栏 */}
{/* 浮动保存条:吸在内容列底部居中,滚动全程可见,不再贴屏幕角落 */}
{dirty && (
<div className="sticky bottom-4 z-30 flex justify-center pointer-events-none">
<div
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-between gap-3 border-t px-4 py-3 sm:px-6"
className="pointer-events-auto flex items-center gap-3 rounded-full px-4 py-2"
style={{
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
backdropFilter: "blur(12px)",
borderColor: "var(--line)",
border: "1px solid var(--line)",
boxShadow: "var(--shadow-lg)",
}}
>
<p className="meta text-[12px]">
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: "var(--gold)" }} aria-hidden />
<span className="meta flex items-center gap-1.5 text-[12px]">
{dirtyDot}
布局有未保存的修改
</p>
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveConfig()}>
</span>
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
撤销修改
</button>
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
{busy ? "保存中…" : "保存布局"}
</button>
</div>
</div>
)}
</>
</AdminSettingsWork>
{/* 工具编辑弹层 */}
<Modal

View File

@@ -84,6 +84,9 @@ export default function AdsBuyClient() {
const selectedPay: AdPaymentOption | undefined = cfg?.payments.find((p) => p.id === paymentID);
const selectedDuration = cfg?.durations.find((d) => d.days === days);
const selectedPrice = selectedDuration
? (kind === "text" ? selectedDuration.price_hint_text : selectedDuration.price_hint).trim()
: "";
const canSubmit = !!cfg && cfg.payments.length > 0 && !busy;
const kindLabel = kind === "image" ? "图片广告" : "文字广告";
@@ -224,6 +227,7 @@ export default function AdsBuyClient() {
<p className="text-[12.5px]">
{kindLabel}
{selectedDuration ? ` · ${selectedDuration.label}` : ""}
{selectedPrice ? ` · ${selectedPrice}` : ""}
</p>
</div>
@@ -331,9 +335,17 @@ export default function AdsBuyClient() {
<div className="grid grid-cols-5 gap-2">
{cfg.durations.map((d) => {
const active = days === d.days;
const price = (kind === "text" ? d.price_hint_text : d.price_hint).trim();
return (
<ChipTab key={d.days} active={active} onClick={() => setDays(d.days)} compact>
{d.label.replace(/\s/g, "")}
<ChipTab
key={d.days}
active={active}
onClick={() => setDays(d.days)}
compact
priced={!!price}
>
<span>{d.label.replace(/\s/g, "")}</span>
{price ? <span className="j13-ads-chip-price">{price}</span> : null}
</ChipTab>
);
})}
@@ -455,17 +467,21 @@ function ChipTab({
onClick,
children,
compact,
priced,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
compact?: boolean;
priced?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}`}
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}${
priced ? " is-priced" : ""
}`}
>
{children}
</button>

View File

@@ -15,7 +15,6 @@ import {
SiteDocPageNav,
SiteDocAnnoNav,
SiteDocMasthead,
SiteDocBreadcrumb,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { webPageJsonLd } from "@/lib/jsonLd";
@@ -115,14 +114,6 @@ export default async function AnnouncementPage({ params }: PageProps) {
}
>
<article className="panel p-6 sm:p-8">
<SiteDocBreadcrumb
className="is-ruled"
items={[
{ href: "/", label: "首页" },
{ href: "/announcements", label: "站点公告" },
{ label: a.title },
]}
/>
<SiteDocMasthead
kicker="站点公告"
title={a.title}

View File

@@ -12,7 +12,6 @@ import {
SiteDocShell,
SiteDocIdentity,
SiteDocPageNav,
SiteDocBreadcrumb,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { canonicalPage } from "@/lib/siteUrl";
@@ -156,13 +155,6 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
className="px-5 sm:px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<SiteDocBreadcrumb
className="mb-3"
items={[
{ href: "/", label: "首页" },
{ label: "站点公告" },
]}
/>
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}

View File

@@ -114,12 +114,14 @@ export default function ChatShell({
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
/** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */
const lastSentIdByRoom = useRef<Map<number, number>>(new Map());
/** 搜索重开私聊:router.push 完成前 activeRoomId 仍为 null,列表刷新需据此抑制目标会话未读 */
const enteringRoomIdRef = useRef<number | null>(null);
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
const applyRooms = useCallback(
(list: ChatRoomView[]) => {
const next = (list || []).map((r) => {
if (activeRoomId && r.id === activeRoomId) {
if ((activeRoomId && r.id === activeRoomId) || r.id === enteringRoomIdRef.current) {
return { ...r, unread_count: 0 };
}
const sentId = lastSentIdByRoom.current.get(r.id);
@@ -302,6 +304,9 @@ export default function ChatShell({
// 进房即清角标;MarkRead 成功后再清一次(防 refresh 抢在已读落库前写回旧未读)
useEffect(() => {
if (!activeRoomId) return;
if (enteringRoomIdRef.current === activeRoomId) {
enteringRoomIdRef.current = null;
}
const clearActiveUnread = () => {
setRooms((prev) =>
prev.map((r) => (r.id === activeRoomId ? { ...r, unread_count: 0 } : r))
@@ -346,6 +351,7 @@ export default function ChatShell({
const openDM = async (uid: number) => {
try {
const { room } = await apiOpenDirectChat(uid);
enteringRoomIdRef.current = room.id;
setQuery("");
setUserHits([]);
router.push(`/chat/${room.id}`);
@@ -468,7 +474,7 @@ export default function ChatShell({
)}
</div>
<div className="flex-1 overflow-y-auto min-h-0">
<div className="j13-chat-scroll flex-1 overflow-y-auto min-h-0">
{query.trim() && (
<div className="px-2 pb-2">
<p

View File

@@ -16,6 +16,7 @@ import {
Undo2,
Reply,
Copy,
ChevronsDown,
} from "lucide-react";
import {
apiListChatMessages,
@@ -419,6 +420,10 @@ export default function ChatRoomClient({
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
const [showJumpBottom, setShowJumpBottom] = useState(false);
const [newBelowCount, setNewBelowCount] = useState(0);
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [members, setMembers] = useState<ChatRoomMember[]>([]);
@@ -550,12 +555,22 @@ export default function ChatRoomClient({
[roomId, isRealMember]
);
// 悬浮钮/新消息引导回底:跳底 + 推进已读 + 清空待看计数
const jumpToBottom = useCallback(() => {
setNewBelowCount(0);
scrollToBottom(true);
markReadToLatest();
}, [scrollToBottom, markReadToLatest]);
useEffect(() => {
if (!canView) return;
realtime.joinRoom(roomId);
void refreshMembers();
// 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下)
markReadToLatest();
// 换房时重置「回到底部」悬浮钮状态
setShowJumpBottom(false);
setNewBelowCount(0);
return () => {
realtime.leaveRoom(roomId);
};
@@ -573,10 +588,13 @@ export default function ChatRoomClient({
requestAnimationFrame(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:靠近 scrollTop=0 即贴底
if (el.scrollTop < 120) {
// column-reverse:-scrollTop 为距底部距离,接近 0 即贴底
if (-el.scrollTop < 120) {
scrollToBottom(true);
markReadToLatest();
} else {
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
setNewBelowCount((c) => c + 1);
}
});
});
@@ -652,8 +670,9 @@ export default function ChatRoomClient({
requestAnimationFrame(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:历史插在上方,用高度差补偿保持视口锚点
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
// column-reverse:历史插在上方,scrollTop 为负;
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
el.scrollTop = prevTop - (el.scrollHeight - prevHeight);
});
} else {
setHasMore(false);
@@ -668,13 +687,20 @@ export default function ChatRoomClient({
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:滚向上方历史 ≈ 接近 max scrollTop
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
const fromBottom = -el.scrollTop;
// 滚向上方历史:距顶部不足 60px 时加载更多
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
loadMore();
}
if (el.scrollTop < 60) {
if (fromBottom < 60) {
markReadToLatest();
}
// 「回到底部」:离开底部一段距离即出现,接近底部时消失
setShowJumpBottom(fromBottom > 160);
if (fromBottom < 120) {
setNewBelowCount(0);
}
}, [loadMore, markReadToLatest]);
const syncComposerHeight = () => {
@@ -1238,7 +1264,7 @@ export default function ChatRoomClient({
<button
onClick={() => togglePanel("members")}
aria-label="成员列表"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
@@ -1252,7 +1278,7 @@ export default function ChatRoomClient({
<button
onClick={() => togglePanel("invite")}
aria-label="邀请成员"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
@@ -1266,7 +1292,7 @@ export default function ChatRoomClient({
<button
onClick={() => togglePanel("settings")}
aria-label="群设置"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
@@ -1280,7 +1306,7 @@ export default function ChatRoomClient({
<button
onClick={handleLeave}
aria-label={isDirect ? "关闭私聊" : "退出群聊"}
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)]"
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] focus-visible:bg-[var(--danger-soft)]"
style={{ color: "var(--ink-3)" }}
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
>
@@ -1290,10 +1316,11 @@ export default function ChatRoomClient({
</div>
</div>
<div className="relative flex-1 flex flex-col" style={{ minHeight: 0 }}>
<div
ref={listRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto flex flex-col-reverse px-4 sm:px-5 py-4"
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
style={{ minHeight: 0 }}
>
{!canView ? (
@@ -1313,7 +1340,7 @@ export default function ChatRoomClient({
<button
onClick={loadMore}
disabled={loadingMore}
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] transition-colors py-2"
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] focus-visible:text-[var(--accent)] transition-colors py-2"
>
{loadingMore ? "加载中..." : "加载更早消息"}
</button>
@@ -1435,7 +1462,7 @@ export default function ChatRoomClient({
</span>
</div>
<div
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text w-full"
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
style={{
background: "var(--panel-2)",
border:
@@ -1531,7 +1558,7 @@ export default function ChatRoomClient({
</div>
)}
<div
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text"
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -1566,7 +1593,7 @@ export default function ChatRoomClient({
<button
type="button"
onClick={() => scrollToMsg(m.reply_to_id!)}
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug [overflow-wrap:anywhere]"
style={{
background: mine
? "color-mix(in srgb, var(--accent) 10%, transparent)"
@@ -1588,6 +1615,35 @@ export default function ChatRoomClient({
</div>
)}
</div>
{/* 回到底部:飞书式悬浮钮,贴近当前对话框右下角;翻历史期间带新消息计数 */}
{canView && showJumpBottom && (
<button
type="button"
onClick={jumpToBottom}
aria-label="回到底部"
data-tip="回到底部"
className="absolute right-4 sm:right-5 bottom-3 z-10 w-10 h-10 rounded-full flex items-center justify-center border border-gray-300 hover:border-gray-400 focus-visible:border-gray-400 transition-colors j13-toast-in text-black hover:bg-gray-50"
style={{
background: "#fff",
boxShadow: "var(--shadow)",
}}
>
<ChevronsDown size={18} />
{newBelowCount > 0 && (
<span
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--bg)",
}}
>
{newBelowCount > 99 ? "99+" : newBelowCount}
</span>
)}
</button>
)}
</div>
{isRealMember ? (
<div
@@ -1710,10 +1766,16 @@ export default function ChatRoomClient({
: "输入消息,@ 可提醒成员"
}
maxLength={2000}
className={`j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
className={`j13-chat-scroll j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
content ? " has-value" : ""
}`}
style={{ color: content ? undefined : "var(--ink)" }}
onFocus={(e) => {
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
composer?.scrollIntoView({ block: "end", behavior: "smooth" });
}}
onInput={(e) => {
const t = e.currentTarget;
t.style.height = "auto";
@@ -1796,7 +1858,7 @@ export default function ChatRoomClient({
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-0 p-4">
<div className="j13-chat-scroll flex-1 overflow-y-auto min-h-0 p-4">
{rightPanel === "members" && (
<MembersPanel
members={members}
@@ -1845,7 +1907,7 @@ export default function ChatRoomClient({
{/* 非嵌入模式:未开右栏时桌面仍显示成员常驻栏 */}
{!embedded && canView && !isDirect && !rightPanel && (
<aside className="hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
<aside className="j13-chat-scroll hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
<MembersPanel
members={members}
meId={meId}
@@ -2032,7 +2094,7 @@ function MembersPanel({
<button
type="button"
onClick={() => onSetAdmin(m.user_id, name, !isTargetAdmin)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded"
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
{isTargetAdmin ? "取消管" : "设管"}
@@ -2043,7 +2105,7 @@ function MembersPanel({
<button
type="button"
onClick={() => onMute(m.user_id, name, !m.muted)}
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1"
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--ink-3)" }}
>
{m.muted ? "解禁" : "禁言"}
@@ -2052,7 +2114,7 @@ function MembersPanel({
type="button"
onClick={() => onKick(m.user_id, name)}
aria-label={`移出 ${name}`}
className="opacity-0 group-hover:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)]"
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)] focus-visible:bg-[var(--danger-soft)]"
style={{ color: "var(--danger)" }}
>
<UserX size={12} />

View File

@@ -9,6 +9,8 @@
/* 浅色(默认)——画廊白底,卡片纯白浮起 */
--bg: #fafbfc;
--panel: #ffffff;
/* 前台 main 纸面:纯白,与页头/页脚(--panel)连成一张白纸,画布只在页缘露出 */
--main-bg: #ffffff;
--panel-2: #f2f4f7;
--tint: #eceff3;
--ink: #161a22;
@@ -103,9 +105,9 @@
--focus-glow: color-mix(in srgb, var(--accent) 22%, transparent);
--focus-glow-danger: color-mix(in srgb, var(--danger) 18%, transparent);
/* 顶栏 68px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
/* 顶栏 56px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
--header-bar: 68px;
--header-bar: 56px;
--header-h: calc(var(--header-bar) + 1px);
--main-pad-y: 1.25rem;
--rail-top: calc(var(--header-h) + var(--main-pad-y));
@@ -130,6 +132,8 @@
/* 近黑冷灰:面板逐层浮起,靛蓝提亮为长春花蓝 */
--bg: #0c0d10;
--panel: #181b21;
/* 暗色 main 纸面:比画布略亮一档,保持「主内容区略浮起」的层级对应 */
--main-bg: #131418;
--panel-2: #20242c;
--tint: #282d37;
--ink: #eef1f6;
@@ -2852,27 +2856,25 @@ body:has([data-compose-viewport]) > footer {
gap: 0.35rem 0.75rem;
min-width: 0;
}
@media (max-width: 640px) {
/* <1024px(移动/平板,底部公共菜单显示区间):三区堆叠、统一左对齐连成整体,
避免「左中右各自断开」的残缺观感;底部避让公共菜单 */
@media (max-width: 1023px) {
.site-footer {
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
}
.site-footer-inner {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.5rem;
}
.site-footer-zone,
.site-footer-zone-left,
.site-footer-zone-center,
.site-footer-zone-right {
justify-self: unset;
width: 100%;
}
.site-footer-zone-left {
justify-content: flex-start;
}
.site-footer-zone-center {
justify-content: center;
}
.site-footer-zone-right {
justify-content: flex-end;
width: 100%;
}
.site-footer-zone:empty {
display: none;
@@ -3720,6 +3722,16 @@ select:focus-visible {
.j13-search-field:focus-visible {
background: var(--panel);
}
/* 聊天界面滚动容器:隐藏滚动条(飞书式无滚动条外观,滚动能力保留) */
.j13-chat-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.j13-chat-scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.j13-chat-composer-mirror {
position: absolute;
inset: 0;
@@ -6269,7 +6281,7 @@ a.j13-mention-token:hover {
padding: 4px 11px;
background: var(--clay-soft);
color: var(--clay);
border-radius: 999px;
border-radius: 4px;
white-space: nowrap;
}
.badge-rec {
@@ -6281,7 +6293,7 @@ a.j13-mention-token:hover {
padding: 4px 11px;
background: var(--gold-soft);
color: var(--gold);
border-radius: 999px;
border-radius: 4px;
white-space: nowrap;
}
.badge-pending {
@@ -6969,16 +6981,6 @@ a.j13-mention-token:hover {
font-weight: 600;
}
/* 正文内嵌面包屑:无独立底块,靠所在面板保证对比 */
.j13-page-crumb {
color: var(--ink-3);
}
.j13-page-crumb.is-ruled {
padding-bottom: 14px;
margin-bottom: 20px;
border-bottom: 1px solid var(--line);
}
/* 板块导航行:默认独立卡片;落入 j13-module 后收成内嵌列表 */
.j13-bcard {
display: flex;
@@ -7063,12 +7065,6 @@ a.j13-mention-token:hover {
flex-direction: column;
gap: 8px;
}
.j13-board-split {
height: 1px;
background: var(--line);
margin: 2px 4px;
flex-shrink: 0;
}
@media (prefers-reduced-motion: reduce) {
.j13-bcard {
transition: none;
@@ -7172,6 +7168,20 @@ a.j13-mention-token:hover {
padding: 0 4px;
font-size: 12.5px;
}
.j13-ads-chip.is-priced {
flex-direction: column;
gap: 2px;
min-height: 0;
padding: 6px 4px;
line-height: 1.15;
}
.j13-ads-chip-price {
font-size: 11px;
font-weight: 500;
line-height: 1;
opacity: 0.75;
white-space: nowrap;
}
.j13-ads-chip.is-on {
border-color: var(--accent);
background: var(--accent);
@@ -7480,6 +7490,7 @@ a.j13-mention-token:hover {
/* 编辑器行(时长档位) */
.j13-ads-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 10px;
@@ -7851,32 +7862,63 @@ a.j13-mention-token:hover {
align-items: center;
gap: 6px;
flex-shrink: 0;
padding: 7px 14px;
border: 1px solid var(--line-2);
background: var(--panel);
padding: 7px 12px;
/* 无框 chip:透明边框占位,选中/悬停切底色时尺寸不跳 */
border: 1px solid transparent;
background: transparent;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
white-space: nowrap;
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease;
transition: background 0.16s ease, color 0.16s ease;
}
.j13-chip:hover,
.j13-chip:focus-visible {
background: var(--item-hover-bg);
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
color: var(--accent);
}
.j13-chip[aria-current="page"],
.j13-chip[aria-current="page"]:hover,
.j13-chip[aria-current="page"]:focus-visible {
background: var(--nav-active-bg);
border-color: var(--nav-active-line);
color: var(--nav-active-ink);
background: var(--accent-soft);
color: var(--accent);
font-weight: 600;
}
.j13-chip:focus-visible {
outline-offset: -2px;
}
/* 板块条两端渐隐:提示「可左右滑动」,滚动到头时对应侧隐藏 */
.j13-chip-fade {
position: absolute;
top: 0;
bottom: 0;
width: 2.5rem;
pointer-events: none;
opacity: 0;
transition: opacity 0.18s ease;
}
.j13-chip-fade.is-on {
opacity: 1;
}
.j13-chip-fade-left {
left: -1rem;
border-radius: 1rem 0 0 1rem;
background: linear-gradient(
to right,
color-mix(in srgb, var(--main-bg) 92%, transparent) 20%,
transparent
);
}
.j13-chip-fade-right {
right: -1rem;
border-radius: 0 1rem 1rem 0;
background: linear-gradient(
to left,
color-mix(in srgb, var(--main-bg) 92%, transparent) 20%,
transparent
);
}
/* overflow:hidden 的列表面板:整行链接外描边会被圆角裁切 */
.panel.overflow-hidden > a:focus-visible {
@@ -8451,29 +8493,196 @@ html.dark .prose-content pre[data-theme] span {
color: var(--accent-on);
outline: none;
}
/* 帖子移动端底栏上方抬高,避免遮挡 */
.j13-back-top.is-lifted {
bottom: 5.25rem;
}
@media (min-width: 1024px) {
.j13-back-top.is-lifted {
bottom: 1.25rem;
}
}
@media (min-width: 640px) {
.j13-back-top {
right: 1.35rem;
bottom: 1.5rem;
}
.j13-back-top.is-lifted {
bottom: 5.25rem;
}
}
@media (min-width: 1024px) {
.j13-back-top.is-lifted {
.j13-back-top {
bottom: 1.5rem;
}
}
/* 移动端抬高避开底部公共菜单(lg 以下显示底栏);置于最后以覆盖上面的 bottom */
@media (max-width: 1023px) {
.j13-back-top {
bottom: calc(4.75rem + env(safe-area-inset-bottom));
}
}
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
@media (max-width: 1023px) {
body:has(.j13-fluid-home) #main {
padding-top: 0;
}
}
/* 首页桌面:顶部留白 20px → 8px,中栏信息流上移贴页头;
sticky 侧栏的 --rail-top/--rail-max-h 在 #main 上重算(custom property
在声明处求值,:root 的 --rail-max-h 不会因子级重定义 --rail-top 而更新) */
@media (min-width: 1024px) {
body:has(.j13-fluid-home) #main {
padding-top: 0.5rem;
--rail-top: calc(var(--header-h) + 0.5rem);
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
}
}
/* ============================================================
前台 main 纸面 + 首页三栏「白纸一体」
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
背景图时让位(保持透出画布)。左栏与窄屏下沉面板无框化,
内容直接坐在纸面上;右栏保留卡片板块感(j13-module 原样)。
============================================================ */
html:not(.j13-has-site-bg) #main {
background: var(--main-bg);
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
}
/* 左栏模块去框:j13-module 退化为无底小节,内容直接坐在纸面上 */
.j13-rail-left .j13-module {
background: transparent;
border: 0;
border-radius: 0;
box-shadow: none;
padding: 0;
}
/* 左栏小节分隔:任意块相邻时画发丝线(含站长自定义工具块) */
.j13-rail-left > * + *,
.j13-fluid-home .j13-flat-rail > * + * {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid var(--line);
}
/* 左栏小节标题:纸面小节标(弱化为 meta 级),标题图标随之缩小 */
.j13-rail-left .j13-module-title {
font-size: 12px;
letter-spacing: 0.05em;
color: var(--ink-3);
}
.j13-rail-left .j13-module-title > svg {
width: 14px;
height: 14px;
}
/* 栏内图标无底色:只留彩色字形本身(收成图标实际大小,避免隐形占位拉大间距) */
.j13-rail-left .j13-bcard-ico,
.j13-fluid-home .j13-flat-rail .j13-bcard-ico {
background: transparent !important; /* 覆盖「全部讨论/管理面板」图标的内联底色 */
border-radius: 0;
width: 20px;
height: 20px;
font-size: 13px;
}
/* 首页三栏竖向分隔线:与页头下边线同色同宽,居中于栏间距(gap-6 / xl:gap-7),
画在中栏伪元素上与左右栏共用一条;类名跟随左右栏显隐,避免悬空线 */
.j13-home-sep-l,
.j13-home-sep-r {
position: relative;
}
@media (min-width: 1024px) {
.j13-home-sep-l::before,
.j13-home-sep-r::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--line);
}
.j13-home-sep-l::before {
left: calc(-0.75rem - 0.5px);
}
.j13-home-sep-r::after {
right: calc(-0.75rem - 0.5px);
}
}
@media (min-width: 1280px) {
.j13-home-sep-l::before {
left: calc(-0.875rem - 0.5px);
}
.j13-home-sep-r::after {
right: calc(-0.875rem - 0.5px);
}
}
/* 左栏分组标题:小号灰字,纸面风格的段落断点 */
.j13-rail-left .j13-rail-group,
.j13-fluid-home .j13-flat-rail .j13-rail-group {
padding: 10px 8px 4px;
font-size: 11px;
font-weight: 500;
color: var(--ink-3);
letter-spacing: 0.02em;
}
.j13-rail-left .j13-rail-group:first-child,
.j13-fluid-home .j13-flat-rail .j13-rail-group:first-child {
padding-top: 2px;
}
/* 手机端下沉面板:推广占位放开到整栏宽(248 定宽是桌面右栏尺寸,
窄屏居中两侧大片空白显得稀疏);/ads/buy 购买页预览保持真实尺寸不受影响 */
@media (max-width: 1023px) {
.j13-fluid-home .j13-flat-rail .j13-ad-promo {
width: 100%;
}
}
/* 左栏板块导航:当前项 accent 竖条 + 浅色底 */
.j13-rail-left .j13-module .j13-bcard {
position: relative;
}
.j13-rail-left .j13-module .j13-bcard[aria-current="page"],
.j13-rail-left .j13-module .j13-bcard[aria-current="page"]:hover,
.j13-rail-left .j13-module .j13-bcard[aria-current="page"]:focus-visible {
background: var(--nav-active-bg);
border-color: transparent;
}
.j13-rail-left .j13-module .j13-bcard[aria-current="page"]::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 55%;
border-radius: 2px;
background: var(--accent);
}
/* 中栏排序 Tab:描边胶囊改下划线式,active 下划线压在首行发丝线上 */
.j13-feed-head [role="tablist"] {
align-items: stretch;
}
.j13-feed-head .j13-tab {
position: relative;
border: 0;
background: transparent;
border-radius: 0;
/* 上下对称,Tab 文字与右侧 meta(self-center)垂直居中对齐;下划线仍贴行底 */
padding: 7px 2px;
}
.j13-feed-head .j13-tab::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: 1px 1px 0 0;
background: transparent;
}
.j13-feed-head .j13-tab:hover,
.j13-feed-head .j13-tab:focus-visible {
background: transparent;
border: 0;
}
.j13-feed-head .j13-tab[aria-selected="true"] {
background: transparent;
border: 0;
}
.j13-feed-head .j13-tab[aria-selected="true"]::after {
background: var(--accent);
}
/* 评论楼层树
主评论全宽;子回复默认折叠。视觉缩进最多两级,更深靠「回复谁」说明。 */

View File

@@ -1,5 +1,5 @@
import OperationalBanner from "@/components/OperationalBanner";
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { cookies, headers } from "next/headers";
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
import "./globals.css";
@@ -76,6 +76,14 @@ export function generateMetadata(): Metadata {
};
}
// 移动端键盘弹出时压缩布局高度(而非仅视觉平移),聊天输入框随 100dvh
// 收缩保持可见,配合聊天页锁视口布局;再叠加聚焦时 scrollIntoView 兜底
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
interactiveWidget: "resizes-content",
};
export default async function RootLayout({
children,
}: {

View File

@@ -1,7 +1,6 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { cookies } from "next/headers";
import { ChevronRight } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { fetchNotifications } from "@/lib/api";
@@ -32,12 +31,6 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
return (
<div className="max-w-2xl mx-auto">
<nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none">
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
<ChevronRight size={13} />
<span style={{ color: "var(--accent)" }}>通知中心</span>
</nav>
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
通知中心
</h1>

View File

@@ -15,7 +15,6 @@ import {
SiteDocPageNav,
SiteDocAnnoNav,
SiteDocMasthead,
SiteDocBreadcrumb,
} from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { webPageJsonLd } from "@/lib/jsonLd";
@@ -123,14 +122,6 @@ export default async function SitePagePublic({ params }: PageProps) {
}
>
<article className="panel p-6 sm:p-8">
<SiteDocBreadcrumb
className="is-ruled"
items={[
{ href: "/", label: "首页" },
{ href: "/about", label: "关于本站" },
{ label: p.title },
]}
/>
<SiteDocMasthead
kicker="官方文件"
title={p.title}

View File

@@ -22,7 +22,6 @@ import PostRow from "@/components/PostRow";
import FeedTabs from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip";
import { WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import { BoardMissingPanel } from "@/components/home/BoardPanels";
import { requestCanonical } from "@/lib/canonicalMeta";
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
@@ -131,6 +130,13 @@ export default async function HomePage({ searchParams }: HomePageProps) {
// 侧栏布局:板块态右栏走 board 配置(左栏跟随首页),其余走 home 配置
const scopeLists = sidebarScopeLists(sidebar.config, showBoardPanels ? "board" : "home");
// 三栏竖向分隔线跟随左右栏显隐(空配置不画悬空线)
const hasLeftRail = scopeLists.left.length > 0;
const hasRightRail = showBoardPanels
? boardSidebar
? scopeLists.right.length > 0
: boardPanelsMissing
: !!(overview && scopeLists.right.length > 0);
const sidebarProps: SidebarBlockProps = {
allowComments,
user,
@@ -202,7 +208,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
</h1>
<p className="meta mt-2">共 {postsData.total} 篇结果 · 按相关度与时间排序</p>
</div>
<div className="panel overflow-hidden">
<div className="panel overflow-hidden max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
{postsData.posts.length > 0 ? (
postsData.posts.map((post, i) => (
<PostRow key={post.id} post={post} showBoard index={i} showViews={false} showLike={false} />
@@ -231,37 +237,30 @@ export default async function HomePage({ searchParams }: HomePageProps) {
if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort;
return (
<div>
<div className="j13-fluid-home">
<JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} user={user} />
<WorkspaceNavMobile boards={boards} />
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "lg:mt-0"}`}>
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
{scopeLists.left.length > 0 && (
<aside className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
<aside className="j13-rail-left hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
<SidebarRenderer blocks={scopeLists.left} widgets={sidebar.widgets} props={sidebarProps} />
</aside>
)}
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
<div className="flex-1 min-w-0">
{/* 中栏:tab 栏与帖子流为同一个整体(左栏切板块只替换这一列) */}
<div
className={`flex-1 min-w-0 ${hasLeftRail ? "j13-home-sep-l" : ""} ${
hasRightRail ? "j13-home-sep-r" : ""
}`}
>
<h1 className="sr-only">{pageName}</h1>
<div className="panel overflow-hidden">
{activeBoard ? (
<div className="px-3 sm:px-4 pt-3">
<SiteDocBreadcrumb
className="is-ruled !mb-0"
items={[
{ href: "/", label: "首页" },
{ label: activeBoard.name },
]}
/>
</div>
) : null}
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 pt-3 pb-2.5">
<div className="max-sm:-mx-4">
<div className="j13-feed-head flex items-stretch justify-between gap-2 px-3 sm:px-4">
<FeedTabs current={sort} boardId={boardId} />
<span className="meta shrink-0 text-[12px] tabular-nums">
<span className="meta self-center shrink-0 text-[12px] tabular-nums">
共 {postsData.total} 篇
</span>
</div>
@@ -329,12 +328,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
{mobileBlocks.length > 0 && (
<div className="mt-6 lg:hidden">
<div className="j13-flat-rail mt-3 lg:hidden">
<SidebarRenderer
blocks={mobileBlocks}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
</div>
)}

View File

@@ -29,8 +29,6 @@ import CommentSection from "@/components/CommentSection";
import PostActions from "@/components/PostActions";
import LikeButton from "@/components/LikeButton";
import FavoriteButton from "@/components/FavoriteButton";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import MobilePostBar from "@/components/MobilePostBar";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import LevelBadge from "@/components/LevelBadge";
@@ -119,7 +117,10 @@ function RelatedPosts({
if (posts.length === 0) return null;
const titleId = `${uid}-title`;
return (
<section className="j13-module shrink-0" aria-labelledby={titleId}>
<section
className="j13-module shrink-0 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
aria-labelledby={titleId}
>
<p className="j13-module-title mb-1.5" id={titleId}>
<Flame size={15} style={{ color: "var(--clay)" }} /> 同板块热议
</p>
@@ -175,7 +176,7 @@ function AuthorCardStacked({
const signature = (u.signature || "").trim();
const { post_count, comment_count, points, streak } = profile.stats;
return (
<div className="panel p-5 sm:p-6">
<div className="panel p-4 sm:p-6 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<p
className="text-[11.5px] font-medium tracking-[0.08em] uppercase mb-3.5"
style={{ color: "var(--ink-3)" }}
@@ -477,15 +478,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
return (
<>
<JsonLd data={jsonLd} />
{/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */}
<MobilePostBar
postId={id}
liked={post.liked}
likeCount={post.like_count}
favorited={post.favorited === true}
allowComments={allowComments}
/>
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
<div
@@ -506,15 +498,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 中栏:正文主体 */}
<div className="min-w-0">
<article className="w-full">
<div className="panel p-6 sm:p-8">
<SiteDocBreadcrumb
className="is-ruled"
items={[
{ href: "/", label: "首页" },
{ href: `/?board=${post.board.id}`, label: post.board.name },
]}
/>
<header className="mb-7">
<div className="panel p-4 sm:p-6 lg:p-8 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
<header className="mb-5 sm:mb-7">
<div className="flex items-start gap-2 flex-wrap mb-6">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
@@ -690,7 +675,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 文末点赞/收藏:分隔线后居中,唯一的互动入口(作者行不再放置) */}
<div
className="mt-9 pt-7 flex justify-center gap-3"
className="mt-6 pt-5 sm:mt-9 sm:pt-7 flex justify-center gap-3"
style={{ borderTop: "1px solid var(--line)" }}
>
<LikeButton
@@ -705,7 +690,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
{profile && (
<div className="lg:hidden mt-6">
<div className="lg:hidden mt-4">
<AuthorCardStacked
profile={profile}
allowComments={allowComments}
@@ -745,7 +730,10 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
}
/>
) : allowComments ? (
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
<section
className="panel p-4 sm:p-6 lg:p-8 mt-4 text-center max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
aria-label="评论区"
>
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}
</p>
@@ -760,7 +748,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
) : null}
{/* 窄屏相关讨论(lg 起由右栏承载) */}
<div className="lg:hidden mt-6">
<div className="lg:hidden mt-4">
<RelatedPosts
posts={related}
boardId={post.board_id}
@@ -782,9 +770,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
</aside>
)}
</div>
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
<div className="lg:hidden h-16" aria-hidden />
</>
);
}

View File

@@ -15,8 +15,8 @@ import Modal from "@/components/Modal";
*
* 两类告知:
* - 账号被封禁(reason=banned):管理员封禁后由 HTTP 拦截链路派发;
* - 登录已失效(reason=session_replaced):其他浏览器登录顶掉旧会话、
* 安全设置剔除本设备等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
* - 登录已失效(reason=session_replaced):安全设置剔除本设备、
* 会话数超上限被淘汰等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
*
* 登录态被顶的感知入口(探测统一收敛在 lib/api 的 apiMe 判定):
* 1) 后端 WS 即时事件 session:replaced(旧设备 WS 在线时秒级感知);
@@ -152,7 +152,7 @@ export default function AccountGuard() {
id="session-ended-dialog-desc"
className="meta text-[13px] leading-relaxed text-center"
>
你的账号已在其他浏览器登录,或登录会话已被移除,当前设备的登录状态已失效。
你的登录会话已被移除(在安全设置中剔除,或会话数超出上限被淘汰),当前设备的登录状态已失效。
请重新登录后继续使用。
</p>
</Modal>

View File

@@ -8,13 +8,9 @@ const SHOW_AFTER = 400;
/**
* 公开站长页回顶:滚过阈值后右下角浮现。
* 短页自然不出现;帖子移动端底栏时抬高,避免叠住操作条。
* 短页自然不出现;移动端位置已为底部公共菜单预留避让(见 globals.css)。
*/
export default function BackToTop({
liftForMobileBar = false,
}: {
liftForMobileBar?: boolean;
}) {
export default function BackToTop() {
const [visible, setVisible] = useState(false);
useEffect(() => {
@@ -29,7 +25,7 @@ export default function BackToTop({
return (
<button
type="button"
className={`j13-back-top j13-toast-in${liftForMobileBar ? " is-lifted" : ""}`}
className="j13-back-top j13-toast-in"
onClick={() => window.scrollTo({ top: 0, behavior: scrollBehavior() })}
aria-label="回到顶部"
>

View File

@@ -1306,7 +1306,11 @@ export default function CommentSection({
}
return (
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
<section
id="comments"
className="panel p-4 sm:p-6 lg:p-8 mt-4 sm:mt-6 scroll-mt-24 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
aria-label="评论区"
>
<div className="flex items-center gap-2.5 mb-6">
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
评论

View File

@@ -4,8 +4,6 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState, useEffect, useRef } from "react";
import {
Menu,
X,
LogIn,
UserPlus,
LogOut,
@@ -19,6 +17,7 @@ import {
ChevronDown,
ShieldCheck,
MessageSquare,
X,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
@@ -43,7 +42,6 @@ export default function Header({
initialTheme = "light",
siteName = "姜十三",
wordmark = "",
slogan = "",
logoLight = "",
logoDark = "",
brandMark = "image_text",
@@ -56,7 +54,6 @@ export default function Header({
initialTheme?: "light" | "dark";
siteName?: string;
wordmark?: string;
slogan?: string;
logoLight?: string;
logoDark?: string;
brandMark?: BrandMark;
@@ -70,13 +67,14 @@ export default function Header({
pathname === "/compose" ||
(pathname?.startsWith("/compose/") ?? false) ||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
const [menuOpen, setMenuOpen] = useState(false);
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
const [user, setUser] = useState<User | null>(initialUser);
const [unread, setUnread] = useState(initialUnread);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
const [searchQuery, setSearchQuery] = useState("");
// 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像)
const [searchOpen, setSearchOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const mobileSearchRef = useRef<HTMLInputElement>(null);
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
useEffect(() => {
@@ -101,7 +99,6 @@ export default function Header({
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
useEffect(() => onForceLogout(() => {
setMenuOpen(false);
apiLogout()
.catch(() => {})
.finally(() => {
@@ -164,8 +161,9 @@ export default function Header({
e.preventDefault();
const q = searchQuery.trim();
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
setMenuOpen(false);
setSearchOpen(false);
searchInputRef.current?.blur();
mobileSearchRef.current?.blur();
};
useEffect(() => {
@@ -203,7 +201,6 @@ export default function Header({
/* ignore */
}
setUser(null);
setMenuOpen(false);
router.refresh();
};
@@ -233,7 +230,6 @@ export default function Header({
fit={brandLogoFit}
name={siteName}
wordmark={wordmark}
slogan={slogan}
logoLight={logoLight}
logoDark={logoDark}
/>
@@ -255,9 +251,19 @@ export default function Header({
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
<div className="hidden lg:block flex-1" aria-hidden />
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
<div className="flex items-center gap-2 lg:gap-3">
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询)。
移动端导航交给底部公共菜单,顶部只保留 搜索/主题/铃铛/头像 */}
<div className="flex items-center gap-2 lg:gap-3 ml-auto lg:ml-0">
{/* 移动端搜索开关 */}
<button
onClick={() => setSearchOpen((v) => !v)}
aria-label="搜索"
aria-expanded={searchOpen}
className={`${iconBtn} lg:hidden`}
style={iconBtnStyle}
>
{searchOpen ? <X size={17} /> : <Search size={17} />}
</button>
<form onSubmit={handleSearch} className="hidden lg:block">
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
<Search
@@ -283,18 +289,14 @@ export default function Header({
</kbd>
</div>
</form>
<ThemeToggle
initialTheme={initialTheme}
className={`${iconBtn} hidden lg:inline-flex`}
style={iconBtnStyle}
/>
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
{user && allowMessages && (
<Link
href="/chat"
prefetch={false}
aria-label="消息"
data-tip="消息"
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
className="relative hidden lg:inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
style={{
color: pathname?.startsWith("/chat")
? "var(--accent)"
@@ -333,95 +335,43 @@ export default function Header({
<UserMenu user={user} onLogout={handleLogout} />
</>
) : (
<div className="hidden lg:flex items-center gap-2">
<div className="flex items-center gap-2">
<Link href="/login" className="btn btn-line btn-sm">
<LogIn size={14} /> 登录
</Link>
<Link href="/register" className="btn btn-primary btn-sm">
<Link href="/register" className="btn btn-primary btn-sm hidden sm:inline-flex">
<UserPlus size={14} /> 注册
</Link>
</div>
)}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="w-10 h-10 flex items-center justify-center rounded-full lg:hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink)" }}
aria-label="菜单"
aria-expanded={menuOpen}
>
{menuOpen ? <X size={19} /> : <Menu size={19} />}
</button>
</div>
</div>
{/* 移动端抽屉 */}
{menuOpen && (
{/* 移动端展开式搜索行 */}
{searchOpen && (
<div
className="lg:hidden border-t px-4 py-4"
className="lg:hidden border-t px-4 py-2.5"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<form onSubmit={handleSearch} className="relative mb-3">
<form onSubmit={handleSearch} className="relative">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
<input
ref={mobileSearchRef}
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={onSearchKeyDown}
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchOpen(false);
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
}
}}
aria-label="搜索帖子"
placeholder="搜索帖子...(Esc 清除)"
placeholder="搜索帖子..."
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
/>
</form>
<div className="flex flex-col">
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
<MobileLink href="/leaderboard" active={pathname?.startsWith("/leaderboard") ?? false}>
<span className="inline-flex items-center gap-2">
<Trophy size={15} /> 排行榜
</span>
</MobileLink>
{user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>}
{user && allowMessages && (
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
<span className="inline-flex items-center gap-2">
<MessageSquare size={15} /> 消息
{chatUnread > 0 && (
<span
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full"
style={{ background: "var(--clay)", color: "#fff" }}
>
{chatUnread > 99 ? "99+" : chatUnread}
</span>
)}
</span>
</MobileLink>
)}
{user && (
<MobileLink href="/notifications">
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
</MobileLink>
)}
{user && <MobileLink href="/settings">账号设置</MobileLink>}
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
<MobileLink href="/admin">
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
</MobileLink>
)}
{!user && <MobileLink href="/login">登录</MobileLink>}
{!user && <MobileLink href="/register">注册</MobileLink>}
</div>
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
<ThemeToggle
initialTheme={initialTheme}
labelMode="labeled"
iconSize={14}
className="btn btn-line btn-sm"
/>
{user && (
<button onClick={handleLogout} className="btn btn-danger btn-sm">
<LogOut size={14} /> 退出
</button>
)}
</div>
</div>
)}
</header>
@@ -455,32 +405,9 @@ function NavPill({
);
}
function MobileLink({
href,
active,
children,
}: {
href: string;
active?: boolean;
children: React.ReactNode;
}) {
return (
<Link
href={href}
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink)",
background: "transparent",
}}
>
{children}
</Link>
);
}
/**
* 桌面端用户菜单:头像 + 昵称触发器,下拉承载个人主页/设置/登出,
* 把零散的图标按钮收拢为一个语义明确的控件
* 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出,
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
*/
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
const [open, setOpen] = useState(false);
@@ -506,12 +433,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
return (
<div className="relative hidden lg:block" ref={menuRef}>
<div className="relative" ref={menuRef}>
<button
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-full pl-1 pr-2.5 py-1 transition-colors"
aria-label="用户菜单"
className="flex items-center gap-2 rounded-full p-1 lg:pl-1 lg:pr-2.5 transition-colors"
style={{
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
background: open ? "var(--accent-soft)" : "var(--panel)",
@@ -519,14 +447,14 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
<span
className="text-[13px] max-w-[90px] truncate"
className="hidden lg:inline text-[13px] max-w-[90px] truncate"
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
>
{user.nickname}
</span>
<ChevronDown
size={14}
className="transition-transform duration-200"
className="hidden lg:block transition-transform duration-200"
style={{
color: "var(--ink-3)",
transform: open ? "rotate(180deg)" : "none",
@@ -562,6 +490,9 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
</div>
</div>
<div className="p-1.5">
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
</Link>
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
</Link>

View File

@@ -134,7 +134,9 @@ export default function ImageInsertModal({
<span className="j13-img-dropzone-title">
{uploading ? "图片上传中…" : "拖拽图片到此处,或点击选择文件"}
</span>
<span className="j13-img-dropzone-hint">支持 JPEG / PNG / WebP</span>
<span className="j13-img-dropzone-hint">
支持 JPEG / PNG / WebP / GIF,自动转存 WebP(GIF 除外)
</span>
</button>
{uploadError && !uploading ? (
<p className="j13-img-modal-error" role="alert">

View File

@@ -413,9 +413,9 @@ export default function MarkdownEditor({
if (!file || disabled) return;
retryFile.current = file;
setUploadError("");
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
setUploadError("仅支持 JPEG / PNG / WebP");
toast("仅支持 JPEG / PNG / WebP", "error");
if (!/^image\/(jpeg|png|webp|gif)$/i.test(file.type)) {
setUploadError("仅支持 JPEG / PNG / WebP / GIF");
toast("仅支持 JPEG / PNG / WebP / GIF", "error");
return;
}
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
@@ -1254,7 +1254,7 @@ export default function MarkdownEditor({
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/webp"
accept="image/jpeg,image/png,image/webp,image/gif"
className="hidden"
onChange={(e) => void uploadImageFile(e.target.files?.[0] ?? null)}
/>

View File

@@ -1,62 +0,0 @@
"use client";
import { MessageSquare } from "lucide-react";
import LikeButton from "./LikeButton";
import FavoriteButton from "./FavoriteButton";
import { scrollBehavior } from "@/lib/motion";
/**
* 帖子详情移动端浮动操作条(lg 以下显示):
* 长文读到中途也能点赞 / 收藏 / 跳评论框;回顶由全局 BackToTop 承担。
*/
export default function MobilePostBar({
postId,
liked,
likeCount,
favorited = false,
allowComments = true,
}: {
postId: string;
liked: boolean;
likeCount: number;
favorited?: boolean;
allowComments?: boolean;
}) {
const scrollToComments = () => {
document.getElementById("comment-input")?.scrollIntoView({
behavior: scrollBehavior(),
block: "center",
});
};
return (
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
<div
className="flex items-center gap-0.5 rounded-full border px-1.5 py-1.5"
style={{
background: "color-mix(in srgb, var(--panel) 90%, transparent)",
borderColor: "var(--line-2)",
boxShadow: "var(--shadow-lg)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
}}
>
{allowComments ? (
<button
type="button"
onClick={scrollToComments}
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
aria-label="跳到评论框"
>
<MessageSquare size={18} />
</button>
) : null}
<div className="j13-bar-like">
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
</div>
<FavoriteButton postId={postId} favorited={favorited} variant="compact" />
</div>
</div>
);
}

View File

@@ -0,0 +1,192 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import {
Home,
Trophy,
PenSquare,
MessageSquare,
User as UserIcon,
} from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
/**
* 移动端全局底部公共菜单(lg 以下显示):
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
*/
export default function MobileTabBar({
user,
initialChatUnread = 0,
}: {
user: User | null;
initialChatUnread?: number;
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
useEffect(() => {
setChatUnread(initialChatUnread);
}, [initialChatUnread]);
// 实时未读:与 Header 同源(chat:unread 对全体成员推送;在当前会话页则忽略)
useEffect(() => {
if (!user || !allowMessages) return;
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
if (!data || typeof data.room_id !== "number") return;
if (pathname === `/chat/${data.room_id}`) return;
const delta = typeof data.delta === "number" ? data.delta : 1;
setChatUnread((c) => Math.max(0, c + delta));
});
return off;
}, [user, pathname, allowMessages]);
// 会话内已读 / 回前台:合并对账(短时间多次同步只打一次 summary)
useEffect(() => {
if (!user || !allowMessages) return;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const pullSummary = () => {
apiChatUnreadSummary()
.then((res) => setChatUnread(res.total || 0))
.catch(() => {});
};
const onSync = () => {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(pullSummary, 250);
};
window.addEventListener("j13-chat-unread-sync", onSync);
const offVisible = onDocumentVisible(pullSummary);
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
window.removeEventListener("j13-chat-unread-sync", onSync);
offVisible();
};
}, [user, allowMessages]);
const showMessages = !!user && allowMessages;
const isActive = (match: (p: string) => boolean) =>
!!pathname && match(pathname);
return (
<nav
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
aria-label="底部导航"
style={{
borderColor: "var(--line)",
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
paddingBottom: "env(safe-area-inset-bottom)",
}}
>
<div className="flex items-stretch">
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
<TabLink
href="/leaderboard"
icon={<Trophy size={20} />}
label="排行榜"
active={isActive((p) => p.startsWith("/leaderboard"))}
/>
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
{showMessages && (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p.startsWith("/chat"))}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
)}
<TabLink
href={user ? `/u/${user.id}` : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`))
}
/>
</div>
</nav>
);
}
function TabLink({
href,
icon,
label,
active,
prominent = false,
badge = 0,
}: {
href: string;
icon: React.ReactNode;
label: string;
active: boolean;
prominent?: boolean;
badge?: number | string;
}) {
const ink = active ? "var(--accent)" : "var(--ink-2)";
return (
<Link
href={href}
prefetch={false}
aria-label={label}
aria-current={active ? "page" : undefined}
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 transition-colors"
style={{ color: ink }}
>
{prominent ? (
<span
className="w-9 h-9 -mt-1 flex items-center justify-center rounded-full"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
aria-hidden="true"
>
{icon}
</span>
) : (
<span className="relative" aria-hidden="true">
{icon}
{badge ? (
<span
className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 text-[9.5px] font-medium flex items-center justify-center rounded-full"
style={{
background: "var(--clay)",
color: "#fff",
border: "2px solid var(--panel)",
}}
>
{badge}
</span>
) : null}
</span>
)}
<span
className={`text-[10px] leading-none ${
prominent ? "mt-0.5" : ""
} ${active ? "font-semibold" : "font-medium"}`}
>
{label}
</span>
</Link>
);
}

View File

@@ -201,8 +201,17 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
</button>
{open && (
<>
{/* 移动端遮罩:点击关闭(桌面不渲染) */}
<div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in"
className="lg:hidden fixed inset-0 z-40"
style={{ background: "rgba(0,0,0,0.35)" }}
onClick={() => setOpen(false)}
aria-hidden="true"
/>
<div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
@@ -210,8 +219,14 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
boxShadow: "var(--shadow-lg)",
}}
>
{/* 移动端底部面板抓手 */}
<div
className="flex items-center justify-between px-5 py-3.5"
className="lg:hidden mx-auto mt-2 mb-[-6px] w-10 h-1 rounded-full shrink-0"
style={{ background: "var(--line-2)" }}
aria-hidden="true"
/>
<div
className="flex items-center justify-between px-5 py-3.5 shrink-0"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
@@ -225,7 +240,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
)}
</div>
<div className="max-h-[24rem] overflow-y-auto">
<div className="flex-1 min-h-0 max-h-[24rem] max-lg:max-h-none overflow-y-auto">
{loading ? (
<p className="text-center py-10 meta">加载中...</p>
) : notifications.length === 0 ? (
@@ -303,7 +318,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
</div>
<div
className="px-4 py-2.5 text-center"
className="px-4 py-2.5 text-center shrink-0"
style={{ borderTop: "1px solid var(--line)" }}
>
<Link
@@ -315,6 +330,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
</Link>
</div>
</div>
</>
)}
</div>
);

View File

@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { useSiteBrand } from "@/components/SiteBrandProvider";
@@ -23,10 +24,9 @@ function pathIsComposeStudio(pathname: string | null) {
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
}
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
function pathIsPostDetail(pathname: string | null) {
if (!pathname) return false;
return /^\/post\/[^/]+$/.test(pathname);
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
function pathHasNoTabBar(pathname: string | null) {
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
}
/**
@@ -85,6 +85,7 @@ export default function SiteChrome({
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
const showTabBar = showBackToTop;
return (
<>
@@ -95,7 +96,6 @@ export default function SiteChrome({
initialTheme={theme}
siteName={brand.site_name}
wordmark={brand.site_wordmark}
slogan={brand.site_slogan}
logoLight={brand.logo_light_url}
logoDark={brand.logo_dark_url}
brandMark={brand.brand_mark}
@@ -105,14 +105,15 @@ export default function SiteChrome({
<main
id="main"
tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-[var(--main-pad-y)] outline-none"
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:pb-[var(--main-pad-y)] outline-none"
>
{children}
</main>
<Footer siteName={brand.site_name} links={brand.footer_links} />
{showBackToTop ? (
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
{showTabBar ? (
<MobileTabBar user={user} initialChatUnread={chatUnread} />
) : null}
{showBackToTop ? <BackToTop /> : null}
</>
);
}

View File

@@ -28,6 +28,7 @@ import {
BarChart3,
Award,
Layers,
Images,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -62,7 +63,7 @@ interface NavGroup {
items: NavItem[];
}
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置
// 分组导航:概览 → 内容 → 成员与经济 → 外观展示 → 系统
const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
@@ -74,6 +75,12 @@ const NAV_GROUPS: NavGroup[] = [
{
title: "内容",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
{
href: "/admin/content",
label: "内容管理",
@@ -99,12 +106,6 @@ const NAV_GROUPS: NavGroup[] = [
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
@@ -134,23 +135,17 @@ const NAV_GROUPS: NavGroup[] = [
icon: Award,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "站点",
title: "外观展示",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
@@ -163,6 +158,23 @@ const NAV_GROUPS: NavGroup[] = [
icon: PanelLeft,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/media",
label: "媒体库",
icon: Images,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
{
title: "系统",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];

View File

@@ -20,6 +20,8 @@ import type {
CheckinStatus,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import { PulseCheckin } from "./CheckinCard";
import { formatCompact } from "@/lib/format";
@@ -34,16 +36,21 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
{items.length === 0 ? (
<p className="meta px-0.5 py-1.5">暂无板块管理员</p>
) : (
<ul className="space-y-0.5">
<ul className="space-y-1.5">
{items.map((u) => {
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar || undefined} size={30} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{name}
</span>
<LevelBadge level={u.level} className="shrink-0" />
{u.badges?.slice(0, 2).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} className="shrink-0" />
))}
<span className="flex-1" />
</Link>
</li>
);

View File

@@ -2,7 +2,8 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Home, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
@@ -27,17 +28,8 @@ export function WorkspaceNavPanel({
return (
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
<p className="j13-module-title shrink-0">
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
板块目录
{boards.length > 0 ? (
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
{boards.length} 个
</span>
) : null}
</p>
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
<div className="j13-rail-scroll j13-board-list min-h-0 flex-1">
<p className="j13-rail-group">浏览</p>
<Link
href="/"
className="j13-bcard"
@@ -53,8 +45,22 @@ export function WorkspaceNavPanel({
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
</Link>
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{user ? (
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<Bookmark size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">我的收藏</span>
</Link>
) : null}
{boards.length > 0 ? (
<>
<p className="j13-rail-group">板块</p>
{boards.map((b) => (
<Link
key={b.id}
@@ -72,6 +78,8 @@ export function WorkspaceNavPanel({
</span>
</Link>
))}
</>
) : null}
</div>
{showAdmin ? (
@@ -105,28 +113,49 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
);
}
// 移动端:发帖入口 + 横向滑动板块条
export function WorkspaceNavMobile({
boards,
user,
}: {
boards: BoardWithCount[];
user: User | null;
}) {
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const composePath =
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
const composeHref = user
? composePath
: `/login?redirect=${encodeURIComponent(composePath)}`;
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const max = el.scrollWidth - el.clientWidth;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(max > 4 && el.scrollLeft < max - 4);
}, []);
useEffect(() => {
updateScrollState();
const el = scrollRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(updateScrollState);
ro.observe(el);
return () => ro.disconnect();
}, [boards, updateScrollState]);
// 进页 / 切板块时把激活 chip 滚到可视区中央
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({
block: "nearest",
inline: "center",
});
}, [activeBoard, boards]);
return (
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6">
<div className="j13-chips">
<Link href={composeHref} className="btn btn-primary btn-sm shrink-0">
<PenSquare size={14} /> 发帖
</Link>
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
<div className="relative">
<div
ref={scrollRef}
className="j13-chips"
onScroll={updateScrollState}
>
<Link
href="/"
className="j13-chip"
@@ -146,6 +175,16 @@ export function WorkspaceNavMobile({
</Link>
))}
</div>
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
<div
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
aria-hidden="true"
/>
<div
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
aria-hidden="true"
/>
</div>
</div>
);
}

View File

@@ -1,43 +0,0 @@
import Link from "next/link";
import { ChevronRight } from "lucide-react";
export type Crumb = { href?: string; label: string };
/** 位置指引:嵌在正文面板顶部,不单独成块 */
export default function SiteDocBreadcrumb({
items,
className,
}: {
items: Crumb[];
className?: string;
}) {
return (
<nav
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
aria-label="面包屑"
>
{items.map((item, i) => {
const last = i === items.length - 1;
const cls = last ? "truncate" : "shrink-0";
return (
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1 min-w-0">
{i > 0 ? <ChevronRight size={12} className="shrink-0 opacity-70" aria-hidden /> : null}
{item.href ? (
<Link
href={item.href}
className={`hover:text-[var(--accent)] transition-colors ${cls}`}
style={last ? { color: "var(--accent)" } : undefined}
>
{item.label}
</Link>
) : (
<span className={cls} style={last ? { color: "var(--accent)" } : undefined}>
{item.label}
</span>
)}
</span>
);
})}
</nav>
);
}

View File

@@ -8,14 +8,11 @@ export default function SiteDocShell({
left,
right,
children,
breadcrumb,
mobileExtras,
}: {
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
breadcrumb?: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
}) {
@@ -30,7 +27,6 @@ export default function SiteDocShell({
</aside>
<div className="min-w-0">
{breadcrumb}
{children}
</div>

View File

@@ -4,6 +4,4 @@ export { default as AboutSiteHeader } from "./AboutSiteHeader";
export { default as SiteDocPageNav } from "./SiteDocPageNav";
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
export { default as SiteDocMasthead } from "./SiteDocMasthead";
export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb";
export type { Crumb } from "./SiteDocBreadcrumb";
export type { SiteDocIdentityKind } from "./SiteDocIdentity";

View File

@@ -13,7 +13,7 @@ import {
type FooterLinksAlign,
} from "./brand";
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
import { CSRF_COOKIE } from "./cookies";
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
import { emitForceLogout } from "./userEvents";
@@ -306,12 +306,14 @@ export interface PublicSponsorItem {
hover_text?: string;
}
/** 板块右栏:管理员公开资料 */
/** 板块右栏:管理员公开资料(含等级与颁发的徽章) */
export interface BoardModerator {
id: number;
username: string;
nickname: string;
avatar: string;
level?: number;
badges?: UserBadgeView[];
}
/** 板块右栏:板级统计 */
@@ -686,13 +688,13 @@ function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
// ===== Refresh token 自动续期 =====
let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
let refreshPromise: ReturnType<typeof apiRefresh> | null = null;
// 被封禁通知去重:同一登录生命周期内只强制下线/弹一次,
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
let bannedNotified = false;
// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。
// 登录态被剔除通知去重(安全设置剔除设备、会话数超上限被淘汰等)。
// sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效"
let sessionEndedNotified = false;
let sessionSeen = false;
@@ -720,22 +722,25 @@ function detectBannedBody(res: Response): void {
.catch(() => {});
}
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。
// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态
// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
credentials: "include",
headers: clientHeaders(),
});
if (res.ok) return { ok: true, banned: false };
if (res.ok) return { ok: true, banned: false, transient: false };
if (res.status >= 500) return { ok: false, banned: false, transient: true };
if (res.status === 403) {
const data = await res.json().catch(() => ({} as { code?: string }));
if (data.code === "account_banned") return { ok: false, banned: true };
if (data.code === "account_banned") return { ok: false, banned: true, transient: false };
}
return { ok: false, banned: false };
return { ok: false, banned: false, transient: false };
} catch {
return { ok: false, banned: false };
return { ok: false, banned: false, transient: true };
}
}
@@ -762,9 +767,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
} else if (result.banned && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
} else if (!result.banned) {
// refresh 失败且非封禁:登录态已失效(被顶/被剔除/过期),
// 曾登录的标签页统一告知,避免界面仍显示已登录
} else if (!result.banned && !result.transient) {
// refresh 明确失败且非封禁:登录态已失效(被顶/被剔除/过期),
// 曾登录的标签页统一告知,避免界面仍显示已登录。
// 瞬态失败(网络/后端暂不可用)不动登录态,等后续请求自然恢复
notifySessionEnded();
}
}
@@ -1399,19 +1405,38 @@ export async function fetchNotifications(page = 1, cookieHeader?: string): Promi
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
export async function apiMe(): Promise<MeResponse> {
const res = await fetch("/api/me", {
let res = await fetch("/api/me", {
credentials: "include",
cache: "no-store",
});
const data: MeResponse = await res.json();
let data: MeResponse = await res.json();
if (data.user) sessionSeen = true;
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
if (data.banned === true && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
} else if (!data.user && res.ok) {
// 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、
// 设备剔除等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
if (sessionSeen && hasAuthCookieHint()) {
// access cookie 到达 15min MaxAge 会被浏览器删除,/api/me 只能以游客
// 应答——这不代表会话被剔除(refresh token 仍有效)。先 refresh 恢复
// access 再复判;真被剔除时 refresh 也会明确失败,结论不变
const refreshed = await apiRefresh();
if (refreshed.ok) {
res = await fetch("/api/me", { credentials: "include", cache: "no-store" });
data = await res.json();
if (data.user) sessionSeen = true;
} else if (refreshed.transient) {
// 网络/后端暂不可用:无法证明登录态失效,不下结论
return data;
}
if (refreshed.banned && !bannedNotified) {
bannedNotified = true;
emitForceLogout("banned");
return data;
}
}
// 曾登录、经复判仍是游客:登录态在别处被剔除(设备剔除、
// 会话超限淘汰等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
notifySessionEnded();
}
return data;
@@ -2243,7 +2268,7 @@ export async function apiUploadAvatar(
return res.json();
}
// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
@@ -2949,6 +2974,7 @@ export type LegacyUserReport = {
total: number;
imported: number;
skipped: number;
excluded: number;
avatar_written: number;
conflicts?: string[];
avatar_missing?: string[];
@@ -2965,9 +2991,13 @@ export type LegacyPostReport = {
total: number;
imported: number;
skipped: number;
excluded: number;
excluded_detail?: string[];
polls_skipped: number;
poll_titles?: string[];
owner_fallback?: string[];
images_written: number;
images_missing?: string[];
failed?: string[];
};
@@ -2975,37 +3005,94 @@ export type LegacyCommentReport = {
total: number;
imported: number;
skipped: number;
excluded: number;
excluded_detail?: string[];
skipped_detail?: string[];
owner_fallback?: string[];
images_written: number;
images_missing?: string[];
failed?: string[];
};
// ===== 预检清单(dry-run 返回,供勾选导入范围) =====
export type LegacyUserPreview = {
id: number;
username: string;
nickname: string;
posts: number;
comments: number;
exists: boolean;
has_avatar: boolean;
};
export type LegacyPostPreview = {
id: number;
title: string;
author: string;
board: string;
created_at: string;
poll: boolean;
published: boolean;
};
export type LegacyCommentPreview = {
id: number;
post_id: number;
post_title: string;
author: string;
excerpt: string;
created_at: string;
published: boolean;
};
export type LegacyImportReport = {
dry_run: boolean;
users: LegacyUserReport;
boards?: LegacyBoardReport;
posts?: LegacyPostReport;
comments?: LegacyCommentReport;
user_list?: LegacyUserPreview[];
post_list?: LegacyPostPreview[];
comment_list?: LegacyCommentPreview[];
notes?: string[];
};
export type LegacyImportOptions = {
withContent: boolean;
dryRun: boolean;
/** 不创建账号的旧用户 ID(内容仍按归属规则落位) */
skipUsers?: number[];
/** 内容归到站长的旧用户 ID */
operatorUsers?: number[];
/** 旧用户 ID → 指定已有账号用户名(内容归属) */
userMap?: { oldId: number; username: string }[];
/** 排除的旧帖 ID(其评论自动跳过) */
skipPosts?: number[];
/** 排除的旧评论 ID */
skipComments?: number[];
};
/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */
/** 上传旧站 SQLite 库(必填)、可选头像 / 帖子图片 zip 与勾选参数,导入用户与内容。幂等可重复执行 */
export async function apiAdminImportLegacy(
source: string,
dbFile: File,
zipFile: File | null,
imagesZipFile: 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");
if (imagesZipFile) form.append("images_zip", imagesZipFile, imagesZipFile.name || "images.zip");
form.append("with_content", String(opts.withContent));
form.append("dry_run", String(opts.dryRun));
if (opts.skipUsers?.length) form.append("skip_users", opts.skipUsers.join(","));
if (opts.operatorUsers?.length) form.append("operator_users", opts.operatorUsers.join(","));
if (opts.userMap?.length)
form.append("user_map", opts.userMap.map((m) => `${m.oldId}:${m.username}`).join(","));
if (opts.skipPosts?.length) form.append("skip_posts", opts.skipPosts.join(","));
if (opts.skipComments?.length) form.append("skip_comments", opts.skipComments.join(","));
const res = await fetchWithRefresh(`/api/admin/import/${source}`, {
method: "POST",
headers: clientHeaders(),
@@ -4101,6 +4188,7 @@ export interface AdDurationOption {
days: number;
label: string;
price_hint: string;
price_hint_text: string;
}
export interface AdPaymentOption {
@@ -4293,6 +4381,35 @@ export async function fetchAdminSidebar(
return res.json();
}
/** 媒体库条目(管理后台全站图片盘点) */
export interface MediaLibraryItem {
url: string;
original_url?: string; // 同名原图(仅当原图与 WebP 并存)
category: string; // image/avatar/background/ads/brand
category_name: string;
name: string;
mime: string;
size: number;
width: number;
height: number;
uploader?: string;
uploaded_at?: string;
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
source_label?: string; // 来源描述(如:帖子《…》下的评论)
}
/** SSR 管理端媒体库:全站图片盘点 */
export async function fetchAdminMediaLibrary(
cookie?: string
): Promise<{ items: MediaLibraryItem[]; counts: Record<string, number>; total: number }> {
const res = await fetch(`${API_BASE}/api/admin/media-library`, {
...ssrInit(cookie ? { Cookie: cookie } : undefined),
credentials: "include",
});
if (!res.ok) throw new Error("加载媒体库失败");
return res.json();
}
export async function apiAdminGetSidebar(): Promise<{
config: SidebarConfig;
widgets: SidebarWidgetRecord[];

View File

@@ -34,7 +34,7 @@ export function authCookieHeader(store: CookieReader): string {
}
/**
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」并校准 IP/UA。
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」。
* 其它 SSR 接口不要用这个,避免把 refresh token 带到无关后端请求。
*/
export function sessionCookieHeader(store: CookieReader): string {

View File

@@ -23,7 +23,7 @@ export const RT_CHAT_UNREAD = "chat:unread";
export const RT_NOTIFICATION_NEW = "notification:new";
export const RT_FEED_CHANGED = "feed:changed";
export const RT_MODERATION_CHANGED = "moderation:changed";
// 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备),推 user:{id}
// 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰),推 user:{id}
export const RT_SESSION_REPLACED = "session:replaced";
export interface RtEnvelope<T = unknown> {

View File

@@ -33,7 +33,7 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
*
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发;
* 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后
* 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰)经探测确认后
* 以 reason=session_replaced 派发。Header 负责复用登出清理链路
* (清 cookie、游客化、router.refresh),AccountGuard 负责弹出对应告知——
* 状态清理与告知 UI 解耦,避免各请求调用方各自处理导致残留