Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5598e1cbb7 | |||
| 46e0cdc0b3 | |||
| 8cfdfcbbf0 | |||
| 11f2131e4b | |||
| 7912b67ddc | |||
| 99c0f0319d |
@@ -387,13 +387,12 @@ func (h *Handlers) SetChatMemberMute(c *gin.Context) {
|
|||||||
sysContent = targetName + " 的禁言已被解除"
|
sysContent = targetName + " 的禁言已被解除"
|
||||||
}
|
}
|
||||||
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
|
sysMsg, _ := h.Chat.SendSystemMessage(roomID, sysContent)
|
||||||
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染)
|
// 广播系统消息到房间(sender_id=0,前端按系统消息渲染);
|
||||||
|
// Data 与普通消息一致直接放消息体(扁平结构),前端按 room_id 匹配后才能即时上屏
|
||||||
if sysMsg != nil && sysMsg.ID > 0 {
|
if sysMsg != nil && sysMsg.ID > 0 {
|
||||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||||
Type: realtime.EventChatMessage,
|
Type: realtime.EventChatMessage,
|
||||||
Data: gin.H{
|
Data: sysMsg,
|
||||||
"message": sysMsg,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), realtime.Envelope{
|
||||||
@@ -682,7 +681,9 @@ func (h *Handlers) broadcastChatRecalled(roomID uint, msg *model.ChatMessage, ne
|
|||||||
}
|
}
|
||||||
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
env := realtime.Envelope{Type: realtime.EventChatRecalled, Data: data}
|
||||||
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
h.Hub.BroadcastRoom(realtime.RoomChat(roomID), env)
|
||||||
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览
|
// 未订阅该房间的成员(侧栏在看别的会话)也需要实时改预览:
|
||||||
|
// 已撤回正文不得残留在侧栏预览里。预览回滚到上一条真实消息由 RecallMessage
|
||||||
|
// 回退 last_message_id 完成,本推送只负责即时抹掉正文、不产生角标与置顶。
|
||||||
if h.Chat == nil {
|
if h.Chat == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,10 @@ func (h *Handlers) SendEmailCode(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP())
|
||||||
|
if errors.Is(e, service.ErrEmailRegistered) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
if !h.quotaResponse(c, wait, e) {
|
if !h.quotaResponse(c, wait, e) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ type updateSettingsRequest struct {
|
|||||||
BrandMark *string `json:"brand_mark"`
|
BrandMark *string `json:"brand_mark"`
|
||||||
BrandLogoSize *string `json:"brand_logo_size"`
|
BrandLogoSize *string `json:"brand_logo_size"`
|
||||||
BrandLogoFit *string `json:"brand_logo_fit"`
|
BrandLogoFit *string `json:"brand_logo_fit"`
|
||||||
|
BrandSloganVisible *bool `json:"brand_slogan_visible"`
|
||||||
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
FooterLinks *[]service.FooterLink `json:"footer_links"`
|
||||||
|
|
||||||
PointsCheckinBase *int `json:"points_checkin_base"`
|
PointsCheckinBase *int `json:"points_checkin_base"`
|
||||||
@@ -120,6 +121,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
|||||||
"brand_mark": saved.BrandMark,
|
"brand_mark": saved.BrandMark,
|
||||||
"brand_logo_size": saved.BrandLogoSize,
|
"brand_logo_size": saved.BrandLogoSize,
|
||||||
"brand_logo_fit": saved.BrandLogoFit,
|
"brand_logo_fit": saved.BrandLogoFit,
|
||||||
|
"brand_slogan_visible": saved.BrandSloganVisible,
|
||||||
"footer_links": saved.FooterLinks,
|
"footer_links": saved.FooterLinks,
|
||||||
|
|
||||||
"points_checkin_base": saved.PointsCheckinBase,
|
"points_checkin_base": saved.PointsCheckinBase,
|
||||||
@@ -160,6 +162,7 @@ func (req *updateSettingsRequest) hasAny() bool {
|
|||||||
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
|
||||||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
|
||||||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
|
||||||
|
req.BrandSloganVisible != nil ||
|
||||||
req.FooterLinks != nil ||
|
req.FooterLinks != nil ||
|
||||||
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
|
||||||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
|
||||||
@@ -540,6 +543,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.BrandSloganVisible != nil {
|
||||||
|
if err := h.Setting.SetBrandSloganVisible(*req.BrandSloganVisible); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.FooterLinks != nil {
|
if req.FooterLinks != nil {
|
||||||
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
|
if err := h.Setting.SetFooterLinks(*req.FooterLinks); err != nil {
|
||||||
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
if errors.Is(err, service.ErrInvalidSiteSetting) {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func InitDB(dsn string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := db.AutoMigrate(
|
if err := db.AutoMigrate(
|
||||||
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
|
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{}, &Blob{},
|
||||||
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
&User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Favorite{}, &Follow{}, &Notification{}, &Checkin{},
|
||||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
|
|||||||
@@ -362,12 +362,13 @@ type PostContentUnlock struct {
|
|||||||
|
|
||||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||||
type PostAttachment struct {
|
type PostAttachment struct {
|
||||||
ObjectID string `gorm:"size:64;index" json:"-"`
|
ObjectID string `gorm:"size:96;index" json:"-"`
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名(blob 去重后即内容 hash)
|
||||||
|
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||||
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||||
Size int `gorm:"not null;default:0" json:"size"`
|
Size int `gorm:"not null;default:0" json:"size"`
|
||||||
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
||||||
@@ -666,13 +667,14 @@ const (
|
|||||||
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
AttachmentSourceLibraryContent = "library_content" // 书库正文内容插图
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment 用户上传的附件(当前仅头像,全部为 WebP)
|
// Attachment 用户上传的附件(头像与帖子插图等公开图片)
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
UserID uint `gorm:"index:idx_attachment_user_kind,priority:1;not null" json:"user_id"`
|
||||||
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
Kind string `gorm:"size:16;index:idx_attachment_user_kind,priority:2;not null" json:"kind"`
|
||||||
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
|
Source string `gorm:"size:24;not null;default:'post'" json:"source"`
|
||||||
URL string `gorm:"size:512;not null" json:"url"`
|
URL string `gorm:"size:512;not null" json:"url"`
|
||||||
|
BlobHash string `gorm:"size:64;not null;default:'';index" json:"-"` // 内容寻址 hash;空=历史文件,不走引用计数
|
||||||
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
MIME string `gorm:"size:32;not null;default:image/webp" json:"mime"`
|
||||||
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
Size int `gorm:"not null;default:0" json:"size"` // 裁剪后文件字节数(大小限制以此为准)
|
||||||
Width int `gorm:"not null;default:0" json:"width"`
|
Width int `gorm:"not null;default:0" json:"width"`
|
||||||
@@ -680,6 +682,20 @@ type Attachment struct {
|
|||||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blob 内容寻址物理文件:对【最终存储字节】算 SHA-256,同内容多次上传共享一份底层存储,
|
||||||
|
// 业务行(Attachment/PostAttachment 及未来的聊天文件消息)只引用 hash,按引用计数回收。
|
||||||
|
// 公开(帖子插图/头像)与私有(付费附件、未来私聊文件)是两个互不可见的命名空间:
|
||||||
|
// 同字节也各自存一份,避免私有文件借公开链接(/api/media/<id>)被越权读取。
|
||||||
|
type Blob struct {
|
||||||
|
Hash string `gorm:"primaryKey;size:64" json:"-"` // 最终存储字节的 SHA-256 hex(图片为转码后的字节)
|
||||||
|
Public bool `gorm:"primaryKey" json:"-"`
|
||||||
|
RefCount int64 `gorm:"not null;default:0" json:"-"`
|
||||||
|
Size int `gorm:"not null;default:0" json:"-"`
|
||||||
|
MIME string `gorm:"size:128;not null;default:''" json:"-"`
|
||||||
|
ObjectID string `gorm:"size:96;not null;default:''" json:"-"` // S3 后端的 StoredObject ID;本地后端为空
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
|
// UserBoard 板块管理员的板块授权(多对多;仅 role=board_admin 的行生效)
|
||||||
type UserBoard struct {
|
type UserBoard struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ type EmailChallenge struct {
|
|||||||
|
|
||||||
// Every remote object pins the immutable configuration revision that wrote it.
|
// Every remote object pins the immutable configuration revision that wrote it.
|
||||||
type StoredObject struct {
|
type StoredObject struct {
|
||||||
ID string `gorm:"primaryKey;size:64" json:"id"`
|
ID string `gorm:"primaryKey;size:96" json:"id"`
|
||||||
ConfigName string `gorm:"size:64;index" json:"-"`
|
ConfigName string `gorm:"size:64;index" json:"-"`
|
||||||
Key string `gorm:"size:512" json:"-"`
|
Key string `gorm:"size:512" json:"-"`
|
||||||
MIME string `gorm:"size:128" json:"-"`
|
MIME string `gorm:"size:128" json:"-"`
|
||||||
|
|||||||
@@ -107,10 +107,13 @@ var dummyPasswordHash, _ = bcrypt.GenerateFromPassword(
|
|||||||
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
[]byte("j13-dummy-password-for-constant-timing"), bcrypt.DefaultCost)
|
||||||
|
|
||||||
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
// Register 用户注册(事务 + 咨询锁,保证空库至多一名 owner)
|
||||||
|
// 邮箱非空时全库唯一(忽略大小写):后期支持邮箱登录,一邮箱只能对应一个账号
|
||||||
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
func (s *AuthService) Register(username, email, password string) (*model.User, error) {
|
||||||
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
if err := NewOperations(s.db, nil).Filter("username", username, 0); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// 邮箱统一小写归一化,避免大小写变体重复注册
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
|
||||||
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,6 +133,16 @@ func (s *AuthService) Register(username, email, password string) (*model.User, e
|
|||||||
if count > 0 {
|
if count > 0 {
|
||||||
return errors.New("用户名已被使用")
|
return errors.New("用户名已被使用")
|
||||||
}
|
}
|
||||||
|
// 邮箱唯一(忽略大小写,兜底防绕过;入口发码处已拦一次)
|
||||||
|
if email != "" {
|
||||||
|
var emailCount int64
|
||||||
|
if err := tx.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&emailCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if emailCount > 0 {
|
||||||
|
return errors.New("该邮箱已被注册")
|
||||||
|
}
|
||||||
|
}
|
||||||
var n int64
|
var n int64
|
||||||
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
if err := tx.Model(&model.User{}).Count(&n).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -681,14 +694,14 @@ func (s *AuthService) UpdateProfile(userID uint, nickname, email, signature stri
|
|||||||
if nickCount > 0 {
|
if nickCount > 0 {
|
||||||
return nil, errors.New("该昵称已被使用")
|
return nil, errors.New("该昵称已被使用")
|
||||||
}
|
}
|
||||||
email = strings.TrimSpace(email)
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
if email != "" {
|
if email != "" {
|
||||||
if !strings.Contains(email, "@") || len(email) > 128 {
|
if !strings.Contains(email, "@") || len(email) > 128 {
|
||||||
return nil, errors.New("邮箱格式不正确")
|
return nil, errors.New("邮箱格式不正确")
|
||||||
}
|
}
|
||||||
// 邮箱唯一性校验(排除自身)
|
// 邮箱唯一性校验(忽略大小写,排除自身),与注册口径一致
|
||||||
var count int64
|
var count int64
|
||||||
s.db.Model(&model.User{}).Where("email = ? AND id <> ?", email, userID).Count(&count)
|
s.db.Model(&model.User{}).Where("LOWER(email) = ? AND id <> ?", email, userID).Count(&count)
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
return nil, errors.New("该邮箱已被使用")
|
return nil, errors.New("该邮箱已被使用")
|
||||||
}
|
}
|
||||||
|
|||||||
223
backend/service/blob_dedup_test.go
Normal file
223
backend/service/blob_dedup_test.go
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/jpeg"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
webpenc "github.com/gen2brain/webp"
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newBlobTestDB(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.Blob{}, &model.Attachment{}, &model.PostAttachment{},
|
||||||
|
&model.User{}, &model.Post{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func webpFixtureBytes() []byte {
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, 20, 10))
|
||||||
|
src.Set(0, 0, color.RGBA{10, 20, 30, 255})
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = webpenc.Encode(&buf, src, webpenc.Options{Quality: ImageWebPQuality})
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveImageBlobDedupAndRefcount(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewUploadService(db, dir)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
data := webpFixtureBytes()
|
||||||
|
|
||||||
|
att1, err := s.SaveImage(1, bytes.NewReader(data), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 1: %v", err)
|
||||||
|
}
|
||||||
|
att2, err := s.SaveImage(2, bytes.NewReader(data), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 2: %v", err)
|
||||||
|
}
|
||||||
|
if att1.URL != att2.URL || att1.BlobHash == "" || att1.BlobHash != att2.BlobHash {
|
||||||
|
t.Fatalf("identical content should share url/hash: %q vs %q", att1.URL, att2.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blob model.Blob
|
||||||
|
if err := db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true).Error; err != nil {
|
||||||
|
t.Fatalf("load blob: %v", err)
|
||||||
|
}
|
||||||
|
if blob.RefCount != 2 {
|
||||||
|
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||||
|
}
|
||||||
|
blobPath := filepath.Join(dir, filepath.FromSlash(att1.URL[len("/uploads/"):]))
|
||||||
|
if fi, err := os.Stat(blobPath); err != nil || fi.IsDir() {
|
||||||
|
t.Fatalf("shared physical file missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删一个引用:文件保留
|
||||||
|
if err := s.DeleteAttachment(1, att1.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAttachment 1: %v", err)
|
||||||
|
}
|
||||||
|
db.First(&blob, "hash = ? AND public = ?", att1.BlobHash, true)
|
||||||
|
if blob.RefCount != 1 {
|
||||||
|
t.Fatalf("refcount want 1, got %d", blob.RefCount)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("blob file should survive while referenced: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一个引用删除:文件与 blob 行一起回收
|
||||||
|
if err := s.DeleteAttachment(2, att2.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteAttachment 2: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("blob file should be removed after last release, stat err=%v", err)
|
||||||
|
}
|
||||||
|
var n int64
|
||||||
|
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att1.BlobHash, true).Count(&n)
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("blob row should be gone, count=%d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveImageTranscodedJPEGDedup(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
dir := t.TempDir()
|
||||||
|
s := NewUploadService(db, dir)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src := image.NewRGBA(image.Rect(0, 0, 48, 36))
|
||||||
|
src.Set(1, 2, color.RGBA{200, 100, 50, 255})
|
||||||
|
var in bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&in, src, nil); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
att1, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 1: %v", err)
|
||||||
|
}
|
||||||
|
att2, err := s.SaveImage(1, bytes.NewReader(in.Bytes()), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage 2: %v", err)
|
||||||
|
}
|
||||||
|
// hash 必须对转码后的字节计算:同源 JPEG 两次上传应命中同一个 WebP blob
|
||||||
|
if att1.URL != att2.URL || att1.BlobHash != att2.BlobHash {
|
||||||
|
t.Fatalf("transcoded JPEG should dedup: %q(%s) vs %q(%s)", att1.URL, att1.BlobHash, att2.URL, att2.BlobHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDraftFileBlobDedupAndRefcount(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
root := t.TempDir()
|
||||||
|
s := NewPostFileService(db, root)
|
||||||
|
if err := s.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure dir: %v", err)
|
||||||
|
}
|
||||||
|
data := []byte("paid attachment content v1")
|
||||||
|
|
||||||
|
a1, err := s.SaveDraftFile(1, "report.pdf", bytes.NewReader(data), 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDraftFile 1: %v", err)
|
||||||
|
}
|
||||||
|
a2, err := s.SaveDraftFile(2, "another-name.pdf", bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDraftFile 2: %v", err)
|
||||||
|
}
|
||||||
|
if a1.BlobHash == "" || a1.BlobHash != a2.BlobHash || a1.StoredName != a2.StoredName {
|
||||||
|
t.Fatalf("same bytes should share blob: %+v vs %+v", a1, a2)
|
||||||
|
}
|
||||||
|
// 原始文件名仍各自保留
|
||||||
|
if a1.Name != "report.pdf" || a2.Name != "another-name.pdf" {
|
||||||
|
t.Fatalf("original names not preserved: %q / %q", a1.Name, a2.Name)
|
||||||
|
}
|
||||||
|
blobPath := s.FilePath(a1)
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("shared private blob missing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var blob model.Blob
|
||||||
|
if err := db.First(&blob, "hash = ? AND public = ?", a1.BlobHash, false).Error; err != nil {
|
||||||
|
t.Fatalf("load private blob: %v", err)
|
||||||
|
}
|
||||||
|
if blob.RefCount != 2 {
|
||||||
|
t.Fatalf("refcount want 2, got %d", blob.RefCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DeleteOwn(1, a1.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteOwn 1: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); err != nil {
|
||||||
|
t.Fatalf("private blob should survive with one ref: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteOwn(2, a2.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteOwn 2: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(blobPath); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("private blob should be removed after last release, stat err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlobPublicPrivateVisibilityIsolation(t *testing.T) {
|
||||||
|
db := newBlobTestDB(t)
|
||||||
|
root := t.TempDir()
|
||||||
|
img := NewUploadService(db, filepath.Join(root, "uploads"))
|
||||||
|
pf := NewPostFileService(db, filepath.Join(root, "private"))
|
||||||
|
if err := img.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure upload dir: %v", err)
|
||||||
|
}
|
||||||
|
if err := pf.EnsureDir(); err != nil {
|
||||||
|
t.Fatalf("ensure private dir: %v", err)
|
||||||
|
}
|
||||||
|
data := webpFixtureBytes()
|
||||||
|
|
||||||
|
att, err := img.SaveImage(1, bytes.NewReader(data), "post")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveImage: %v", err)
|
||||||
|
}
|
||||||
|
file, err := pf.SaveDraftFile(1, "x.webp", bytes.NewReader(data), 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SaveDraftFile: %v", err)
|
||||||
|
}
|
||||||
|
if att.BlobHash != file.BlobHash {
|
||||||
|
t.Fatalf("test premise: same bytes should produce same hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同字节在公开/私有命名空间各持一条 blob,互不复用,防止越权读取
|
||||||
|
var pubs, privs int64
|
||||||
|
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, true).Count(&pubs)
|
||||||
|
db.Model(&model.Blob{}).Where("hash = ? AND public = ?", att.BlobHash, false).Count(&privs)
|
||||||
|
if pubs != 1 || privs != 1 {
|
||||||
|
t.Fatalf("want separate public/private blobs, pubs=%d privs=%d", pubs, privs)
|
||||||
|
}
|
||||||
|
publicPath := filepath.Join(root, "uploads", filepath.FromSlash(att.URL[len("/uploads/"):]))
|
||||||
|
privatePath := pf.FilePath(file)
|
||||||
|
if publicPath == privatePath {
|
||||||
|
t.Fatal("public and private blob must not share a path")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(publicPath); err != nil {
|
||||||
|
t.Fatalf("public blob missing: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(privatePath); err != nil {
|
||||||
|
t.Fatalf("private blob missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
132
backend/service/blob_store.go
Normal file
132
backend/service/blob_store.go
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 内容寻址存储(content-addressed storage):
|
||||||
|
// 物理文件按【最终存储字节】的 SHA-256 命名,业务行只引用 hash 并各持一份引用计数;
|
||||||
|
// 同内容重复上传不再重复落盘/传输,最后一个引用删除时才回收物理文件。
|
||||||
|
// 公开与私有是独立命名空间(见 model.Blob 注释)。
|
||||||
|
|
||||||
|
// sha256Hex 计算字节的 SHA-256 hex
|
||||||
|
func sha256Hex(data []byte) string {
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// hashFile 计算文件内容的 SHA-256 hex,整文件流式读取不进内存
|
||||||
|
func hashFile(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireBlobRef 登记一次 blob 引用:行不存在则创建(计数=1),已存在则计数+1。
|
||||||
|
// 并发下靠 (hash, public) 主键冲突重试,保证不丢计数。
|
||||||
|
func acquireBlobRef(db *gorm.DB, hash string, public bool, size int, mimeType, objectID string) error {
|
||||||
|
for {
|
||||||
|
var b model.Blob
|
||||||
|
err := db.Where("hash = ? AND public = ?", hash, public).First(&b).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
b = model.Blob{
|
||||||
|
Hash: hash,
|
||||||
|
Public: public,
|
||||||
|
RefCount: 1,
|
||||||
|
Size: size,
|
||||||
|
MIME: mimeType,
|
||||||
|
ObjectID: objectID,
|
||||||
|
}
|
||||||
|
if err := db.Create(&b).Error; err != nil {
|
||||||
|
if isUniqueConflict(err) {
|
||||||
|
continue // 并发首建:改为走计数+1
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Model(&model.Blob{}).
|
||||||
|
Where("hash = ? AND public = ?", hash, public).
|
||||||
|
UpdateColumn("ref_count", gorm.Expr("ref_count + 1")).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseBlobRef 释放一次 blob 引用。计数归零时删行并返回 last=true 与该行快照
|
||||||
|
// (快照里的 ObjectID/Hash 供调用方回收物理对象)。行不存在视为幂等成功。
|
||||||
|
func releaseBlobRef(db *gorm.DB, hash string, public bool) (model.Blob, bool, error) {
|
||||||
|
var removed model.Blob
|
||||||
|
var last bool
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var b model.Blob
|
||||||
|
// 行锁:postgres 下 FOR UPDATE 串行化同 blob 的并发释放;sqlite 写事务天然串行
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
First(&b, "hash = ? AND public = ?", hash, public).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if b.RefCount <= 1 {
|
||||||
|
result := tx.Where("hash = ? AND public = ? AND ref_count <= 1", hash, public).Delete(&model.Blob{})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected > 0 {
|
||||||
|
removed = b
|
||||||
|
last = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return tx.Model(&model.Blob{}).
|
||||||
|
Where("hash = ? AND public = ?", hash, public).
|
||||||
|
UpdateColumn("ref_count", b.RefCount-1).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return model.Blob{}, false, err
|
||||||
|
}
|
||||||
|
return removed, last, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// placeLocalBlob 把临时文件原子放到内容寻址目标路径:
|
||||||
|
// 目标已存在说明同内容已落盘(字节必然一致),直接丢弃源文件;否则整体 rename。
|
||||||
|
// src 与 dst 同一路径时不做操作。
|
||||||
|
func placeLocalBlob(src, dst string) error {
|
||||||
|
if abs, err := filepath.Abs(src); err == nil {
|
||||||
|
if dstAbs, err := filepath.Abs(dst); err == nil && abs == dstAbs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
if src != dst {
|
||||||
|
_ = os.Remove(src)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
_ = os.Remove(src)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
SettingKeyBrandMark = "brand_mark"
|
SettingKeyBrandMark = "brand_mark"
|
||||||
SettingKeyBrandLogoSize = "brand_logo_size"
|
SettingKeyBrandLogoSize = "brand_logo_size"
|
||||||
SettingKeyBrandLogoFit = "brand_logo_fit"
|
SettingKeyBrandLogoFit = "brand_logo_fit"
|
||||||
|
SettingKeyBrandSloganVisible = "brand_slogan_visible"
|
||||||
|
|
||||||
BrandSlotLight = "logo_light"
|
BrandSlotLight = "logo_light"
|
||||||
BrandSlotDark = "logo_dark"
|
BrandSlotDark = "logo_dark"
|
||||||
@@ -43,6 +44,8 @@ const (
|
|||||||
BrandLogoSize3x1 = "3x1"
|
BrandLogoSize3x1 = "3x1"
|
||||||
BrandLogoSize4x1 = "4x1"
|
BrandLogoSize4x1 = "4x1"
|
||||||
BrandLogoSize169 = "16x9"
|
BrandLogoSize169 = "16x9"
|
||||||
|
// BrandLogoSizeAuto 原图比例:自由裁剪,页眉图槽按图片原始比例自适应宽度
|
||||||
|
BrandLogoSizeAuto = "auto"
|
||||||
|
|
||||||
BrandLogoFitCover = "cover"
|
BrandLogoFitCover = "cover"
|
||||||
BrandLogoFitContain = "contain"
|
BrandLogoFitContain = "contain"
|
||||||
@@ -114,7 +117,7 @@ func NormalizeBrandLogoSize(raw string) (string, bool) {
|
|||||||
switch strings.TrimSpace(raw) {
|
switch strings.TrimSpace(raw) {
|
||||||
case "", BrandLogoSizeSQ:
|
case "", BrandLogoSizeSQ:
|
||||||
return BrandLogoSizeSQ, true
|
return BrandLogoSizeSQ, true
|
||||||
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169:
|
case BrandLogoSize2x1, BrandLogoSize3x1, BrandLogoSize4x1, BrandLogoSize169, BrandLogoSizeAuto:
|
||||||
return strings.TrimSpace(raw), true
|
return strings.TrimSpace(raw), true
|
||||||
default:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
@@ -367,6 +370,15 @@ func (s *SettingService) fillBrand(out *PublicSiteSettings) error {
|
|||||||
out.BrandLogoFit = f
|
out.BrandLogoFit = f
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
sv, found, err := s.getValue(SettingKeyBrandSloganVisible)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
out.BrandSloganVisible = sv == "true"
|
||||||
|
} else {
|
||||||
|
out.BrandSloganVisible = true
|
||||||
|
}
|
||||||
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
|
rawLinks, found, err := s.getValue(SettingKeyFooterLinks)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -483,6 +495,14 @@ func (s *SettingService) SetBrandLogoFit(raw string) error {
|
|||||||
return s.putValue(SettingKeyBrandLogoFit, v)
|
return s.putValue(SettingKeyBrandLogoFit, v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetBrandSloganVisible 控制标语是否在页眉显示;true 为缺省值,存库时删 key 以保持精简。
|
||||||
|
func (s *SettingService) SetBrandSloganVisible(visible bool) error {
|
||||||
|
if visible {
|
||||||
|
return s.deleteKey(SettingKeyBrandSloganVisible)
|
||||||
|
}
|
||||||
|
return s.putValue(SettingKeyBrandSloganVisible, "false")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) setBrandURL(key, slot, url string) error {
|
func (s *SettingService) setBrandURL(key, slot, url string) error {
|
||||||
u, ok := NormalizeBrandURL(slot, url)
|
u, ok := NormalizeBrandURL(slot, url)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ func (s *ChatService) Membership(roomID, userID uint) (*model.ChatRoomMember, er
|
|||||||
return s.membership(s.db, roomID, userID)
|
return s.membership(s.db, roomID, userID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送)
|
// unreadCounts 批量计算访问者在给定群集合中的未读数(消息 ID 大于已读水位、且排除本人发送与系统提示)
|
||||||
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
||||||
out := make(map[uint]int64)
|
out := make(map[uint]int64)
|
||||||
if userID == 0 || len(roomIDs) == 0 {
|
if userID == 0 || len(roomIDs) == 0 {
|
||||||
@@ -141,7 +141,7 @@ func (s *ChatService) unreadCounts(userID uint, roomIDs []uint) map[uint]int64 {
|
|||||||
s.db.Table("chat_messages AS m").
|
s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", roomIDs, userID).
|
Where("m.room_id IN ? AND m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", roomIDs, userID, false).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows)
|
Scan(&rows)
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
@@ -767,8 +767,10 @@ func (s *ChatService) Kick(operatorID, roomID, targetID uint, oversee bool) erro
|
|||||||
return s.removeMember(roomID, targetID)
|
return s.removeMember(roomID, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true),
|
// SendSystemMessage 向房间落库一条系统提示消息(sender_id=0,is_system=true)。
|
||||||
// 并推进房间 last_message_id 与 updated_at。失败返回 nil + error。
|
// 只推进房间 updated_at,不推进 last_message_id:系统提示不作为会话预览,
|
||||||
|
// 也不参与排序顶置,避免禁言/解禁等管理动作在会话列表伪装成「新消息」。
|
||||||
|
// 失败返回 nil + error。
|
||||||
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.ChatMessage, error) {
|
||||||
msg := &model.ChatMessage{
|
msg := &model.ChatMessage{
|
||||||
RoomID: roomID,
|
RoomID: roomID,
|
||||||
@@ -782,10 +784,7 @@ func (s *ChatService) SendSystemMessage(roomID uint, content string) (*model.Cha
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
return tx.Model(&model.ChatRoom{}).Where("id = ?", roomID).
|
||||||
Updates(map[string]interface{}{
|
Update("updated_at", time.Now()).Error
|
||||||
"last_message_id": msg.ID,
|
|
||||||
"updated_at": time.Now(),
|
|
||||||
}).Error
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1020,7 +1019,7 @@ func (s *ChatService) SendMessage(senderID, roomID uint, content string, replyTo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := s.db.Preload("Sender").First(msg, msg.ID).Error; err != nil {
|
if err := s.db.Preload("Sender").Preload("Sender.Badges.Badge").First(msg, msg.ID).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
for _, uid := range mentionIDs {
|
for _, uid := range mentionIDs {
|
||||||
@@ -1109,7 +1108,8 @@ func (s *ChatService) ListMessages(userID, roomID, beforeID uint, size int, over
|
|||||||
q = q.Where("id < ?", beforeID)
|
q = q.Where("id < ?", beforeID)
|
||||||
}
|
}
|
||||||
var msgs []model.ChatMessage
|
var msgs []model.ChatMessage
|
||||||
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Recaller").Find(&msgs).Error; err != nil {
|
// Sender.Badges.Badge 嵌套预加载:消息气泡需展示发送者等级与徽章(与成员列表一致)
|
||||||
|
if err := q.Order("id DESC").Limit(size + 1).Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").Find(&msgs).Error; err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
hasMore := len(msgs) > size
|
hasMore := len(msgs) > size
|
||||||
@@ -1138,7 +1138,7 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
var msg model.ChatMessage
|
var msg model.ChatMessage
|
||||||
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).First(&msg).Error; err != nil {
|
if err := s.db.Where("id = ? AND room_id = ?", messageID, roomID).Preload("Sender").First(&msg).Error; err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, false, ErrChatMsgNotFound
|
return nil, false, ErrChatMsgNotFound
|
||||||
}
|
}
|
||||||
@@ -1147,6 +1147,10 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
if msg.RecalledAt != nil {
|
if msg.RecalledAt != nil {
|
||||||
return &msg, false, nil // 幂等
|
return &msg, false, nil // 幂等
|
||||||
}
|
}
|
||||||
|
// 站长的消息禁止群主/群管/站点消息管撤回(仅全站监管可撤回)
|
||||||
|
if msg.Sender.Role == model.RoleOwner && !oversee {
|
||||||
|
return nil, false, ErrChatRecallDenied
|
||||||
|
}
|
||||||
if msg.SenderID == operatorID {
|
if msg.SenderID == operatorID {
|
||||||
if !oversee {
|
if !oversee {
|
||||||
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
if _, err := s.membership(s.db, roomID, operatorID); err != nil {
|
||||||
@@ -1165,7 +1169,17 @@ func (s *ChatService) RecallMessage(operatorID, roomID, messageID uint, oversee
|
|||||||
}
|
}
|
||||||
msg.RecalledAt = &now
|
msg.RecalledAt = &now
|
||||||
msg.RecalledBy = operatorID
|
msg.RecalledBy = operatorID
|
||||||
_ = s.db.Preload("Sender").Preload("Recaller").First(&msg, msg.ID)
|
// 撤回的是房间当前预览消息时,回退 last_message_id 到最近一条未撤回的非系统消息:
|
||||||
|
// 撤回不在会话列表留下「已撤回」预览,也不让房间因此停留在列表前排,避免被当成新消息提醒。
|
||||||
|
var prevID uint
|
||||||
|
_ = s.db.Model(&model.ChatMessage{}).
|
||||||
|
Where("room_id = ? AND deleted_at IS NULL AND recalled_at IS NULL AND is_system = ?", roomID, false).
|
||||||
|
Order("id DESC").Limit(1).
|
||||||
|
Select("id").Row().Scan(&prevID)
|
||||||
|
_ = s.db.Model(&model.ChatRoom{}).
|
||||||
|
Where("id = ? AND last_message_id = ?", roomID, msg.ID).
|
||||||
|
Update("last_message_id", prevID).Error
|
||||||
|
_ = s.db.Preload("Sender").Preload("Sender.Badges.Badge").Preload("Recaller").First(&msg, msg.ID)
|
||||||
return &msg, true, nil
|
return &msg, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1345,13 +1359,13 @@ func (s *ChatService) MarkRead(userID, roomID, messageID uint) error {
|
|||||||
Update("last_read_message_id", messageID).Error
|
Update("last_read_message_id", messageID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnreadSummary 当前用户所有群的未读明细与总数
|
// UnreadSummary 当前用户所有群的未读明细与总数(系统提示消息不计入)
|
||||||
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
func (s *ChatService) UnreadSummary(userID uint) (int64, []UnreadRoom, error) {
|
||||||
var rows []UnreadRoom
|
var rows []UnreadRoom
|
||||||
err := s.db.Table("chat_messages AS m").
|
err := s.db.Table("chat_messages AS m").
|
||||||
Select("m.room_id AS room_id, count(*) AS cnt").
|
Select("m.room_id AS room_id, count(*) AS cnt").
|
||||||
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
Joins("JOIN chat_room_members AS rm ON rm.room_id = m.room_id AND rm.user_id = ?", userID).
|
||||||
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL", userID).
|
Where("m.id > rm.last_read_message_id AND m.sender_id <> ? AND m.deleted_at IS NULL AND m.recalled_at IS NULL AND m.is_system = ?", userID, false).
|
||||||
Group("m.room_id").
|
Group("m.room_id").
|
||||||
Scan(&rows).Error
|
Scan(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type MediaLibraryItem struct {
|
|||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
|
Uploader string `json:"uploader,omitempty"` // 上传者昵称(取自附件记录,无记录为空)
|
||||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
|
RefCount int `json:"ref_count,omitempty"` // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||||||
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
|
SourceURL string `json:"source_url,omitempty"` // 来源回链(帖子/评论锚点/用户主页)
|
||||||
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
|
SourceLabel string `json:"source_label,omitempty"` // 来源描述(如:帖子《…》下的评论)
|
||||||
}
|
}
|
||||||
@@ -91,13 +92,29 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
metaByURL := make(map[string]attMeta, len(rows))
|
// 同 URL 多条记录(内容寻址去重后,重复上传共享同一文件):
|
||||||
// 远程存储对象(/api/media/)不在磁盘上,直接作为条目加入
|
// 上传者/时间取最早一条,计数即该文件的引用次数
|
||||||
items := make([]MediaLibraryItem, 0, len(rows))
|
type urlMeta struct {
|
||||||
|
meta attMeta
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
agg := make(map[string]*urlMeta, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
metaByURL[r.URL] = r // 本地文件在磁盘扫描时按 URL 合并元数据
|
if a, ok := agg[r.URL]; ok {
|
||||||
if !strings.HasPrefix(r.URL, "/api/media/") {
|
a.count++
|
||||||
continue
|
if r.CreatedAt.Before(a.meta.CreatedAt) {
|
||||||
|
a.meta = r
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
agg[r.URL] = &urlMeta{meta: r, count: 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 远程存储对象(/api/media/)不在磁盘上,按 URL 去重后直接作为条目加入
|
||||||
|
items := make([]MediaLibraryItem, 0, len(agg))
|
||||||
|
for url, a := range agg {
|
||||||
|
r := a.meta
|
||||||
|
if !strings.HasPrefix(url, "/api/media/") {
|
||||||
|
continue // 本地文件在磁盘扫描时按 URL 合并元数据
|
||||||
}
|
}
|
||||||
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
if r.Kind != model.AttachmentKindImage && r.Kind != model.AttachmentKindAvatar {
|
||||||
continue // 未知 kind 不进媒体库
|
continue // 未知 kind 不进媒体库
|
||||||
@@ -109,10 +126,10 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
catKey, catName := mediaCategoryForAttachment(r.Kind, r.Source)
|
||||||
ca := r.CreatedAt
|
ca := r.CreatedAt
|
||||||
items = append(items, MediaLibraryItem{
|
items = append(items, MediaLibraryItem{
|
||||||
URL: r.URL, Category: catKey, CategoryName: catName,
|
URL: url, Category: catKey, CategoryName: catName,
|
||||||
Name: strings.TrimPrefix(r.URL, "/api/media/"), MIME: r.MIME,
|
Name: strings.TrimPrefix(url, "/api/media/"), MIME: r.MIME,
|
||||||
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
Size: int64(r.Size), Width: r.Width, Height: r.Height,
|
||||||
Uploader: uploader, UploadedAt: &ca,
|
Uploader: uploader, UploadedAt: &ca, RefCount: a.count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +169,9 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
mt := info.ModTime()
|
mt := info.ModTime()
|
||||||
item.UploadedAt = &mt
|
item.UploadedAt = &mt
|
||||||
}
|
}
|
||||||
if m, ok := metaByURL[url]; ok {
|
if a, ok := agg[url]; ok {
|
||||||
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
// 附件记录有上传者与准确创建时间,覆盖磁盘信息
|
||||||
|
m := a.meta
|
||||||
uploader := m.Nickname
|
uploader := m.Nickname
|
||||||
if uploader == "" {
|
if uploader == "" {
|
||||||
uploader = m.Username
|
uploader = m.Username
|
||||||
@@ -161,6 +179,7 @@ func (s *UploadService) AdminMediaLibrary() ([]MediaLibraryItem, map[string]int,
|
|||||||
ca := m.CreatedAt
|
ca := m.CreatedAt
|
||||||
item.Uploader = uploader
|
item.Uploader = uploader
|
||||||
item.UploadedAt = &ca
|
item.UploadedAt = &ca
|
||||||
|
item.RefCount = a.count
|
||||||
if m.Size > 0 {
|
if m.Size > 0 {
|
||||||
item.Size = int64(m.Size)
|
item.Size = int64(m.Size)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func newMediaLibraryService(t *testing.T) (*UploadService, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.Attachment{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}, &model.User{}, &model.Post{}, &model.Comment{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -315,6 +315,82 @@ func TestAdminMediaLibraryBookCategory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminMediaLibraryDedupAggregation(t *testing.T) {
|
||||||
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
writeMediaFile(t, dir, "images/dup.webp", []byte("fake-webp-bytes"))
|
||||||
|
|
||||||
|
mkUser := func(username, nickname string) model.User {
|
||||||
|
if err := s.db.Create(&model.User{Username: username, Nickname: nickname}).Error; err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
var u model.User
|
||||||
|
if err := s.db.Where("username = ?", username).First(&u).Error; err != nil {
|
||||||
|
t.Fatalf("load user: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
alice := mkUser("alice", "爱丽丝")
|
||||||
|
bob := mkUser("bob", "阿Bob")
|
||||||
|
|
||||||
|
early := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
late := early.Add(2 * time.Hour)
|
||||||
|
|
||||||
|
// 同一张图被两人先后上传(内容寻址去重后共享同一文件/对象)
|
||||||
|
mkAtt := func(u model.User, url string, at time.Time) {
|
||||||
|
if err := s.db.Create(&model.Attachment{
|
||||||
|
UserID: u.ID, Kind: model.AttachmentKindImage,
|
||||||
|
URL: url, MIME: "image/webp",
|
||||||
|
Size: 100, Width: 10, Height: 10, CreatedAt: at,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create attachment %s: %v", url, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mkAtt(alice, "/uploads/images/dup.webp", early)
|
||||||
|
mkAtt(bob, "/uploads/images/dup.webp", late)
|
||||||
|
mkAtt(bob, "/api/media/rdup", early)
|
||||||
|
mkAtt(alice, "/api/media/rdup", late)
|
||||||
|
|
||||||
|
items, _, err := s.AdminMediaLibrary()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AdminMediaLibrary: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
countByURL := map[string]int{}
|
||||||
|
byURL := map[string]MediaLibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
countByURL[it.URL]++
|
||||||
|
byURL[it.URL] = it
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地去重文件:单个条目,上传者/时间取最早上传的爱丽丝,引用计数 2
|
||||||
|
local, ok := byURL["/uploads/images/dup.webp"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("dedup local file missing")
|
||||||
|
}
|
||||||
|
if countByURL["/uploads/images/dup.webp"] != 1 {
|
||||||
|
t.Fatal("dedup local file should appear exactly once")
|
||||||
|
}
|
||||||
|
if local.Uploader != "爱丽丝" || local.RefCount != 2 {
|
||||||
|
t.Fatalf("local dedup meta wrong: %+v", local)
|
||||||
|
}
|
||||||
|
if local.UploadedAt == nil || !local.UploadedAt.Equal(early) {
|
||||||
|
t.Fatalf("local dedup time should be earliest upload, got %v", local.UploadedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程对象同 URL 两条记录:也只出一个条目,取最早的 bob
|
||||||
|
remote, ok := byURL["/api/media/rdup"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("dedup remote object missing")
|
||||||
|
}
|
||||||
|
if countByURL["/api/media/rdup"] != 1 {
|
||||||
|
t.Fatal("dedup remote object should appear exactly once")
|
||||||
|
}
|
||||||
|
if remote.Uploader != "阿Bob" || remote.RefCount != 2 {
|
||||||
|
t.Fatalf("remote dedup meta wrong: %+v", remote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMediaThumb(t *testing.T) {
|
func TestMediaThumb(t *testing.T) {
|
||||||
s, dir := newMediaLibraryService(t)
|
s, dir := newMediaLibraryService(t)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ import (
|
|||||||
|
|
||||||
type mailPayload struct{ To, Subject, Body string }
|
type mailPayload struct{ To, Subject, Body string }
|
||||||
|
|
||||||
|
// ErrEmailRegistered 注册发码时邮箱已被占用,handler 据此返回 400 业务提示
|
||||||
|
var ErrEmailRegistered = errors.New("该邮箱已被注册,请直接登录")
|
||||||
|
|
||||||
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
func (o *Operations) smtp(ctx context.Context, c MailConfig, p *mailPayload, id string) error {
|
||||||
password, e := o.open(c.Password, "mail:password")
|
password, e := o.open(c.Password, "mail:password")
|
||||||
if e != nil {
|
if e != nil {
|
||||||
@@ -392,6 +395,16 @@ func (o *Operations) SendCode(email, purpose, ip string) (int, error) {
|
|||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if purpose == "register" {
|
||||||
|
// 入口拦截:邮箱已被占用则不发码,省一次邮件资源,也给用户明确提示
|
||||||
|
var n int64
|
||||||
|
if e = o.db.Model(&model.User{}).Where("LOWER(email) = ?", email).Count(&n).Error; e != nil {
|
||||||
|
return 0, e
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
return 0, ErrEmailRegistered
|
||||||
|
}
|
||||||
|
}
|
||||||
code := randomCode()
|
code := randomCode()
|
||||||
hash := counterKey(email + ":" + purpose + ":" + code)
|
hash := counterKey(email + ":" + purpose + ":" + code)
|
||||||
e = o.db.Transaction(func(tx *gorm.DB) error {
|
e = o.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
func (o *Operations) s3(c StorageConfig) (*minio.Client, error) {
|
||||||
@@ -137,6 +139,65 @@ func (o *Operations) StoreFile(path, mimeType string, public bool) (string, erro
|
|||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// blobObjectID 内容寻址对象的稳定 ID:公开/私有加不同前缀,同字节也不共享可见性。
|
||||||
|
func blobObjectID(hash string, public bool) string {
|
||||||
|
if public {
|
||||||
|
return "pub_" + hash
|
||||||
|
}
|
||||||
|
return "prv_" + hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreFileHashed 按内容 hash 幂等上传:同一 (hash, public) 只存一份,重复上传跳过传输。
|
||||||
|
// 本地后端返回空 ID(文件留在本地 blob 目录,由调用方做引用计数)。
|
||||||
|
func (o *Operations) StoreFileHashed(path, mimeType string, public bool, hash string) (string, error) {
|
||||||
|
v, version, e := o.read(o.db, "storage")
|
||||||
|
if e != nil {
|
||||||
|
return "", errors.New("读取存储配置失败")
|
||||||
|
}
|
||||||
|
c := *v.(*StorageConfig)
|
||||||
|
if c.Backend == "local" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
id := blobObjectID(hash, public)
|
||||||
|
// hash 相同字节必然相同:对象已存在时直接复用,省掉整次上传
|
||||||
|
var existing model.StoredObject
|
||||||
|
if e := o.db.First(&existing, "id = ?", id).Error; e == nil {
|
||||||
|
return id, nil
|
||||||
|
} else if !errors.Is(e, gorm.ErrRecordNotFound) {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
client, e := o.s3(c)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
f, e := os.Open(path)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
stat, e := f.Stat()
|
||||||
|
if e != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
key := c.Prefix + "objects/" + id
|
||||||
|
if _, e = client.PutObject(ctx, c.Bucket, key, f, stat.Size(), minio.PutObjectOptions{ContentType: mimeType, DisableMultipart: true}); e != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return "", errors.New("上传到 S3 失败,当前目标未自动切换")
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
obj := model.StoredObject{ID: id, ConfigName: fmt.Sprintf("storage-%d", version), Key: key, MIME: mimeType, Public: public}
|
||||||
|
if e = o.db.Create(&obj).Error; e != nil {
|
||||||
|
if isUniqueConflict(e) { // 并发:另一请求已写入同内容对象,直接复用
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
_ = client.RemoveObject(ctx, c.Bucket, key, minio.RemoveObjectOptions{})
|
||||||
|
return "", errors.New("保存文件记录失败")
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
func (o *Operations) OpenObject(ctx context.Context, id string, requirePublic bool) (io.ReadCloser, string, error) {
|
||||||
var obj model.StoredObject
|
var obj model.StoredObject
|
||||||
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
if e := o.db.First(&obj, "id = ?", id).Error; e != nil {
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -145,8 +147,9 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」
|
// LimitReader 多读 1 字节以区分「刚好上限」与「超限」;落盘同时计算内容 hash
|
||||||
written, copyErr := io.Copy(f, io.LimitReader(src, maxB+1))
|
hasher := sha256.New()
|
||||||
|
written, copyErr := io.Copy(io.MultiWriter(f, hasher), io.LimitReader(src, maxB+1))
|
||||||
_ = f.Close()
|
_ = f.Close()
|
||||||
if copyErr != nil {
|
if copyErr != nil {
|
||||||
_ = os.Remove(tmp)
|
_ = os.Remove(tmp)
|
||||||
@@ -167,28 +170,45 @@ func (s *PostFileService) SaveDraftFile(userID uint, originalName string, src io
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
att := &model.PostAttachment{
|
// 内容寻址:私有 blob 以 hash 命名(不带扩展名,下载时以 att.MIME + 原文件名下发)
|
||||||
PostID: 0,
|
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||||
UserID: userID,
|
final := absPath(s.dir, hash)
|
||||||
Name: name,
|
var objectID string
|
||||||
StoredName: stored,
|
|
||||||
MIME: mimeType,
|
|
||||||
Size: int(written),
|
|
||||||
PricePoints: pricePoints,
|
|
||||||
}
|
|
||||||
if s.ops != nil {
|
if s.ops != nil {
|
||||||
id, e := s.ops.StoreFile(full, att.MIME, false)
|
id, e := s.ops.StoreFileHashed(full, mimeType, false, hash)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
_ = os.Remove(full)
|
_ = os.Remove(full)
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
att.ObjectID = id
|
objectID = id
|
||||||
if id != "" {
|
if id != "" {
|
||||||
_ = os.Remove(full)
|
_ = os.Remove(full)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.db.Create(att).Error; err != nil {
|
if objectID == "" {
|
||||||
_ = os.Remove(full)
|
if e := placeLocalBlob(full, final); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
att := &model.PostAttachment{
|
||||||
|
PostID: 0,
|
||||||
|
UserID: userID,
|
||||||
|
Name: name,
|
||||||
|
StoredName: hash,
|
||||||
|
BlobHash: hash,
|
||||||
|
ObjectID: objectID,
|
||||||
|
MIME: mimeType,
|
||||||
|
Size: int(written),
|
||||||
|
PricePoints: pricePoints,
|
||||||
|
}
|
||||||
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := acquireBlobRef(tx, hash, false, int(written), mimeType, objectID); e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return tx.Create(att).Error
|
||||||
|
}); err != nil {
|
||||||
|
s.releasePostBlobBestEffort(hash)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
@@ -283,14 +303,51 @@ func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
|||||||
if att.UserID != userID {
|
if att.UserID != userID {
|
||||||
return ErrAttachmentForbidden
|
return ErrAttachmentForbidden
|
||||||
}
|
}
|
||||||
path := absPath(s.dir, att.StoredName)
|
|
||||||
if err := s.db.Delete(&att).Error; err != nil {
|
if err := s.db.Delete(&att).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if att.BlobHash != "" {
|
||||||
|
return s.releasePostBlob(&att)
|
||||||
|
}
|
||||||
if s.ops != nil && att.ObjectID != "" {
|
if s.ops != nil && att.ObjectID != "" {
|
||||||
return s.ops.RemoveObject(att.ObjectID)
|
return s.ops.RemoveObject(att.ObjectID)
|
||||||
}
|
}
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(absPath(s.dir, att.StoredName))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// releasePostBlob 私有附件 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||||
|
func (s *PostFileService) releasePostBlob(att *model.PostAttachment) error {
|
||||||
|
b, last, err := releaseBlobRef(s.db, att.BlobHash, false)
|
||||||
|
if err != nil || !last {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.removePrivateBlobObject(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releasePostBlobBestEffort 附件记录落库失败时回滚刚加的引用,错误只记日志
|
||||||
|
func (s *PostFileService) releasePostBlobBestEffort(hash string) {
|
||||||
|
b, last, err := releaseBlobRef(s.db, hash, false)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[post-file] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.removePrivateBlobObject(b); err != nil {
|
||||||
|
log.Printf("[post-file] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePrivateBlobObject 引用归零后的物理回收(私有 blob 文件名即 hash,无扩展名)
|
||||||
|
func (s *PostFileService) removePrivateBlobObject(b model.Blob) error {
|
||||||
|
if b.ObjectID != "" && s.ops != nil {
|
||||||
|
return s.ops.RemoveObject(b.ObjectID)
|
||||||
|
}
|
||||||
|
if err := os.Remove(absPath(s.dir, b.Hash)); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("[post-file] 删除 blob 文件失败 hash=%s: %v", b.Hash, err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ type PublicSiteSettings struct {
|
|||||||
BrandMark string `json:"brand_mark"`
|
BrandMark string `json:"brand_mark"`
|
||||||
BrandLogoSize string `json:"brand_logo_size"`
|
BrandLogoSize string `json:"brand_logo_size"`
|
||||||
BrandLogoFit string `json:"brand_logo_fit"`
|
BrandLogoFit string `json:"brand_logo_fit"`
|
||||||
|
BrandSloganVisible bool `json:"brand_slogan_visible"`
|
||||||
FooterLinks []FooterLink `json:"footer_links"`
|
FooterLinks []FooterLink `json:"footer_links"`
|
||||||
|
|
||||||
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
// 积分增长规则(经济规则公开透明,签到卡等可直出)
|
||||||
|
|||||||
@@ -113,46 +113,57 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
|||||||
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
return nil, errors.New("头像尺寸需在 64~512px 之间")
|
||||||
}
|
}
|
||||||
|
|
||||||
nameBytes := make([]byte, 16)
|
hash := sha256Hex(data)
|
||||||
if _, err := rand.Read(nameBytes); err != nil {
|
filename := hash + ".webp"
|
||||||
|
fullPath := filepath.Join(s.dir, "avatars", filename)
|
||||||
|
url := "/uploads/avatars/" + filename
|
||||||
|
var objectID string
|
||||||
|
writeLocal := func() error {
|
||||||
|
if _, err := os.Stat(fullPath); err == nil {
|
||||||
|
return nil // 同内容头像已落盘,字节必然一致,无需重写
|
||||||
|
}
|
||||||
|
return os.WriteFile(fullPath, data, 0o644)
|
||||||
|
}
|
||||||
|
if s.ops != nil {
|
||||||
|
if err := writeLocal(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
filename := hex.EncodeToString(nameBytes) + ".webp"
|
id, e := s.ops.StoreFileHashed(fullPath, "image/webp", true, hash)
|
||||||
fullPath := filepath.Join(s.dir, "avatars", filename)
|
if e != nil {
|
||||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
_ = os.Remove(fullPath)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
objectID = id
|
||||||
|
if id != "" {
|
||||||
|
url = "/api/media/" + id
|
||||||
|
_ = os.Remove(fullPath)
|
||||||
|
}
|
||||||
|
} else if err := writeLocal(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
att := &model.Attachment{
|
att := &model.Attachment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Kind: model.AttachmentKindAvatar,
|
Kind: model.AttachmentKindAvatar,
|
||||||
URL: "/uploads/avatars/" + filename,
|
URL: url,
|
||||||
|
BlobHash: hash,
|
||||||
MIME: "image/webp",
|
MIME: "image/webp",
|
||||||
Size: len(data),
|
Size: len(data),
|
||||||
Width: cfg.Width,
|
Width: cfg.Width,
|
||||||
Height: cfg.Height,
|
Height: cfg.Height,
|
||||||
}
|
}
|
||||||
|
// 附件记录、blob 引用与用户头像更新在同一事务内完成
|
||||||
if s.ops != nil {
|
|
||||||
id, e := s.ops.StoreFile(fullPath, att.MIME, true)
|
|
||||||
if e != nil {
|
|
||||||
_ = os.Remove(fullPath)
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
if id != "" {
|
|
||||||
att.URL = "/api/media/" + id
|
|
||||||
_ = os.Remove(fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 附件记录与用户头像更新在同一事务内完成
|
|
||||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := acquireBlobRef(tx, hash, true, len(data), "image/webp", objectID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Create(att).Error; err != nil {
|
if err := tx.Create(att).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
return tx.Model(&model.User{}).Where("id = ?", userID).Update("avatar", att.URL).Error
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = os.Remove(fullPath) // DB 失败时清理已落盘文件
|
s.releaseBlobBestEffort(hash, true, "/uploads/avatars/"+filename)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
@@ -351,29 +362,51 @@ func (s *UploadService) SaveImage(userID uint, src io.Reader, source string) (*m
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 对最终存储字节做内容寻址(JPEG/PNG 已在上面转成 WebP,同图重复上传才能命中)
|
||||||
|
hash, err := hashFile(storePath)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(storePath)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
finalName := hash + strings.ToLower(filepath.Ext(storeName))
|
||||||
|
url := "/uploads/images/" + finalName
|
||||||
|
var objectID string
|
||||||
|
if s.ops != nil {
|
||||||
|
id, e := s.ops.StoreFileHashed(storePath, storeMime, true, hash)
|
||||||
|
if e != nil {
|
||||||
|
_ = os.Remove(storePath)
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
objectID = id
|
||||||
|
if id != "" {
|
||||||
|
// 远程后端:以对象 ID 为访问地址,本地临时副本回收
|
||||||
|
url = "/api/media/" + id
|
||||||
|
_ = os.Remove(storePath)
|
||||||
|
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
} else if e := placeLocalBlob(storePath, filepath.Join(s.dir, "images", finalName)); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
|
||||||
att := &model.Attachment{
|
att := &model.Attachment{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Kind: model.AttachmentKindImage,
|
Kind: model.AttachmentKindImage,
|
||||||
Source: normalizeImageSource(source),
|
Source: normalizeImageSource(source),
|
||||||
URL: "/uploads/images/" + storeName,
|
URL: url,
|
||||||
|
BlobHash: hash,
|
||||||
MIME: storeMime,
|
MIME: storeMime,
|
||||||
Size: int(storeSize),
|
Size: int(storeSize),
|
||||||
Width: w,
|
Width: w,
|
||||||
Height: h,
|
Height: h,
|
||||||
}
|
}
|
||||||
if s.ops != nil {
|
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
id, e := s.ops.StoreFile(storePath, att.MIME, true)
|
if err := acquireBlobRef(tx, hash, true, int(storeSize), storeMime, objectID); err != nil {
|
||||||
if e != nil {
|
return err
|
||||||
_ = os.Remove(storePath)
|
|
||||||
return nil, e
|
|
||||||
}
|
}
|
||||||
if id != "" {
|
return tx.Create(att).Error
|
||||||
att.URL = "/api/media/" + id
|
}); err != nil {
|
||||||
_ = os.Remove(storePath)
|
s.releaseBlobBestEffort(hash, true, url)
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.db.Create(att).Error; err != nil {
|
|
||||||
_ = os.Remove(storePath)
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return att, nil
|
return att, nil
|
||||||
@@ -722,6 +755,10 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if att.BlobHash != "" {
|
||||||
|
return s.releaseAttachmentBlob(&att)
|
||||||
|
}
|
||||||
|
|
||||||
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
if s.ops != nil && strings.HasPrefix(att.URL, "/api/media/") {
|
||||||
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
if e := s.ops.RemoveObject(RemoteObjectID(att.URL)); e != nil {
|
||||||
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||||
@@ -736,4 +773,47 @@ func (s *UploadService) DeleteAttachment(userID, attachmentID uint) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// releaseAttachmentBlob 公开图片 blob 引用计数-1;最后一个引用删除时回收物理文件
|
||||||
|
func (s *UploadService) releaseAttachmentBlob(att *model.Attachment) error {
|
||||||
|
b, last, err := releaseBlobRef(s.db, att.BlobHash, true)
|
||||||
|
if err != nil || !last {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.removePublicBlobObject(b, att.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseBlobBestEffort 业务记录落库失败时回滚刚加的引用,错误只记日志
|
||||||
|
func (s *UploadService) releaseBlobBestEffort(hash string, public bool, localURL string) {
|
||||||
|
b, last, err := releaseBlobRef(s.db, hash, public)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[upload] 回滚 blob 引用失败 hash=%s: %v", hash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !last {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.removePublicBlobObject(b, localURL); err != nil {
|
||||||
|
log.Printf("[upload] 回滚 blob 物理文件失败 hash=%s: %v", hash, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removePublicBlobObject 引用归零后的物理回收:远程对象失败需上抛(与历史行为一致),
|
||||||
|
// 本地文件失败只记日志(下次同内容上传会自愈覆盖)。
|
||||||
|
func (s *UploadService) removePublicBlobObject(b model.Blob, localURL string) error {
|
||||||
|
if b.ObjectID != "" && s.ops != nil {
|
||||||
|
if e := s.ops.RemoveObject(b.ObjectID); e != nil {
|
||||||
|
return errors.New("记录已删除,远程文件清理未完成,请联系管理员")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if localURL == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
abs := filepath.Join(s.dir, filepath.FromSlash(strings.TrimPrefix(localURL, "/uploads/")))
|
||||||
|
if err := os.Remove(abs); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Printf("[upload] 删除 blob 文件失败 hash=%s path=%s: %v", b.Hash, abs, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
func (s *UploadService) WithOperations(o *Operations) { s.ops = o }
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func newUploadTestService(t *testing.T) (*UploadService, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("open sqlite: %v", err)
|
t.Fatalf("open sqlite: %v", err)
|
||||||
}
|
}
|
||||||
if err := db.AutoMigrate(&model.Attachment{}); err != nil {
|
if err := db.AutoMigrate(&model.Attachment{}, &model.Blob{}); err != nil {
|
||||||
t.Fatalf("migrate: %v", err)
|
t.Fatalf("migrate: %v", err)
|
||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function DetailsInner() {
|
|||||||
<div className="w-full sm:w-56">
|
<div className="w-full sm:w-56">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={path}
|
value={path}
|
||||||
onChange={(v) => { setPath(v); }}
|
onChange={(v) => { setPath(v); resetPage(); }}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setPath(""); resetPage(); }}
|
onClear={() => { setPath(""); resetPage(); }}
|
||||||
placeholder="路径模糊匹配,回车应用"
|
placeholder="路径模糊匹配,回车应用"
|
||||||
@@ -148,7 +148,7 @@ function DetailsInner() {
|
|||||||
<div className="w-full sm:w-48">
|
<div className="w-full sm:w-48">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={ip}
|
value={ip}
|
||||||
onChange={setIp}
|
onChange={(v) => { setIp(v); resetPage(); }}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setIp(""); resetPage(); }}
|
onClear={() => { setIp(""); resetPage(); }}
|
||||||
placeholder="按 IP 精确筛选"
|
placeholder="按 IP 精确筛选"
|
||||||
@@ -158,7 +158,7 @@ function DetailsInner() {
|
|||||||
<div className="w-full sm:w-44">
|
<div className="w-full sm:w-44">
|
||||||
<AdminSearchField
|
<AdminSearchField
|
||||||
value={bot}
|
value={bot}
|
||||||
onChange={setBot}
|
onChange={(v) => { setBot(v); resetPage(); }}
|
||||||
onSubmit={resetPage}
|
onSubmit={resetPage}
|
||||||
onClear={() => { setBot(""); resetPage(); }}
|
onClear={() => { setBot(""); resetPage(); }}
|
||||||
placeholder="按爬虫名精确筛选"
|
placeholder="按爬虫名精确筛选"
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ export default function MediaLibrary({
|
|||||||
<Modal
|
<Modal
|
||||||
open={active !== null}
|
open={active !== null}
|
||||||
onClose={() => setActive(null)}
|
onClose={() => setActive(null)}
|
||||||
title={active?.name ?? ""}
|
title={<span className="break-all">{active?.name ?? ""}</span>}
|
||||||
|
ariaLabel={active?.name ?? ""}
|
||||||
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
||||||
maxWidthClass="max-w-2xl"
|
maxWidthClass="max-w-2xl"
|
||||||
dismissOnOverlay
|
dismissOnOverlay
|
||||||
@@ -218,6 +219,14 @@ export default function MediaLibrary({
|
|||||||
["格式", active.mime],
|
["格式", active.mime],
|
||||||
["上传者", active.uploader || "—"],
|
["上传者", active.uploader || "—"],
|
||||||
["时间", formatTime(active.uploaded_at)],
|
["时间", formatTime(active.uploaded_at)],
|
||||||
|
...(active.ref_count && active.ref_count > 1
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
"引用",
|
||||||
|
`${active.ref_count} 次上传 · 去重节省 ${formatBytes(active.size * (active.ref_count - 1))}`,
|
||||||
|
],
|
||||||
|
]
|
||||||
|
: []),
|
||||||
].map(([k, v]) => (
|
].map(([k, v]) => (
|
||||||
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
||||||
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
|
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
|
||||||
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
|
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
|
||||||
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -25,6 +25,7 @@ export default function BasicIdentityPanel({
|
|||||||
}, []);
|
}, []);
|
||||||
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
const [wordmark, setWordmark] = useState(initial.site_wordmark);
|
||||||
const [slogan, setSlogan] = useState(initial.site_slogan);
|
const [slogan, setSlogan] = useState(initial.site_slogan);
|
||||||
|
const [sloganVisible, setSloganVisible] = useState(initial.brand_slogan_visible);
|
||||||
const [desc, setDesc] = useState(initial.site_description);
|
const [desc, setDesc] = useState(initial.site_description);
|
||||||
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
|
||||||
const [kwInput, setKwInput] = useState("");
|
const [kwInput, setKwInput] = useState("");
|
||||||
@@ -32,6 +33,7 @@ export default function BasicIdentityPanel({
|
|||||||
name: initial.site_name,
|
name: initial.site_name,
|
||||||
wordmark: initial.site_wordmark,
|
wordmark: initial.site_wordmark,
|
||||||
slogan: initial.site_slogan,
|
slogan: initial.site_slogan,
|
||||||
|
sloganVisible: initial.brand_slogan_visible,
|
||||||
desc: initial.site_description,
|
desc: initial.site_description,
|
||||||
keywords: initial.site_keywords,
|
keywords: initial.site_keywords,
|
||||||
});
|
});
|
||||||
@@ -44,6 +46,7 @@ export default function BasicIdentityPanel({
|
|||||||
name.trim() !== saved.name ||
|
name.trim() !== saved.name ||
|
||||||
wordmark.trim() !== saved.wordmark ||
|
wordmark.trim() !== saved.wordmark ||
|
||||||
slogan.trim() !== saved.slogan ||
|
slogan.trim() !== saved.slogan ||
|
||||||
|
sloganVisible !== saved.sloganVisible ||
|
||||||
desc.trim() !== saved.desc ||
|
desc.trim() !== saved.desc ||
|
||||||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
|
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ export default function BasicIdentityPanel({
|
|||||||
setName(saved.name);
|
setName(saved.name);
|
||||||
setWordmark(saved.wordmark);
|
setWordmark(saved.wordmark);
|
||||||
setSlogan(saved.slogan);
|
setSlogan(saved.slogan);
|
||||||
|
setSloganVisible(saved.sloganVisible);
|
||||||
setDesc(saved.desc);
|
setDesc(saved.desc);
|
||||||
setKeywords([...saved.keywords]);
|
setKeywords([...saved.keywords]);
|
||||||
setKwInput("");
|
setKwInput("");
|
||||||
@@ -98,6 +102,7 @@ export default function BasicIdentityPanel({
|
|||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
wordmark: wordmark.trim(),
|
wordmark: wordmark.trim(),
|
||||||
slogan: slogan.trim(),
|
slogan: slogan.trim(),
|
||||||
|
sloganVisible,
|
||||||
desc: desc.trim(),
|
desc: desc.trim(),
|
||||||
keywords: [...keywords],
|
keywords: [...keywords],
|
||||||
};
|
};
|
||||||
@@ -115,6 +120,7 @@ export default function BasicIdentityPanel({
|
|||||||
site_name: snap.name,
|
site_name: snap.name,
|
||||||
site_wordmark: snap.wordmark,
|
site_wordmark: snap.wordmark,
|
||||||
site_slogan: snap.slogan,
|
site_slogan: snap.slogan,
|
||||||
|
brand_slogan_visible: snap.sloganVisible,
|
||||||
site_description: snap.desc,
|
site_description: snap.desc,
|
||||||
site_keywords: snap.keywords,
|
site_keywords: snap.keywords,
|
||||||
});
|
});
|
||||||
@@ -123,12 +129,14 @@ export default function BasicIdentityPanel({
|
|||||||
name: res.site_name,
|
name: res.site_name,
|
||||||
wordmark: res.site_wordmark,
|
wordmark: res.site_wordmark,
|
||||||
slogan: res.site_slogan,
|
slogan: res.site_slogan,
|
||||||
|
sloganVisible: res.brand_slogan_visible,
|
||||||
desc: res.site_description,
|
desc: res.site_description,
|
||||||
keywords: res.site_keywords,
|
keywords: res.site_keywords,
|
||||||
});
|
});
|
||||||
setName((v) => (v.trim() === snap.name ? res.site_name : v));
|
setName((v) => (v.trim() === snap.name ? res.site_name : v));
|
||||||
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
|
||||||
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
|
||||||
|
setSloganVisible((v) => (v === snap.sloganVisible ? res.brand_slogan_visible : v));
|
||||||
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
|
||||||
setKeywords((prev) =>
|
setKeywords((prev) =>
|
||||||
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
|
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
|
||||||
@@ -199,18 +207,49 @@ export default function BasicIdentityPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AdminField
|
<div className="admin-identity-field">
|
||||||
|
<div className="admin-field-head">
|
||||||
|
<label className="field-label" htmlFor="site-slogan">
|
||||||
|
标语
|
||||||
|
</label>
|
||||||
|
<span className="admin-field-count" aria-hidden>
|
||||||
|
{slogan.length}/{BRAND_LIMITS.slogan}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 mb-2"
|
||||||
|
title={slogan.trim() ? "在页眉站名下方显示副标题" : "先填写标语后可开启"}
|
||||||
|
>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={sloganVisible}
|
||||||
|
disabled={!slogan.trim()}
|
||||||
|
onChange={() => {
|
||||||
|
setSloganVisible((v) => !v);
|
||||||
|
clearSaveError();
|
||||||
|
}}
|
||||||
|
label="页眉显示标语"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-[12px]"
|
||||||
|
style={{ color: slogan.trim() ? "var(--ink-2)" : "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
页眉显示标语
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-field-shell">
|
||||||
|
<input
|
||||||
id="site-slogan"
|
id="site-slogan"
|
||||||
label="标语"
|
className="admin-field-input"
|
||||||
className="admin-identity-field"
|
|
||||||
maxLength={BRAND_LIMITS.slogan}
|
maxLength={BRAND_LIMITS.slogan}
|
||||||
value={slogan}
|
value={slogan}
|
||||||
onChange={(v) => {
|
onChange={(e) => {
|
||||||
setSlogan(v);
|
setSlogan(e.target.value);
|
||||||
clearSaveError();
|
clearSaveError();
|
||||||
}}
|
}}
|
||||||
placeholder="技术分享与讨论"
|
placeholder="技术分享与讨论"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<p className="admin-identity-title-preview" aria-live="polite">
|
<p className="admin-identity-title-preview" aria-live="polite">
|
||||||
浏览器标题预览 · <strong>{preview}</strong>
|
浏览器标题预览 · <strong>{preview}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { Images, Loader2 } from "lucide-react";
|
import { Images, Loader2 } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Modal from "@/components/Modal";
|
import Modal from "@/components/Modal";
|
||||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api";
|
||||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
import { normalizeBrandURL, type BrandSlot } from "@/lib/brand";
|
||||||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||||
|
|
||||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||||
@@ -13,6 +13,13 @@ const SLOT_LABEL: Record<BrandSlot, string> = {
|
|||||||
favicon: "Favicon",
|
favicon: "Favicon",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
|
||||||
|
export type BrandPickItem = {
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
attachmentId: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export default function BrandMediaPicker({
|
export default function BrandMediaPicker({
|
||||||
open,
|
open,
|
||||||
slot,
|
slot,
|
||||||
@@ -22,9 +29,9 @@ export default function BrandMediaPicker({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
slot: BrandSlot;
|
slot: BrandSlot;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onPick: (item: MediaAttachment) => void;
|
onPick: (item: BrandPickItem) => void;
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
const [items, setItems] = useState<BrandPickItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
@@ -33,16 +40,29 @@ export default function BrandMediaPicker({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
void apiMyMedia()
|
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
|
||||||
.then((res) => {
|
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
|
||||||
|
.then(([mediaRes, libRes]) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (res.error) {
|
if (mediaRes.error) {
|
||||||
setError(res.error);
|
setError(mediaRes.error);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const all = res.attachments || [];
|
const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({
|
||||||
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
|
key: `att-${a.id}`,
|
||||||
|
url: a.url,
|
||||||
|
attachmentId: a.id,
|
||||||
|
}));
|
||||||
|
const seen = new Set(out.map((i) => i.url));
|
||||||
|
for (const it of libRes?.items || []) {
|
||||||
|
if (it.category !== "brand") continue;
|
||||||
|
if (!normalizeBrandURL(slot, it.url)) continue;
|
||||||
|
if (seen.has(it.url)) continue;
|
||||||
|
seen.add(it.url);
|
||||||
|
out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null });
|
||||||
|
}
|
||||||
|
setItems(out);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setError("加载媒体库失败");
|
if (!cancelled) setError("加载媒体库失败");
|
||||||
@@ -59,7 +79,7 @@ export default function BrandMediaPicker({
|
|||||||
<Modal
|
<Modal
|
||||||
open={open}
|
open={open}
|
||||||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||||
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
maxWidthClass="max-w-lg"
|
maxWidthClass="max-w-lg"
|
||||||
dismissOnOverlay
|
dismissOnOverlay
|
||||||
@@ -73,12 +93,12 @@ export default function BrandMediaPicker({
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
<p className="meta text-[13px] py-6 text-center">媒体库还没有图片,先上传一张吧。</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onPick(item);
|
onPick(item);
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
|
import { Check, Crop, ImagePlus, Images, Link2, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import {
|
import {
|
||||||
AdminSegmented,
|
AdminSegmented,
|
||||||
AdminSettingsActions,
|
AdminSettingsActions,
|
||||||
AdminSettingsGroup,
|
AdminSettingsGroup,
|
||||||
} from "@/components/admin";
|
} from "@/components/admin";
|
||||||
|
import BrandCropModal from "@/components/BrandCropModal";
|
||||||
import BrandLockup from "@/components/BrandLockup";
|
import BrandLockup from "@/components/BrandLockup";
|
||||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
BRAND_LOGO_FITS,
|
|
||||||
BRAND_LOGO_SIZES,
|
|
||||||
brandAccept,
|
brandAccept,
|
||||||
brandAliasPath,
|
brandAliasPath,
|
||||||
brandFileOk,
|
brandFileOk,
|
||||||
|
normalizeBrandURL,
|
||||||
type BrandLogoFit,
|
type BrandLogoFit,
|
||||||
type BrandLogoSize,
|
type BrandLogoSize,
|
||||||
type BrandMark,
|
type BrandMark,
|
||||||
@@ -77,6 +77,9 @@ export default function BrandSeoPanel({
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveError, setSaveError] = useState("");
|
const [saveError, setSaveError] = useState("");
|
||||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||||
|
const [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
|
||||||
|
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
|
||||||
|
const [cropSeedUrl, setCropSeedUrl] = useState("");
|
||||||
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||||
const blobs = useRef<string[]>([]);
|
const blobs = useRef<string[]>([]);
|
||||||
const saveGen = useRef(0);
|
const saveGen = useRef(0);
|
||||||
@@ -123,6 +126,18 @@ export default function BrandSeoPanel({
|
|||||||
return draft.committed;
|
return draft.committed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCrop = (slot: BrandSlot, file: File | null, url: string) => {
|
||||||
|
setCropSlot(slot);
|
||||||
|
setCropSeedFile(file);
|
||||||
|
setCropSeedUrl(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCrop = () => {
|
||||||
|
setCropSlot(null);
|
||||||
|
setCropSeedFile(null);
|
||||||
|
setCropSeedUrl("");
|
||||||
|
};
|
||||||
|
|
||||||
const discard = () => {
|
const discard = () => {
|
||||||
[light, dark, favicon].forEach((d) => {
|
[light, dark, favicon].forEach((d) => {
|
||||||
if (d.blob) URL.revokeObjectURL(d.blob);
|
if (d.blob) URL.revokeObjectURL(d.blob);
|
||||||
@@ -237,8 +252,8 @@ export default function BrandSeoPanel({
|
|||||||
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
<img
|
||||||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
|
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(384)) : preview}
|
||||||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
|
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : undefined}
|
||||||
alt=""
|
alt=""
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -277,7 +292,14 @@ export default function BrandSeoPanel({
|
|||||||
toast(err);
|
toast(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// SVG / ICO 无需裁剪,直接走原流程
|
||||||
|
const isVector = file.name.toLowerCase().endsWith(".svg");
|
||||||
|
const isIco = file.name.toLowerCase().endsWith(".ico");
|
||||||
|
if (isVector || isIco) {
|
||||||
replaceFile(slot, file);
|
replaceFile(slot, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCrop(slot, file, "");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -289,6 +311,17 @@ export default function BrandSeoPanel({
|
|||||||
>
|
>
|
||||||
<Images size={14} /> 库
|
<Images size={14} /> 库
|
||||||
</button>
|
</button>
|
||||||
|
{preview && !/\.(svg|ico)(\?.*)?$/i.test(preview) ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line btn-sm"
|
||||||
|
disabled={saving}
|
||||||
|
title="调整画幅、铺法或重新裁剪"
|
||||||
|
onClick={() => openCrop(slot, null, preview)}
|
||||||
|
>
|
||||||
|
<Crop size={14} /> 调整
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="admin-icon-btn"
|
className="admin-icon-btn"
|
||||||
@@ -337,7 +370,7 @@ export default function BrandSeoPanel({
|
|||||||
frame
|
frame
|
||||||
name={initial.site_name}
|
name={initial.site_name}
|
||||||
wordmark={initial.site_wordmark}
|
wordmark={initial.site_wordmark}
|
||||||
slogan={initial.site_slogan}
|
slogan={initial.brand_slogan_visible ? initial.site_slogan : ""}
|
||||||
logoLight={previewOf(light)}
|
logoLight={previewOf(light)}
|
||||||
logoDark={previewOf(dark)}
|
logoDark={previewOf(dark)}
|
||||||
/>
|
/>
|
||||||
@@ -364,46 +397,9 @@ export default function BrandSeoPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{mark !== "text" ? (
|
{mark !== "text" ? (
|
||||||
<>
|
<p className="admin-brand-studio-field text-[11.5px] self-center mb-0.5" style={{ color: "var(--ink-3)" }}>
|
||||||
<div className="admin-brand-studio-field">
|
画幅与铺法在上传/调整图片的裁剪弹窗中设置
|
||||||
<span className="admin-brand-studio-kicker">比例</span>
|
</p>
|
||||||
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
|
|
||||||
{BRAND_LOGO_SIZES.map((item) => {
|
|
||||||
const on = logoSize === item.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
|
||||||
onClick={() => {
|
|
||||||
setLogoSize(item.id);
|
|
||||||
if (saveError) setSaveError("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="admin-brand-size-chip-label">{item.label}</span>
|
|
||||||
<span className="admin-brand-size-chip-px">
|
|
||||||
{item.w}×{item.h}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="admin-brand-studio-field">
|
|
||||||
<span className="admin-brand-studio-kicker">铺法</span>
|
|
||||||
<AdminSegmented
|
|
||||||
role="group"
|
|
||||||
size="sm"
|
|
||||||
ariaLabel="适配方式"
|
|
||||||
value={logoFit}
|
|
||||||
onChange={(v) => {
|
|
||||||
setLogoFit(v);
|
|
||||||
if (saveError) setSaveError("");
|
|
||||||
}}
|
|
||||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -418,11 +414,60 @@ export default function BrandSeoPanel({
|
|||||||
onClose={() => setPicker(null)}
|
onClose={() => setPicker(null)}
|
||||||
onPick={(item) => {
|
onPick={(item) => {
|
||||||
if (!picker) return;
|
if (!picker) return;
|
||||||
const [prev, set] = drafts[picker];
|
const slot = picker;
|
||||||
|
setPicker(null);
|
||||||
|
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
|
||||||
|
const brandURL = normalizeBrandURL(slot, item.url);
|
||||||
|
if (brandURL) {
|
||||||
|
const [prev, set] = drafts[slot];
|
||||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = item.url.toLowerCase();
|
||||||
|
const isVector = lower.endsWith(".svg");
|
||||||
|
const isIco = lower.endsWith(".ico");
|
||||||
|
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
|
||||||
|
if ((isVector || isIco) && item.attachmentId != null) {
|
||||||
|
const [prev, set] = drafts[slot];
|
||||||
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
|
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openCrop(slot, null, item.url);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{cropSlot ? (
|
||||||
|
<BrandCropModal
|
||||||
|
open
|
||||||
|
slot={cropSlot}
|
||||||
|
seedFile={cropSeedFile}
|
||||||
|
seedUrl={cropSeedUrl}
|
||||||
|
logoSize={logoSize}
|
||||||
|
logoFit={logoFit}
|
||||||
|
onSizeChange={(s) => {
|
||||||
|
setLogoSize(s);
|
||||||
|
if (saveError) setSaveError("");
|
||||||
|
}}
|
||||||
|
onFitChange={(f) => {
|
||||||
|
setLogoFit(f);
|
||||||
|
if (saveError) setSaveError("");
|
||||||
|
}}
|
||||||
|
onClose={closeCrop}
|
||||||
|
onSeedConsumed={() => {
|
||||||
|
setCropSeedFile(null);
|
||||||
|
setCropSeedUrl("");
|
||||||
|
}}
|
||||||
|
onCropped={(url) => {
|
||||||
|
const [, set] = drafts[cropSlot];
|
||||||
|
set((prev) => {
|
||||||
|
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||||
|
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||||
|
});
|
||||||
|
closeCrop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</AdminSettingsGroup>
|
</AdminSettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
@@ -581,6 +581,14 @@ export default function ChatRoomClient({
|
|||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
// 加载历史的在飞锁(同步 ref,滚动事件连发时 state 闭包来不及更新,见 loadMore)
|
||||||
|
const loadingMoreRef = useRef(false);
|
||||||
|
// 上翻加载完成后恢复 scrollTop:column-reverse 理论上 prepend 不移动视口,
|
||||||
|
// 但 React 重渲染期间某些浏览器/场景会重置 scrollTop,强制恢复兜底
|
||||||
|
const stabilizeScrollRef = useRef(false);
|
||||||
|
const stabilizeScrollValueRef = useRef(0);
|
||||||
|
// handleScroll 冷却:一次自动加载后 800ms 内不再触发,防链式冲顶
|
||||||
|
const lastAutoLoadRef = useRef(0);
|
||||||
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
||||||
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
||||||
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
||||||
@@ -700,6 +708,9 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const openUserCard = useCallback(
|
const openUserCard = useCallback(
|
||||||
(u: User) => {
|
(u: User) => {
|
||||||
|
// 菜单互斥:打开资料卡前关掉两个右键菜单
|
||||||
|
setCtxMenu(null);
|
||||||
|
setUserCtxMenu(null);
|
||||||
const mem = members.find((m) => m.user_id === u.id);
|
const mem = members.find((m) => m.user_id === u.id);
|
||||||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
|
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
|
||||||
},
|
},
|
||||||
@@ -810,8 +821,9 @@ export default function ChatRoomClient({
|
|||||||
if (-el.scrollTop < 120) {
|
if (-el.scrollTop < 120) {
|
||||||
scrollToBottom(true);
|
scrollToBottom(true);
|
||||||
markReadToLatest();
|
markReadToLatest();
|
||||||
} else {
|
} else if (data.sender_id !== 0) {
|
||||||
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
|
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示;
|
||||||
|
// 系统提示(禁言/解禁等管理动作)不计入「新消息」提醒
|
||||||
setNewBelowCount((c) => c + 1);
|
setNewBelowCount((c) => c + 1);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -908,41 +920,60 @@ export default function ChatRoomClient({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
|
// 在飞锁必须用 ref:滚动事件在一次上翻里连发,React 尚未重渲染时
|
||||||
|
// loadingMore(state 闭包)仍是 false,会并发连发多页请求。
|
||||||
|
if (loadingMoreRef.current) return;
|
||||||
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
|
||||||
|
loadingMoreRef.current = true;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
const el0 = listRef.current;
|
// 保存当前滚动位置,prepend 完成后恢复(防 React 重渲染导致的意外重置)
|
||||||
const prevHeight = el0?.scrollHeight ?? 0;
|
const el = listRef.current;
|
||||||
const prevTop = el0?.scrollTop ?? 0;
|
const savedScrollTop = el?.scrollTop ?? 0;
|
||||||
try {
|
try {
|
||||||
const before = messages[0].id;
|
const before = messages[0].id;
|
||||||
const res = await apiListChatMessages(roomId, before, 30);
|
const res = await apiListChatMessages(roomId, before, 30);
|
||||||
if ((res.messages || []).length > 0) {
|
if ((res.messages || []).length > 0) {
|
||||||
setMessages((prev) => [...res.messages, ...prev]);
|
// 去重兜底:防止旧闭包重复拉同一页导致重复 key
|
||||||
setHasMore(res.has_more);
|
setMessages((prev) => {
|
||||||
requestAnimationFrame(() => {
|
const seen = new Set(prev.map((p) => p.id));
|
||||||
const el = listRef.current;
|
const fresh = res.messages.filter((x) => !seen.has(x.id));
|
||||||
if (!el) return;
|
return fresh.length ? [...fresh, ...prev] : prev;
|
||||||
// column-reverse:历史插在上方,scrollTop 为负;
|
|
||||||
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
|
|
||||||
el.scrollTop = prevTop - (el.scrollHeight - prevHeight);
|
|
||||||
});
|
});
|
||||||
|
setHasMore(res.has_more);
|
||||||
|
// 标记需要在提交后恢复滚动位置
|
||||||
|
stabilizeScrollValueRef.current = savedScrollTop;
|
||||||
|
stabilizeScrollRef.current = true;
|
||||||
} else {
|
} else {
|
||||||
setHasMore(false);
|
setHasMore(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast("加载历史消息失败");
|
toast("加载历史消息失败");
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingMoreRef.current = false;
|
||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [canView, hasMore, loadingMore, messages, roomId]);
|
}, [canView, hasMore, loadingMore, messages, roomId]);
|
||||||
|
|
||||||
|
// prepend 完成后恢复滚动位置:column-reverse 理论上应稳定,但某些浏览器在
|
||||||
|
// React 提交 DOM 时会重置 scrollTop,强制恢复以保证视口不动
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!stabilizeScrollRef.current) return;
|
||||||
|
stabilizeScrollRef.current = false;
|
||||||
|
const el = listRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = stabilizeScrollValueRef.current;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = listRef.current;
|
const el = listRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
||||||
const fromBottom = -el.scrollTop;
|
const fromBottom = -el.scrollTop;
|
||||||
// 滚向上方历史:距顶部不足 60px 时加载更多
|
// 滚向上方历史:距顶部不足 60px 时加载更多;加 800ms 冷却防链式冲顶
|
||||||
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
|
const now = Date.now();
|
||||||
|
if (el.scrollHeight - el.clientHeight - fromBottom < 60 && now - lastAutoLoadRef.current > 800) {
|
||||||
|
lastAutoLoadRef.current = now;
|
||||||
loadMore();
|
loadMore();
|
||||||
}
|
}
|
||||||
if (fromBottom < 60) {
|
if (fromBottom < 60) {
|
||||||
@@ -1285,6 +1316,9 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||||||
if (m.recalled_at) return;
|
if (m.recalled_at) return;
|
||||||
|
// 菜单互斥:打开消息菜单前关掉头像菜单与资料卡
|
||||||
|
setUserCtxMenu(null);
|
||||||
|
setUserCard(null);
|
||||||
setCtxMenu({ x, y, message: m });
|
setCtxMenu({ x, y, message: m });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1368,6 +1402,10 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
const handleMute = (uid: number, nickname: string, muted: boolean) => {
|
||||||
if (!canKickMembers) return;
|
if (!canKickMembers) return;
|
||||||
|
// 入口可能是头像右键菜单或成员列表按钮:先兜底关闭浮层菜单,
|
||||||
|
// 避免低概率时序下菜单残留并与弹窗叠层(菜单 z-index 高于弹窗)
|
||||||
|
setUserCtxMenu(null);
|
||||||
|
setCtxMenu(null);
|
||||||
if (muted) {
|
if (muted) {
|
||||||
// 打开时长选择弹窗(QQ 风格)
|
// 打开时长选择弹窗(QQ 风格)
|
||||||
setMuteTarget({ uid, nickname });
|
setMuteTarget({ uid, nickname });
|
||||||
@@ -1440,7 +1478,11 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const canRecallMsg = (m: ChatMessage) => {
|
const canRecallMsg = (m: ChatMessage) => {
|
||||||
if (m.recalled_at) return false;
|
if (m.recalled_at) return false;
|
||||||
if (canModerateChat) return true;
|
if (canModerateChat) {
|
||||||
|
// 站长(站点 Owner)的消息仅全站监管可撤回,群主/群管/站点消息管无权
|
||||||
|
if (!oversee && isSiteOwner(m.sender.role)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return m.sender_id === meId && isRealMember;
|
return m.sender_id === meId && isRealMember;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1752,7 +1794,9 @@ export default function ChatRoomClient({
|
|||||||
ref={listRef}
|
ref={listRef}
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden 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 }}
|
// overflowAnchor:none:column-reverse 原点在底部,prepend 天然不移动视口;
|
||||||
|
// 关掉浏览器原生锚定,避免它对顶部内容变化做多余补偿
|
||||||
|
style={{ minHeight: 0, overflowAnchor: "none" }}
|
||||||
>
|
>
|
||||||
{!canView ? (
|
{!canView ? (
|
||||||
<div className="text-center py-12 px-4">
|
<div className="text-center py-12 px-4">
|
||||||
@@ -1796,9 +1840,14 @@ export default function ChatRoomClient({
|
|||||||
new Date(prev.created_at).getTime() <
|
new Date(prev.created_at).getTime() <
|
||||||
5 * 60_000;
|
5 * 60_000;
|
||||||
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
const reEditText = mine ? takeReEditDraft(m.id) : null;
|
||||||
|
// 发送者的群内角色(群主/管理徽标展示用;私聊不需要)
|
||||||
|
const senderRoomRole = !isDirect
|
||||||
|
? members.find((x) => x.user_id === m.sender_id)?.role
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// 系统消息(如禁言通知):居中灰字
|
// 系统消息(如禁言通知):居中灰字;禁言类通知红色突出
|
||||||
if (m.sender_id === 0) {
|
if (m.sender_id === 0) {
|
||||||
|
const isMuteNotice = m.content.includes("已被禁言");
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
@@ -1806,7 +1855,12 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
className="text-[12px] leading-relaxed text-center max-w-[90%]"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{
|
||||||
|
color: isMuteNotice
|
||||||
|
? "var(--danger)"
|
||||||
|
: "var(--ink-3)",
|
||||||
|
fontWeight: isMuteNotice ? 600 : undefined,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{m.content}
|
{m.content}
|
||||||
</p>
|
</p>
|
||||||
@@ -1881,6 +1935,9 @@ export default function ChatRoomClient({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const mem = members.find((x) => x.user_id === m.sender_id);
|
const mem = members.find((x) => x.user_id === m.sender_id);
|
||||||
|
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||||||
|
setCtxMenu(null);
|
||||||
|
setUserCard(null);
|
||||||
setUserCtxMenu({
|
setUserCtxMenu({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
@@ -1913,6 +1970,18 @@ export default function ChatRoomClient({
|
|||||||
>
|
>
|
||||||
{m.sender.nickname}
|
{m.sender.nickname}
|
||||||
</span>
|
</span>
|
||||||
|
{!isDirect && senderRoomRole === "admin" && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium px-1 rounded"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{!isDirect && (
|
{!isDirect && (
|
||||||
<>
|
<>
|
||||||
<LevelBadge level={m.sender.level} />
|
<LevelBadge level={m.sender.level} />
|
||||||
@@ -1928,23 +1997,37 @@ export default function ChatRoomClient({
|
|||||||
<div
|
<div
|
||||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] 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={{
|
style={{
|
||||||
background: "var(--panel-2)",
|
background:
|
||||||
|
"color-mix(in srgb, var(--gold) 7%, var(--panel-2))",
|
||||||
border:
|
border:
|
||||||
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
|
"1.5px dashed color-mix(in srgb, var(--gold) 55%, transparent)",
|
||||||
color: "var(--ink)",
|
color: "var(--ink)",
|
||||||
borderBottomRightRadius: mine ? 4 : undefined,
|
borderBottomRightRadius: mine ? 4 : undefined,
|
||||||
borderBottomLeftRadius: mine ? undefined : 4,
|
borderBottomLeftRadius: mine ? undefined : 4,
|
||||||
opacity: 0.92,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
className="inline-flex items-center gap-1 mb-1.5 text-[11px]"
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[10.5px] font-medium px-1.5 py-px rounded-full"
|
||||||
|
style={{
|
||||||
|
color: "var(--gold)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--gold) 14%, transparent)",
|
||||||
|
border:
|
||||||
|
"1px solid color-mix(in srgb, var(--gold) 35%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Undo2 size={10} strokeWidth={2.4} />
|
||||||
|
已撤回
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[10.5px]"
|
||||||
style={{ color: "var(--ink-3)" }}
|
style={{ color: "var(--ink-3)" }}
|
||||||
>
|
>
|
||||||
<Undo2 size={11} strokeWidth={2.4} />
|
仅管理可见
|
||||||
已撤回
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: "var(--ink-2)" }}>
|
<div className="italic" style={{ color: "var(--ink-2)" }}>
|
||||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1977,6 +2060,9 @@ export default function ChatRoomClient({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const mem = members.find((x) => x.user_id === m.sender_id);
|
const mem = members.find((x) => x.user_id === m.sender_id);
|
||||||
|
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
|
||||||
|
setCtxMenu(null);
|
||||||
|
setUserCard(null);
|
||||||
setUserCtxMenu({
|
setUserCtxMenu({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
@@ -2032,6 +2118,23 @@ export default function ChatRoomClient({
|
|||||||
)}
|
)}
|
||||||
{m.sender.nickname}
|
{m.sender.nickname}
|
||||||
</button>
|
</button>
|
||||||
|
{!isDirect && senderRoomRole === "admin" && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-medium px-1 rounded"
|
||||||
|
style={{
|
||||||
|
color: "var(--accent)",
|
||||||
|
background:
|
||||||
|
"color-mix(in srgb, var(--accent) 12%, transparent)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
管理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* 群聊与私聊都展示等级/徽章标识 */}
|
||||||
|
<LevelBadge level={m.sender.level} />
|
||||||
|
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
|
||||||
|
<BadgeChip key={ub.id} badge={ub.badge} />
|
||||||
|
))}
|
||||||
<span className="meta text-[10.5px]">
|
<span className="meta text-[10.5px]">
|
||||||
{formatTime(m.created_at)}
|
{formatTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
@@ -2434,7 +2537,8 @@ export default function ChatRoomClient({
|
|||||||
</aside>
|
</aside>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ctxMenu && (
|
{/* 弹层打开期间不再渲染右键菜单:渲染级互斥兜底,杜绝菜单悬在弹窗上方 */}
|
||||||
|
{ctxMenu && !muteTarget && !confirm && !userCard && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={ctxMenu.x}
|
x={ctxMenu.x}
|
||||||
y={ctxMenu.y}
|
y={ctxMenu.y}
|
||||||
@@ -2443,7 +2547,7 @@ export default function ChatRoomClient({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userCtxMenu && (
|
{userCtxMenu && !muteTarget && !confirm && !userCard && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={userCtxMenu.x}
|
x={userCtxMenu.x}
|
||||||
y={userCtxMenu.y}
|
y={userCtxMenu.y}
|
||||||
|
|||||||
@@ -237,7 +237,11 @@ body {
|
|||||||
/* 亚像素渲染在 Windows 高分屏更实;强制 grayscale 会让 400/500 中文偏虚 */
|
/* 亚像素渲染在 Windows 高分屏更实;强制 grayscale 会让 400/500 中文偏虚 */
|
||||||
-webkit-font-smoothing: auto;
|
-webkit-font-smoothing: auto;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
font-synthesis: none; /* 禁止伪粗,避免缺档字重糊成一团 */
|
/* 长手属性只禁伪粗(缺档字重糊成一团);放开伪斜体,让 em/斜体按钮生效。
|
||||||
|
首行兜底:不支持长手属性的老浏览器维持全禁。 */
|
||||||
|
font-synthesis: none;
|
||||||
|
font-synthesis-weight: none;
|
||||||
|
font-synthesis-style: auto;
|
||||||
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
|
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4699,7 +4703,9 @@ a.j13-mention-token:hover {
|
|||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
}
|
}
|
||||||
.admin-brand-swatch img {
|
.admin-brand-swatch img {
|
||||||
max-height: 3.5rem;
|
/* 按槽位高度铺满,宽度按比例自适应;超宽时由 max-width + contain 收边 */
|
||||||
|
height: 100%;
|
||||||
|
width: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@@ -5144,6 +5150,15 @@ a.j13-mention-token:hover {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.j13-brand-slot[data-size="auto"] {
|
||||||
|
width: auto;
|
||||||
|
max-width: 176px;
|
||||||
|
}
|
||||||
|
.j13-brand-slot[data-size="auto"] > .j13-brand-logo {
|
||||||
|
width: auto;
|
||||||
|
max-width: 176px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.j13-brand-slot[data-frame="1"] {
|
.j13-brand-slot[data-frame="1"] {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
background: color-mix(in srgb, var(--panel) 70%, transparent);
|
||||||
@@ -8271,6 +8286,248 @@ a.j13-mention-token:hover {
|
|||||||
--entry-title-size: 1.4rem;
|
--entry-title-size: 1.4rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----- 正文短代码:[details] 折叠 / [bilibili][youtube] 视频 / ==高亮== / [kbd] 键帽 ----- */
|
||||||
|
.prose-content .j13-md-details {
|
||||||
|
margin: 1rem 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--panel);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details > summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
color: var(--ink);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details > summary:hover {
|
||||||
|
background: var(--tint);
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details[open] > summary {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details .j13-md-details-caret {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--ink-3);
|
||||||
|
transition: transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details[open] .j13-md-details-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details-body {
|
||||||
|
padding: 0.35rem 0.9rem 0.6rem;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details-body > :first-child {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
.prose-content .j13-md-details-body > :last-child {
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 视频嵌入:16:9 容器 + 懒加载 iframe 铺满 */
|
||||||
|
.j13-md-video {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
margin: 1rem 0;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--tint);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.j13-md-video iframe {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==高亮==:荧光笔。UA 默认黄底黑字在暗色下刺眼,显式覆盖 */
|
||||||
|
.prose-content mark {
|
||||||
|
background: color-mix(in srgb, #fde047 55%, transparent);
|
||||||
|
color: inherit;
|
||||||
|
padding: 0.05em 0.25em;
|
||||||
|
border-radius: 4px;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
.dark .prose-content mark {
|
||||||
|
background: color-mix(in srgb, #facc15 30%, transparent);
|
||||||
|
color: #fef9c3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* [kbd] 键帽:等宽 + 下边加厚模拟按键立体 */
|
||||||
|
.prose-content kbd {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1em 0.45em;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.45;
|
||||||
|
vertical-align: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑器「文字颜色」弹窗:前景/背景两行,每行预设 + 色轮 + hex 输入 */
|
||||||
|
.j13-md-color-popover {
|
||||||
|
width: 288px;
|
||||||
|
}
|
||||||
|
.j13-md-color-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.j13-md-color-row-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.j13-md-color-row-items {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.j13-md-color-none {
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.j13-md-color-none.is-active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.j13-md-color-dot {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.j13-md-color-dot.is-active {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
/* 自定义取色器触发按钮:彩虹渐变底 + 吸管图标 */
|
||||||
|
.j13-md-color-wheel {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line-2);
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #fff;
|
||||||
|
background: conic-gradient(
|
||||||
|
from 210deg,
|
||||||
|
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.j13-md-color-wheel svg {
|
||||||
|
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.65));
|
||||||
|
}
|
||||||
|
.j13-md-color-wheel:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.j13-md-color-wheel.is-active {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.j13-md-color-hex {
|
||||||
|
flex: 1 1 90px;
|
||||||
|
min-width: 90px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* 内联 HSV 取色器:在弹层文档流内展开,只撑开不浮层,不遮挡预览 */
|
||||||
|
.j13-md-hsv {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.j13-md-hsv-sv {
|
||||||
|
position: relative;
|
||||||
|
height: 110px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: crosshair;
|
||||||
|
touch-action: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.j13-md-hsv-sv-thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.j13-md-hsv-hue {
|
||||||
|
position: relative;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
touch-action: none;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.j13-md-hsv-hue-thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 10px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.35);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* 实时预览置于弹层顶部:取色器在行内展开时始终可见,未选背景时透明棋盘格 */
|
||||||
|
.j13-md-color-preview {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 7px 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background:
|
||||||
|
repeating-conic-gradient(var(--tint) 0% 25%, transparent 0% 50%) 0 0 / 14px 14px;
|
||||||
|
}
|
||||||
|
.j13-md-color-preview span {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
||||||
.prose-content h2 {
|
.prose-content h2 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
groupSections,
|
groupSections,
|
||||||
type ChapterNav,
|
type ChapterNav,
|
||||||
} from "@/lib/librarySections";
|
} from "@/lib/librarySections";
|
||||||
import { extractToc, estimateReadingMinutes, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
|
import { extractToc, estimateReadingMinutes, formatReadingDuration, markdownHeadingToc, buildTocFromSegments } from "@/lib/toc";
|
||||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||||
import { wrapImplicitEntries } from "@/lib/entryBlocks";
|
import { wrapImplicitEntries } from "@/lib/entryBlocks";
|
||||||
import PostToc, { type TocItem } from "@/components/PostToc";
|
import PostToc, { type TocItem } from "@/components/PostToc";
|
||||||
@@ -223,7 +223,7 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
|
|||||||
{current.title}
|
{current.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="meta mt-0.5 text-[10.5px] leading-tight">
|
<p className="meta mt-0.5 text-[10.5px] leading-tight">
|
||||||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
第 {nav.index + 1} / {nav.total} 章 · 约 {formatReadingDuration(chapterMinutes)} 读完
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -265,7 +265,7 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
|
|||||||
<header>
|
<header>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="meta text-[12px]">
|
<p className="meta text-[12px]">
|
||||||
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
|
第 {nav.index + 1} / {nav.total} 章 · 约 {formatReadingDuration(chapterMinutes)} 读完
|
||||||
</p>
|
</p>
|
||||||
<BookSearch
|
<BookSearch
|
||||||
slug={d.slug}
|
slug={d.slug}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import AcceptedAnswerCard from "@/components/AcceptedAnswerCard";
|
|||||||
import { canModerateBoard } from "@/lib/roles";
|
import { canModerateBoard } from "@/lib/roles";
|
||||||
import { IconComment, IconEye } from "@/components/Icons";
|
import { IconComment, IconEye } from "@/components/Icons";
|
||||||
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
import { normalizePostType, postTypeLabel, contentAccessLabel, typeStatusLabel, typeStatusClass } from "@/lib/postMeta";
|
||||||
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
|
import { extractToc, estimateReadingMinutes, formatReadingDuration, markdownHeadingToc } from "@/lib/toc";
|
||||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
|
import { absoluteCanonical, canonicalPage } from "@/lib/siteUrl";
|
||||||
import { postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
|
import { postPath, userPath, type UrlStyle } from "@/lib/urlStyle";
|
||||||
@@ -450,7 +450,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
|
<p className="truncate text-[13px] font-bold leading-tight" style={{ color: "var(--ink)" }}>
|
||||||
{post.title}
|
{post.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="meta mt-0.5 text-[10.5px] leading-tight">约 {readingMinutes} 分钟读完</p>
|
<p className="meta mt-0.5 text-[10.5px] leading-tight">约 {formatReadingDuration(readingMinutes)} 读完</p>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
left={leftRailNode ? { label: "导航", content: leftRailNode } : undefined}
|
left={leftRailNode ? { label: "导航", content: leftRailNode } : undefined}
|
||||||
@@ -599,8 +599,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
|||||||
<IconComment size={14} /> {post.comment_count} 评论
|
<IconComment size={14} /> {post.comment_count} 评论
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${readingMinutes} 分钟`}>
|
<span className="inline-flex items-center gap-1.5" aria-label={`阅读约需 ${formatReadingDuration(readingMinutes)}`}>
|
||||||
<Clock size={14} /> 约 {readingMinutes} 分钟读完
|
<Clock size={14} /> 约 {formatReadingDuration(readingMinutes)} 读完
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
|
<p className="meta w-full text-[12px] sm:w-auto sm:ml-auto">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { UserPlus } from "lucide-react";
|
import { UserPlus } from "lucide-react";
|
||||||
@@ -44,8 +44,33 @@ export default function RegisterClient({
|
|||||||
const [notice, setNotice] = useState(registerNotice);
|
const [notice, setNotice] = useState(registerNotice);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [sentMessage, setSentMessage] = useState("");
|
const [sentMessage, setSentMessage] = useState("");
|
||||||
|
// 重发倒计时(秒);>0 期间按钮锁定不可点
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const countdownTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const verifyEmail = initialVerifyEmail;
|
const verifyEmail = initialVerifyEmail;
|
||||||
|
|
||||||
|
// 卸载时清理倒计时,避免泄漏
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (countdownTimer.current) clearInterval(countdownTimer.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCountdown = () => {
|
||||||
|
if (countdownTimer.current) clearInterval(countdownTimer.current);
|
||||||
|
setCountdown(60);
|
||||||
|
countdownTimer.current = setInterval(() => {
|
||||||
|
setCountdown((c) => {
|
||||||
|
if (c <= 1) {
|
||||||
|
if (countdownTimer.current) clearInterval(countdownTimer.current);
|
||||||
|
countdownTimer.current = null;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return c - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
// 已登录用户不必再注册,直接回首页
|
// 已登录用户不必再注册,直接回首页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasAuthCookieHint()) return;
|
if (!hasAuthCookieHint()) return;
|
||||||
@@ -166,8 +191,9 @@ export default function RegisterClient({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-line shrink-0"
|
className="btn btn-line shrink-0"
|
||||||
disabled={sending || !email}
|
disabled={sending || countdown > 0 || !email}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
// 点击即锁定,防止重复发送
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -176,6 +202,7 @@ export default function RegisterClient({
|
|||||||
purpose: "register",
|
purpose: "register",
|
||||||
});
|
});
|
||||||
setSentMessage(r.message);
|
setSentMessage(r.message);
|
||||||
|
startCountdown();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -183,7 +210,11 @@ export default function RegisterClient({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sending ? "正在排队…" : "发送验证码"}
|
{sending
|
||||||
|
? "正在排队…"
|
||||||
|
: countdown > 0
|
||||||
|
? `${countdown}s 后可重发`
|
||||||
|
: "发送验证码"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{sentMessage && (
|
{sentMessage && (
|
||||||
|
|||||||
502
frontend/components/BrandCropModal.tsx
Normal file
502
frontend/components/BrandCropModal.tsx
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import Cropper, { type Area } from "react-easy-crop";
|
||||||
|
import "react-easy-crop/react-easy-crop.css";
|
||||||
|
import {
|
||||||
|
UploadCloud, ZoomIn, Check, Loader2, Images, RotateCcw, Crop,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiMyMedia, apiUploadBrand,
|
||||||
|
type MediaAttachment,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage";
|
||||||
|
import Modal from "@/components/Modal";
|
||||||
|
import BrandLockup from "@/components/BrandLockup";
|
||||||
|
import { AdminSegmented } from "@/components/admin";
|
||||||
|
import {
|
||||||
|
BRAND_LOGO_FITS,
|
||||||
|
BRAND_LOGO_SIZES,
|
||||||
|
BRAND_LOGO_SIZE_ASPECT,
|
||||||
|
brandFileOk,
|
||||||
|
brandLogoBox,
|
||||||
|
suggestLogoSize,
|
||||||
|
type BrandLogoFit,
|
||||||
|
type BrandLogoSize,
|
||||||
|
type BrandSlot,
|
||||||
|
} from "@/lib/brand";
|
||||||
|
|
||||||
|
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
|
||||||
|
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface BrandCropModalProps {
|
||||||
|
open: boolean;
|
||||||
|
slot: BrandSlot;
|
||||||
|
onClose: () => void;
|
||||||
|
/** 裁剪完成后返回新 URL */
|
||||||
|
onCropped: (url: string) => void;
|
||||||
|
/** 外部传入的文件:打开后直接进入裁剪 */
|
||||||
|
seedFile?: File | null;
|
||||||
|
/** 外部传入的 URL(如媒体库图片):打开后直接进入裁剪 */
|
||||||
|
seedUrl?: string;
|
||||||
|
/** 已消费 seedFile / seedUrl */
|
||||||
|
onSeedConsumed?: () => void;
|
||||||
|
/** 当前显示画幅(与工具栏同源,受控) */
|
||||||
|
logoSize?: BrandLogoSize;
|
||||||
|
/** 当前铺法(与工具栏同源,受控) */
|
||||||
|
logoFit?: BrandLogoFit;
|
||||||
|
/** 画幅变更回调(同步到父组件工具栏) */
|
||||||
|
onSizeChange?: (size: BrandLogoSize) => void;
|
||||||
|
/** 铺法变更回调(同步到父组件工具栏) */
|
||||||
|
onFitChange?: (fit: BrandLogoFit) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BrandCropModal({
|
||||||
|
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
|
||||||
|
logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange,
|
||||||
|
}: BrandCropModalProps) {
|
||||||
|
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||||||
|
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||||
|
const [srcIsBlob, setSrcIsBlob] = useState(false);
|
||||||
|
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
// 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅)
|
||||||
|
const detectedSrcRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪
|
||||||
|
const isFavicon = slot === "favicon";
|
||||||
|
const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize;
|
||||||
|
const isAuto = effectiveSize === "auto";
|
||||||
|
const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize];
|
||||||
|
|
||||||
|
// 打开时拉取媒体库
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setError("");
|
||||||
|
apiMyMedia()
|
||||||
|
.then((res) => setMedia(res.attachments || []))
|
||||||
|
.catch(() => setMedia([]));
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次
|
||||||
|
useEffect(() => {
|
||||||
|
if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return;
|
||||||
|
detectedSrcRef.current = imageSrc;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight));
|
||||||
|
};
|
||||||
|
img.src = imageSrc;
|
||||||
|
}, [imageSrc, isFavicon, onSizeChange]);
|
||||||
|
|
||||||
|
// 画幅变化时重置构图位置(换画幅=重新构图;缩放保留)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
}, [effectiveSize, open]);
|
||||||
|
|
||||||
|
// 关闭时复位并释放本地 blob URL
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setImageSrc((prev) => {
|
||||||
|
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
setSrcIsBlob(false);
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
setZoom(1);
|
||||||
|
setPixelCrop(null);
|
||||||
|
setError("");
|
||||||
|
setBusy(false);
|
||||||
|
setDragOver(false);
|
||||||
|
detectedSrcRef.current = null;
|
||||||
|
}
|
||||||
|
}, [open, slot]);
|
||||||
|
|
||||||
|
const onCropComplete = useCallback((_: Area, areaPixels: Area) => {
|
||||||
|
setPixelCrop(areaPixels);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enterCrop = useCallback((src: string, isBlob: boolean) => {
|
||||||
|
setError("");
|
||||||
|
setImageSrc((prev) => {
|
||||||
|
if (prev && prev.startsWith("blob:")) URL.revokeObjectURL(prev);
|
||||||
|
return src;
|
||||||
|
});
|
||||||
|
setSrcIsBlob(isBlob);
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
setZoom(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** 校验并进入裁剪;返回错误文案,成功返回空串 */
|
||||||
|
const acceptImageFile = useCallback((file: File): string => {
|
||||||
|
const err = brandFileOk(slot, file);
|
||||||
|
if (err) return err;
|
||||||
|
if (file.size > SOURCE_MAX_BYTES) {
|
||||||
|
return "原图不能超过 15MB,裁剪后的品牌图需在 2MB 以内";
|
||||||
|
}
|
||||||
|
enterCrop(URL.createObjectURL(file), true);
|
||||||
|
return "";
|
||||||
|
}, [enterCrop, slot]);
|
||||||
|
|
||||||
|
// 外部传入:弹窗打开后立刻进入裁剪
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (seedFile) {
|
||||||
|
const errMsg = acceptImageFile(seedFile);
|
||||||
|
if (errMsg) setError(errMsg);
|
||||||
|
onSeedConsumed?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (seedUrl) {
|
||||||
|
enterCrop(seedUrl, false);
|
||||||
|
onSeedConsumed?.();
|
||||||
|
}
|
||||||
|
}, [open, seedFile, seedUrl, acceptImageFile, enterCrop, onSeedConsumed]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击媒体库图片:直接进入裁剪界面
|
||||||
|
* 品牌图不做"直接选用",统一走裁剪后上传,保证比例一致
|
||||||
|
*/
|
||||||
|
const handlePick = (item: MediaAttachment) => {
|
||||||
|
if (busy) return;
|
||||||
|
enterCrop(item.url, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
const errMsg = acceptImageFile(file);
|
||||||
|
if (errMsg) setError(errMsg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDropZoneDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!busy) setDragOver(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDropZoneDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDropZoneDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragOver(false);
|
||||||
|
if (busy) return;
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const errMsg = acceptImageFile(file);
|
||||||
|
if (errMsg) setError(errMsg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const backToLibrary = () => {
|
||||||
|
if (imageSrc && srcIsBlob) URL.revokeObjectURL(imageSrc);
|
||||||
|
setImageSrc(null);
|
||||||
|
setSrcIsBlob(false);
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmUpload = async () => {
|
||||||
|
if (!imageSrc || busy) return;
|
||||||
|
if (!isAuto && !pixelCrop) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
// 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出
|
||||||
|
const blob = isAuto
|
||||||
|
? await getFullBrandWebp(imageSrc)
|
||||||
|
: await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!);
|
||||||
|
const file = new File([blob], "brand.webp", { type: "image/webp" });
|
||||||
|
const url = await apiUploadBrand(slot, file);
|
||||||
|
onCropped(url);
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "上传失败,请重试");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotLabel = slot === "logo_light" ? "浅色 Logo" : slot === "logo_dark" ? "深色 Logo" : "Favicon";
|
||||||
|
// 裁剪区底色与槽位主题相反:浅色主题 Logo(通常为深色图)用浅底,深色主题 Logo 用深底
|
||||||
|
const cropBg = slot === "logo_dark" ? "#0c0d12" : "#f2f3f5";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title={imageSrc ? `裁剪${slotLabel}` : `上传${slotLabel}`}
|
||||||
|
onClose={onClose}
|
||||||
|
busy={busy}
|
||||||
|
dismissOnOverlay={!busy}
|
||||||
|
maxWidthClass="max-w-[440px]"
|
||||||
|
panelClassName="p-5"
|
||||||
|
ariaLabel={`上传${slotLabel}`}
|
||||||
|
>
|
||||||
|
{error && <div className="alert-error mb-4">{error}</div>}
|
||||||
|
|
||||||
|
{!imageSrc ? (
|
||||||
|
<>
|
||||||
|
{/* 选择新图片:点击或拖放 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={onDropZoneDragOver}
|
||||||
|
onDragLeave={onDropZoneDragLeave}
|
||||||
|
onDrop={onDropZoneDrop}
|
||||||
|
className="w-full flex flex-col items-center justify-center gap-2 rounded-xl py-7 px-4 text-center transition-colors hover:border-[var(--accent)]"
|
||||||
|
style={{
|
||||||
|
border: `1.5px dashed ${dragOver ? "var(--accent)" : "var(--line-2)"}`,
|
||||||
|
background: dragOver
|
||||||
|
? "color-mix(in srgb, var(--accent) 10%, var(--panel-2))"
|
||||||
|
: "var(--panel-2)",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-flex w-11 h-11 items-center justify-center rounded-full"
|
||||||
|
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<UploadCloud size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[13.5px] font-bold" style={{ color: "var(--ink)" }}>
|
||||||
|
{dragOver ? "松开即可开始裁剪" : "点击选择或拖放图片"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[12px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||||
|
支持 PNG / WebP / SVG{slot === "favicon" ? " / ICO" : ""},裁剪后统一转为 WebP,大小不超过 2MB
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={slot === "favicon" ? "image/png,image/webp,image/svg+xml,.ico,image/x-icon" : "image/png,image/webp,image/svg+xml"}
|
||||||
|
className="hidden"
|
||||||
|
onChange={onFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 媒体库 */}
|
||||||
|
{media.length > 0 && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<p
|
||||||
|
className="flex items-center gap-1.5 text-[12px] font-bold tracking-[0.04em] mb-2.5"
|
||||||
|
style={{ color: "var(--ink-3)" }}
|
||||||
|
>
|
||||||
|
<Images size={13} /> 我的图片(点击后进入裁剪)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{media.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePick(item)}
|
||||||
|
disabled={busy}
|
||||||
|
className="relative group aspect-square rounded-lg overflow-hidden transition-transform hover:scale-105 disabled:cursor-default"
|
||||||
|
style={{ boxShadow: "0 0 0 1px var(--line)" }}
|
||||||
|
data-tip="裁剪后设为品牌图"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={item.url}
|
||||||
|
alt="媒体库图片"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--ink) 45%, transparent)", color: "#fff" }}
|
||||||
|
>
|
||||||
|
<Crop size={13} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);favicon 固定方形不展示 */}
|
||||||
|
{!isFavicon && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1.5">
|
||||||
|
<span className="text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>画幅</span>
|
||||||
|
<span className="text-[11px]" style={{ color: "var(--ink-3)" }}>已按图片比例推荐,可手动调整</span>
|
||||||
|
</div>
|
||||||
|
<div className="admin-brand-size-row" role="group" aria-label="显示画幅">
|
||||||
|
{BRAND_LOGO_SIZES.map((item) => {
|
||||||
|
const on = effectiveSize === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
||||||
|
onClick={() => onSizeChange?.(item.id)}
|
||||||
|
>
|
||||||
|
<span className="admin-brand-size-chip-label">{item.label}</span>
|
||||||
|
<span className="admin-brand-size-chip-px">
|
||||||
|
{item.w ? `${item.w}×${item.h}` : "自适应"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */}
|
||||||
|
{isAuto ? (
|
||||||
|
<div
|
||||||
|
className="relative w-full h-72 rounded-xl overflow-hidden flex items-center justify-center p-4"
|
||||||
|
style={{ background: cropBg }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imageSrc || ""}
|
||||||
|
alt="整图预览"
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="absolute left-2.5 top-2.5 text-[11px] px-1.5 py-0.5 rounded"
|
||||||
|
style={{
|
||||||
|
background: "rgba(0,0,0,.45)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
整图使用,不裁剪
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="relative w-full h-72 rounded-xl overflow-hidden"
|
||||||
|
style={{ background: cropBg }}
|
||||||
|
>
|
||||||
|
<Cropper
|
||||||
|
image={imageSrc}
|
||||||
|
crop={crop}
|
||||||
|
zoom={zoom}
|
||||||
|
aspect={cropAspect}
|
||||||
|
cropShape="rect"
|
||||||
|
showGrid={true}
|
||||||
|
onCropChange={setCrop}
|
||||||
|
onZoomChange={setZoom}
|
||||||
|
onCropComplete={onCropComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 缩放 + 重置 */}
|
||||||
|
<div className="flex items-center gap-3 mt-4">
|
||||||
|
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
value={zoom}
|
||||||
|
onChange={(e) => setZoom(Number(e.target.value))}
|
||||||
|
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
|
||||||
|
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
|
||||||
|
aria-label="缩放"
|
||||||
|
/>
|
||||||
|
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setZoom(1);
|
||||||
|
setCrop({ x: 0, y: 0 });
|
||||||
|
}}
|
||||||
|
className="btn btn-line btn-sm shrink-0"
|
||||||
|
title="重置缩放与位置"
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> 重置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */}
|
||||||
|
{!isFavicon && (
|
||||||
|
isAuto ? (
|
||||||
|
<p className="text-[11.5px] mt-3" style={{ color: "var(--ink-3)" }}>
|
||||||
|
原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-[12px] font-semibold shrink-0" style={{ color: "var(--ink-3)" }}>铺法</span>
|
||||||
|
<AdminSegmented
|
||||||
|
role="group"
|
||||||
|
size="sm"
|
||||||
|
ariaLabel="适配方式"
|
||||||
|
value={logoFit}
|
||||||
|
onChange={(v) => onFitChange?.(v)}
|
||||||
|
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11.5px] mt-1.5" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{logoFit === "contain"
|
||||||
|
? "适应:完整显示图片,比例不匹配时留空(推荐)"
|
||||||
|
: logoFit === "cover"
|
||||||
|
? "填充:裁切填满画幅"
|
||||||
|
: logoFit === "repeat"
|
||||||
|
? "平铺:重复铺满画幅"
|
||||||
|
: "拉伸:变形填满画幅"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
|
||||||
|
<div
|
||||||
|
className="mt-3 rounded-xl px-3.5 py-2.5 flex items-center min-h-[56px]"
|
||||||
|
style={{
|
||||||
|
background: cropBg,
|
||||||
|
...(slot === "logo_dark"
|
||||||
|
? ({ "--ink": "#f5f6f8", "--ink-3": "rgba(255,255,255,.55)" } as CSSProperties)
|
||||||
|
: null),
|
||||||
|
}}
|
||||||
|
aria-label="页眉预览"
|
||||||
|
>
|
||||||
|
<BrandLockup
|
||||||
|
mode="image_text"
|
||||||
|
size={effectiveSize}
|
||||||
|
fit={logoFit}
|
||||||
|
name="站点名称"
|
||||||
|
wordmark=""
|
||||||
|
logoLight={imageSrc || ""}
|
||||||
|
logoDark=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 输出说明 + 操作 */}
|
||||||
|
<div className="flex items-center justify-between mt-5">
|
||||||
|
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
{isAuto
|
||||||
|
? "输出 WebP · 高度 40px,宽度随原图比例"
|
||||||
|
: `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
|
||||||
|
<RotateCcw size={13.5} /> 重选
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={confirmUpload} disabled={busy} className="btn btn-primary btn-sm">
|
||||||
|
{busy ? <Loader2 size={14} className="animate-spin" /> : <Check size={14} />}
|
||||||
|
{busy ? "上传中" : "确认上传"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ export default function BrandLockup({
|
|||||||
const showImage = mode !== "text";
|
const showImage = mode !== "text";
|
||||||
const showText = mode !== "image";
|
const showText = mode !== "image";
|
||||||
const box = brandLogoBox(size);
|
const box = brandLogoBox(size);
|
||||||
|
// auto 图槽宽度随图(可达最宽画幅量级),按 176 取密度变体保证清晰
|
||||||
|
const cssPx = size === "auto" ? 176 : Math.max(box.w, box.h);
|
||||||
const src = logoLight || logoDark;
|
const src = logoLight || logoDark;
|
||||||
const both = !!(logoLight && logoDark && logoLight !== logoDark);
|
const both = !!(logoLight && logoDark && logoLight !== logoDark);
|
||||||
const useBox = showImage && (!!src || frame);
|
const useBox = showImage && (!!src || frame);
|
||||||
@@ -41,16 +43,17 @@ export default function BrandLockup({
|
|||||||
className="j13-brand-slot"
|
className="j13-brand-slot"
|
||||||
data-frame={frame ? "1" : undefined}
|
data-frame={frame ? "1" : undefined}
|
||||||
data-fit={src ? fit : undefined}
|
data-fit={src ? fit : undefined}
|
||||||
style={{ width: box.w, height: box.h }}
|
data-size={size}
|
||||||
|
style={{ width: box.w || undefined, height: box.h }}
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
both ? (
|
both ? (
|
||||||
<>
|
<>
|
||||||
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
|
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={cssPx} />
|
||||||
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
|
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={cssPx} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
|
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={cssPx} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Bubble h={box.h} label={showText ? undefined : label} />
|
<Bubble h={box.h} label={showText ? undefined : label} />
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export default function Header({
|
|||||||
brandMark = "image_text",
|
brandMark = "image_text",
|
||||||
brandLogoSize = "sq",
|
brandLogoSize = "sq",
|
||||||
brandLogoFit = "contain",
|
brandLogoFit = "contain",
|
||||||
|
slogan = "",
|
||||||
|
sloganVisible = true,
|
||||||
}: {
|
}: {
|
||||||
initialUser: User | null;
|
initialUser: User | null;
|
||||||
initialUnread?: number;
|
initialUnread?: number;
|
||||||
@@ -74,6 +76,8 @@ export default function Header({
|
|||||||
brandMark?: BrandMark;
|
brandMark?: BrandMark;
|
||||||
brandLogoSize?: BrandLogoSize;
|
brandLogoSize?: BrandLogoSize;
|
||||||
brandLogoFit?: BrandLogoFit;
|
brandLogoFit?: BrandLogoFit;
|
||||||
|
slogan?: string;
|
||||||
|
sloganVisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -274,6 +278,7 @@ export default function Header({
|
|||||||
fit={brandLogoFit}
|
fit={brandLogoFit}
|
||||||
name={siteName}
|
name={siteName}
|
||||||
wordmark={wordmark}
|
wordmark={wordmark}
|
||||||
|
slogan={sloganVisible ? slogan : ""}
|
||||||
logoLight={logoLight}
|
logoLight={logoLight}
|
||||||
logoDark={logoDark}
|
logoDark={logoDark}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -36,17 +36,17 @@ function TextCover({ title }: { title: string }) {
|
|||||||
style={{ "--cover-h": coverHue(name) } as CSSProperties}
|
style={{ "--cover-h": coverHue(name) } as CSSProperties}
|
||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
|
<span className="j13-library-title text-[17px] font-bold leading-snug text-center line-clamp-3 break-words tracking-wide">
|
||||||
{name}
|
{name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExtChip({ ext }: { ext: string }) {
|
function ExtChip({ ext, className = "" }: { ext: string; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
|
className={`text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide ${className}`}
|
||||||
style={{
|
style={{
|
||||||
color: "var(--accent)",
|
color: "var(--accent)",
|
||||||
background: "var(--accent-soft)",
|
background: "var(--accent-soft)",
|
||||||
@@ -65,33 +65,45 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
|
|||||||
const excerpt = plainExcerpt(doc.description ?? "");
|
const excerpt = plainExcerpt(doc.description ?? "");
|
||||||
return (
|
return (
|
||||||
<Link href={href} className="group flex flex-col min-w-0">
|
<Link href={href} className="group flex flex-col min-w-0">
|
||||||
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
|
{/* 横版封面框(宽大于高):模糊底衬完整显示任意比例图 + 悬停浮出简介 */}
|
||||||
<div
|
<div
|
||||||
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
|
className="relative w-full aspect-[3/2] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
|
||||||
style={{ background: "var(--panel-2)" }}
|
style={{ background: "var(--panel-2)" }}
|
||||||
>
|
>
|
||||||
{doc.cover_url ? (
|
{doc.cover_url ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
<>
|
||||||
|
{/* 底层:同图放大模糊填充,竖版/非 3:2 封面不露黑边 */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={variantURL(doc.cover_url, 384)}
|
src={variantURL(doc.cover_url, 384)}
|
||||||
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
|
alt=""
|
||||||
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
|
aria-hidden
|
||||||
alt={doc.title}
|
className="absolute inset-0 w-full h-full object-cover scale-125 blur-lg opacity-70"
|
||||||
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
|
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
{/* 上层:完整封面居中显示 */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={variantURL(doc.cover_url, 384)}
|
||||||
|
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
|
||||||
|
sizes="(max-width:640px) 100vw, (max-width:1024px) 46vw, (max-width:1280px) 30vw, 300px"
|
||||||
|
alt={doc.title}
|
||||||
|
className="absolute inset-0 w-full h-full object-contain transition-transform duration-300 group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TextCover title={doc.title} />
|
<TextCover title={doc.title} />
|
||||||
)}
|
)}
|
||||||
{/* 书脊/书口光影,营造实体书体积感 */}
|
{/* 边缘内阴影,营造卡片体积感 */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-0 rounded-[10px] pointer-events-none"
|
className="absolute inset-0 rounded-[inherit] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
background:
|
|
||||||
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
|
|
||||||
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
|
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -103,7 +115,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
|
|||||||
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
|
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
|
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-4">{excerpt}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +165,7 @@ function DocCard({ doc, href }: { doc: LibraryDocListItem; href: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
|
* 书库目录:横版封面卡片网格(手机端单列全宽)+ 书名/作者即时过滤(目录接口一次返回全部已发布
|
||||||
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
|
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
|
||||||
*/
|
*/
|
||||||
export default function LibrarySearchGrid({
|
export default function LibrarySearchGrid({
|
||||||
@@ -230,7 +242,7 @@ export default function LibrarySearchGrid({
|
|||||||
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
|
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
|
<div className="mt-8 grid grid-cols-1 gap-x-5 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||||
{filtered.map((d) => (
|
{filtered.map((d) => (
|
||||||
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
|
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type { HideKind } from "@/lib/hideBlocks";
|
|||||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||||
import MdEntries from "@/components/markdown/MdEntries";
|
import MdEntries from "@/components/markdown/MdEntries";
|
||||||
|
import MdDetails from "@/components/markdown/MdDetails";
|
||||||
|
import MdVideo from "@/components/markdown/MdVideo";
|
||||||
|
|
||||||
type MarkdownBodyProps = {
|
type MarkdownBodyProps = {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -75,6 +77,21 @@ export default async function MarkdownBody({
|
|||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (seg.type === "details") {
|
||||||
|
const inner = seg.body.trim()
|
||||||
|
? await renderMarkdownToReact(expandDyEmojiShortcodes(seg.body))
|
||||||
|
: null;
|
||||||
|
nodes.push(
|
||||||
|
<MdDetails key={`details-${i}`} title={seg.title}>
|
||||||
|
{inner}
|
||||||
|
</MdDetails>
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seg.type === "video") {
|
||||||
|
nodes.push(<MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const kind = seg.kind as HideKind;
|
const kind = seg.kind as HideKind;
|
||||||
if (seg.locked) {
|
if (seg.locked) {
|
||||||
nodes.push(
|
nodes.push(
|
||||||
|
|||||||
@@ -3,11 +3,14 @@
|
|||||||
import ReactMarkdown, { type Components } from "react-markdown";
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
|
import { mergeMarkdownComponents } from "@/components/markdown/mergeComponents";
|
||||||
|
import { remarkInlineShortcodes } from "@/components/markdown/remarkInlineShortcodes";
|
||||||
import { splitContentSegments } from "@/lib/directiveBlocks";
|
import { splitContentSegments } from "@/lib/directiveBlocks";
|
||||||
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
|
import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
|
||||||
import type { HideKind } from "@/lib/hideBlocks";
|
import type { HideKind } from "@/lib/hideBlocks";
|
||||||
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
import MdHideBlock from "@/components/markdown/MdHideBlock";
|
||||||
import MdTimeline from "@/components/markdown/MdTimeline";
|
import MdTimeline from "@/components/markdown/MdTimeline";
|
||||||
|
import MdDetails from "@/components/markdown/MdDetails";
|
||||||
|
import MdVideo from "@/components/markdown/MdVideo";
|
||||||
import { MdEntriesView } from "@/components/markdown/MdEntries";
|
import { MdEntriesView } from "@/components/markdown/MdEntries";
|
||||||
|
|
||||||
type MarkdownBodyClientProps = {
|
type MarkdownBodyClientProps = {
|
||||||
@@ -49,7 +52,11 @@ export default function MarkdownBodyClient({
|
|||||||
if (seg.type === "md") {
|
if (seg.type === "md") {
|
||||||
if (!seg.text.trim()) return null;
|
if (!seg.text.trim()) return null;
|
||||||
return (
|
return (
|
||||||
<ReactMarkdown key={`md-${i}`} remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown
|
||||||
|
key={`md-${i}`}
|
||||||
|
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||||
|
components={mdComponents}
|
||||||
|
>
|
||||||
{seg.text}
|
{seg.text}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
);
|
);
|
||||||
@@ -61,7 +68,10 @@ export default function MarkdownBodyClient({
|
|||||||
items={seg.items}
|
items={seg.items}
|
||||||
renderBody={(it) =>
|
renderBody={(it) =>
|
||||||
it.body.trim() ? (
|
it.body.trim() ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||||
|
components={nestedMd}
|
||||||
|
>
|
||||||
{expandDyEmojiShortcodes(it.body)}
|
{expandDyEmojiShortcodes(it.body)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : null
|
) : null
|
||||||
@@ -76,13 +86,33 @@ export default function MarkdownBodyClient({
|
|||||||
items={seg.items}
|
items={seg.items}
|
||||||
titleIds={entriesTitleIds?.[i]}
|
titleIds={entriesTitleIds?.[i]}
|
||||||
renderMd={(t) => (
|
renderMd={(t) => (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||||
|
components={nestedMd}
|
||||||
|
>
|
||||||
{expandDyEmojiShortcodes(t)}
|
{expandDyEmojiShortcodes(t)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (seg.type === "details") {
|
||||||
|
return (
|
||||||
|
<MdDetails key={`details-${i}`} title={seg.title}>
|
||||||
|
{seg.body.trim() ? (
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||||
|
components={nestedMd}
|
||||||
|
>
|
||||||
|
{expandDyEmojiShortcodes(seg.body)}
|
||||||
|
</ReactMarkdown>
|
||||||
|
) : null}
|
||||||
|
</MdDetails>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (seg.type === "video") {
|
||||||
|
return <MdVideo key={`video-${i}`} provider={seg.provider} id={seg.id} />;
|
||||||
|
}
|
||||||
const kind = seg.kind as HideKind;
|
const kind = seg.kind as HideKind;
|
||||||
if (seg.locked) {
|
if (seg.locked) {
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +130,10 @@ export default function MarkdownBodyClient({
|
|||||||
return (
|
return (
|
||||||
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
||||||
{seg.body.trim() ? (
|
{seg.body.trim() ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm, remarkInlineShortcodes]}
|
||||||
|
components={nestedMd}
|
||||||
|
>
|
||||||
{expandDyEmojiShortcodes(seg.body)}
|
{expandDyEmojiShortcodes(seg.body)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ import {
|
|||||||
Ellipsis,
|
Ellipsis,
|
||||||
Smile,
|
Smile,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
|
Highlighter,
|
||||||
|
Keyboard,
|
||||||
|
Palette,
|
||||||
|
MonitorPlay,
|
||||||
|
Pipette,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
|
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
@@ -42,7 +47,8 @@ import ImageInsertModal from "@/components/ImageInsertModal";
|
|||||||
import TimelineEditor from "@/components/TimelineEditor";
|
import TimelineEditor from "@/components/TimelineEditor";
|
||||||
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
|
||||||
import EmojiPicker from "@/components/EmojiPicker";
|
import EmojiPicker from "@/components/EmojiPicker";
|
||||||
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
|
import { resolveTimelineBlockForEdit, parseVideoInput } from "@/lib/directiveBlocks";
|
||||||
|
import { isValidColorValue } from "@/lib/inlineShortcodes";
|
||||||
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
|
||||||
import { postPath } from "@/lib/urlStyle";
|
import { postPath } from "@/lib/urlStyle";
|
||||||
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
import { useUrlStyle } from "@/components/SiteBrandProvider";
|
||||||
@@ -96,7 +102,61 @@ type WrapOpts = {
|
|||||||
linePrefix?: string;
|
linePrefix?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | null;
|
type PopoverKind = "link" | "code" | "hide" | "heading" | "more" | "emoji" | "color" | "video" | null;
|
||||||
|
|
||||||
|
/** 文字颜色弹窗预设色板(均为合法短代码颜色值) */
|
||||||
|
const COLOR_FG_PRESETS = [
|
||||||
|
"#e03131", "#e8590c", "#f08c00", "#2f9e44", "#0c8599",
|
||||||
|
"#1971c2", "#6741d9", "#c2255c", "#1f2328", "#5f6b7a",
|
||||||
|
];
|
||||||
|
const COLOR_BG_PRESETS = [
|
||||||
|
"#fff3bf", "#ffe3e3", "#d3f9d8", "#d0ebff",
|
||||||
|
"#e5dbff", "#ffd8a8", "#ffff8f", "#e9ecef",
|
||||||
|
];
|
||||||
|
|
||||||
|
type HSV = { h: number; s: number; v: number };
|
||||||
|
|
||||||
|
/** #rgb/#rrggbb → HSV;非 hex(如颜色名)返回 null */
|
||||||
|
function hexToHsv(hex: string): HSV | null {
|
||||||
|
const m = /^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(hex.trim());
|
||||||
|
if (!m) return null;
|
||||||
|
let x = m[1];
|
||||||
|
if (x.length === 3) x = x.split("").map((c) => c + c).join("");
|
||||||
|
const r = parseInt(x.slice(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(x.slice(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(x.slice(4, 6), 16) / 255;
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const min = Math.min(r, g, b);
|
||||||
|
const d = max - min;
|
||||||
|
let h = 0;
|
||||||
|
if (d !== 0) {
|
||||||
|
if (max === r) h = ((g - b) / d) % 6;
|
||||||
|
else if (max === g) h = (b - r) / d + 2;
|
||||||
|
else h = (r - g) / d + 4;
|
||||||
|
h *= 60;
|
||||||
|
if (h < 0) h += 360;
|
||||||
|
}
|
||||||
|
return { h, s: max === 0 ? 0 : d / max, v: max };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HSV → #rrggbb */
|
||||||
|
function hsvToHex({ h, s, v }: HSV): string {
|
||||||
|
const c = v * s;
|
||||||
|
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||||
|
const m = v - c;
|
||||||
|
let r = 0, g = 0, b = 0;
|
||||||
|
if (h < 60) [r, g, b] = [c, x, 0];
|
||||||
|
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||||
|
else if (h < 180) [r, g, b] = [0, c, x];
|
||||||
|
else if (h < 240) [r, g, b] = [0, x, c];
|
||||||
|
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const to = (n: number) =>
|
||||||
|
Math.max(0, Math.min(255, Math.round((n + m) * 255)))
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `#${to(r)}${to(g)}${to(b)}`;
|
||||||
|
}
|
||||||
|
|
||||||
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
|
const CODE_LANGS = ["plaintext", "aardio", ...SHIKI_LANG_IDS.filter((l) => l !== "plaintext")] as const;
|
||||||
|
|
||||||
@@ -190,6 +250,16 @@ export default function MarkdownEditor({
|
|||||||
const [hidePoints, setHidePoints] = useState(10);
|
const [hidePoints, setHidePoints] = useState(10);
|
||||||
const [hidePassword, setHidePassword] = useState("");
|
const [hidePassword, setHidePassword] = useState("");
|
||||||
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
|
const [hideStep, setHideStep] = useState<"menu" | "points" | "password">("menu");
|
||||||
|
/** 颜色弹窗:前景/背景独立选择,可同时设置;null=不设置该侧 */
|
||||||
|
const [colorFg, setColorFg] = useState<string | null>("#e03131");
|
||||||
|
const [colorBg, setColorBg] = useState<string | null>(null);
|
||||||
|
/** 色轮/自定义输入框的当前值(始终 #hex,供 HSV 取色器使用) */
|
||||||
|
const [colorFgWheel, setColorFgWheel] = useState("#e03131");
|
||||||
|
const [colorBgWheel, setColorBgWheel] = useState("#fff3bf");
|
||||||
|
/** 内联 HSV 取色器展开侧;打开期间 colorHsv 为该侧当前色 */
|
||||||
|
const [activeWheel, setActiveWheel] = useState<"fg" | "bg" | null>(null);
|
||||||
|
const [colorHsv, setColorHsv] = useState<HSV>({ h: 0, s: 1, v: 1 });
|
||||||
|
const [videoInput, setVideoInput] = useState("");
|
||||||
const [toolNarrow, setToolNarrow] = useState(false);
|
const [toolNarrow, setToolNarrow] = useState(false);
|
||||||
const [uploadError, setUploadError] = useState("");
|
const [uploadError, setUploadError] = useState("");
|
||||||
const [modKey, setModKey] = useState("Ctrl");
|
const [modKey, setModKey] = useState("Ctrl");
|
||||||
@@ -504,6 +574,14 @@ export default function MarkdownEditor({
|
|||||||
setLinkAdvanced(false);
|
setLinkAdvanced(false);
|
||||||
}
|
}
|
||||||
if (kind === "code") setCodeQuery("");
|
if (kind === "code") setCodeQuery("");
|
||||||
|
if (kind === "color") {
|
||||||
|
setColorFg("#e03131");
|
||||||
|
setColorBg(null);
|
||||||
|
setColorFgWheel("#e03131");
|
||||||
|
setColorBgWheel("#fff3bf");
|
||||||
|
setActiveWheel(null);
|
||||||
|
}
|
||||||
|
if (kind === "video") setVideoInput("");
|
||||||
if (kind === "hide") {
|
if (kind === "hide") {
|
||||||
setHideStep("menu");
|
setHideStep("menu");
|
||||||
setHidePoints(10);
|
setHidePoints(10);
|
||||||
@@ -614,6 +692,176 @@ export default function MarkdownEditor({
|
|||||||
setPopover(null);
|
setPopover(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 颜色短代码插入:前景/背景可独立或组合。
|
||||||
|
* 两侧都有 → [color=前景 bg=背景]…[/color];仅一侧 → [color] / [bg]。
|
||||||
|
*/
|
||||||
|
const applyColor = () => {
|
||||||
|
const fg = colorFg?.trim();
|
||||||
|
const bg = colorBg?.trim();
|
||||||
|
if (!fg && !bg) {
|
||||||
|
toast("请至少选择前景色或背景色", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((fg && !isValidColorValue(fg)) || (bg && !isValidColorValue(bg))) {
|
||||||
|
toast("颜色仅支持 #RGB/#RRGGBB/#RRGGBBAA 或英文颜色名", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreSel();
|
||||||
|
const el = taRef.current;
|
||||||
|
const start = el?.selectionStart ?? 0;
|
||||||
|
const end = el?.selectionEnd ?? 0;
|
||||||
|
const selected = value.slice(start, end);
|
||||||
|
const body = selected || "彩色文字";
|
||||||
|
let md: string;
|
||||||
|
if (fg && bg) md = `[color=${fg} bg=${bg}]${body}[/color]`;
|
||||||
|
else if (fg) md = `[color=${fg}]${body}[/color]`;
|
||||||
|
else md = `[bg=${bg}]${body}[/bg]`;
|
||||||
|
const next = value.slice(0, start) + md + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const caret = start + md.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
setPopover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 内联 HSV 取色器开关:打开时以该侧当前 hex(或上次色轮值)初始化 */
|
||||||
|
const toggleColorWheel = (side: "fg" | "bg") => {
|
||||||
|
if (activeWheel === side) {
|
||||||
|
setActiveWheel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wheelHex = side === "fg" ? colorFgWheel : colorBgWheel;
|
||||||
|
setColorHsv(hexToHsv(wheelHex) ?? { h: 0, s: 1, v: 1 });
|
||||||
|
setActiveWheel(side);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** HSV 拖动 → 同步该侧 wheel hex 与选中色 */
|
||||||
|
const commitHsv = (next: HSV) => {
|
||||||
|
setColorHsv(next);
|
||||||
|
const hex = hsvToHex(next);
|
||||||
|
if (activeWheel === "fg") {
|
||||||
|
setColorFgWheel(hex);
|
||||||
|
setColorFg(hex);
|
||||||
|
} else if (activeWheel === "bg") {
|
||||||
|
setColorBgWheel(hex);
|
||||||
|
setColorBg(hex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 饱和度/明度面板拖拽:坐标以真实渲染元素 rect 映射(pointer capture 保证移出仍跟手)。
|
||||||
|
* 注意:必须在同步阶段取出 currentTarget 存入 el——React 在事件派发结束后会把
|
||||||
|
* e.currentTarget 置空,延迟回调里再读会拿到 null 导致拖拽中断。
|
||||||
|
*/
|
||||||
|
const onSvPointer = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
const update = (clientX: number, clientY: number) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const s = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||||
|
const v = 1 - Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
|
||||||
|
setColorHsv((prev) => {
|
||||||
|
const next = { ...prev, s, v };
|
||||||
|
const hex = hsvToHex(next);
|
||||||
|
if (activeWheel === "fg") {
|
||||||
|
setColorFgWheel(hex);
|
||||||
|
setColorFg(hex);
|
||||||
|
} else if (activeWheel === "bg") {
|
||||||
|
setColorBgWheel(hex);
|
||||||
|
setColorBg(hex);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
update(e.clientX, e.clientY);
|
||||||
|
const onMove = (ev: PointerEvent) => update(ev.clientX, ev.clientY);
|
||||||
|
const cleanup = () => {
|
||||||
|
el.removeEventListener("pointermove", onMove);
|
||||||
|
el.removeEventListener("pointerup", cleanup);
|
||||||
|
el.removeEventListener("pointercancel", cleanup);
|
||||||
|
};
|
||||||
|
el.addEventListener("pointermove", onMove);
|
||||||
|
el.addEventListener("pointerup", cleanup);
|
||||||
|
el.addEventListener("pointercancel", cleanup);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 色相条拖拽(水平);同样在同步阶段缓存元素 */
|
||||||
|
const onHuePointer = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const el = e.currentTarget;
|
||||||
|
el.setPointerCapture(e.pointerId);
|
||||||
|
const update = (clientX: number) => {
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const h = Math.max(0, Math.min(360, ((clientX - rect.left) / rect.width) * 360));
|
||||||
|
commitHsv({ ...colorHsv, h });
|
||||||
|
};
|
||||||
|
update(e.clientX);
|
||||||
|
const onMove = (ev: PointerEvent) => update(ev.clientX);
|
||||||
|
const cleanup = () => {
|
||||||
|
el.removeEventListener("pointermove", onMove);
|
||||||
|
el.removeEventListener("pointerup", cleanup);
|
||||||
|
el.removeEventListener("pointercancel", cleanup);
|
||||||
|
};
|
||||||
|
el.addEventListener("pointermove", onMove);
|
||||||
|
el.addEventListener("pointerup", cleanup);
|
||||||
|
el.addEventListener("pointercancel", cleanup);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** [bilibili]/[youtube] 整行插入:表单裸输入统一走 parseVideoInput */
|
||||||
|
const applyVideo = () => {
|
||||||
|
const parsed = parseVideoInput(videoInput);
|
||||||
|
if (!parsed) {
|
||||||
|
toast("请输入 B站 BV 号/链接 或 YouTube 视频 ID/链接", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreSel();
|
||||||
|
const el = taRef.current;
|
||||||
|
const start = el?.selectionStart ?? 0;
|
||||||
|
const end = el?.selectionEnd ?? 0;
|
||||||
|
const tag = `[${parsed.provider}]${parsed.id}[/${parsed.provider}]`;
|
||||||
|
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||||
|
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||||
|
const inserted = padBefore + tag + padAfter;
|
||||||
|
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const caret = start + inserted.length;
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
});
|
||||||
|
setPopover(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** [details 标题]…[/details] 折叠面板:包住选区或占位正文 */
|
||||||
|
const insertDetails = () => {
|
||||||
|
restoreSel();
|
||||||
|
const el = taRef.current;
|
||||||
|
const start = el?.selectionStart ?? 0;
|
||||||
|
const end = el?.selectionEnd ?? 0;
|
||||||
|
const selected = value.slice(start, end);
|
||||||
|
const body = selected || "折叠内容";
|
||||||
|
const open = "[details 点击展开]";
|
||||||
|
const block = `${open}\n${body}\n[/details]`;
|
||||||
|
const padBefore = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
|
||||||
|
const padAfter = end < value.length && value[end] !== "\n" ? "\n" : "";
|
||||||
|
const inserted = padBefore + block + padAfter;
|
||||||
|
const next = value.slice(0, start) + inserted + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
const bodyStart = start + padBefore.length + open.length + 1;
|
||||||
|
el.setSelectionRange(bodyStart, bodyStart + body.length);
|
||||||
|
});
|
||||||
|
setPopover(null);
|
||||||
|
};
|
||||||
|
|
||||||
const openTimelineEditor = () => {
|
const openTimelineEditor = () => {
|
||||||
rememberSel();
|
rememberSel();
|
||||||
const el = taRef.current;
|
const el = taRef.current;
|
||||||
@@ -923,6 +1171,164 @@ export default function MarkdownEditor({
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
// 文字颜色弹层:前景/背景两行独立选择,可同时设置;每行含预设、内联 HSV 取色器与自定义输入。
|
||||||
|
// 用普通函数调用而非 <ColorPickRow/> 组件:避免内联组件每次渲染重挂载导致输入框失焦。
|
||||||
|
const renderColorRow = (
|
||||||
|
side: "fg" | "bg",
|
||||||
|
label: string,
|
||||||
|
presets: readonly string[],
|
||||||
|
picked: string | null
|
||||||
|
) => {
|
||||||
|
const setPicked = (v: string | null) =>
|
||||||
|
side === "fg" ? setColorFg(v) : setColorBg(v);
|
||||||
|
const wheelOpen = activeWheel === side;
|
||||||
|
return (
|
||||||
|
<div className="j13-md-color-row">
|
||||||
|
<span className="j13-md-color-row-label">{label}</span>
|
||||||
|
<div className="j13-md-color-row-items">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`j13-md-color-none${picked === null ? " is-active" : ""}`}
|
||||||
|
onClick={() => setPicked(null)}
|
||||||
|
>
|
||||||
|
无
|
||||||
|
</button>
|
||||||
|
{presets.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className={`j13-md-color-dot${picked?.toLowerCase() === c.toLowerCase() ? " is-active" : ""}`}
|
||||||
|
style={{ background: c }}
|
||||||
|
data-tip={c}
|
||||||
|
aria-label={`使用 ${c}`}
|
||||||
|
onClick={() => setPicked(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`j13-md-color-wheel${wheelOpen ? " is-active" : ""}`}
|
||||||
|
data-tip="自定义颜色"
|
||||||
|
aria-label="自定义颜色"
|
||||||
|
aria-expanded={wheelOpen}
|
||||||
|
onClick={() => toggleColorWheel(side)}
|
||||||
|
>
|
||||||
|
<Pipette size={12} />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
className="j13-md-color-hex j13-md-popover-input"
|
||||||
|
value={picked ?? ""}
|
||||||
|
placeholder="#e03131 或 tomato"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setPicked(v);
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
|
||||||
|
if (side === "fg") setColorFgWheel(v);
|
||||||
|
else setColorBgWheel(v);
|
||||||
|
if (wheelOpen) setColorHsv(hexToHsv(v) ?? colorHsv);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
applyColor();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{wheelOpen && (
|
||||||
|
<div className="j13-md-hsv">
|
||||||
|
<div
|
||||||
|
className="j13-md-hsv-sv"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to top, #000, rgba(0,0,0,0)), linear-gradient(to right, #fff, ${hsvToHex({ h: colorHsv.h, s: 1, v: 1 })})`,
|
||||||
|
}}
|
||||||
|
onPointerDown={onSvPointer}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="j13-md-hsv-sv-thumb"
|
||||||
|
style={{
|
||||||
|
left: `${colorHsv.s * 100}%`,
|
||||||
|
top: `${(1 - colorHsv.v) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="j13-md-hsv-hue" onPointerDown={onHuePointer}>
|
||||||
|
<span
|
||||||
|
className="j13-md-hsv-hue-thumb"
|
||||||
|
style={{ left: `${(colorHsv.h / 360) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorPopover =
|
||||||
|
popover === "color" ? (
|
||||||
|
<div
|
||||||
|
className={`j13-md-popover j13-md-popover-form j13-md-color-popover${toolNarrow ? " j13-md-popover-center" : ""}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="文字颜色"
|
||||||
|
>
|
||||||
|
<div className="j13-md-color-preview">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: colorFg ?? "inherit",
|
||||||
|
backgroundColor: colorBg ?? "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Aa 示例文字
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{renderColorRow("fg", "前景", COLOR_FG_PRESETS, colorFg)}
|
||||||
|
{renderColorRow("bg", "背景", COLOR_BG_PRESETS, colorBg)}
|
||||||
|
<div className="j13-md-popover-footer">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={() => applyColor()}>
|
||||||
|
插入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// 视频嵌入弹层:裸输入统一交给 parseVideoInput 归一化
|
||||||
|
const videoPopover =
|
||||||
|
popover === "video" ? (
|
||||||
|
<div
|
||||||
|
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="插入视频"
|
||||||
|
>
|
||||||
|
<label className="j13-md-field">
|
||||||
|
<span>B站 / YouTube</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={videoInput}
|
||||||
|
onChange={(e) => setVideoInput(e.target.value)}
|
||||||
|
className="j13-md-popover-input"
|
||||||
|
placeholder="BV1… / 视频链接 / YouTube ID"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
applyVideo();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="j13-md-popover-footer">
|
||||||
|
<button type="button" className="btn btn-line btn-sm" onClick={() => setPopover(null)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary btn-sm" onClick={applyVideo}>
|
||||||
|
插入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const ToolBtn = ({
|
const ToolBtn = ({
|
||||||
label,
|
label,
|
||||||
icon,
|
icon,
|
||||||
@@ -981,6 +1387,31 @@ export default function MarkdownEditor({
|
|||||||
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
|
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isComment && !toolNarrow && (
|
||||||
|
<ToolBtn
|
||||||
|
label="高亮 =="
|
||||||
|
icon={<Highlighter size={15} />}
|
||||||
|
onClick={() => runWrap({ before: "==", after: "==", placeholder: "高亮" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isComment && !toolNarrow && (
|
||||||
|
<ToolBtn
|
||||||
|
label="键帽"
|
||||||
|
icon={<Keyboard size={15} />}
|
||||||
|
onClick={() => runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!toolNarrow && (
|
||||||
|
<div className="j13-md-popover-anchor relative">
|
||||||
|
<ToolBtn
|
||||||
|
label="文字颜色"
|
||||||
|
icon={<Palette size={15} />}
|
||||||
|
active={popover === "color"}
|
||||||
|
onClick={() => openPopover("color")}
|
||||||
|
/>
|
||||||
|
{popover === "color" && colorPopover}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isComment && !toolNarrow && <Sep />}
|
{!isComment && !toolNarrow && <Sep />}
|
||||||
{!isComment && (
|
{!isComment && (
|
||||||
<div className="j13-md-popover-anchor relative">
|
<div className="j13-md-popover-anchor relative">
|
||||||
@@ -1217,6 +1648,9 @@ export default function MarkdownEditor({
|
|||||||
)}
|
)}
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
|
||||||
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "==", after: "==", placeholder: "高亮" }); setPopover(null); }}>高亮</button>
|
||||||
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "[kbd]", after: "[/kbd]", placeholder: "Ctrl" }); setPopover(null); }}>键帽</button>
|
||||||
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("color")}>颜色…</button>
|
||||||
{!isComment && (
|
{!isComment && (
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
|
||||||
)}
|
)}
|
||||||
@@ -1230,12 +1664,20 @@ export default function MarkdownEditor({
|
|||||||
)}
|
)}
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
|
||||||
|
{!isComment && (
|
||||||
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => insertDetails()}>折叠面板</button>
|
||||||
|
)}
|
||||||
|
{!isComment && (
|
||||||
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("video")}>视频…</button>
|
||||||
|
)}
|
||||||
{toolNarrow && (
|
{toolNarrow && (
|
||||||
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
|
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{toolNarrow && codePopover}
|
{toolNarrow && codePopover}
|
||||||
|
{toolNarrow && popover === "color" && colorPopover}
|
||||||
|
{toolNarrow && popover === "video" && videoPopover}
|
||||||
{toolNarrow && popover === "emoji" && !isComment && (
|
{toolNarrow && popover === "emoji" && !isComment && (
|
||||||
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
|
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
|
||||||
<EmojiPicker onPick={pickEmoji} />
|
<EmojiPicker onPick={pickEmoji} />
|
||||||
@@ -1259,6 +1701,17 @@ export default function MarkdownEditor({
|
|||||||
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
|
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!isComment && !toolNarrow && (
|
||||||
|
<div className="j13-md-popover-anchor relative">
|
||||||
|
<ToolBtn
|
||||||
|
label="视频"
|
||||||
|
icon={<MonitorPlay size={15} />}
|
||||||
|
active={popover === "video"}
|
||||||
|
onClick={() => openPopover("video")}
|
||||||
|
/>
|
||||||
|
{popover === "video" && videoPopover}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{allowHide && !toolNarrow ? (
|
{allowHide && !toolNarrow ? (
|
||||||
<div className="j13-md-popover-anchor relative">
|
<div className="j13-md-popover-anchor relative">
|
||||||
<ToolBtn
|
<ToolBtn
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
|
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeBrandSloganVisible, normalizeFooterLinks } from "@/lib/brand";
|
||||||
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
|
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
|
||||||
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
||||||
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
|
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
|
||||||
@@ -19,6 +19,7 @@ export type SiteBrand = {
|
|||||||
brand_mark: PublicSettings["brand_mark"];
|
brand_mark: PublicSettings["brand_mark"];
|
||||||
brand_logo_size: PublicSettings["brand_logo_size"];
|
brand_logo_size: PublicSettings["brand_logo_size"];
|
||||||
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
||||||
|
brand_slogan_visible: boolean;
|
||||||
footer_links: PublicSettings["footer_links"];
|
footer_links: PublicSettings["footer_links"];
|
||||||
url_style: UrlStyle;
|
url_style: UrlStyle;
|
||||||
mobile_tabs: MobileTabKey[];
|
mobile_tabs: MobileTabKey[];
|
||||||
@@ -38,6 +39,7 @@ function fromSettings(s: PublicSettings): SiteBrand {
|
|||||||
brand_mark: s.brand_mark,
|
brand_mark: s.brand_mark,
|
||||||
brand_logo_size: s.brand_logo_size,
|
brand_logo_size: s.brand_logo_size,
|
||||||
brand_logo_fit: s.brand_logo_fit,
|
brand_logo_fit: s.brand_logo_fit,
|
||||||
|
brand_slogan_visible: s.brand_slogan_visible,
|
||||||
footer_links: s.footer_links,
|
footer_links: s.footer_links,
|
||||||
url_style: s.url_style,
|
url_style: s.url_style,
|
||||||
mobile_tabs: s.mobile_tabs,
|
mobile_tabs: s.mobile_tabs,
|
||||||
@@ -56,6 +58,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
|||||||
a.brand_mark === b.brand_mark &&
|
a.brand_mark === b.brand_mark &&
|
||||||
a.brand_logo_size === b.brand_logo_size &&
|
a.brand_logo_size === b.brand_logo_size &&
|
||||||
a.brand_logo_fit === b.brand_logo_fit &&
|
a.brand_logo_fit === b.brand_logo_fit &&
|
||||||
|
a.brand_slogan_visible === b.brand_slogan_visible &&
|
||||||
a.url_style === b.url_style &&
|
a.url_style === b.url_style &&
|
||||||
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
|
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
|
||||||
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
|
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
|
||||||
@@ -108,6 +111,7 @@ export function useSiteBrand(): SiteBrand {
|
|||||||
brand_mark: "image_text",
|
brand_mark: "image_text",
|
||||||
brand_logo_size: "sq",
|
brand_logo_size: "sq",
|
||||||
brand_logo_fit: "contain",
|
brand_logo_fit: "contain",
|
||||||
|
brand_slogan_visible: true,
|
||||||
footer_links: [],
|
footer_links: [],
|
||||||
url_style: "default",
|
url_style: "default",
|
||||||
mobile_tabs: [...DEFAULT_MOBILE_TABS],
|
mobile_tabs: [...DEFAULT_MOBILE_TABS],
|
||||||
@@ -159,6 +163,10 @@ export default function SiteBrandProvider({
|
|||||||
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
||||||
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
||||||
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
||||||
|
brand_slogan_visible:
|
||||||
|
raw.brand_slogan_visible !== undefined
|
||||||
|
? normalizeBrandSloganVisible(raw.brand_slogan_visible)
|
||||||
|
: prev.brand_slogan_visible,
|
||||||
footer_links: Array.isArray(raw.footer_links)
|
footer_links: Array.isArray(raw.footer_links)
|
||||||
? normalizeFooterLinks(raw.footer_links)
|
? normalizeFooterLinks(raw.footer_links)
|
||||||
: prev.footer_links,
|
: prev.footer_links,
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export default function SiteChrome({
|
|||||||
brandMark={brand.brand_mark}
|
brandMark={brand.brand_mark}
|
||||||
brandLogoSize={brand.brand_logo_size}
|
brandLogoSize={brand.brand_logo_size}
|
||||||
brandLogoFit={brand.brand_logo_fit}
|
brandLogoFit={brand.brand_logo_fit}
|
||||||
|
slogan={brand.site_slogan}
|
||||||
|
sloganVisible={brand.brand_slogan_visible}
|
||||||
/>
|
/>
|
||||||
<main
|
<main
|
||||||
id="main"
|
id="main"
|
||||||
|
|||||||
24
frontend/components/markdown/MdDetails.tsx
Normal file
24
frontend/components/markdown/MdDetails.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
|
type MdDetailsProps = {
|
||||||
|
title: string;
|
||||||
|
/** 正文已渲染的 React 节点(markdown);空正文可不传 */
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [details 标题]…[/details] 折叠面板:原生 details/summary,无 JS、SSR 友好。
|
||||||
|
* 正文 markdown 由调用方(MarkdownBody / MarkdownBodyClient)渲染后传入。
|
||||||
|
*/
|
||||||
|
export default function MdDetails({ title, children }: MdDetailsProps) {
|
||||||
|
return (
|
||||||
|
<details className="j13-md-details">
|
||||||
|
<summary>
|
||||||
|
<ChevronDown size={15} className="j13-md-details-caret" aria-hidden />
|
||||||
|
<span className="j13-md-details-title">{title}</span>
|
||||||
|
</summary>
|
||||||
|
{children ? <div className="j13-md-details-body">{children}</div> : null}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
frontend/components/markdown/MdVideo.tsx
Normal file
35
frontend/components/markdown/MdVideo.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
type VideoProvider = "bilibili" | "youtube";
|
||||||
|
|
||||||
|
type MdVideoProps = {
|
||||||
|
provider: VideoProvider;
|
||||||
|
/** bilibili 为 BV 号;youtube 为视频 ID(parseVideoLine 已校验) */
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function embedUrl(provider: VideoProvider, id: string): string {
|
||||||
|
if (provider === "bilibili") {
|
||||||
|
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(
|
||||||
|
id
|
||||||
|
)}&autoplay=0&danmaku=0&high_quality=1`;
|
||||||
|
}
|
||||||
|
return `https://www.youtube-nocookie.com/embed/${encodeURIComponent(id)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [bilibili]…[/bilibili] / [youtube]…[/youtube] 视频嵌入:
|
||||||
|
* 16:9 容器 + 懒加载 iframe(bilibili 官方播放器 / youtube 隐私域名),纯静态无 JS。
|
||||||
|
*/
|
||||||
|
export default function MdVideo({ provider, id }: MdVideoProps) {
|
||||||
|
return (
|
||||||
|
<div className="j13-md-video">
|
||||||
|
<iframe
|
||||||
|
src={embedUrl(provider, id)}
|
||||||
|
title={provider === "bilibili" ? `bilibili 视频 ${id}` : `YouTube 视频 ${id}`}
|
||||||
|
loading="lazy"
|
||||||
|
allowFullScreen
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
allow="accelerometer; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/components/markdown/remarkInlineShortcodes.ts
Normal file
58
frontend/components/markdown/remarkInlineShortcodes.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import type { Root as MdastRoot } from "mdast";
|
||||||
|
import type { Text } from "mdast";
|
||||||
|
import { visit, SKIP } from "unist-util-visit";
|
||||||
|
import { splitInlineShortcodes } from "@/lib/inlineShortcodes";
|
||||||
|
|
||||||
|
const QUICK_RE = /==|\[(?:kbd|color|bg)[=\]]/i;
|
||||||
|
|
||||||
|
type MdastParent = { children: Array<Record<string, unknown>>; type: string };
|
||||||
|
|
||||||
|
/** 行内短代码 → mdast 节点(mdast-util-to-hast 支持 data.hName / data.hProperties) */
|
||||||
|
function segToNodes(
|
||||||
|
seg: Exclude<ReturnType<typeof splitInlineShortcodes>, null>[number]
|
||||||
|
): Record<string, unknown>[] {
|
||||||
|
if (seg.type === "text") return [{ type: "text", value: seg.value }];
|
||||||
|
if (seg.type === "mark" || seg.type === "kbd") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "emphasis",
|
||||||
|
data: { hName: seg.type },
|
||||||
|
children: [{ type: "text", value: seg.value }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
const style = [
|
||||||
|
seg.fg ? `color:${seg.fg}` : null,
|
||||||
|
seg.bg ? `background-color:${seg.bg}` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(";");
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: "emphasis",
|
||||||
|
data: { hName: "span", hProperties: { style } },
|
||||||
|
children: [{ type: "text", value: seg.value }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* remark 插件:把文本节点中的 ==高亮== / [kbd] / [color] / [bg] 展开为
|
||||||
|
* mark/kbd/span 节点。code/inlineCode 是字面节点没有子文本,天然免疫;
|
||||||
|
* 未命中短代码的文本原样保留。
|
||||||
|
*/
|
||||||
|
export function remarkInlineShortcodes() {
|
||||||
|
return (tree: MdastRoot) => {
|
||||||
|
visit(tree, "text", (node: Text, index, parent) => {
|
||||||
|
const p = parent as unknown as MdastParent | null;
|
||||||
|
if (!p || index === undefined) return;
|
||||||
|
const value = node.value;
|
||||||
|
if (!QUICK_RE.test(value)) return;
|
||||||
|
const segs = splitInlineShortcodes(value);
|
||||||
|
if (!segs) return;
|
||||||
|
const nodes = segs.flatMap(segToNodes);
|
||||||
|
p.children.splice(index, 1, ...nodes);
|
||||||
|
return [SKIP, index + nodes.length];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import type { Root } from "hast";
|
|||||||
import type { Root as MdastRoot } from "mdast";
|
import type { Root as MdastRoot } from "mdast";
|
||||||
import { visit } from "unist-util-visit";
|
import { visit } from "unist-util-visit";
|
||||||
import { mergeMarkdownComponents } from "./mergeComponents";
|
import { mergeMarkdownComponents } from "./mergeComponents";
|
||||||
|
import { remarkInlineShortcodes } from "./remarkInlineShortcodes";
|
||||||
import {
|
import {
|
||||||
getPrettyCodeHighlighter,
|
getPrettyCodeHighlighter,
|
||||||
resolveShikiLang,
|
resolveShikiLang,
|
||||||
@@ -54,6 +55,7 @@ export async function renderMarkdownToReact(
|
|||||||
const processor = unified()
|
const processor = unified()
|
||||||
.use(remarkParse)
|
.use(remarkParse)
|
||||||
.use(remarkGfm)
|
.use(remarkGfm)
|
||||||
|
.use(remarkInlineShortcodes)
|
||||||
.use(remarkNormalizeCodeLang)
|
.use(remarkNormalizeCodeLang)
|
||||||
.use(remarkRehype)
|
.use(remarkRehype)
|
||||||
.use(rehypePrettyCode, { ...prettyCodeOptions });
|
.use(rehypePrettyCode, { ...prettyCodeOptions });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Clock } from "lucide-react";
|
import { Clock } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/format";
|
import { formatDate } from "@/lib/format";
|
||||||
|
import { formatReadingDuration } from "@/lib/toc";
|
||||||
|
|
||||||
function formatDateTime(dateStr: string) {
|
function formatDateTime(dateStr: string) {
|
||||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||||
@@ -83,9 +84,9 @@ export default function SiteDocMasthead({
|
|||||||
{readingMinutes != null ? (
|
{readingMinutes != null ? (
|
||||||
<span
|
<span
|
||||||
className="meta inline-flex items-center gap-1.5 text-[12.5px] sm:ml-auto"
|
className="meta inline-flex items-center gap-1.5 text-[12.5px] sm:ml-auto"
|
||||||
aria-label={`阅读约需 ${readingMinutes} 分钟`}
|
aria-label={`阅读约需 ${formatReadingDuration(readingMinutes)}`}
|
||||||
>
|
>
|
||||||
<Clock size={13} /> 约 {readingMinutes} 分钟读完
|
<Clock size={13} /> 约 {formatReadingDuration(readingMinutes)} 读完
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
normalizeBrandLogoFit,
|
normalizeBrandLogoFit,
|
||||||
normalizeBrandLogoSize,
|
normalizeBrandLogoSize,
|
||||||
normalizeBrandMark,
|
normalizeBrandMark,
|
||||||
|
normalizeBrandSloganVisible,
|
||||||
normalizeBrandURL,
|
normalizeBrandURL,
|
||||||
normalizeFooterLinks,
|
normalizeFooterLinks,
|
||||||
normalizeKeywords,
|
normalizeKeywords,
|
||||||
@@ -1041,6 +1042,8 @@ export interface PublicSettings {
|
|||||||
brand_logo_size: BrandLogoSize;
|
brand_logo_size: BrandLogoSize;
|
||||||
/** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */
|
/** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */
|
||||||
brand_logo_fit: BrandLogoFit;
|
brand_logo_fit: BrandLogoFit;
|
||||||
|
/** 标语是否在页眉显示;缺省 true */
|
||||||
|
brand_slogan_visible: boolean;
|
||||||
footer_links: FooterLink[];
|
footer_links: FooterLink[];
|
||||||
allow_register: boolean;
|
allow_register: boolean;
|
||||||
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||||||
@@ -1144,6 +1147,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
|||||||
brand_mark: "image_text",
|
brand_mark: "image_text",
|
||||||
brand_logo_size: "sq",
|
brand_logo_size: "sq",
|
||||||
brand_logo_fit: "contain",
|
brand_logo_fit: "contain",
|
||||||
|
brand_slogan_visible: true,
|
||||||
footer_links: [],
|
footer_links: [],
|
||||||
allow_register: true,
|
allow_register: true,
|
||||||
allow_comments: true,
|
allow_comments: true,
|
||||||
@@ -1235,6 +1239,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
|||||||
brand_mark: normalizeBrandMark(data?.brand_mark),
|
brand_mark: normalizeBrandMark(data?.brand_mark),
|
||||||
brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size),
|
brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size),
|
||||||
brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit),
|
brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit),
|
||||||
|
brand_slogan_visible: normalizeBrandSloganVisible(data?.brand_slogan_visible),
|
||||||
footer_links: normalizeFooterLinks(data?.footer_links),
|
footer_links: normalizeFooterLinks(data?.footer_links),
|
||||||
allow_register: data?.allow_register !== false,
|
allow_register: data?.allow_register !== false,
|
||||||
allow_comments: data?.allow_comments !== false,
|
allow_comments: data?.allow_comments !== false,
|
||||||
@@ -3760,6 +3765,7 @@ export type UpdateSiteSettingsBody = {
|
|||||||
brand_mark?: BrandMark;
|
brand_mark?: BrandMark;
|
||||||
brand_logo_size?: BrandLogoSize;
|
brand_logo_size?: BrandLogoSize;
|
||||||
brand_logo_fit?: BrandLogoFit;
|
brand_logo_fit?: BrandLogoFit;
|
||||||
|
brand_slogan_visible?: boolean;
|
||||||
footer_links?: FooterLink[];
|
footer_links?: FooterLink[];
|
||||||
allow_register?: boolean;
|
allow_register?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
@@ -4825,6 +4831,7 @@ export interface MediaLibraryItem {
|
|||||||
height: number;
|
height: number;
|
||||||
uploader?: string;
|
uploader?: string;
|
||||||
uploaded_at?: string;
|
uploaded_at?: string;
|
||||||
|
ref_count?: number; // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||||||
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
|
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
|
||||||
source_label?: string; // 来源描述(如:帖子《…》下的评论)
|
source_label?: string; // 来源描述(如:帖子《…》下的评论)
|
||||||
}
|
}
|
||||||
@@ -4841,6 +4848,20 @@ export async function fetchAdminMediaLibrary(
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 客户端管理端媒体库(带令牌刷新),品牌选图等后台场景用 */
|
||||||
|
export async function apiAdminMediaLibrary(): Promise<{
|
||||||
|
items: MediaLibraryItem[];
|
||||||
|
counts: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const res = await fetchWithRefresh("/api/admin/media-library", {
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || "加载媒体库失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiAdminGetSidebar(): Promise<{
|
export async function apiAdminGetSidebar(): Promise<{
|
||||||
config: SidebarConfig;
|
config: SidebarConfig;
|
||||||
widgets: SidebarWidgetRecord[];
|
widgets: SidebarWidgetRecord[];
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
|
|||||||
/** 页眉品牌形式 */
|
/** 页眉品牌形式 */
|
||||||
export type BrandMark = "image_text" | "image" | "text";
|
export type BrandMark = "image_text" | "image" | "text";
|
||||||
|
|
||||||
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */
|
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例;auto=原图比例自适应宽度 */
|
||||||
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9";
|
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9" | "auto";
|
||||||
|
|
||||||
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
|
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
|
||||||
{ id: "sq", label: "方标", w: 36, h: 36 },
|
{ id: "sq", label: "方标", w: 36, h: 36 },
|
||||||
@@ -37,6 +37,7 @@ export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h:
|
|||||||
{ id: "3x1", label: "3:1", w: 120, h: 40 },
|
{ id: "3x1", label: "3:1", w: 120, h: 40 },
|
||||||
{ id: "4x1", label: "4:1", w: 176, h: 44 },
|
{ id: "4x1", label: "4:1", w: 176, h: 44 },
|
||||||
{ id: "16x9", label: "16:9", w: 96, h: 54 },
|
{ id: "16x9", label: "16:9", w: 96, h: 54 },
|
||||||
|
{ id: "auto", label: "原图", w: 0, h: 40 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function normalizeBrandMark(raw: unknown): BrandMark {
|
export function normalizeBrandMark(raw: unknown): BrandMark {
|
||||||
@@ -47,10 +48,38 @@ export function normalizeBrandMark(raw: unknown): BrandMark {
|
|||||||
|
|
||||||
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
|
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
|
||||||
const s = String(raw ?? "").trim();
|
const s = String(raw ?? "").trim();
|
||||||
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s;
|
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9" || s === "auto") return s;
|
||||||
return "sq";
|
return "sq";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 各固定画幅的宽高比(auto 不在此表,代表自由比例) */
|
||||||
|
export const BRAND_LOGO_SIZE_ASPECT: Partial<Record<BrandLogoSize, number>> = {
|
||||||
|
sq: 1,
|
||||||
|
"2x1": 2,
|
||||||
|
"3x1": 3,
|
||||||
|
"4x1": 4,
|
||||||
|
"16x9": 16 / 9,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据图片原始比例推荐画幅:
|
||||||
|
* 与某个固定画幅差距 ≤15% 时推荐该画幅,否则推荐 auto(原图自适应)。
|
||||||
|
*/
|
||||||
|
export function suggestLogoSize(naturalW: number, naturalH: number): BrandLogoSize {
|
||||||
|
if (!naturalW || !naturalH) return "auto";
|
||||||
|
const ratio = naturalW / naturalH;
|
||||||
|
let best: BrandLogoSize = "sq";
|
||||||
|
let bestDelta = Infinity;
|
||||||
|
for (const [id, a] of Object.entries(BRAND_LOGO_SIZE_ASPECT) as [BrandLogoSize, number][]) {
|
||||||
|
const delta = Math.abs(ratio - a) / a;
|
||||||
|
if (delta < bestDelta) {
|
||||||
|
bestDelta = delta;
|
||||||
|
best = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestDelta <= 0.15 ? best : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
|
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
|
||||||
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
|
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
|
||||||
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
|
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
|
||||||
@@ -72,6 +101,13 @@ export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit {
|
|||||||
return "contain";
|
return "contain";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 标语是否在页眉显示;缺省 true(与后端缺省一致) */
|
||||||
|
export function normalizeBrandSloganVisible(raw: unknown): boolean {
|
||||||
|
if (typeof raw === "boolean") return raw;
|
||||||
|
if (typeof raw === "string") return raw.trim().toLowerCase() !== "false";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export const BRAND_LIMITS = {
|
export const BRAND_LIMITS = {
|
||||||
wordmark: 16,
|
wordmark: 16,
|
||||||
slogan: 32,
|
slogan: 32,
|
||||||
@@ -187,9 +223,3 @@ export function brandFileOk(slot: BrandSlot, file: File): string | null {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mediaURLFitsSlot(slot: BrandSlot, url: string): boolean {
|
|
||||||
const u = url.toLowerCase();
|
|
||||||
if (slot === "favicon") return /\.(png|webp|svg|ico)(\?|$)/.test(u);
|
|
||||||
return /\.(png|webp|svg)(\?|$)/.test(u);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ export const AVATAR_OUTPUT_SIZE = 320;
|
|||||||
// 裁剪后 WebP 文件大小上限(与后端 service.AvatarMaxBytes 保持一致)
|
// 裁剪后 WebP 文件大小上限(与后端 service.AvatarMaxBytes 保持一致)
|
||||||
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
export const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
// 品牌 Logo 输出高度上限(与后端 brandMaxDim 对齐)
|
||||||
|
export const BRAND_MAX_DIM = 2048;
|
||||||
|
// 裁剪后品牌图大小上限(与后端 BrandLogoMaxBytes 对齐)
|
||||||
|
export const BRAND_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -18,6 +23,25 @@ function canvasToWebp(canvas: HTMLCanvasElement, quality: number): Promise<Blob
|
|||||||
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
|
return new Promise((resolve) => canvas.toBlob(resolve, "image/webp", quality));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按裁剪区域导出 WebP。
|
||||||
|
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
|
||||||
|
*/
|
||||||
|
async function exportWebp(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
maxBytes: number
|
||||||
|
): Promise<Blob> {
|
||||||
|
let last: Blob | null = null;
|
||||||
|
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
|
||||||
|
const blob = await canvasToWebp(canvas, quality);
|
||||||
|
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
|
||||||
|
last = blob;
|
||||||
|
if (blob.size <= maxBytes) return blob;
|
||||||
|
}
|
||||||
|
if (last && last.size <= maxBytes) return last;
|
||||||
|
throw new Error("裁剪后的图片仍超过大小限制,请缩小图片后再试");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按裁剪区域导出正方形 WebP。
|
* 按裁剪区域导出正方形 WebP。
|
||||||
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
|
* 自动按 0.92 → 0.65 降质量,直到满足大小上限;仍超限则抛出错误。
|
||||||
@@ -47,13 +71,75 @@ export async function getCroppedAvatarWebp(
|
|||||||
AVATAR_OUTPUT_SIZE
|
AVATAR_OUTPUT_SIZE
|
||||||
);
|
);
|
||||||
|
|
||||||
let last: Blob | null = null;
|
return exportWebp(canvas, AVATAR_MAX_BYTES);
|
||||||
for (const quality of [0.92, 0.85, 0.75, 0.65]) {
|
}
|
||||||
const blob = await canvasToWebp(canvas, quality);
|
|
||||||
if (!blob) throw new Error("当前浏览器不支持 WebP 导出,请使用最新版 Chrome/Edge/Safari");
|
/**
|
||||||
last = blob;
|
* 按裁剪区域导出任意比例 WebP(品牌 Logo 用)。
|
||||||
if (blob.size <= AVATAR_MAX_BYTES) return blob;
|
* 输出高度限制在 BRAND_MAX_DIM 以内,宽度按比例自适应。
|
||||||
}
|
*/
|
||||||
if (last && last.size <= AVATAR_MAX_BYTES) return last;
|
export async function getCroppedBrandWebp(
|
||||||
throw new Error("裁剪后的图片仍超过 2MB,请缩小图片后再试");
|
imageSrc: string,
|
||||||
|
pixelCrop: Area,
|
||||||
|
aspect: number
|
||||||
|
): Promise<Blob> {
|
||||||
|
const image = await loadImage(imageSrc);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
// 以高度为基准,宽度按纵横比计算;若超宽则按宽度反推高度
|
||||||
|
let outH = pixelCrop.height;
|
||||||
|
let outW = Math.round(outH * aspect);
|
||||||
|
if (outW > BRAND_MAX_DIM) {
|
||||||
|
outW = BRAND_MAX_DIM;
|
||||||
|
outH = Math.round(outW / aspect);
|
||||||
|
}
|
||||||
|
if (outH > BRAND_MAX_DIM) {
|
||||||
|
outH = BRAND_MAX_DIM;
|
||||||
|
outW = Math.round(outH * aspect);
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = outW;
|
||||||
|
canvas.height = outH;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("当前浏览器不支持图片裁剪");
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
ctx.drawImage(
|
||||||
|
image,
|
||||||
|
pixelCrop.x,
|
||||||
|
pixelCrop.y,
|
||||||
|
pixelCrop.width,
|
||||||
|
pixelCrop.height,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
outW,
|
||||||
|
outH
|
||||||
|
);
|
||||||
|
|
||||||
|
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整图直传(「原图自适应」用):不做任何裁剪,仅按比例缩放到 BRAND_MAX_DIM 内并导出 WebP。
|
||||||
|
* 页眉图框高度固定、宽度按图片原始比例自适应。
|
||||||
|
*/
|
||||||
|
export async function getFullBrandWebp(imageSrc: string): Promise<Blob> {
|
||||||
|
const image = await loadImage(imageSrc);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
|
||||||
|
const scale = Math.min(1, BRAND_MAX_DIM / Math.max(image.naturalWidth, image.naturalHeight));
|
||||||
|
const outW = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||||
|
const outH = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||||
|
|
||||||
|
canvas.width = outW;
|
||||||
|
canvas.height = outH;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("当前浏览器不支持图片处理");
|
||||||
|
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = "high";
|
||||||
|
ctx.drawImage(image, 0, 0, outW, outH);
|
||||||
|
|
||||||
|
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||||||
}
|
}
|
||||||
|
|||||||
117
frontend/lib/directiveBlocks.test.ts
Normal file
117
frontend/lib/directiveBlocks.test.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
DETAILS_DEFAULT_TITLE,
|
||||||
|
isDetailsOpen,
|
||||||
|
parseDetailsOpen,
|
||||||
|
parseVideoInput,
|
||||||
|
parseVideoLine,
|
||||||
|
splitContentSegments,
|
||||||
|
} from "./directiveBlocks.ts";
|
||||||
|
|
||||||
|
describe("parseDetailsOpen / isDetailsOpen", () => {
|
||||||
|
it("开标记整行匹配,标题缺省「点击展开」", () => {
|
||||||
|
assert.equal(isDetailsOpen("[details]"), true);
|
||||||
|
assert.equal(parseDetailsOpen("[details]"), DETAILS_DEFAULT_TITLE);
|
||||||
|
assert.equal(parseDetailsOpen("[details 点击展开看结论]"), "点击展开看结论");
|
||||||
|
assert.equal(isDetailsOpen("[details 标题] 后缀"), false);
|
||||||
|
assert.equal(isDetailsOpen("[details]"), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseVideoInput", () => {
|
||||||
|
it("BV 号与 B站链接", () => {
|
||||||
|
assert.deepEqual(parseVideoInput("BV1GJ411x7h7"), {
|
||||||
|
provider: "bilibili",
|
||||||
|
id: "BV1GJ411x7h7",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoInput("https://www.bilibili.com/video/BV1GJ411x7h7/?p=1"), {
|
||||||
|
provider: "bilibili",
|
||||||
|
id: "BV1GJ411x7h7",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("YouTube ID 与链接", () => {
|
||||||
|
assert.deepEqual(parseVideoInput("dQw4w9WgXcQ"), {
|
||||||
|
provider: "youtube",
|
||||||
|
id: "dQw4w9WgXcQ",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoInput("https://youtu.be/dQw4w9WgXcQ"), {
|
||||||
|
provider: "youtube",
|
||||||
|
id: "dQw4w9WgXcQ",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoInput("https://www.youtube.com/watch?v=dQw4w9WgXcQ"), {
|
||||||
|
provider: "youtube",
|
||||||
|
id: "dQw4w9WgXcQ",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("非法输入返回 null", () => {
|
||||||
|
assert.equal(parseVideoInput(""), null);
|
||||||
|
assert.equal(parseVideoInput("hello world"), null);
|
||||||
|
assert.equal(parseVideoInput("https://evil.com/<script>"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseVideoLine", () => {
|
||||||
|
it("整行短代码匹配且大小写不敏感", () => {
|
||||||
|
assert.deepEqual(parseVideoLine("[bilibili]BV1GJ411x7h7[/bilibili]"), {
|
||||||
|
provider: "bilibili",
|
||||||
|
id: "BV1GJ411x7h7",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoLine("[BILIBILI]https://www.bilibili.com/video/BV1GJ411x7h7[/BILIBILI]"), {
|
||||||
|
provider: "bilibili",
|
||||||
|
id: "BV1GJ411x7h7",
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseVideoLine("[youtube]dQw4w9WgXcQ[/youtube]"), {
|
||||||
|
provider: "youtube",
|
||||||
|
id: "dQw4w9WgXcQ",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("标签与平台不一致或内容非法时返回 null", () => {
|
||||||
|
assert.equal(parseVideoLine("[bilibili]dQw4w9WgXcQ[/bilibili]"), null);
|
||||||
|
assert.equal(parseVideoLine("[youtube]BV1GJ411x7h7[/youtube]"), null);
|
||||||
|
assert.equal(parseVideoLine("[bilibili]随便[/bilibili]"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitContentSegments: details/video", () => {
|
||||||
|
it("[details] 块拆分,正文保留 markdown", () => {
|
||||||
|
const segs = splitContentSegments(
|
||||||
|
"前文\n\n[details 结论]\n**加粗** 内容\n[/details]\n\n后文"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
segs.map((s) => s.type),
|
||||||
|
["md", "details", "md"]
|
||||||
|
);
|
||||||
|
const d = segs[1];
|
||||||
|
assert.equal(d.type === "details" ? d.title : "", "结论");
|
||||||
|
assert.equal(d.type === "details" ? d.body : "", "**加粗** 内容");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("details 嵌套开标签:外层按 md,内层与闭合配对(与 timeline 语义一致)", () => {
|
||||||
|
const segs = splitContentSegments("[details A]\n[details B]\n[/details]");
|
||||||
|
assert.deepEqual(segs.map((s) => s.type), ["md", "details"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未闭合的 details 整段按 md", () => {
|
||||||
|
const segs = splitContentSegments("正文\n\n[details A]\n没有闭合标记");
|
||||||
|
assert.deepEqual(segs.map((s) => s.type), ["md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("代码围栏内伪语法不拆", () => {
|
||||||
|
const segs = splitContentSegments("```\n[details 假的]\n[/details]\n```");
|
||||||
|
assert.deepEqual(segs.map((s) => s.type), ["md"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("视频整行拆为 video 段;行内出现不拆", () => {
|
||||||
|
const segs = splitContentSegments(
|
||||||
|
"看这个:\n\n[bilibili]BV1GJ411x7h7[/bilibili]\n\n文中 [youtube]dQw4w9WgXcQ[/youtube] 不拆"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
segs.map((s) => s.type),
|
||||||
|
["md", "video", "md"]
|
||||||
|
);
|
||||||
|
const v = segs[1];
|
||||||
|
assert.equal(v.type === "video" ? v.provider : "", "bilibili");
|
||||||
|
assert.equal(v.type === "video" ? v.id : "", "BV1GJ411x7h7");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,7 +25,9 @@ export type ContentSegment =
|
|||||||
body: string;
|
body: string;
|
||||||
}
|
}
|
||||||
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
| { type: "timeline"; items: TimelineItem[]; rawBody: string }
|
||||||
| { type: "entries"; items: EntryItem[]; rawBody: string };
|
| { type: "entries"; items: EntryItem[]; rawBody: string }
|
||||||
|
| { type: "details"; title: string; body: string }
|
||||||
|
| { type: "video"; provider: "bilibili" | "youtube"; id: string };
|
||||||
|
|
||||||
function isTimelineClose(line: string): boolean {
|
function isTimelineClose(line: string): boolean {
|
||||||
return line.trim().toLowerCase() === "[/timeline]";
|
return line.trim().toLowerCase() === "[/timeline]";
|
||||||
@@ -35,6 +37,68 @@ function isTimelineOpen(trimmed: string): boolean {
|
|||||||
return trimmed.toLowerCase() === "[timeline]";
|
return trimmed.toLowerCase() === "[timeline]";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- [details] 折叠面板 ---------- */
|
||||||
|
|
||||||
|
const DETAILS_OPEN_RE = /^\[details(?:\s+([^\]]*))?\]$/i;
|
||||||
|
export const DETAILS_DEFAULT_TITLE = "点击展开";
|
||||||
|
|
||||||
|
function isDetailsClose(line: string): boolean {
|
||||||
|
return line.trim().toLowerCase() === "[/details]";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开标记整行匹配:[details 标题文字];标题缺省「点击展开」 */
|
||||||
|
export function isDetailsOpen(trimmed: string): boolean {
|
||||||
|
return DETAILS_OPEN_RE.test(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDetailsOpen(trimmed: string): string {
|
||||||
|
const m = trimmed.match(DETAILS_OPEN_RE);
|
||||||
|
const title = (m?.[1] ?? "").trim();
|
||||||
|
return title || DETAILS_DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- [bilibili] / [youtube] 视频嵌入(整行单标记) ---------- */
|
||||||
|
|
||||||
|
const VIDEO_LINE_RE = /^\[(bilibili|youtube)\]([^\[\]]+)\[\/\1\]$/i;
|
||||||
|
const BILI_BV_RE = /^BV[0-9A-Za-z]{8,12}$/;
|
||||||
|
const BILI_URL_BV_RE = /\/video\/(BV[0-9A-Za-z]{8,12})/;
|
||||||
|
const YT_ID_RE = /^[0-9A-Za-z_-]{6,20}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 裸输入解析:BV 号 / B站视频链接 / YouTube ID / youtu.be、watch?v= 链接。
|
||||||
|
* 短代码行与编辑器「插入视频」表单共用;解析失败返回 null。
|
||||||
|
*/
|
||||||
|
export function parseVideoInput(
|
||||||
|
raw: string
|
||||||
|
): { provider: "bilibili" | "youtube"; id: string } | null {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (!s || /[\s<>"']/.test(s)) return null;
|
||||||
|
const bvFromUrl = s.match(BILI_URL_BV_RE);
|
||||||
|
if (bvFromUrl) return { provider: "bilibili", id: bvFromUrl[1] };
|
||||||
|
if (BILI_BV_RE.test(s)) return { provider: "bilibili", id: s };
|
||||||
|
if (/youtu\.?be/i.test(s)) {
|
||||||
|
const m = s.match(/(?:youtu\.be\/|[?&]v=)([0-9A-Za-z_-]{6,20})/);
|
||||||
|
return m ? { provider: "youtube", id: m[1] } : null;
|
||||||
|
}
|
||||||
|
if (YT_ID_RE.test(s)) return { provider: "youtube", id: s };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整行 `[bilibili]…[/bilibili]` / `[youtube]…[/youtube]`。
|
||||||
|
* 标签名与内容平台不一致、或内容解析失败时返回 null(该行按普通 Markdown 处理)。
|
||||||
|
*/
|
||||||
|
export function parseVideoLine(
|
||||||
|
trimmed: string
|
||||||
|
): { provider: "bilibili" | "youtube"; id: string } | null {
|
||||||
|
const m = trimmed.match(VIDEO_LINE_RE);
|
||||||
|
if (!m) return null;
|
||||||
|
const provider = m[1].toLowerCase() as "bilibili" | "youtube";
|
||||||
|
const parsed = parseVideoInput(m[2]);
|
||||||
|
if (!parsed || parsed.provider !== provider) return null;
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
* 先拆时间线,再对普通 md/hide 段做 hide 拆分。
|
||||||
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
* 时间线与 hide 互不嵌套;解析失败时整段当普通 Markdown。
|
||||||
@@ -53,7 +117,11 @@ export function splitContentSegments(
|
|||||||
}
|
}
|
||||||
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
const lines = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||||
const raw: Array<
|
const raw: Array<
|
||||||
{ type: "md"; text: string } | { type: "timeline"; body: string } | { type: "entries"; body: string }
|
| { type: "md"; text: string }
|
||||||
|
| { type: "timeline"; body: string }
|
||||||
|
| { type: "entries"; body: string }
|
||||||
|
| { type: "details"; title: string; body: string }
|
||||||
|
| { type: "video"; provider: "bilibili" | "youtube"; id: string }
|
||||||
> = [];
|
> = [];
|
||||||
let buf: string[] = [];
|
let buf: string[] = [];
|
||||||
let inCode = false;
|
let inCode = false;
|
||||||
@@ -137,6 +205,29 @@ export function splitContentSegments(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [details 标题]…[/details] 折叠面板:嵌套开标签视为失败按 md 处理
|
||||||
|
if (isDetailsOpen(trimmed)) {
|
||||||
|
const hit = scanBlock(isDetailsOpen, isDetailsClose);
|
||||||
|
if (!hit) {
|
||||||
|
buf.push(lines[i]);
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flushMd();
|
||||||
|
raw.push({ type: "details", title: parseDetailsOpen(trimmed), body: hit.body });
|
||||||
|
i = hit.end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [bilibili]…[/bilibili] / [youtube]…[/youtube] 整行视频嵌入
|
||||||
|
const video = parseVideoLine(trimmed);
|
||||||
|
if (video) {
|
||||||
|
flushMd();
|
||||||
|
raw.push({ type: "video", ...video });
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
// [entries:off] 例外块:body 原样进 md 缓冲(标记行丢弃),
|
||||||
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
// 不再参与 entries/timeline 拆分;未闭合时整块当普通 Markdown
|
||||||
if (isEntriesOffOpen(trimmed)) {
|
if (isEntriesOffOpen(trimmed)) {
|
||||||
@@ -174,6 +265,14 @@ export function splitContentSegments(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (part.type === "details") {
|
||||||
|
out.push({ type: "details", title: part.title, body: part.body });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (part.type === "video") {
|
||||||
|
out.push({ type: "video", provider: part.provider, id: part.id });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
const hideSegs = splitHideSegments(part.text) as HideSegment[];
|
||||||
for (const s of hideSegs) {
|
for (const s of hideSegs) {
|
||||||
if (s.type === "md") {
|
if (s.type === "md") {
|
||||||
|
|||||||
80
frontend/lib/inlineShortcodes.test.ts
Normal file
80
frontend/lib/inlineShortcodes.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import {
|
||||||
|
isValidColorValue,
|
||||||
|
splitInlineShortcodes,
|
||||||
|
} from "./inlineShortcodes.ts";
|
||||||
|
|
||||||
|
describe("isValidColorValue", () => {
|
||||||
|
it("放行 #hex 与英文颜色名", () => {
|
||||||
|
assert.equal(isValidColorValue("#e03131"), true);
|
||||||
|
assert.equal(isValidColorValue("#FFF"), true);
|
||||||
|
assert.equal(isValidColorValue("#ffffffff"), true);
|
||||||
|
assert.equal(isValidColorValue("tomato"), true);
|
||||||
|
});
|
||||||
|
it("拒绝注入向量与非法值", () => {
|
||||||
|
assert.equal(isValidColorValue("url(https://evil)", ), false);
|
||||||
|
assert.equal(isValidColorValue("red;padding:0"), false);
|
||||||
|
assert.equal(isValidColorValue("#GGG"), false);
|
||||||
|
assert.equal(isValidColorValue(""), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("splitInlineShortcodes", () => {
|
||||||
|
it("无短代码时返回 null", () => {
|
||||||
|
assert.equal(splitInlineShortcodes("普通文本 == 未闭合 [kbd]未闭合"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("==高亮==", () => {
|
||||||
|
assert.deepEqual(splitInlineShortcodes("前==高亮==后"), [
|
||||||
|
{ type: "text", value: "前" },
|
||||||
|
{ type: "mark", value: "高亮" },
|
||||||
|
{ type: "text", value: "后" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("[kbd]", () => {
|
||||||
|
assert.deepEqual(splitInlineShortcodes("[kbd]Ctrl+C[/kbd]"), [
|
||||||
|
{ type: "kbd", value: "Ctrl+C" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("[color=前景] 与 [bg=背景]", () => {
|
||||||
|
assert.deepEqual(splitInlineShortcodes("[color=#e03131]红字[/color]"), [
|
||||||
|
{ type: "color", value: "红字", fg: "#e03131" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(splitInlineShortcodes("[bg=#fff3bf]黄底[/bg]"), [
|
||||||
|
{ type: "color", value: "黄底", bg: "#fff3bf" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("[color=前景 bg=背景] 组合", () => {
|
||||||
|
assert.deepEqual(splitInlineShortcodes("[color=#fff bg=#c92a2a]白字红底[/color]"), [
|
||||||
|
{ type: "color", value: "白字红底", fg: "#fff", bg: "#c92a2a" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非法颜色整段回退为原文(返回 null 表示无需改写)", () => {
|
||||||
|
assert.equal(splitInlineShortcodes("[color=url(https://evil)]x[/color]"), null);
|
||||||
|
assert.equal(splitInlineShortcodes("[bg=red;padding:0]x[/bg]"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("未闭合标记保持字面", () => {
|
||||||
|
assert.equal(splitInlineShortcodes("[color=#fff]未闭合"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("混合片段按顺序拆分", () => {
|
||||||
|
assert.deepEqual(splitInlineShortcodes("a==b==[kbd]C[/kbd]d"), [
|
||||||
|
{ type: "text", value: "a" },
|
||||||
|
{ type: "mark", value: "b" },
|
||||||
|
{ type: "kbd", value: "C" },
|
||||||
|
{ type: "text", value: "d" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("大小写不敏感", () => {
|
||||||
|
assert.deepEqual(splitInlineShortcodes("[KBD]Esc[/KBD]"), [
|
||||||
|
{ type: "kbd", value: "Esc" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
75
frontend/lib/inlineShortcodes.ts
Normal file
75
frontend/lib/inlineShortcodes.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 行内短代码解析(纯函数):
|
||||||
|
* ==高亮== / [kbd]Ctrl[/kbd] / [color=前景( bg=背景)]文字[/color] / [bg=背景]文字[/bg]
|
||||||
|
* 颜色值白名单(#hex 或 CSS 颜色名),非法值整段按普通文本回退,防 CSS 注入。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type InlineShortcodeSegment =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "mark"; value: string }
|
||||||
|
| { type: "kbd"; value: string }
|
||||||
|
| { type: "color"; value: string; fg?: string; bg?: string };
|
||||||
|
|
||||||
|
export const COLOR_VALUE_RE = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]{1,32})$/;
|
||||||
|
|
||||||
|
export function isValidColorValue(v: string): boolean {
|
||||||
|
return COLOR_VALUE_RE.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描顺序:[kbd] → [color] → [bg] → ==…==。
|
||||||
|
* 标签内容不含 [ 与换行,避免嵌套歧义;未闭合标记保持字面。
|
||||||
|
* 无任何短代码命中时返回 null(调用方保留原节点)。
|
||||||
|
*/
|
||||||
|
const SCAN_RE =
|
||||||
|
/\[kbd\]([^\[\]\n]+)\[\/kbd\]|\[color=([^\]\s\[]+)(?:\s+bg=([^\]\s\[]+))?\]([^\[\]\n]+)\[\/color\]|\[bg=([^\]\s\[]+)\]([^\[\]\n]+)\[\/bg\]|==([^=\n]+)==/gi;
|
||||||
|
|
||||||
|
export function splitInlineShortcodes(
|
||||||
|
text: string
|
||||||
|
): InlineShortcodeSegment[] | null {
|
||||||
|
if (!text) return null;
|
||||||
|
const re = new RegExp(SCAN_RE.source, "gi");
|
||||||
|
const out: InlineShortcodeSegment[] = [];
|
||||||
|
let last = 0;
|
||||||
|
let changed = false;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text))) {
|
||||||
|
const [full, kbd, colorFg, colorBg, colorBody, bgVal, bgBody, markBody] = m;
|
||||||
|
const before = text.slice(last, m.index);
|
||||||
|
if (before) out.push({ type: "text", value: before });
|
||||||
|
last = m.index + full.length;
|
||||||
|
|
||||||
|
if (kbd !== undefined) {
|
||||||
|
out.push({ type: "kbd", value: kbd });
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (colorFg !== undefined) {
|
||||||
|
if (!isValidColorValue(colorFg) || (colorBg !== undefined && !isValidColorValue(colorBg))) {
|
||||||
|
out.push({ type: "text", value: full });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(
|
||||||
|
colorBg
|
||||||
|
? { type: "color", value: colorBody, fg: colorFg, bg: colorBg }
|
||||||
|
: { type: "color", value: colorBody, fg: colorFg }
|
||||||
|
);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (bgVal !== undefined) {
|
||||||
|
if (!isValidColorValue(bgVal)) {
|
||||||
|
out.push({ type: "text", value: full });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({ type: "color", value: bgBody, bg: bgVal });
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push({ type: "mark", value: markBody });
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
const rest = text.slice(last);
|
||||||
|
if (rest) out.push({ type: "text", value: rest });
|
||||||
|
return changed ? out : null;
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ export interface RtSettingsData {
|
|||||||
brand_mark?: string;
|
brand_mark?: string;
|
||||||
brand_logo_size?: string;
|
brand_logo_size?: string;
|
||||||
brand_logo_fit?: string;
|
brand_logo_fit?: string;
|
||||||
|
brand_slogan_visible?: boolean;
|
||||||
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
|
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
|
||||||
allow_register?: boolean;
|
allow_register?: boolean;
|
||||||
allow_comments?: boolean;
|
allow_comments?: boolean;
|
||||||
|
|||||||
@@ -167,3 +167,11 @@ export function estimateReadingMinutes(text: string, fallbackLen = 0): number {
|
|||||||
const len = text.trim().length > 0 ? text.trim().length : fallbackLen;
|
const len = text.trim().length > 0 ? text.trim().length : fallbackLen;
|
||||||
return Math.max(1, Math.ceil(len / 400));
|
return Math.max(1, Math.ceil(len / 400));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 阅读时长展示:≥60 分钟换算成「X 小时 Y 分钟」,避免大分钟数需口算 */
|
||||||
|
export function formatReadingDuration(minutes: number): string {
|
||||||
|
if (minutes < 60) return `${minutes} 分钟`;
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
return m > 0 ? `${h} 小时 ${m} 分钟` : `${h} 小时`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"postbuild": "node scripts/extract-font-css.mjs",
|
"postbuild": "node scripts/extract-font-css.mjs",
|
||||||
"start": "next start -p 3000",
|
"start": "next start -p 3000",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/widgetLint.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts lib/bookSearch.test.ts"
|
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/widgetLint.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts lib/bookSearch.test.ts lib/inlineShortcodes.test.ts lib/directiveBlocks.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user