feat: add ads/sponsor/captcha modules and polish home & chat UI
This commit is contained in:
253
backend/handler/ad.go
Normal file
253
backend/handler/ad.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/middleware"
|
||||||
|
"github.com/freefire/jiang13-bbs/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptchaIssue GET /api/captcha
|
||||||
|
func (h *Handlers) CaptchaIssue(c *gin.Context) {
|
||||||
|
if h.Ads == nil || h.Ads.Captcha() == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "验证码服务不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, img, err := h.Ads.Captcha().Issue()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成验证码失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"id": id, "image": img})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdsPublicConfig GET /api/ads/config
|
||||||
|
func (h *Handlers) AdsPublicConfig(c *gin.Context) {
|
||||||
|
cfg, err := h.Ads.PublicConfig()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取配置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
type adSubmitReq struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ContactEmail string `json:"contact_email"`
|
||||||
|
LinkURL string `json:"link_url"`
|
||||||
|
ImageURL string `json:"image_url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TextColor string `json:"text_color"`
|
||||||
|
BgColor string `json:"bg_color"`
|
||||||
|
DurationDays int `json:"duration_days"`
|
||||||
|
PaymentID string `json:"payment_id"`
|
||||||
|
BuyerNote string `json:"buyer_note"`
|
||||||
|
CaptchaID string `json:"captcha_id"`
|
||||||
|
CaptchaCode string `json:"captcha_code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdsSubmit POST /api/ads
|
||||||
|
func (h *Handlers) AdsSubmit(c *gin.Context) {
|
||||||
|
var req adSubmitReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ad, err := h.Ads.Submit(service.AdSubmitInput{
|
||||||
|
Kind: req.Kind,
|
||||||
|
ContactEmail: req.ContactEmail,
|
||||||
|
LinkURL: req.LinkURL,
|
||||||
|
ImageURL: req.ImageURL,
|
||||||
|
Title: req.Title,
|
||||||
|
TextColor: req.TextColor,
|
||||||
|
BgColor: req.BgColor,
|
||||||
|
DurationDays: req.DurationDays,
|
||||||
|
PaymentID: req.PaymentID,
|
||||||
|
BuyerNote: req.BuyerNote,
|
||||||
|
CaptchaID: req.CaptchaID,
|
||||||
|
CaptchaCode: req.CaptchaCode,
|
||||||
|
IP: c.ClientIP(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadRequest
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, service.ErrAdCaptcha):
|
||||||
|
status = http.StatusForbidden
|
||||||
|
case errors.Is(err, service.ErrAdDisabled):
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ad": ad, "message": "提交成功,请完成付款并等待审核"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetAdsConfig GET /api/admin/ads/config
|
||||||
|
func (h *Handlers) AdminGetAdsConfig(c *gin.Context) {
|
||||||
|
cfg, err := h.Ads.GetConfig()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "读取失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSaveAdsConfig PUT /api/admin/ads/config
|
||||||
|
func (h *Handlers) AdminSaveAdsConfig(c *gin.Context) {
|
||||||
|
var req service.AdConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.Ads.SaveConfig(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListAds GET /api/admin/ads
|
||||||
|
func (h *Handlers) AdminListAds(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||||
|
list, total, err := h.Ads.AdminList(c.Query("status"), page, size)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ads": list, "total": total, "page": page, "size": size})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminApproveAd PUT /api/admin/ads/:id/approve
|
||||||
|
func (h *Handlers) AdminApproveAd(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := middleware.CurrentActor(c)
|
||||||
|
ad, err := h.Ads.Approve(actor.ID, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ad": ad})
|
||||||
|
}
|
||||||
|
|
||||||
|
type adRejectReq struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRejectAd PUT /api/admin/ads/:id/reject
|
||||||
|
func (h *Handlers) AdminRejectAd(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req adRejectReq
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
actor := middleware.CurrentActor(c)
|
||||||
|
ad, err := h.Ads.Reject(actor.ID, uint(id), req.Reason)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ad": ad})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminDeleteAd DELETE /api/admin/ads/:id
|
||||||
|
func (h *Handlers) AdminDeleteAd(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Ads.Delete(uint(id)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
type adSortReq struct {
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSortAd PUT /api/admin/ads/:id/sort
|
||||||
|
func (h *Handlers) AdminSortAd(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req adSortReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Ads.SetSort(uint(id), req.SortOrder); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUploadAdQR POST /api/admin/upload/ad-qr — 收款码上传到 /uploads/ads/
|
||||||
|
func (h *Handlers) AdminUploadAdQR(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.AdAssetMaxBytes+4096)
|
||||||
|
fh, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择图片文件"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f, err := fh.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
url, err := h.Upload.SaveAdAsset(f)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"url": url})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetSponsorsConfig GET /api/admin/sponsors/config
|
||||||
|
func (h *Handlers) AdminGetSponsorsConfig(c *gin.Context) {
|
||||||
|
if h.Ads == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.Ads.GetSponsorsConfig()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSaveSponsorsConfig PUT /api/admin/sponsors/config
|
||||||
|
func (h *Handlers) AdminSaveSponsorsConfig(c *gin.Context) {
|
||||||
|
if h.Ads == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "服务不可用"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req service.SponsorsConfig
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.Ads.SaveSponsorsConfig(req)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, cfg)
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ type Handlers struct {
|
|||||||
Telemetry *service.TelemetryService
|
Telemetry *service.TelemetryService
|
||||||
Analytics *service.AnalyticsService
|
Analytics *service.AnalyticsService
|
||||||
HidePwd *service.HidePasswordCookie
|
HidePwd *service.HidePasswordCookie
|
||||||
|
Ads *service.AdService
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolvePublishStatus 决定新帖/新评的初始状态:
|
// resolvePublishStatus 决定新帖/新评的初始状态:
|
||||||
|
|||||||
@@ -38,5 +38,11 @@ func (h *Handlers) Overview(c *gin.Context) {
|
|||||||
"sidebar_pages": data.SidebarPages,
|
"sidebar_pages": data.SidebarPages,
|
||||||
"new_users": data.NewUsers,
|
"new_users": data.NewUsers,
|
||||||
"checkin": data.Checkin,
|
"checkin": data.Checkin,
|
||||||
|
"ads": data.Ads,
|
||||||
|
"ads_panel_title": data.AdsPanelTitle,
|
||||||
|
"ads_enabled": data.AdsEnabled,
|
||||||
|
"sponsors": data.Sponsors,
|
||||||
|
"sponsors_panel_title": data.SponsorsPanelTitle,
|
||||||
|
"sponsors_enabled": data.SponsorsEnabled,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
44
backend/model/ad.go
Normal file
44
backend/model/ad.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 广告类型与状态
|
||||||
|
const (
|
||||||
|
AdKindImage = "image"
|
||||||
|
AdKindText = "text"
|
||||||
|
|
||||||
|
AdStatusPending = "pending"
|
||||||
|
AdStatusActive = "active"
|
||||||
|
AdStatusRejected = "rejected"
|
||||||
|
AdStatusExpired = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ad 赞助广告(游客可申购,管理员审核后展示于首页侧栏)
|
||||||
|
type Ad struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Kind string `gorm:"size:16;not null;index" json:"kind"` // image | text
|
||||||
|
Status string `gorm:"size:16;not null;index" json:"status"` // pending/active/rejected/expired
|
||||||
|
ContactEmail string `gorm:"size:128;not null" json:"contact_email"`
|
||||||
|
LinkURL string `gorm:"size:512;not null" json:"link_url"`
|
||||||
|
ImageURL string `gorm:"size:512" json:"image_url"` // 图片广告
|
||||||
|
Title string `gorm:"size:64" json:"title"` // 文字广告
|
||||||
|
TextColor string `gorm:"size:16" json:"text_color"`
|
||||||
|
BgColor string `gorm:"size:16" json:"bg_color"`
|
||||||
|
DurationDays int `gorm:"not null" json:"duration_days"`
|
||||||
|
PaymentID string `gorm:"size:64;not null" json:"payment_id"`
|
||||||
|
BuyerNote string `gorm:"size:200" json:"buyer_note"`
|
||||||
|
StartsAt *time.Time `json:"starts_at"`
|
||||||
|
EndsAt *time.Time `json:"ends_at"`
|
||||||
|
RejectReason string `gorm:"size:200" json:"reject_reason"`
|
||||||
|
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||||
|
SubmitIP string `gorm:"size:45" json:"-"`
|
||||||
|
ReviewedAt *time.Time `json:"reviewed_at"`
|
||||||
|
ReviewedBy *uint `json:"reviewed_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func InitDB(dsn string) error {
|
|||||||
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||||
&PostPollVote{}, &PostLotteryEntry{},
|
&PostPollVote{}, &PostLotteryEntry{}, &Ad{},
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("自动迁移失败: %w", err)
|
return fmt.Errorf("自动迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -78,6 +78,11 @@ func InitDB(dsn string) error {
|
|||||||
return fmt.Errorf("login_logs 默认值修正失败: %w", err)
|
return fmt.Errorf("login_logs 默认值修正失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 早期 site_settings.value 可能是 varchar(255);广告/赞助商 JSON 会超长
|
||||||
|
if err := prepareSiteSettingValueText(db); err != nil {
|
||||||
|
return fmt.Errorf("site_settings.value 扩容失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// 新表结构就位后删除遗留的明文列
|
// 新表结构就位后删除遗留的明文列
|
||||||
if err := dropLegacyRefreshTokenColumn(db); err != nil {
|
if err := dropLegacyRefreshTokenColumn(db); err != nil {
|
||||||
return fmt.Errorf("refresh token 旧列清理失败: %w", err)
|
return fmt.Errorf("refresh token 旧列清理失败: %w", err)
|
||||||
@@ -152,6 +157,28 @@ func prepareAnnouncementPinnedColumn(db *gorm.DB) error {
|
|||||||
return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error
|
return db.Exec(`ALTER TABLE announcements ADD COLUMN pinned boolean NOT NULL DEFAULT false`).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepareSiteSettingValueText 将 site_settings.value 从 varchar(255) 扩为 text(幂等)
|
||||||
|
func prepareSiteSettingValueText(db *gorm.DB) error {
|
||||||
|
var tableCount int64
|
||||||
|
if err := db.Raw(`SELECT count(1) FROM information_schema.tables WHERE table_name = 'site_settings'`).
|
||||||
|
Scan(&tableCount).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tableCount == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var dataType string
|
||||||
|
if err := db.Raw(`SELECT data_type FROM information_schema.columns
|
||||||
|
WHERE table_name = 'site_settings' AND column_name = 'value'`).
|
||||||
|
Scan(&dataType).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if dataType == "" || dataType == "text" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return db.Exec(`ALTER TABLE site_settings ALTER COLUMN value TYPE text`).Error
|
||||||
|
}
|
||||||
|
|
||||||
// prepareRefreshTokenSessionColumns 存量 refresh_tokens 加 logged_in_at / last_used_at:
|
// prepareRefreshTokenSessionColumns 存量 refresh_tokens 加 logged_in_at / last_used_at:
|
||||||
// 先可空加列,用 created_at 回填后再收紧 NOT NULL。
|
// 先可空加列,用 created_at 回填后再收紧 NOT NULL。
|
||||||
func prepareRefreshTokenSessionColumns(db *gorm.DB) error {
|
func prepareRefreshTokenSessionColumns(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
chatSvc := service.NewChatService(model.DB, notifSvc)
|
chatSvc := service.NewChatService(model.DB, notifSvc)
|
||||||
telemetrySvc := service.NewTelemetryService(model.DB)
|
telemetrySvc := service.NewTelemetryService(model.DB)
|
||||||
analyticsSvc := service.NewAnalyticsService(model.DB)
|
analyticsSvc := service.NewAnalyticsService(model.DB)
|
||||||
|
adSvc := service.NewAdService(model.DB, service.NewCaptchaStore())
|
||||||
|
overviewSvc.WithAds(adSvc)
|
||||||
if err := uploadSvc.EnsureDir(); err != nil {
|
if err := uploadSvc.EnsureDir(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -106,6 +108,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
Telemetry: telemetrySvc,
|
Telemetry: telemetrySvc,
|
||||||
Analytics: analyticsSvc,
|
Analytics: analyticsSvc,
|
||||||
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
HidePwd: service.NewHidePasswordCookie(cfg.JWTSecret, !cfg.DevMode),
|
||||||
|
Ads: adSvc,
|
||||||
}
|
}
|
||||||
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
// 通知落库后统一推 WS 红点(点赞/评论/审核/@ 等共用)
|
||||||
notifSvc.OnNotifyNew = func(userID uint) {
|
notifSvc.OnNotifyNew = func(userID uint) {
|
||||||
@@ -161,6 +164,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
|
pubAPI.POST("/auth/refresh", middleware.CSRFMiddleware(), h.Refresh)
|
||||||
// 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验)
|
// 登出不依赖有效登录态:封禁/登录态失效后前端仍需凭它清除 cookie(CSRF 仍校验)
|
||||||
pubAPI.POST("/logout", middleware.CSRFMiddleware(), h.Logout)
|
pubAPI.POST("/logout", middleware.CSRFMiddleware(), h.Logout)
|
||||||
|
pubAPI.GET("/captcha", middleware.RateLimitMiddleware(limiter, service.RateCaptcha), h.CaptchaIssue)
|
||||||
|
pubAPI.GET("/ads/config", h.AdsPublicConfig)
|
||||||
|
pubAPI.POST("/ads", middleware.CSRFMiddleware(), middleware.RateLimitMiddleware(limiter, service.RateAdSubmit), h.AdsSubmit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 需登录 API(先鉴权,再 CSRF 防护)
|
// 需登录 API(先鉴权,再 CSRF 防护)
|
||||||
@@ -284,6 +290,19 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
|||||||
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
announceAPI.PUT("/pages/:id", h.AdminUpdateSitePage)
|
||||||
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
announceAPI.DELETE("/pages/:id", h.AdminDeleteSitePage)
|
||||||
|
|
||||||
|
// 广告位管理(管理员及以上)
|
||||||
|
adsAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||||
|
adsAPI.GET("/ads/config", h.AdminGetAdsConfig)
|
||||||
|
adsAPI.PUT("/ads/config", h.AdminSaveAdsConfig)
|
||||||
|
adsAPI.GET("/ads", h.AdminListAds)
|
||||||
|
adsAPI.PUT("/ads/:id/approve", h.AdminApproveAd)
|
||||||
|
adsAPI.PUT("/ads/:id/reject", h.AdminRejectAd)
|
||||||
|
adsAPI.PUT("/ads/:id/sort", h.AdminSortAd)
|
||||||
|
adsAPI.DELETE("/ads/:id", h.AdminDeleteAd)
|
||||||
|
adsAPI.POST("/upload/ad-qr", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.AdminUploadAdQR)
|
||||||
|
adsAPI.GET("/sponsors/config", h.AdminGetSponsorsConfig)
|
||||||
|
adsAPI.PUT("/sponsors/config", h.AdminSaveSponsorsConfig)
|
||||||
|
|
||||||
// 站点设置(超级管理员/站长)
|
// 站点设置(超级管理员/站长)
|
||||||
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings)
|
||||||
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
opsAPI := staffAPI.Group("/settings/modules", authMW.RequirePerm(service.PermSettings))
|
||||||
|
|||||||
580
backend/service/ad.go
Normal file
580
backend/service/ad.go
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingKeyAdsConfig = "ads_config"
|
||||||
|
|
||||||
|
AdMaxTitleRunes = 24
|
||||||
|
AdMaxNoteRunes = 100
|
||||||
|
AdMaxImageShow = 5
|
||||||
|
AdMaxTextShow = 6
|
||||||
|
AdMaxActiveShow = AdMaxImageShow + AdMaxTextShow
|
||||||
|
AdDefaultTitle = "自助推广"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAdNotFound = errors.New("广告不存在")
|
||||||
|
ErrAdForbidden = errors.New("无权操作")
|
||||||
|
ErrAdInvalid = errors.New("广告参数无效")
|
||||||
|
ErrAdCaptcha = errors.New("验证码错误或已过期")
|
||||||
|
ErrAdDisabled = errors.New("自助推广暂未开放")
|
||||||
|
ErrAdBadPayment = errors.New("请选择有效的支付方式")
|
||||||
|
ErrAdBadDuration = errors.New("请选择有效的投放时长")
|
||||||
|
hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{6})$`)
|
||||||
|
adImageHTTPSRe = regexp.MustCompile(`(?i)^https://[^\s\\]{1,500}$`)
|
||||||
|
adImageUploadRe = regexp.MustCompile(`(?i)^/uploads/(ads|images|brand)/[0-9a-f]{32}\.(png|jpe?g|gif|webp)$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdDurationOption 可购时长档位
|
||||||
|
type AdDurationOption struct {
|
||||||
|
Days int `json:"days"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
PriceHint string `json:"price_hint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdPaymentOption 收款方式(展示二维码,人工确认)
|
||||||
|
type AdPaymentOption struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"` // alipay | wechat | other
|
||||||
|
QRURL string `json:"qr_url"`
|
||||||
|
Hint string `json:"hint"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdConfig 广告位运营配置
|
||||||
|
type AdConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
PanelTitle string `json:"panel_title"`
|
||||||
|
Durations []AdDurationOption `json:"durations"`
|
||||||
|
Payments []AdPaymentOption `json:"payments"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultAdConfig() AdConfig {
|
||||||
|
return AdConfig{
|
||||||
|
Enabled: true,
|
||||||
|
PanelTitle: AdDefaultTitle,
|
||||||
|
Durations: []AdDurationOption{
|
||||||
|
{Days: 30, Label: "1 个月", PriceHint: ""},
|
||||||
|
{Days: 60, Label: "2 个月", PriceHint: ""},
|
||||||
|
{Days: 90, Label: "3 个月", PriceHint: ""},
|
||||||
|
{Days: 180, Label: "6 个月", PriceHint: ""},
|
||||||
|
{Days: 365, Label: "12 个月", PriceHint: ""},
|
||||||
|
},
|
||||||
|
Payments: []AdPaymentOption{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdService 广告业务
|
||||||
|
type AdService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
captcha *CaptchaStore
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAdService(db *gorm.DB, captcha *CaptchaStore) *AdService {
|
||||||
|
return &AdService{db: db, captcha: captcha}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) Captcha() *CaptchaStore { return s.captcha }
|
||||||
|
|
||||||
|
func (s *AdService) loadConfig() (AdConfig, error) {
|
||||||
|
var row model.SiteSetting
|
||||||
|
err := s.db.Where("key = ?", SettingKeyAdsConfig).First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return DefaultAdConfig(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return AdConfig{}, err
|
||||||
|
}
|
||||||
|
cfg := DefaultAdConfig()
|
||||||
|
if strings.TrimSpace(row.Value) == "" {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
||||||
|
return DefaultAdConfig(), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.PanelTitle) == "" || cfg.PanelTitle == "赞助商" {
|
||||||
|
cfg.PanelTitle = AdDefaultTitle
|
||||||
|
}
|
||||||
|
if isLegacyAdDurations(cfg.Durations) {
|
||||||
|
cfg.Durations = DefaultAdConfig().Durations
|
||||||
|
}
|
||||||
|
for i := range cfg.Durations {
|
||||||
|
if strings.TrimSpace(cfg.Durations[i].PriceHint) == "面议" {
|
||||||
|
cfg.Durations[i].PriceHint = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) GetConfig() (AdConfig, error) {
|
||||||
|
return s.loadConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicConfig 购买页可见配置(仅启用的支付方式)
|
||||||
|
func (s *AdService) PublicConfig() (AdConfig, error) {
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
pays := make([]AdPaymentOption, 0, len(cfg.Payments))
|
||||||
|
for _, p := range cfg.Payments {
|
||||||
|
if p.Enabled && strings.TrimSpace(p.QRURL) != "" {
|
||||||
|
pays = append(pays, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.Payments = pays
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) SaveConfig(in AdConfig) (AdConfig, error) {
|
||||||
|
out, err := normalizeAdConfig(in)
|
||||||
|
if err != nil {
|
||||||
|
return AdConfig{}, err
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return AdConfig{}, err
|
||||||
|
}
|
||||||
|
row := model.SiteSetting{Key: SettingKeyAdsConfig, Value: string(b), UpdatedAt: time.Now()}
|
||||||
|
if err := s.db.Save(&row).Error; err != nil {
|
||||||
|
return AdConfig{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdConfig(in AdConfig) (AdConfig, error) {
|
||||||
|
out := DefaultAdConfig()
|
||||||
|
out.Enabled = in.Enabled
|
||||||
|
title := strings.TrimSpace(in.PanelTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = AdDefaultTitle
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(title) > 16 {
|
||||||
|
return AdConfig{}, ErrAdInvalid
|
||||||
|
}
|
||||||
|
out.PanelTitle = title
|
||||||
|
|
||||||
|
if len(in.Durations) == 0 || len(in.Durations) > 12 {
|
||||||
|
return AdConfig{}, errors.New("请配置 1–12 个时长档位")
|
||||||
|
}
|
||||||
|
durs := make([]AdDurationOption, 0, len(in.Durations))
|
||||||
|
seenDays := map[int]struct{}{}
|
||||||
|
for _, d := range in.Durations {
|
||||||
|
if d.Days < 1 || d.Days > 366 {
|
||||||
|
return AdConfig{}, errors.New("时长须为 1–366 天")
|
||||||
|
}
|
||||||
|
if _, ok := seenDays[d.Days]; ok {
|
||||||
|
return AdConfig{}, errors.New("时长档位不可重复")
|
||||||
|
}
|
||||||
|
seenDays[d.Days] = struct{}{}
|
||||||
|
label := strings.TrimSpace(d.Label)
|
||||||
|
if label == "" {
|
||||||
|
label = formatDaysLabel(d.Days)
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(label) > 20 {
|
||||||
|
return AdConfig{}, ErrAdInvalid
|
||||||
|
}
|
||||||
|
price := strings.TrimSpace(d.PriceHint)
|
||||||
|
if price == "面议" {
|
||||||
|
price = ""
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(price) > 32 {
|
||||||
|
return AdConfig{}, ErrAdInvalid
|
||||||
|
}
|
||||||
|
durs = append(durs, AdDurationOption{Days: d.Days, Label: label, PriceHint: price})
|
||||||
|
}
|
||||||
|
out.Durations = durs
|
||||||
|
|
||||||
|
if len(in.Payments) > 8 {
|
||||||
|
return AdConfig{}, errors.New("支付方式最多 8 个")
|
||||||
|
}
|
||||||
|
pays := make([]AdPaymentOption, 0, len(in.Payments))
|
||||||
|
seenID := map[string]struct{}{}
|
||||||
|
for i, p := range in.Payments {
|
||||||
|
id := strings.TrimSpace(p.ID)
|
||||||
|
if id == "" {
|
||||||
|
id = "pay_" + strings.TrimSpace(strings.ToLower(p.Kind)) + "_" + itoa(i+1)
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z0-9_-]{2,32}$`).MatchString(id) {
|
||||||
|
return AdConfig{}, errors.New("支付方式 ID 非法")
|
||||||
|
}
|
||||||
|
if _, ok := seenID[id]; ok {
|
||||||
|
return AdConfig{}, errors.New("支付方式 ID 重复")
|
||||||
|
}
|
||||||
|
seenID[id] = struct{}{}
|
||||||
|
name := strings.TrimSpace(p.Name)
|
||||||
|
if name == "" || utf8.RuneCountInString(name) > 20 {
|
||||||
|
return AdConfig{}, errors.New("支付方式名称无效")
|
||||||
|
}
|
||||||
|
kind := strings.TrimSpace(strings.ToLower(p.Kind))
|
||||||
|
switch kind {
|
||||||
|
case "alipay", "wechat", "other":
|
||||||
|
default:
|
||||||
|
return AdConfig{}, errors.New("支付类型须为 alipay / wechat / other")
|
||||||
|
}
|
||||||
|
qr, ok := normalizeAdAssetURL(p.QRURL, true)
|
||||||
|
if p.Enabled && !ok {
|
||||||
|
return AdConfig{}, errors.New("启用中的支付方式须配置有效收款码图片")
|
||||||
|
}
|
||||||
|
if !p.Enabled && strings.TrimSpace(p.QRURL) != "" && !ok {
|
||||||
|
return AdConfig{}, errors.New("收款码图片地址无效")
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
qr = ""
|
||||||
|
}
|
||||||
|
hint := strings.TrimSpace(p.Hint)
|
||||||
|
if utf8.RuneCountInString(hint) > 80 {
|
||||||
|
return AdConfig{}, ErrAdInvalid
|
||||||
|
}
|
||||||
|
pays = append(pays, AdPaymentOption{
|
||||||
|
ID: id, Name: name, Kind: kind, QRURL: qr, Hint: hint, Enabled: p.Enabled,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out.Payments = pays
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLegacyAdDurations 识别早期默认 7/30/90 天档位,读出时换成月档
|
||||||
|
func isLegacyAdDurations(durs []AdDurationOption) bool {
|
||||||
|
if len(durs) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return durs[0].Days == 7 && durs[1].Days == 30 && durs[2].Days == 90
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDaysLabel(days int) string {
|
||||||
|
switch days {
|
||||||
|
case 30:
|
||||||
|
return "1 个月"
|
||||||
|
case 60:
|
||||||
|
return "2 个月"
|
||||||
|
case 90:
|
||||||
|
return "3 个月"
|
||||||
|
case 180:
|
||||||
|
return "6 个月"
|
||||||
|
case 365:
|
||||||
|
return "12 个月"
|
||||||
|
default:
|
||||||
|
return itoa(days) + " 天"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b [12]byte
|
||||||
|
i := len(b)
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
for n > 0 {
|
||||||
|
i--
|
||||||
|
b[i] = byte('0' + n%10)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
i--
|
||||||
|
b[i] = '-'
|
||||||
|
}
|
||||||
|
return string(b[i:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdAssetURL(raw string, allowEmpty bool) (string, bool) {
|
||||||
|
u := strings.TrimSpace(raw)
|
||||||
|
if u == "" {
|
||||||
|
return "", allowEmpty
|
||||||
|
}
|
||||||
|
if adImageUploadRe.MatchString(u) {
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
if adImageHTTPSRe.MatchString(u) {
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAdLinkURL(raw string) (string, bool) {
|
||||||
|
u := strings.TrimSpace(raw)
|
||||||
|
if u == "" || utf8.RuneCountInString(u) > 500 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
lower := strings.ToLower(u)
|
||||||
|
if strings.HasPrefix(lower, "javascript:") || strings.HasPrefix(lower, "data:") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(lower, "https://") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(u)
|
||||||
|
if err != nil || parsed.Host == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return u, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeHexColor(raw, fallback string) (string, bool) {
|
||||||
|
s := strings.TrimSpace(raw)
|
||||||
|
if s == "" {
|
||||||
|
return fallback, true
|
||||||
|
}
|
||||||
|
if !hexColorRe.MatchString(s) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdSubmitInput 游客提交
|
||||||
|
type AdSubmitInput struct {
|
||||||
|
Kind string
|
||||||
|
ContactEmail string
|
||||||
|
LinkURL string
|
||||||
|
ImageURL string
|
||||||
|
Title string
|
||||||
|
TextColor string
|
||||||
|
BgColor string
|
||||||
|
DurationDays int
|
||||||
|
PaymentID string
|
||||||
|
BuyerNote string
|
||||||
|
CaptchaID string
|
||||||
|
CaptchaCode string
|
||||||
|
IP string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) Submit(in AdSubmitInput) (*model.Ad, error) {
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return nil, ErrAdDisabled
|
||||||
|
}
|
||||||
|
if s.captcha == nil || !s.captcha.Verify(in.CaptchaID, in.CaptchaCode) {
|
||||||
|
return nil, ErrAdCaptcha
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := strings.TrimSpace(in.Kind)
|
||||||
|
if kind != model.AdKindImage && kind != model.AdKindText {
|
||||||
|
return nil, ErrAdInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
email := strings.TrimSpace(in.ContactEmail)
|
||||||
|
if _, err := mail.ParseAddress(email); err != nil || len(email) > 128 {
|
||||||
|
return nil, errors.New("请填写有效的联系邮箱")
|
||||||
|
}
|
||||||
|
link, ok := normalizeAdLinkURL(in.LinkURL)
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("广告链接须为 https 地址")
|
||||||
|
}
|
||||||
|
|
||||||
|
var daysOK bool
|
||||||
|
for _, d := range cfg.Durations {
|
||||||
|
if d.Days == in.DurationDays {
|
||||||
|
daysOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !daysOK {
|
||||||
|
return nil, ErrAdBadDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
payOK := false
|
||||||
|
payID := strings.TrimSpace(in.PaymentID)
|
||||||
|
for _, p := range cfg.Payments {
|
||||||
|
if p.Enabled && p.ID == payID && strings.TrimSpace(p.QRURL) != "" {
|
||||||
|
payOK = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !payOK {
|
||||||
|
return nil, ErrAdBadPayment
|
||||||
|
}
|
||||||
|
|
||||||
|
note := strings.TrimSpace(in.BuyerNote)
|
||||||
|
if utf8.RuneCountInString(note) > AdMaxNoteRunes {
|
||||||
|
return nil, errors.New("备注过长")
|
||||||
|
}
|
||||||
|
|
||||||
|
ad := &model.Ad{
|
||||||
|
Kind: kind,
|
||||||
|
Status: model.AdStatusPending,
|
||||||
|
ContactEmail: email,
|
||||||
|
LinkURL: link,
|
||||||
|
DurationDays: in.DurationDays,
|
||||||
|
PaymentID: payID,
|
||||||
|
BuyerNote: note,
|
||||||
|
SubmitIP: strings.TrimSpace(in.IP),
|
||||||
|
}
|
||||||
|
|
||||||
|
switch kind {
|
||||||
|
case model.AdKindImage:
|
||||||
|
img, ok := normalizeAdAssetURL(in.ImageURL, false)
|
||||||
|
if !ok || !strings.HasPrefix(strings.ToLower(img), "https://") {
|
||||||
|
return nil, errors.New("图片地址须为 https(支持 png/jpg/gif/webp)")
|
||||||
|
}
|
||||||
|
ad.ImageURL = img
|
||||||
|
case model.AdKindText:
|
||||||
|
title := strings.TrimSpace(in.Title)
|
||||||
|
if title == "" || utf8.RuneCountInString(title) > AdMaxTitleRunes {
|
||||||
|
return nil, errors.New("请填写 1–24 字广告标题")
|
||||||
|
}
|
||||||
|
tc, ok := normalizeHexColor(in.TextColor, "#1E293B")
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("文字颜色须为 #RRGGBB")
|
||||||
|
}
|
||||||
|
bc, ok := normalizeHexColor(in.BgColor, "#F1F5F9")
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.New("背景颜色须为 #RRGGBB")
|
||||||
|
}
|
||||||
|
ad.Title = title
|
||||||
|
ad.TextColor = tc
|
||||||
|
ad.BgColor = bc
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Create(ad).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ad, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivePublic 侧栏展示用(自动视过期为失效;按类型各取上限)
|
||||||
|
func (s *AdService) ActivePublic() ([]model.Ad, AdConfig, error) {
|
||||||
|
cfg, err := s.loadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return nil, cfg, err
|
||||||
|
}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return []model.Ad{}, cfg, nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
_ = s.db.Model(&model.Ad{}).
|
||||||
|
Where("status = ? AND ends_at IS NOT NULL AND ends_at < ?", model.AdStatusActive, now).
|
||||||
|
Update("status", model.AdStatusExpired).Error
|
||||||
|
|
||||||
|
base := s.db.Where("status = ?", model.AdStatusActive).
|
||||||
|
Where("(ends_at IS NULL OR ends_at >= ?)", now).
|
||||||
|
Order("sort_order ASC, id DESC")
|
||||||
|
|
||||||
|
var images, texts []model.Ad
|
||||||
|
if err := base.Session(&gorm.Session{}).Where("kind = ?", model.AdKindImage).
|
||||||
|
Limit(AdMaxImageShow).Find(&images).Error; err != nil {
|
||||||
|
return nil, cfg, err
|
||||||
|
}
|
||||||
|
if err := base.Session(&gorm.Session{}).Where("kind = ?", model.AdKindText).
|
||||||
|
Limit(AdMaxTextShow).Find(&texts).Error; err != nil {
|
||||||
|
return nil, cfg, err
|
||||||
|
}
|
||||||
|
list := make([]model.Ad, 0, len(images)+len(texts))
|
||||||
|
list = append(list, images...)
|
||||||
|
list = append(list, texts...)
|
||||||
|
return list, cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) AdminList(status string, page, size int) ([]model.Ad, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if size < 1 || size > 50 {
|
||||||
|
size = 20
|
||||||
|
}
|
||||||
|
q := s.db.Model(&model.Ad{})
|
||||||
|
status = strings.TrimSpace(status)
|
||||||
|
if status != "" {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var list []model.Ad
|
||||||
|
err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error
|
||||||
|
return list, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) Approve(actorID, id uint) (*model.Ad, error) {
|
||||||
|
var ad model.Ad
|
||||||
|
if err := s.db.First(&ad, id).Error; err != nil {
|
||||||
|
return nil, ErrAdNotFound
|
||||||
|
}
|
||||||
|
if ad.Status != model.AdStatusPending && ad.Status != model.AdStatusExpired && ad.Status != model.AdStatusRejected {
|
||||||
|
if ad.Status != model.AdStatusActive {
|
||||||
|
return nil, errors.New("当前状态不可上架")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
ends := now.Add(time.Duration(ad.DurationDays) * 24 * time.Hour)
|
||||||
|
ad.Status = model.AdStatusActive
|
||||||
|
ad.StartsAt = &now
|
||||||
|
ad.EndsAt = &ends
|
||||||
|
ad.ReviewedAt = &now
|
||||||
|
ad.ReviewedBy = &actorID
|
||||||
|
ad.RejectReason = ""
|
||||||
|
if err := s.db.Save(&ad).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ad, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) Reject(actorID, id uint, reason string) (*model.Ad, error) {
|
||||||
|
var ad model.Ad
|
||||||
|
if err := s.db.First(&ad, id).Error; err != nil {
|
||||||
|
return nil, ErrAdNotFound
|
||||||
|
}
|
||||||
|
if ad.Status != model.AdStatusPending && ad.Status != model.AdStatusActive {
|
||||||
|
return nil, errors.New("当前状态不可拒绝")
|
||||||
|
}
|
||||||
|
reason = strings.TrimSpace(reason)
|
||||||
|
if utf8.RuneCountInString(reason) > 100 {
|
||||||
|
return nil, errors.New("拒绝原因过长")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
ad.Status = model.AdStatusRejected
|
||||||
|
ad.RejectReason = reason
|
||||||
|
ad.ReviewedAt = &now
|
||||||
|
ad.ReviewedBy = &actorID
|
||||||
|
if err := s.db.Save(&ad).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ad, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) Delete(id uint) error {
|
||||||
|
res := s.db.Delete(&model.Ad{}, id)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrAdNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) SetSort(id uint, sort int) error {
|
||||||
|
res := s.db.Model(&model.Ad{}).Where("id = ?", id).Update("sort_order", sort)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return ErrAdNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
236
backend/service/captcha.go
Normal file
236
backend/service/captcha.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
captchaTTL = 5 * time.Minute
|
||||||
|
captchaLen = 5
|
||||||
|
captchaCharset = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" // 去掉易混 I/O/0/1
|
||||||
|
)
|
||||||
|
|
||||||
|
type captchaEntry struct {
|
||||||
|
answer string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptchaStore 内存图形验证码(单实例足够;重启后未用完的码失效)
|
||||||
|
type CaptchaStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
data map[string]captchaEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCaptchaStore() *CaptchaStore {
|
||||||
|
s := &CaptchaStore{data: make(map[string]captchaEntry)}
|
||||||
|
go s.loopPurge()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptchaStore) loopPurge() {
|
||||||
|
t := time.NewTicker(2 * time.Minute)
|
||||||
|
defer t.Stop()
|
||||||
|
for range t.C {
|
||||||
|
s.mu.Lock()
|
||||||
|
now := time.Now()
|
||||||
|
for id, e := range s.data {
|
||||||
|
if now.After(e.expiresAt) {
|
||||||
|
delete(s.data, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue 生成验证码,返回 id 与 PNG data URL
|
||||||
|
func (s *CaptchaStore) Issue() (id, dataURL string, err error) {
|
||||||
|
answer, err := randomCaptchaText(captchaLen)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
id = hex.EncodeToString(b)
|
||||||
|
img, err := renderCaptchaPNG(answer)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.data[id] = captchaEntry{answer: strings.ToUpper(answer), expiresAt: time.Now().Add(captchaTTL)}
|
||||||
|
s.mu.Unlock()
|
||||||
|
dataURL = "data:image/png;base64," + base64.StdEncoding.EncodeToString(img)
|
||||||
|
return id, dataURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 校验并立即作废(一次性)
|
||||||
|
func (s *CaptchaStore) Verify(id, answer string) bool {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
answer = strings.ToUpper(strings.TrimSpace(answer))
|
||||||
|
if id == "" || answer == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
e, ok := s.data[id]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(s.data, id)
|
||||||
|
if time.Now().After(e.expiresAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return e.answer == answer
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomCaptchaText(n int) (string, error) {
|
||||||
|
var b strings.Builder
|
||||||
|
max := big.NewInt(int64(len(captchaCharset)))
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
v, err := rand.Int(rand.Reader, max)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b.WriteByte(captchaCharset[v.Int64()])
|
||||||
|
}
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderCaptchaPNG(text string) ([]byte, error) {
|
||||||
|
const w, h = 140, 44
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
bg := color.RGBA{R: 248, G: 250, B: 252, A: 255}
|
||||||
|
draw.Draw(img, img.Bounds(), &image.Uniform{C: bg}, image.Point{}, draw.Src)
|
||||||
|
|
||||||
|
// 噪点
|
||||||
|
for i := 0; i < 180; i++ {
|
||||||
|
x, _ := rand.Int(rand.Reader, big.NewInt(w))
|
||||||
|
y, _ := rand.Int(rand.Reader, big.NewInt(h))
|
||||||
|
img.Set(int(x.Int64()), int(y.Int64()), color.RGBA{R: 180, G: 190, B: 200, A: 255})
|
||||||
|
}
|
||||||
|
// 干扰线
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
x0, _ := rand.Int(rand.Reader, big.NewInt(w))
|
||||||
|
y0, _ := rand.Int(rand.Reader, big.NewInt(h))
|
||||||
|
x1, _ := rand.Int(rand.Reader, big.NewInt(w))
|
||||||
|
y1, _ := rand.Int(rand.Reader, big.NewInt(h))
|
||||||
|
drawLine(img, int(x0.Int64()), int(y0.Int64()), int(x1.Int64()), int(y1.Int64()), color.RGBA{R: 160, G: 170, B: 185, A: 255})
|
||||||
|
}
|
||||||
|
|
||||||
|
fg := color.RGBA{R: 30, G: 41, B: 59, A: 255}
|
||||||
|
startX := 12
|
||||||
|
for i, ch := range text {
|
||||||
|
ox := startX + i*24
|
||||||
|
oy := 10 + int(i%2)*2
|
||||||
|
drawGlyph(img, ch, ox, oy, fg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawLine(img *image.RGBA, x0, y0, x1, y1 int, c color.Color) {
|
||||||
|
dx := abs(x1 - x0)
|
||||||
|
dy := -abs(y1 - y0)
|
||||||
|
sx, sy := 1, 1
|
||||||
|
if x0 >= x1 {
|
||||||
|
sx = -1
|
||||||
|
}
|
||||||
|
if y0 >= y1 {
|
||||||
|
sy = -1
|
||||||
|
}
|
||||||
|
err := dx + dy
|
||||||
|
for {
|
||||||
|
img.Set(x0, y0, c)
|
||||||
|
if x0 == x1 && y0 == y1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
e2 := 2 * err
|
||||||
|
if e2 >= dy {
|
||||||
|
err += dy
|
||||||
|
x0 += sx
|
||||||
|
}
|
||||||
|
if e2 <= dx {
|
||||||
|
err += dx
|
||||||
|
y0 += sy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func abs(v int) int {
|
||||||
|
if v < 0 {
|
||||||
|
return -v
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简易 5×7 点阵(仅验证码字符集)
|
||||||
|
var glyphBits = map[rune][7]string{
|
||||||
|
'A': {"01110", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||||
|
'B': {"11110", "10001", "10001", "11110", "10001", "10001", "11110"},
|
||||||
|
'C': {"01111", "10000", "10000", "10000", "10000", "10000", "01111"},
|
||||||
|
'D': {"11110", "10001", "10001", "10001", "10001", "10001", "11110"},
|
||||||
|
'E': {"11111", "10000", "10000", "11110", "10000", "10000", "11111"},
|
||||||
|
'F': {"11111", "10000", "10000", "11110", "10000", "10000", "10000"},
|
||||||
|
'G': {"01111", "10000", "10000", "10011", "10001", "10001", "01111"},
|
||||||
|
'H': {"10001", "10001", "10001", "11111", "10001", "10001", "10001"},
|
||||||
|
'J': {"00111", "00010", "00010", "00010", "00010", "10010", "01100"},
|
||||||
|
'K': {"10001", "10010", "10100", "11000", "10100", "10010", "10001"},
|
||||||
|
'L': {"10000", "10000", "10000", "10000", "10000", "10000", "11111"},
|
||||||
|
'M': {"10001", "11011", "10101", "10001", "10001", "10001", "10001"},
|
||||||
|
'N': {"10001", "11001", "10101", "10011", "10001", "10001", "10001"},
|
||||||
|
'P': {"11110", "10001", "10001", "11110", "10000", "10000", "10000"},
|
||||||
|
'Q': {"01110", "10001", "10001", "10001", "10101", "10010", "01101"},
|
||||||
|
'R': {"11110", "10001", "10001", "11110", "10100", "10010", "10001"},
|
||||||
|
'S': {"01111", "10000", "10000", "01110", "00001", "00001", "11110"},
|
||||||
|
'T': {"11111", "00100", "00100", "00100", "00100", "00100", "00100"},
|
||||||
|
'U': {"10001", "10001", "10001", "10001", "10001", "10001", "01110"},
|
||||||
|
'V': {"10001", "10001", "10001", "10001", "10001", "01010", "00100"},
|
||||||
|
'W': {"10001", "10001", "10001", "10001", "10101", "10101", "01010"},
|
||||||
|
'X': {"10001", "10001", "01010", "00100", "01010", "10001", "10001"},
|
||||||
|
'Y': {"10001", "10001", "01010", "00100", "00100", "00100", "00100"},
|
||||||
|
'Z': {"11111", "00001", "00010", "00100", "01000", "10000", "11111"},
|
||||||
|
'2': {"01110", "10001", "00001", "00010", "00100", "01000", "11111"},
|
||||||
|
'3': {"11110", "00001", "00001", "01110", "00001", "00001", "11110"},
|
||||||
|
'4': {"00010", "00110", "01010", "10010", "11111", "00010", "00010"},
|
||||||
|
'5': {"11111", "10000", "11110", "00001", "00001", "10001", "01110"},
|
||||||
|
'6': {"01110", "10000", "10000", "11110", "10001", "10001", "01110"},
|
||||||
|
'7': {"11111", "00001", "00010", "00100", "01000", "01000", "01000"},
|
||||||
|
'8': {"01110", "10001", "10001", "01110", "10001", "10001", "01110"},
|
||||||
|
'9': {"01110", "10001", "10001", "01111", "00001", "00001", "01110"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func drawGlyph(img *image.RGBA, ch rune, ox, oy int, c color.Color) {
|
||||||
|
bits, ok := glyphBits[ch]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scale := 2
|
||||||
|
for row, line := range bits {
|
||||||
|
for col, cell := range line {
|
||||||
|
if cell != '1' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for dy := 0; dy < scale; dy++ {
|
||||||
|
for dx := 0; dx < scale; dx++ {
|
||||||
|
img.Set(ox+col*scale+dx, oy+row*scale+dy, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,13 +10,19 @@ import (
|
|||||||
|
|
||||||
// OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数
|
// OverviewService 首页聚合服务:一次请求返回统计、热门榜、活跃用户、板块计数
|
||||||
type OverviewService struct {
|
type OverviewService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
|
ads *AdService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOverviewService(db *gorm.DB) *OverviewService {
|
func NewOverviewService(db *gorm.DB) *OverviewService {
|
||||||
return &OverviewService{db: db}
|
return &OverviewService{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *OverviewService) WithAds(ads *AdService) *OverviewService {
|
||||||
|
s.ads = ads
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
// OverviewStats 社区整体统计(社区脉搏卡)
|
// OverviewStats 社区整体统计(社区脉搏卡)
|
||||||
type OverviewStats struct {
|
type OverviewStats struct {
|
||||||
Posts int64 `json:"posts"`
|
Posts int64 `json:"posts"`
|
||||||
@@ -75,6 +81,32 @@ type OverviewData struct {
|
|||||||
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
SidebarPages []SidebarPageItem `json:"sidebar_pages"`
|
||||||
NewUsers []NewUserItem `json:"new_users"`
|
NewUsers []NewUserItem `json:"new_users"`
|
||||||
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
Checkin *CheckinStatus `json:"checkin,omitempty"`
|
||||||
|
Ads []PublicAdItem `json:"ads"`
|
||||||
|
AdsPanelTitle string `json:"ads_panel_title"`
|
||||||
|
AdsEnabled bool `json:"ads_enabled"`
|
||||||
|
Sponsors []PublicSponsorItem `json:"sponsors"`
|
||||||
|
SponsorsPanelTitle string `json:"sponsors_panel_title"`
|
||||||
|
SponsorsEnabled bool `json:"sponsors_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicAdItem 侧栏广告条目
|
||||||
|
type PublicAdItem struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
LinkURL string `json:"link_url"`
|
||||||
|
ImageURL string `json:"image_url,omitempty"`
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
TextColor string `json:"text_color,omitempty"`
|
||||||
|
BgColor string `json:"bg_color,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicSponsorItem 侧栏赞助商
|
||||||
|
type PublicSponsorItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LinkURL string `json:"link_url,omitempty"`
|
||||||
|
HoverText string `json:"hover_text,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -286,6 +318,36 @@ LIMIT 5`
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.Ads = []PublicAdItem{}
|
||||||
|
data.AdsPanelTitle = AdDefaultTitle
|
||||||
|
data.Sponsors = []PublicSponsorItem{}
|
||||||
|
data.SponsorsPanelTitle = SponsorDefaultTitle
|
||||||
|
if s.ads != nil {
|
||||||
|
list, cfg, err := s.ads.ActivePublic()
|
||||||
|
if err == nil {
|
||||||
|
data.AdsEnabled = cfg.Enabled
|
||||||
|
data.AdsPanelTitle = cfg.PanelTitle
|
||||||
|
for _, a := range list {
|
||||||
|
data.Ads = append(data.Ads, PublicAdItem{
|
||||||
|
ID: a.ID, Kind: a.Kind, LinkURL: a.LinkURL,
|
||||||
|
ImageURL: a.ImageURL, Title: a.Title,
|
||||||
|
TextColor: a.TextColor, BgColor: a.BgColor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scfg, slist, err := s.ads.PublicSponsors()
|
||||||
|
if err == nil {
|
||||||
|
data.SponsorsEnabled = scfg.Enabled
|
||||||
|
data.SponsorsPanelTitle = scfg.PanelTitle
|
||||||
|
for _, it := range slist {
|
||||||
|
data.Sponsors = append(data.Sponsors, PublicSponsorItem{
|
||||||
|
ID: it.ID, Name: it.Name, LogoURL: it.LogoURL,
|
||||||
|
LinkURL: it.LinkURL, HoverText: it.HoverText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ const (
|
|||||||
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
||||||
RateHidePassword = "hide_password" // 密码隐藏块尝试
|
RateHidePassword = "hide_password" // 密码隐藏块尝试
|
||||||
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
|
RateTimelineGit = "timeline_git" // Git 提交导入 10/分钟
|
||||||
|
RateAdSubmit = "ad_submit" // 广告申购 5/分钟(按 IP)
|
||||||
|
RateCaptcha = "captcha" // 验证码刷新 30/分钟
|
||||||
)
|
)
|
||||||
|
|
||||||
// DefaultRateLimiter 创建默认速率限制器
|
// DefaultRateLimiter 创建默认速率限制器
|
||||||
@@ -96,5 +98,7 @@ func DefaultRateLimiter() *RateLimiter {
|
|||||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||||
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
|
rl.SetLimit(RateHidePassword, 20) // 密码尝试 20/分钟(按 IP)
|
||||||
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
rl.SetLimit(RateTimelineGit, 10) // 时间线 Git 导入 10/分钟
|
||||||
|
rl.SetLimit(RateAdSubmit, 5) // 广告申购 5/分钟
|
||||||
|
rl.SetLimit(RateCaptcha, 30) // 验证码 30/分钟
|
||||||
return rl
|
return rl
|
||||||
}
|
}
|
||||||
|
|||||||
198
backend/service/sponsor.go
Normal file
198
backend/service/sponsor.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/freefire/jiang13-bbs/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingKeySponsorsConfig = "sponsors_config"
|
||||||
|
SponsorDefaultTitle = "赞助商"
|
||||||
|
SponsorMaxItems = 24
|
||||||
|
SponsorMaxNameRunes = 32
|
||||||
|
SponsorMaxHoverRunes = 120
|
||||||
|
)
|
||||||
|
|
||||||
|
// SponsorItem 侧栏赞助商条目
|
||||||
|
type SponsorItem struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LogoURL string `json:"logo_url"`
|
||||||
|
LinkURL string `json:"link_url"`
|
||||||
|
HoverText string `json:"hover_text"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SponsorsConfig 赞助商板块配置
|
||||||
|
type SponsorsConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
PanelTitle string `json:"panel_title"`
|
||||||
|
Items []SponsorItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultSponsorsConfig() SponsorsConfig {
|
||||||
|
return SponsorsConfig{
|
||||||
|
Enabled: true,
|
||||||
|
PanelTitle: SponsorDefaultTitle,
|
||||||
|
Items: []SponsorItem{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) loadSponsorsConfig() (SponsorsConfig, error) {
|
||||||
|
var row model.SiteSetting
|
||||||
|
err := s.db.Where("key = ?", SettingKeySponsorsConfig).First(&row).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return DefaultSponsorsConfig(), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return SponsorsConfig{}, err
|
||||||
|
}
|
||||||
|
cfg := DefaultSponsorsConfig()
|
||||||
|
if strings.TrimSpace(row.Value) == "" {
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(row.Value), &cfg); err != nil {
|
||||||
|
return DefaultSponsorsConfig(), nil
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cfg.PanelTitle) == "" {
|
||||||
|
cfg.PanelTitle = SponsorDefaultTitle
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) GetSponsorsConfig() (SponsorsConfig, error) {
|
||||||
|
return s.loadSponsorsConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicSponsors 侧栏展示用(仅启用且有 logo 的条目)
|
||||||
|
func (s *AdService) PublicSponsors() (SponsorsConfig, []SponsorItem, error) {
|
||||||
|
cfg, err := s.loadSponsorsConfig()
|
||||||
|
if err != nil {
|
||||||
|
return cfg, nil, err
|
||||||
|
}
|
||||||
|
if !cfg.Enabled {
|
||||||
|
return cfg, []SponsorItem{}, nil
|
||||||
|
}
|
||||||
|
out := make([]SponsorItem, 0, len(cfg.Items))
|
||||||
|
for _, it := range cfg.Items {
|
||||||
|
if !it.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(it.LogoURL) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, it)
|
||||||
|
}
|
||||||
|
return cfg, out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AdService) SaveSponsorsConfig(in SponsorsConfig) (SponsorsConfig, error) {
|
||||||
|
out, err := normalizeSponsorsConfig(in)
|
||||||
|
if err != nil {
|
||||||
|
return SponsorsConfig{}, err
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return SponsorsConfig{}, err
|
||||||
|
}
|
||||||
|
row := model.SiteSetting{Key: SettingKeySponsorsConfig, Value: string(b), UpdatedAt: time.Now()}
|
||||||
|
if err := s.db.Save(&row).Error; err != nil {
|
||||||
|
return SponsorsConfig{}, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSponsorsConfig(in SponsorsConfig) (SponsorsConfig, error) {
|
||||||
|
out := DefaultSponsorsConfig()
|
||||||
|
out.Enabled = in.Enabled
|
||||||
|
title := strings.TrimSpace(in.PanelTitle)
|
||||||
|
if title == "" {
|
||||||
|
title = SponsorDefaultTitle
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(title) > 16 {
|
||||||
|
return SponsorsConfig{}, errors.New("侧栏标题最多 16 字")
|
||||||
|
}
|
||||||
|
out.PanelTitle = title
|
||||||
|
|
||||||
|
if len(in.Items) > SponsorMaxItems {
|
||||||
|
return SponsorsConfig{}, fmt.Errorf("赞助商最多 %d 个", SponsorMaxItems)
|
||||||
|
}
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
items := make([]SponsorItem, 0, len(in.Items))
|
||||||
|
for i, raw := range in.Items {
|
||||||
|
id := strings.TrimSpace(raw.ID)
|
||||||
|
if id == "" {
|
||||||
|
id = fmt.Sprintf("sp_%d", i+1)
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
return SponsorsConfig{}, errors.New("赞助商 id 重复")
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(raw.Name)
|
||||||
|
if name == "" {
|
||||||
|
return SponsorsConfig{}, errors.New("请填写赞助商名称")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(name) > SponsorMaxNameRunes {
|
||||||
|
return SponsorsConfig{}, fmt.Errorf("赞助商名称最多 %d 字", SponsorMaxNameRunes)
|
||||||
|
}
|
||||||
|
|
||||||
|
logo := strings.TrimSpace(raw.LogoURL)
|
||||||
|
if logo == "" {
|
||||||
|
return SponsorsConfig{}, errors.New("请上传或填写赞助商 logo")
|
||||||
|
}
|
||||||
|
if !validSponsorLogoURL(logo) {
|
||||||
|
return SponsorsConfig{}, errors.New("logo 须为 https 或本站 /uploads/ 图片")
|
||||||
|
}
|
||||||
|
|
||||||
|
link := strings.TrimSpace(raw.LinkURL)
|
||||||
|
if link != "" {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||||
|
return SponsorsConfig{}, errors.New("赞助商链接须为 http(s) 地址")
|
||||||
|
}
|
||||||
|
link = u.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
hover := strings.TrimSpace(raw.HoverText)
|
||||||
|
if utf8.RuneCountInString(hover) > SponsorMaxHoverRunes {
|
||||||
|
return SponsorsConfig{}, fmt.Errorf("赞助说明最多 %d 字", SponsorMaxHoverRunes)
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, SponsorItem{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
LogoURL: logo,
|
||||||
|
LinkURL: link,
|
||||||
|
HoverText: hover,
|
||||||
|
Enabled: raw.Enabled,
|
||||||
|
SortOrder: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
out.Items = items
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validSponsorLogoURL(u string) bool {
|
||||||
|
if adImageUploadRe.MatchString(u) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(u), "https://") && adImageHTTPSRe.MatchString(u) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 允许常见本站 uploads 路径(赞助 logo)
|
||||||
|
low := strings.ToLower(u)
|
||||||
|
if strings.HasPrefix(low, "/uploads/") && len(u) <= 500 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"image"
|
"image"
|
||||||
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
_ "image/png"
|
_ "image/png"
|
||||||
"io"
|
"io"
|
||||||
@@ -77,6 +78,9 @@ func (s *UploadService) EnsureDir() error {
|
|||||||
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Join(s.dir, "backgrounds"), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(s.dir, "ads"), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755)
|
return os.MkdirAll(filepath.Join(s.dir, "brand"), 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +170,10 @@ func detectImageFormat(data []byte) (imageFormat, error) {
|
|||||||
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||||
return imageFormat{ext: ".webp", mime: "image/webp"}, nil
|
return imageFormat{ext: ".webp", mime: "image/webp"}, nil
|
||||||
}
|
}
|
||||||
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
|
if len(data) >= 6 && (string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a") {
|
||||||
|
return imageFormat{ext: ".gif", mime: "image/gif"}, nil
|
||||||
|
}
|
||||||
|
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP / GIF")
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
func decodeImageSizeReader(r io.Reader, mime string) (w, h int, err error) {
|
||||||
@@ -403,6 +410,91 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) {
|
|||||||
return "/uploads/backgrounds/" + filename, nil
|
return "/uploads/backgrounds/" + filename, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const AdAssetMaxBytes = 2 << 20 // 收款码 / 广告素材 2MB
|
||||||
|
|
||||||
|
// SaveAdAsset 保存广告相关图片(收款码等)到 uploads/ads,保留原格式含 GIF
|
||||||
|
func (s *UploadService) SaveAdAsset(src io.Reader) (string, error) {
|
||||||
|
if src == nil {
|
||||||
|
return "", errors.New("文件为空")
|
||||||
|
}
|
||||||
|
tooLarge := func() error { return errors.New("图片不能超过 2MB") }
|
||||||
|
|
||||||
|
head := make([]byte, 12)
|
||||||
|
n, err := io.ReadFull(src, head)
|
||||||
|
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||||
|
return "", errors.New("读取图片失败")
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return "", errors.New("文件为空")
|
||||||
|
}
|
||||||
|
format, err := detectImageFormat(head[:n])
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(s.dir, "ads"), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
nameBytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nameBytes); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||||
|
fullPath := filepath.Join(s.dir, "ads", filename)
|
||||||
|
tmp := fullPath + ".partial"
|
||||||
|
if s.ops != nil {
|
||||||
|
release, e := s.ops.BeginTemporary(tmp)
|
||||||
|
if e != nil {
|
||||||
|
return "", e
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
remain := AdAssetMaxBytes + 1 - int64(n)
|
||||||
|
if remain < 0 {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", tooLarge()
|
||||||
|
}
|
||||||
|
if _, err := f.Write(head[:n]); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
writtenRest, copyErr := io.Copy(f, io.LimitReader(src, remain))
|
||||||
|
_ = f.Close()
|
||||||
|
if copyErr != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", copyErr
|
||||||
|
}
|
||||||
|
if int64(n)+writtenRest > AdAssetMaxBytes {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", tooLarge()
|
||||||
|
}
|
||||||
|
rf, err := os.Open(tmp)
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
w, h, err := decodeImageSizeReader(rf, format.mime)
|
||||||
|
_ = rf.Close()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if w < 1 || h < 1 || w > 4096 || h > 4096 {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", errors.New("图片尺寸无效")
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, fullPath); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "/uploads/ads/" + filename, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置
|
// CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置
|
||||||
func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) {
|
func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) {
|
||||||
var att model.Attachment
|
var att model.Attachment
|
||||||
|
|||||||
1103
frontend/app/admin/ads/AdsAdmin.tsx
Normal file
1103
frontend/app/admin/ads/AdsAdmin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
56
frontend/app/admin/ads/page.tsx
Normal file
56
frontend/app/admin/ads/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
import AdsAdmin from "./AdsAdmin";
|
||||||
|
import { authCookieHeader } from "@/lib/cookies";
|
||||||
|
import {
|
||||||
|
fetchAdminAds,
|
||||||
|
fetchAdminAdsConfig,
|
||||||
|
fetchAdminSponsorsConfig,
|
||||||
|
type AdConfig,
|
||||||
|
type AdRecord,
|
||||||
|
type SponsorsConfig,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { getMeCached } from "@/lib/serverData";
|
||||||
|
import { isAdminOrAbove } from "@/lib/roles";
|
||||||
|
import Forbidden from "../Forbidden";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "推广与赞助",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function AdminAdsPage() {
|
||||||
|
const cookie = authCookieHeader(await cookies());
|
||||||
|
const me = await getMeCached(cookie || undefined);
|
||||||
|
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||||
|
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ads: AdRecord[] = [];
|
||||||
|
let cfg: AdConfig | null = null;
|
||||||
|
let sponsors: SponsorsConfig | null = null;
|
||||||
|
let initError = "";
|
||||||
|
try {
|
||||||
|
const [list, config, sp] = await Promise.all([
|
||||||
|
fetchAdminAds(cookie || undefined),
|
||||||
|
fetchAdminAdsConfig(cookie || undefined),
|
||||||
|
fetchAdminSponsorsConfig(cookie || undefined),
|
||||||
|
]);
|
||||||
|
ads = list.ads ?? [];
|
||||||
|
cfg = config;
|
||||||
|
sponsors = sp;
|
||||||
|
} catch (e) {
|
||||||
|
initError = e instanceof Error ? e.message : "加载失败";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||||
|
<AdsAdmin
|
||||||
|
initialAds={ads}
|
||||||
|
initialConfig={cfg}
|
||||||
|
initialSponsors={sponsors}
|
||||||
|
initError={initError}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
574
frontend/app/ads/buy/AdsBuyClient.tsx
Normal file
574
frontend/app/ads/buy/AdsBuyClient.tsx
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
RefreshCw,
|
||||||
|
ImageIcon,
|
||||||
|
Type,
|
||||||
|
CheckCircle2,
|
||||||
|
Mail,
|
||||||
|
Link2,
|
||||||
|
Shield,
|
||||||
|
AlertCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
apiAdsPublicConfig,
|
||||||
|
apiFetchCaptcha,
|
||||||
|
apiSubmitAd,
|
||||||
|
type AdConfig,
|
||||||
|
type AdPaymentOption,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import ImageLightbox, { type LightboxItem } from "@/components/markdown/ImageLightbox";
|
||||||
|
import { AdText } from "@/components/AdText";
|
||||||
|
import { toast } from "@/lib/toast";
|
||||||
|
import {
|
||||||
|
AD_IMAGE_HEIGHT,
|
||||||
|
AD_IMAGE_SIZE_LABEL,
|
||||||
|
AD_IMAGE_SIZE_LABEL_2X,
|
||||||
|
AD_IMAGE_WIDTH,
|
||||||
|
AD_TEXT_COL_WIDTH,
|
||||||
|
} from "@/lib/ads";
|
||||||
|
|
||||||
|
type Kind = "image" | "text";
|
||||||
|
|
||||||
|
export default function AdsBuyClient() {
|
||||||
|
const [cfg, setCfg] = useState<AdConfig | null>(null);
|
||||||
|
const [loadErr, setLoadErr] = useState("");
|
||||||
|
const [kind, setKind] = useState<Kind>("image");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [link, setLink] = useState("");
|
||||||
|
const [imageURL, setImageURL] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [textColor, setTextColor] = useState("#1E293B");
|
||||||
|
const [bgColor, setBgColor] = useState("#F1F5F9");
|
||||||
|
const [days, setDays] = useState(0);
|
||||||
|
const [paymentID, setPaymentID] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [captchaID, setCaptchaID] = useState("");
|
||||||
|
const [captchaImg, setCaptchaImg] = useState("");
|
||||||
|
const [captchaCode, setCaptchaCode] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
const [lightbox, setLightbox] = useState<{ items: LightboxItem[]; index: number } | null>(null);
|
||||||
|
|
||||||
|
const refreshCaptcha = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const c = await apiFetchCaptcha();
|
||||||
|
setCaptchaID(c.id);
|
||||||
|
setCaptchaImg(c.image);
|
||||||
|
setCaptchaCode("");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "验证码加载失败", "error");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
apiAdsPublicConfig()
|
||||||
|
.then((c) => {
|
||||||
|
if (!alive) return;
|
||||||
|
setCfg(c);
|
||||||
|
if (c.durations[0]) setDays(c.durations[0].days);
|
||||||
|
const pay = c.payments.find((p) => p.enabled && p.qr_url);
|
||||||
|
if (pay) setPaymentID(pay.id);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setLoadErr(e instanceof Error ? e.message : "加载失败");
|
||||||
|
});
|
||||||
|
void refreshCaptcha();
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [refreshCaptcha]);
|
||||||
|
|
||||||
|
const selectedPay: AdPaymentOption | undefined = cfg?.payments.find((p) => p.id === paymentID);
|
||||||
|
const selectedDuration = cfg?.durations.find((d) => d.days === days);
|
||||||
|
const canSubmit = !!cfg && cfg.payments.length > 0 && !busy;
|
||||||
|
const kindLabel = kind === "image" ? "图片广告" : "文字广告";
|
||||||
|
|
||||||
|
const openLightbox = (src: string, alt = "") => {
|
||||||
|
setLightbox({ items: [{ src, alt }], index: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canSubmit) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await apiSubmitAd({
|
||||||
|
kind,
|
||||||
|
contact_email: email,
|
||||||
|
link_url: link,
|
||||||
|
image_url: kind === "image" ? imageURL : "",
|
||||||
|
title: kind === "text" ? title : "",
|
||||||
|
text_color: kind === "text" ? textColor : "",
|
||||||
|
bg_color: kind === "text" ? bgColor : "",
|
||||||
|
duration_days: days,
|
||||||
|
payment_id: paymentID,
|
||||||
|
buyer_note: note,
|
||||||
|
captcha_id: captchaID,
|
||||||
|
captcha_code: captchaCode,
|
||||||
|
});
|
||||||
|
setDone(true);
|
||||||
|
toast("提交成功,请完成付款并等待审核", "ok");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : "提交失败", "error");
|
||||||
|
void refreshCaptcha();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loadErr) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="j13-ads-card p-8 text-center">
|
||||||
|
<p className="font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
{loadErr}
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn btn-line mt-5">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cfg) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="j13-ads-card p-6">
|
||||||
|
<p className="meta">加载中…</p>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cfg.enabled) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="j13-ads-card p-8 text-center">
|
||||||
|
<p className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
|
||||||
|
自助推广暂未开放
|
||||||
|
</p>
|
||||||
|
<Link href="/" className="btn btn-primary mt-5">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<div className="j13-ads-card overflow-hidden text-center">
|
||||||
|
<div className="j13-ads-card-head">
|
||||||
|
<h1 className="text-[17px] font-bold">申请已提交</h1>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 px-5 py-6">
|
||||||
|
<CheckCircle2 size={36} className="mx-auto" style={{ color: "var(--ok)" }} />
|
||||||
|
<p className="meta mx-auto max-w-sm text-[13px] leading-relaxed">
|
||||||
|
请完成付款,管理员确认后将出现在首页侧栏。
|
||||||
|
</p>
|
||||||
|
{selectedPay?.qr_url ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mx-auto block cursor-zoom-in"
|
||||||
|
onClick={() => openLightbox(selectedPay.qr_url, `${selectedPay.name}收款码`)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={selectedPay.qr_url}
|
||||||
|
alt="收款码"
|
||||||
|
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
|
||||||
|
style={{ borderColor: "var(--line)" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<div className="flex justify-center gap-3 pt-1">
|
||||||
|
<Link href="/" className="btn btn-primary">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-line"
|
||||||
|
onClick={() => {
|
||||||
|
setDone(false);
|
||||||
|
void refreshCaptcha();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
再投一条
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{lightbox ? (
|
||||||
|
<ImageLightbox
|
||||||
|
items={lightbox.items}
|
||||||
|
index={lightbox.index}
|
||||||
|
onClose={() => setLightbox(null)}
|
||||||
|
onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Shell>
|
||||||
|
<form onSubmit={onSubmit} className="j13-ads-card overflow-hidden">
|
||||||
|
<div className="j13-ads-card-head">
|
||||||
|
<h1 className="text-[17px] font-bold tracking-tight">自助购买广告位</h1>
|
||||||
|
<p className="text-[12.5px]">
|
||||||
|
{kindLabel}
|
||||||
|
{selectedDuration ? ` · ${selectedDuration.label}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 px-4 py-4 sm:px-5 sm:py-5">
|
||||||
|
<AdPreview
|
||||||
|
kind={kind}
|
||||||
|
imageURL={imageURL}
|
||||||
|
title={title}
|
||||||
|
textColor={textColor}
|
||||||
|
bgColor={bgColor}
|
||||||
|
onImageClick={() => openLightbox(imageURL, "图片广告预览")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<ChipTab active={kind === "image"} onClick={() => setKind("image")}>
|
||||||
|
<ImageIcon size={15} /> 图片广告
|
||||||
|
</ChipTab>
|
||||||
|
<ChipTab active={kind === "text"} onClick={() => setKind("text")}>
|
||||||
|
<Type size={15} /> 文字广告
|
||||||
|
</ChipTab>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconField icon={<Mail size={15} />}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
className="j13-ads-input"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="联系邮箱"
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
|
||||||
|
<IconField icon={<Link2 size={15} />}>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
className="j13-ads-input"
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
placeholder="广告链接,带 https"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
|
||||||
|
{kind === "image" ? (
|
||||||
|
<>
|
||||||
|
<IconField icon={<ImageIcon size={15} />}>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
className="j13-ads-input"
|
||||||
|
value={imageURL}
|
||||||
|
onChange={(e) => setImageURL(e.target.value)}
|
||||||
|
placeholder={`图片地址,带 https,尺寸 ${AD_IMAGE_SIZE_LABEL}(高清 ${AD_IMAGE_SIZE_LABEL_2X})`}
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<IconField icon={<Type size={15} />}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
maxLength={24}
|
||||||
|
className="j13-ads-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="广告标题,最多 24 字"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<label className="j13-ads-field flex items-center gap-2 px-2.5">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={normalizeHex(textColor)}
|
||||||
|
onChange={(e) => setTextColor(e.target.value.toUpperCase())}
|
||||||
|
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="j13-ads-input !pl-0 font-mono text-[12.5px]"
|
||||||
|
value={textColor}
|
||||||
|
onChange={(e) => setTextColor(e.target.value)}
|
||||||
|
aria-label="文字颜色"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="j13-ads-field flex items-center gap-2 px-2.5">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={normalizeHex(bgColor)}
|
||||||
|
onChange={(e) => setBgColor(e.target.value.toUpperCase())}
|
||||||
|
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="j13-ads-input !pl-0 font-mono text-[12.5px]"
|
||||||
|
value={bgColor}
|
||||||
|
onChange={(e) => setBgColor(e.target.value)}
|
||||||
|
aria-label="背景颜色"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-5 gap-2">
|
||||||
|
{cfg.durations.map((d) => {
|
||||||
|
const active = days === d.days;
|
||||||
|
return (
|
||||||
|
<ChipTab key={d.days} active={active} onClick={() => setDays(d.days)} compact>
|
||||||
|
{d.label.replace(/\s/g, "")}
|
||||||
|
</ChipTab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cfg.payments.length === 0 ? (
|
||||||
|
<div
|
||||||
|
className="flex gap-2 rounded-lg border px-3 py-2.5 text-[12.5px] leading-relaxed"
|
||||||
|
style={{
|
||||||
|
borderColor: "color-mix(in srgb, var(--accent) 28%, var(--line))",
|
||||||
|
background: "var(--accent-soft)",
|
||||||
|
color: "var(--ink-2)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertCircle size={14} className="mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
|
||||||
|
<span>站长尚未配置收款码。</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{cfg.payments.map((p) => (
|
||||||
|
<ChipTab key={p.id} active={paymentID === p.id} onClick={() => setPaymentID(p.id)}>
|
||||||
|
{p.name}
|
||||||
|
</ChipTab>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{selectedPay?.qr_url ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="mx-auto block cursor-zoom-in rounded-xl border bg-white p-2.5"
|
||||||
|
style={{ borderColor: "var(--line)" }}
|
||||||
|
onClick={() => openLightbox(selectedPay.qr_url, `${selectedPay.name}收款码`)}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={selectedPay.qr_url}
|
||||||
|
alt={`${selectedPay.name}收款码`}
|
||||||
|
className="h-36 w-36 object-contain"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{selectedPay?.hint ? (
|
||||||
|
<p className="meta text-center text-[12px] leading-relaxed">{selectedPay.hint}</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<IconField icon={<Mail size={15} />}>
|
||||||
|
<input
|
||||||
|
className="j13-ads-input"
|
||||||
|
maxLength={100}
|
||||||
|
value={note}
|
||||||
|
onChange={(e) => setNote(e.target.value)}
|
||||||
|
placeholder="备注(可选)"
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
|
||||||
|
<div className="flex items-stretch gap-2">
|
||||||
|
<IconField icon={<Shield size={15} />} className="min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="j13-ads-input uppercase tracking-widest"
|
||||||
|
value={captchaCode}
|
||||||
|
onChange={(e) => setCaptchaCode(e.target.value)}
|
||||||
|
placeholder="图片验证码"
|
||||||
|
autoComplete="off"
|
||||||
|
maxLength={8}
|
||||||
|
/>
|
||||||
|
</IconField>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void refreshCaptcha()}
|
||||||
|
className="shrink-0 overflow-hidden rounded-lg border"
|
||||||
|
style={{ borderColor: "var(--line)" }}
|
||||||
|
title="点击刷新"
|
||||||
|
aria-label="刷新验证码"
|
||||||
|
>
|
||||||
|
{captchaImg ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={captchaImg} alt="验证码" width={110} height={42} className="block h-[42px] w-[110px] object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="meta flex h-[42px] w-[110px] items-center justify-center text-[12px]">
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className="j13-ads-submit" disabled={!canSubmit}>
|
||||||
|
{busy ? "提交中…" : "提交申请"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center">
|
||||||
|
<Link href="/" className="meta text-[12.5px] hover:text-[var(--accent)]">
|
||||||
|
返回首页
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{lightbox ? (
|
||||||
|
<ImageLightbox
|
||||||
|
items={lightbox.items}
|
||||||
|
index={lightbox.index}
|
||||||
|
onClose={() => setLightbox(null)}
|
||||||
|
onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Shell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="mx-auto w-full max-w-[560px] px-4 py-6 sm:py-10">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipTab({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
compact,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconField({
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className={`j13-ads-field ${className}`.trim()}>
|
||||||
|
<span className="j13-ads-field-ico" aria-hidden>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上线预览:复用首页侧栏 .j13-ad-promo 结构与样式,
|
||||||
|
* 图片位 248×60、文字位两列网格,尺寸/圆角与侧栏完全一致。
|
||||||
|
*/
|
||||||
|
function AdPreview({
|
||||||
|
kind,
|
||||||
|
imageURL,
|
||||||
|
title,
|
||||||
|
textColor,
|
||||||
|
bgColor,
|
||||||
|
onImageClick,
|
||||||
|
}: {
|
||||||
|
kind: Kind;
|
||||||
|
imageURL: string;
|
||||||
|
title: string;
|
||||||
|
textColor: string;
|
||||||
|
bgColor: string;
|
||||||
|
onImageClick: () => void;
|
||||||
|
}) {
|
||||||
|
const text = title.trim();
|
||||||
|
return (
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="meta mb-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||||
|
上线预览 · 尺寸与首页侧栏一致
|
||||||
|
</p>
|
||||||
|
<div className="j13-ad-promo">
|
||||||
|
{kind === "image" ? (
|
||||||
|
<ul className="space-y-2" aria-label="图片广告预览">
|
||||||
|
<li>
|
||||||
|
{imageURL.startsWith("https://") ? (
|
||||||
|
<a
|
||||||
|
href={imageURL}
|
||||||
|
className="j13-ad-image-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onImageClick();
|
||||||
|
}}
|
||||||
|
title="点击放大预览"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={imageURL}
|
||||||
|
alt="图片广告预览"
|
||||||
|
className="j13-ad-image"
|
||||||
|
width={AD_IMAGE_WIDTH}
|
||||||
|
height={AD_IMAGE_HEIGHT}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="j13-ad-slot j13-ad-slot-image">
|
||||||
|
图片广告位 {AD_IMAGE_SIZE_LABEL}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<ul
|
||||||
|
className="j13-ad-text-grid"
|
||||||
|
style={{ display: "flex", justifyContent: "center" }}
|
||||||
|
aria-label="文字广告预览"
|
||||||
|
>
|
||||||
|
<li style={{ width: AD_TEXT_COL_WIDTH, minWidth: 0 }}>
|
||||||
|
{text.length > 0 ? (
|
||||||
|
<AdText title={text} textColor={textColor} bgColor={bgColor} />
|
||||||
|
) : (
|
||||||
|
<span className="j13-ad-slot j13-ad-slot-text">文字广告位</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHex(v: string): string {
|
||||||
|
const s = v.trim();
|
||||||
|
if (/^#[0-9A-Fa-f]{6}$/.test(s)) return s;
|
||||||
|
if (/^[0-9A-Fa-f]{6}$/.test(s)) return `#${s}`;
|
||||||
|
return "#1E293B";
|
||||||
|
}
|
||||||
15
frontend/app/ads/buy/page.tsx
Normal file
15
frontend/app/ads/buy/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import AdsBuyClient from "./AdsBuyClient";
|
||||||
|
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
return {
|
||||||
|
title: "自助购买广告位",
|
||||||
|
description: "在首页侧栏投放图片或文字推广,支持游客申购。",
|
||||||
|
...(await requestCanonical("/ads/buy")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdsBuyPage() {
|
||||||
|
return <AdsBuyClient />;
|
||||||
|
}
|
||||||
@@ -738,8 +738,7 @@ function CreateGroupModal({
|
|||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
placeholder="2-32 个字符"
|
placeholder="2-32 个字符"
|
||||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
className="field"
|
||||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -751,8 +750,7 @@ function CreateGroupModal({
|
|||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
|
className="field resize-none"
|
||||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -796,8 +794,7 @@ function CreateGroupModal({
|
|||||||
value={memberInput}
|
value={memberInput}
|
||||||
onChange={(e) => setMemberInput(e.target.value)}
|
onChange={(e) => setMemberInput(e.target.value)}
|
||||||
placeholder="用户名,多个用空格或逗号分隔"
|
placeholder="用户名,多个用空格或逗号分隔"
|
||||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
className="field"
|
||||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1856,12 +1856,7 @@ function InvitePanel({
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
placeholder="username1, username2"
|
placeholder="username1, username2"
|
||||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
className="field"
|
||||||
style={{
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
color: "var(--ink)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
@@ -1929,12 +1924,7 @@ function SettingsPanel({
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
maxLength={32}
|
maxLength={32}
|
||||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
className="field"
|
||||||
style={{
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
color: "var(--ink)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -1949,12 +1939,7 @@ function SettingsPanel({
|
|||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
maxLength={200}
|
maxLength={200}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
|
className="field resize-none"
|
||||||
style={{
|
|
||||||
background: "var(--bg)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
color: "var(--ink)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -89,6 +89,12 @@
|
|||||||
--r: 12px;
|
--r: 12px;
|
||||||
--r-sm: 12px;
|
--r-sm: 12px;
|
||||||
|
|
||||||
|
/* 文本控件:柔和默认边 + 贴边细聚焦(见下方 .j13-control-* / 各输入选择器) */
|
||||||
|
--control-border: var(--line);
|
||||||
|
--control-border-hover: color-mix(in srgb, var(--line-2) 40%, var(--line));
|
||||||
|
--focus-glow: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
--focus-glow-danger: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
|
||||||
/* 顶栏 68px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
|
/* 顶栏 68px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
|
||||||
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
||||||
--header-bar: 68px;
|
--header-bar: 68px;
|
||||||
@@ -167,6 +173,12 @@
|
|||||||
--bg-grid-line: rgba(238, 241, 246, 0.045);
|
--bg-grid-line: rgba(238, 241, 246, 0.045);
|
||||||
--bg-grid-size: 28px;
|
--bg-grid-size: 28px;
|
||||||
|
|
||||||
|
/* 暗色沿用浅色同款 color-mix 公式(依赖上方已覆写的 --line / --accent 等) */
|
||||||
|
--control-border: var(--line);
|
||||||
|
--control-border-hover: color-mix(in srgb, var(--line-2) 40%, var(--line));
|
||||||
|
--focus-glow: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||||
|
--focus-glow-danger: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||||
|
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1341,18 +1353,13 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
|
||||||
}
|
}
|
||||||
.j13-deadline-field:last-child {
|
.j13-deadline-field:last-child {
|
||||||
width: 8.75rem;
|
width: 8.75rem;
|
||||||
}
|
}
|
||||||
.j13-deadline-field:focus-within {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.j13-deadline-field-icon {
|
.j13-deadline-field-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1639,26 +1646,20 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
}
|
||||||
.j13-compose-field::placeholder { color: var(--ink-3); }
|
.j13-compose-field::placeholder { color: var(--ink-3); }
|
||||||
.j13-compose-field:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.j13-compose-field:disabled {
|
.j13-compose-field:disabled {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.j13-compose-field-invalid {
|
.j13-compose-field-invalid {
|
||||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
||||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
|
box-shadow: 0 0 0 2px var(--focus-glow-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
.j13-compose-title {
|
.j13-compose-title {
|
||||||
@@ -1697,13 +1698,8 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
|||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
||||||
}
|
|
||||||
.j13-tag-field:focus-within {
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
}
|
||||||
.j13-tag-field-input,
|
.j13-tag-field-input,
|
||||||
.j13-tag-field-input:focus,
|
.j13-tag-field-input:focus,
|
||||||
@@ -1723,23 +1719,13 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
|||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
padding: 8px 10px 8px 8px;
|
padding: 8px 10px 8px 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.15s ease;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.j13-board-trigger:hover:not(:disabled) {
|
|
||||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
|
||||||
}
|
|
||||||
.j13-board-trigger:focus { outline: none; }
|
.j13-board-trigger:focus { outline: none; }
|
||||||
.j13-board-trigger:focus-visible,
|
|
||||||
.j13-board-trigger[aria-expanded="true"] {
|
|
||||||
outline: none !important;
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.j13-board-trigger:disabled {
|
.j13-board-trigger:disabled {
|
||||||
opacity: 0.65;
|
opacity: 0.65;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -2000,15 +1986,11 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
|||||||
height: 34px;
|
height: 34px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.j13-md-popover-input:focus {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 0;
|
|
||||||
}
|
|
||||||
.j13-md-popover-list {
|
.j13-md-popover-list {
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -2327,19 +2309,14 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
|||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--control-border);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.j13-hide-pwd-input:focus {
|
|
||||||
outline: 2px solid var(--accent);
|
|
||||||
outline-offset: 0;
|
|
||||||
}
|
|
||||||
.j13-hide-pwd-input.is-invalid {
|
.j13-hide-pwd-input.is-invalid {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
outline: none;
|
box-shadow: 0 0 0 2px var(--focus-glow-danger);
|
||||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 22%, transparent);
|
|
||||||
}
|
}
|
||||||
.j13-hide-pwd-error {
|
.j13-hide-pwd-error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -3361,11 +3338,91 @@ h1, h2, h3, h4 {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 消息侧栏搜索:单层描边,避免外框 + focus-visible 双线 */
|
/* ---------- 文本控件:统一柔边 + 贴边 2px 聚焦(消双层嵌套) ----------
|
||||||
.j13-chat-search:focus-within {
|
未分层,才能压过上方全局 :focus-visible 的 outline-offset。 */
|
||||||
border-color: var(--accent) !important;
|
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]):not([type="hidden"]):focus,
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]):not([type="hidden"]):focus-visible,
|
||||||
|
textarea:focus,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus,
|
||||||
|
select:focus-visible {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field,
|
||||||
|
.j13-compose-field,
|
||||||
|
.j13-search-field,
|
||||||
|
.j13-md-popover-input,
|
||||||
|
.j13-hide-pwd-input,
|
||||||
|
.j13-tl-field,
|
||||||
|
.j13-tl-urls,
|
||||||
|
.j13-tl-select,
|
||||||
|
.admin-doc-number {
|
||||||
|
border-color: var(--control-border);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.field:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-compose-field:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-search-field:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-md-popover-input:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-hide-pwd-input:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-tl-field:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-tl-urls:hover:not(:focus):not(:disabled),
|
||||||
|
.j13-tl-select:hover:not(:focus):not(:disabled),
|
||||||
|
.admin-doc-number:hover:not(:focus):not(:disabled) {
|
||||||
|
border-color: var(--control-border-hover);
|
||||||
|
}
|
||||||
|
.field:focus,
|
||||||
|
.j13-compose-field:focus,
|
||||||
|
.j13-search-field:focus,
|
||||||
|
.j13-search-field:focus-visible,
|
||||||
|
.j13-md-popover-input:focus,
|
||||||
|
.j13-hide-pwd-input:focus,
|
||||||
|
.j13-tl-field:focus,
|
||||||
|
.j13-tl-urls:focus,
|
||||||
|
.j13-tl-select:focus,
|
||||||
|
.admin-doc-number:focus {
|
||||||
|
outline: none !important;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 壳层:边框画在父级,内层输入取消描边 */
|
||||||
|
.admin-field-shell,
|
||||||
|
.admin-stepper,
|
||||||
|
.j13-tag-field,
|
||||||
|
.j13-board-trigger,
|
||||||
|
.j13-deadline-field,
|
||||||
|
.j13-chat-search,
|
||||||
|
.j13-chat-composer,
|
||||||
|
.admin-doc-slug {
|
||||||
|
border-color: var(--control-border);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.admin-field-shell:hover:not(:focus-within),
|
||||||
|
.admin-stepper:hover:not(:focus-within),
|
||||||
|
.j13-tag-field:hover:not(:focus-within),
|
||||||
|
.j13-board-trigger:hover:not(:disabled):not(:focus-visible):not([aria-expanded="true"]),
|
||||||
|
.j13-deadline-field:hover:not(:focus-within),
|
||||||
|
.j13-chat-search:hover:not(:focus-within),
|
||||||
|
.j13-chat-composer:hover:not(:focus-within),
|
||||||
|
.admin-doc-slug:hover:not(:focus-within) {
|
||||||
|
border-color: var(--control-border-hover);
|
||||||
|
}
|
||||||
|
.admin-field-shell:focus-within,
|
||||||
|
.admin-stepper:focus-within,
|
||||||
|
.j13-tag-field:focus-within,
|
||||||
|
.j13-deadline-field:focus-within,
|
||||||
|
.j13-chat-search:focus-within,
|
||||||
|
.j13-chat-composer:focus-within,
|
||||||
|
.admin-doc-slug:focus-within,
|
||||||
|
.j13-board-trigger:focus-visible,
|
||||||
|
.j13-board-trigger[aria-expanded="true"] {
|
||||||
|
outline: none !important;
|
||||||
|
border-color: var(--accent) !important;
|
||||||
|
box-shadow: 0 0 0 2px var(--focus-glow);
|
||||||
|
}
|
||||||
|
|
||||||
.j13-chat-search-input,
|
.j13-chat-search-input,
|
||||||
.j13-chat-search-input:focus,
|
.j13-chat-search-input:focus,
|
||||||
.j13-chat-search-input:focus-visible {
|
.j13-chat-search-input:focus-visible {
|
||||||
@@ -3373,11 +3430,6 @@ h1, h2, h3, h4 {
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 聊天输入区:与搜索框同款聚焦态 */
|
|
||||||
.j13-chat-composer:focus-within {
|
|
||||||
border-color: var(--accent) !important;
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.j13-chat-composer-input,
|
.j13-chat-composer-input,
|
||||||
.j13-chat-composer-input:focus,
|
.j13-chat-composer-input:focus,
|
||||||
.j13-chat-composer-input:focus-visible {
|
.j13-chat-composer-input:focus-visible {
|
||||||
@@ -3393,22 +3445,14 @@ h1, h2, h3, h4 {
|
|||||||
color: transparent;
|
color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 后台输入:与聊天搜索同一套壳层聚焦(未分层,才能压过全局 :focus-visible 双线) */
|
|
||||||
.admin-field-shell,
|
.admin-field-shell,
|
||||||
.admin-stepper {
|
.admin-stepper {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
|
||||||
}
|
|
||||||
.admin-field-shell:hover:not(:focus-within),
|
|
||||||
.admin-stepper:hover:not(:focus-within) {
|
|
||||||
border-color: color-mix(in srgb, var(--line-2) 40%, var(--line));
|
|
||||||
}
|
}
|
||||||
.admin-field-shell:focus-within,
|
.admin-field-shell:focus-within,
|
||||||
.admin-stepper:focus-within {
|
.admin-stepper:focus-within {
|
||||||
border-color: var(--accent) !important;
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
.admin-field-input,
|
.admin-field-input,
|
||||||
@@ -3431,7 +3475,19 @@ h1, h2, h3, h4 {
|
|||||||
border-color: var(--danger) !important;
|
border-color: var(--danger) !important;
|
||||||
}
|
}
|
||||||
.admin-field-shell.is-invalid:focus-within {
|
.admin-field-shell.is-invalid:focus-within {
|
||||||
box-shadow: 0 0 0 3px var(--danger-soft);
|
box-shadow: 0 0 0 2px var(--focus-glow-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏搜索:pill 圆角,聚焦同款贴边细环 */
|
||||||
|
.j13-search-field {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--control-border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.j13-search-field:focus,
|
||||||
|
.j13-search-field:focus-visible {
|
||||||
|
background: var(--panel);
|
||||||
}
|
}
|
||||||
.j13-chat-composer-mirror {
|
.j13-chat-composer-mirror {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -4847,15 +4903,11 @@ a.j13-mention-token:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-height: 2.5rem;
|
min-height: 2.5rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.admin-doc-slug:focus-within {
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.admin-doc-slug.is-invalid {
|
.admin-doc-slug.is-invalid {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
@@ -4905,17 +4957,12 @@ a.j13-mention-token:hover {
|
|||||||
width: 7.5rem;
|
width: 7.5rem;
|
||||||
height: 2.5rem;
|
height: 2.5rem;
|
||||||
padding: 0 0.75rem;
|
padding: 0 0.75rem;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 0.7rem;
|
border-radius: 0.7rem;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.admin-doc-number:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
||||||
}
|
|
||||||
.admin-doc-actions {
|
.admin-doc-actions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -5767,21 +5814,14 @@ a.j13-mention-token:hover {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 12px 15px;
|
padding: 12px 15px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
/* 占位符不使用 opacity:透明度与底色合成后浅色仅 3.7:1,实色 ink-3 达 5.72:1 */
|
/* 占位符不使用 opacity:透明度与底色合成后浅色仅 3.7:1,实色 ink-3 达 5.72:1 */
|
||||||
.field::placeholder { color: var(--ink-3); }
|
.field::placeholder { color: var(--ink-3); }
|
||||||
.field:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent);
|
|
||||||
box-shadow: 0 0 0 3.5px var(--accent-soft);
|
|
||||||
background: var(--panel);
|
|
||||||
}
|
|
||||||
textarea.field {
|
textarea.field {
|
||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -6291,7 +6331,7 @@ a.j13-mention-token:hover {
|
|||||||
.j13-tl-field,
|
.j13-tl-field,
|
||||||
.j13-tl-select {
|
.j13-tl-select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1px solid var(--line-2);
|
border: 1px solid var(--control-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -6568,6 +6608,429 @@ a.j13-mention-token:hover {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 赞助商 logo 网格:悬停 / 聚焦显示赞助说明 */
|
||||||
|
.j13-sponsors-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.j13-sponsor {
|
||||||
|
position: relative;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.j13-sponsor-hit {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 76px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-2);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.j13-sponsor-hit:hover,
|
||||||
|
.j13-sponsor-hit:focus-visible {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.j13-sponsor-logo {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 52px;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自助购买广告位:紧凑单栏卡片,色跟随主题 accent */
|
||||||
|
.j13-ads-card {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.j13-ads-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-on);
|
||||||
|
}
|
||||||
|
.j13-ads-card-head p {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
.j13-ads-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.j13-ads-chip:hover:not(.is-on) {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
.j13-ads-chip.is-compact {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.j13-ads-chip.is-on {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-on);
|
||||||
|
box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||||
|
}
|
||||||
|
.j13-ads-chip:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.j13-ads-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 12px 0 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel);
|
||||||
|
}
|
||||||
|
.j13-ads-field:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--line-2) 35%, var(--line));
|
||||||
|
}
|
||||||
|
.j13-ads-field:focus-within {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--focus-glow);
|
||||||
|
}
|
||||||
|
.j13-ads-field-ico {
|
||||||
|
display: inline-flex;
|
||||||
|
color: var(--ink-3);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.j13-ads-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.j13-ads-input::placeholder {
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
.j13-ads-submit {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-on);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.1),
|
||||||
|
0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent);
|
||||||
|
transition: background 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
|
||||||
|
}
|
||||||
|
.j13-ads-submit:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hi);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
|
||||||
|
0 8px 18px color-mix(in srgb, var(--accent) 32%, transparent);
|
||||||
|
}
|
||||||
|
.j13-ads-submit:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.j13-ads-submit:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自助推广侧栏占位:空槽可点去购买 */
|
||||||
|
.j13-ad-slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||||
|
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
.j13-ad-slot:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
/* 自助推广:图片宽 = 两列文字总宽(248 = 121+6+121) */
|
||||||
|
.j13-ad-promo {
|
||||||
|
width: 248px;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.j13-ad-slot-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
max-width: none;
|
||||||
|
margin-inline: 0;
|
||||||
|
min-height: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.j13-ad-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 60px;
|
||||||
|
max-width: none;
|
||||||
|
margin-inline: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.j13-ad-image-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin-inline: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
transition: opacity 0.15s ease, transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
.j13-ad-image-link:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||||
|
box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--accent) 32%, transparent);
|
||||||
|
}
|
||||||
|
.j13-ad-text-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.j13-ad-text-grid > li {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.j13-ad-slot-text {
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.j13-ad-text {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 0;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.2;
|
||||||
|
transition: opacity 0.15s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
.j13-ad-text.is-overflow {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.j13-ad-text:hover,
|
||||||
|
.j13-ad-text:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
box-shadow: 0 4px 12px -3px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
.j13-ad-text:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.j13-ad-text-track {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.j13-ad-text-track.is-overflow {
|
||||||
|
width: max-content;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
.j13-ad-text:hover .j13-ad-text-track.is-overflow,
|
||||||
|
.j13-ad-text:focus-visible .j13-ad-text-track.is-overflow {
|
||||||
|
animation: j13-ad-marquee 10s linear infinite;
|
||||||
|
}
|
||||||
|
.j13-ad-text-run {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
@keyframes j13-ad-marquee {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.j13-ad-text-track {
|
||||||
|
animation: none;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.j13-ad-text-run[aria-hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.j13-ad-text-run {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.j13-ad-image-link:hover,
|
||||||
|
.j13-ad-text:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
.j13-ad-slot:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 后台「推广与赞助」工作台 ── */
|
||||||
|
/* 复用站点设置骨架(.admin-settings-*):左粘性菜单 + 右内容,此处仅补充区块样式 */
|
||||||
|
/* 锚点跳转时为粘性导航留出避让空间,避免遮挡标题 */
|
||||||
|
[data-section] {
|
||||||
|
scroll-margin-top: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 申购行:缩略 + 信息 + 操作同一行,操作始终紧邻数据 */
|
||||||
|
.j13-ads-adrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.j13-ads-adrow:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||||
|
box-shadow: 0 4px 14px -6px color-mix(in srgb, var(--ink) 18%, transparent);
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.j13-ads-adrow {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.j13-ads-adrow > div:last-child {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.j13-ads-thumb {
|
||||||
|
width: 88px;
|
||||||
|
height: 44px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.j13-ads-thumb img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 编辑器行(时长档位) */
|
||||||
|
.j13-ads-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收款码 / 赞助商编辑器:左侧预览 + 右侧字段 */
|
||||||
|
.j13-ads-editor {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: opacity 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.j13-ads-editor.is-off {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.j13-ads-qrbox {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.j13-ads-qrbox:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
.j13-ads-logo {
|
||||||
|
width: 120px;
|
||||||
|
height: 56px;
|
||||||
|
max-width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.j13-ads-editor {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.j13-ads-qrbox,
|
||||||
|
.j13-ads-logo {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 140px;
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 关于本站:右栏底部署录式目录,与内容模块区分开 */
|
/* 关于本站:右栏底部署录式目录,与内容模块区分开 */
|
||||||
.j13-about-lead {
|
.j13-about-lead {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -8158,7 +8621,7 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
|||||||
.ops-select:focus {
|
.ops-select:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
box-shadow: 0 0 0 2px var(--focus-glow);
|
||||||
}
|
}
|
||||||
.ops-datetime { display: flex; flex-direction: column; gap: 0.35rem; max-width: min(22rem, 100%); }
|
.ops-datetime { display: flex; flex-direction: column; gap: 0.35rem; max-width: min(22rem, 100%); }
|
||||||
.ops-datetime-input { width: 100%; max-width: 16rem; }
|
.ops-datetime-input { width: 100%; max-width: 16rem; }
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
canManagePages={canManagePages}
|
canManagePages={canManagePages}
|
||||||
sidebarPages={sidebarPages}
|
sidebarPages={sidebarPages}
|
||||||
siteDescription={publicSettings.site_description}
|
siteDescription={publicSettings.site_description}
|
||||||
|
ads={overview.ads ?? []}
|
||||||
|
adsPanelTitle={overview.ads_panel_title || "自助推广"}
|
||||||
|
adsEnabled={overview.ads_enabled !== false}
|
||||||
|
sponsors={overview.sponsors ?? []}
|
||||||
|
sponsorsPanelTitle={overview.sponsors_panel_title || "赞助商"}
|
||||||
|
sponsorsEnabled={overview.sponsors_enabled !== false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -311,6 +317,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
|||||||
canManagePages={canManagePages}
|
canManagePages={canManagePages}
|
||||||
sidebarPages={sidebarPages}
|
sidebarPages={sidebarPages}
|
||||||
siteDescription={publicSettings.site_description}
|
siteDescription={publicSettings.site_description}
|
||||||
|
ads={overview.ads ?? []}
|
||||||
|
adsPanelTitle={overview.ads_panel_title || "自助推广"}
|
||||||
|
adsEnabled={overview.ads_enabled !== false}
|
||||||
|
sponsors={overview.sponsors ?? []}
|
||||||
|
sponsorsPanelTitle={overview.sponsors_panel_title || "赞助商"}
|
||||||
|
sponsorsEnabled={overview.sponsors_enabled !== false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
73
frontend/components/AdText.tsx
Normal file
73
frontend/components/AdText.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
|
type AdTextProps = {
|
||||||
|
title: string;
|
||||||
|
textColor?: string;
|
||||||
|
bgColor?: string;
|
||||||
|
href?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文字广告:仅当文字宽度超出容器时才启用跑马灯滚动,
|
||||||
|
* 能完整显示时居中静止。结构与样式复用侧栏 .j13-ad-text。
|
||||||
|
*/
|
||||||
|
export function AdText({ title, textColor, bgColor, href, className = "" }: AdTextProps) {
|
||||||
|
const trackRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [overflow, setOverflow] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const track = trackRef.current;
|
||||||
|
if (!track) return;
|
||||||
|
const measure = () => {
|
||||||
|
const run = track.querySelector<HTMLElement>(".j13-ad-text-run");
|
||||||
|
const container = track.parentElement;
|
||||||
|
if (!run || !container) return;
|
||||||
|
setOverflow(run.scrollWidth > container.clientWidth);
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
window.addEventListener("resize", measure);
|
||||||
|
return () => window.removeEventListener("resize", measure);
|
||||||
|
}, [title]);
|
||||||
|
|
||||||
|
const cls = `j13-ad-text${overflow ? " is-overflow" : ""}${className ? ` ${className}` : ""}`;
|
||||||
|
const style: CSSProperties = { color: textColor, background: bgColor };
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<span
|
||||||
|
className={`j13-ad-text-track${overflow ? " is-overflow" : ""}`}
|
||||||
|
ref={trackRef}
|
||||||
|
>
|
||||||
|
<span className="j13-ad-text-run">{title}</span>
|
||||||
|
{overflow ? (
|
||||||
|
<span className="j13-ad-text-run" aria-hidden>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
className={cls}
|
||||||
|
style={style}
|
||||||
|
data-tip={title}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={cls} style={style} data-tip={title}>
|
||||||
|
{inner}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdText;
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
@@ -263,12 +263,7 @@ export default function Header({
|
|||||||
onKeyDown={onSearchKeyDown}
|
onKeyDown={onSearchKeyDown}
|
||||||
placeholder="搜索帖子..."
|
placeholder="搜索帖子..."
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||||
style={{
|
|
||||||
background: "var(--panel)",
|
|
||||||
color: "var(--ink)",
|
|
||||||
border: "1px solid var(--line)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<kbd
|
<kbd
|
||||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
||||||
@@ -364,8 +359,7 @@ export default function Header({
|
|||||||
onKeyDown={onSearchKeyDown}
|
onKeyDown={onSearchKeyDown}
|
||||||
aria-label="搜索帖子"
|
aria-label="搜索帖子"
|
||||||
placeholder="搜索帖子...(Esc 清除)"
|
placeholder="搜索帖子...(Esc 清除)"
|
||||||
className="w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
||||||
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line-2)" }}
|
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileText,
|
FileText,
|
||||||
|
CircleDollarSign,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||||
@@ -87,10 +88,16 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/pages",
|
href: "/admin/pages",
|
||||||
label: "单页",
|
label: "单页管理",
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
visible: (u) => isAdminOrAbove(u.role),
|
visible: (u) => isAdminOrAbove(u.role),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/ads",
|
||||||
|
label: "推广与赞助",
|
||||||
|
icon: CircleDollarSign,
|
||||||
|
visible: (u) => isAdminOrAbove(u.role),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Pin,
|
Pin,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
CircleDollarSign,
|
||||||
|
Handshake,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
PostListItem,
|
PostListItem,
|
||||||
@@ -22,13 +24,17 @@ import type {
|
|||||||
NewUser,
|
NewUser,
|
||||||
CheckinStatus,
|
CheckinStatus,
|
||||||
SidebarPageItem,
|
SidebarPageItem,
|
||||||
|
PublicAdItem,
|
||||||
|
PublicSponsorItem,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
|
import { AdText } from "@/components/AdText";
|
||||||
import { PulseCheckin } from "./CheckinCard";
|
import { PulseCheckin } from "./CheckinCard";
|
||||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||||
import { pageGlyph } from "@/lib/pageGlyph";
|
import { pageGlyph } from "@/lib/pageGlyph";
|
||||||
import { AboutSiteHeader } from "@/components/site-doc";
|
import { AboutSiteHeader } from "@/components/site-doc";
|
||||||
|
import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads";
|
||||||
|
|
||||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||||
|
|
||||||
@@ -427,8 +433,157 @@ type PanelsProps = {
|
|||||||
canManagePages?: boolean;
|
canManagePages?: boolean;
|
||||||
sidebarPages?: SidebarPageItem[];
|
sidebarPages?: SidebarPageItem[];
|
||||||
siteDescription?: string;
|
siteDescription?: string;
|
||||||
|
ads?: PublicAdItem[];
|
||||||
|
adsPanelTitle?: string;
|
||||||
|
adsEnabled?: boolean;
|
||||||
|
sponsors?: PublicSponsorItem[];
|
||||||
|
sponsorsPanelTitle?: string;
|
||||||
|
sponsorsEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function AdsPanel({
|
||||||
|
items,
|
||||||
|
title,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
items: PublicAdItem[];
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const IMAGE_SLOTS = 5;
|
||||||
|
const TEXT_SLOTS = 6;
|
||||||
|
const images = items.filter((a) => a.kind === "image").slice(0, IMAGE_SLOTS);
|
||||||
|
const texts = items.filter((a) => a.kind === "text").slice(0, TEXT_SLOTS);
|
||||||
|
const imageSlots = Array.from({ length: IMAGE_SLOTS }, (_, i) => images[i] ?? null);
|
||||||
|
const textSlots = Array.from({ length: TEXT_SLOTS }, (_, i) => texts[i] ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label={title || "自助推广"}>
|
||||||
|
<p className="j13-module-title mb-3">
|
||||||
|
<CircleDollarSign size={15} style={{ color: "var(--accent)" }} />
|
||||||
|
{title || "自助推广"}
|
||||||
|
<Link
|
||||||
|
href="/ads/buy"
|
||||||
|
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||||
|
>
|
||||||
|
购买广告位
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="j13-ad-promo">
|
||||||
|
<ul className="space-y-2" aria-label="图片广告位">
|
||||||
|
{imageSlots.map((ad, i) =>
|
||||||
|
ad ? (
|
||||||
|
<li key={`img-${ad.id}`}>
|
||||||
|
<a
|
||||||
|
href={ad.link_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
className="j13-ad-image-link"
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={ad.image_url}
|
||||||
|
alt="推广"
|
||||||
|
className="j13-ad-image"
|
||||||
|
width={AD_IMAGE_WIDTH}
|
||||||
|
height={AD_IMAGE_HEIGHT}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={`img-slot-${i}`}>
|
||||||
|
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-image">
|
||||||
|
<span>图片广告位 {AD_IMAGE_SIZE_LABEL}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<ul className="j13-ad-text-grid" aria-label="文字广告位">
|
||||||
|
{textSlots.map((ad, i) =>
|
||||||
|
ad ? (
|
||||||
|
<li key={`txt-${ad.id}`}>
|
||||||
|
<AdText
|
||||||
|
title={ad.title ?? ""}
|
||||||
|
textColor={ad.text_color || "#1E293B"}
|
||||||
|
bgColor={ad.bg_color || "#F1F5F9"}
|
||||||
|
href={ad.link_url}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
<li key={`txt-slot-${i}`}>
|
||||||
|
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-text">
|
||||||
|
文字广告位
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SponsorsPanel({
|
||||||
|
items,
|
||||||
|
title,
|
||||||
|
enabled,
|
||||||
|
}: {
|
||||||
|
items: PublicSponsorItem[];
|
||||||
|
title: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}) {
|
||||||
|
if (!enabled || items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="j13-module" aria-label={title || "赞助商"}>
|
||||||
|
<p className="j13-module-title mb-3">
|
||||||
|
<Handshake size={15} style={{ color: "var(--accent)" }} />
|
||||||
|
{title || "赞助商"}
|
||||||
|
</p>
|
||||||
|
<ul className="j13-sponsors-grid">
|
||||||
|
{items.map((sp) => {
|
||||||
|
const tip = (sp.hover_text || sp.name || "").trim();
|
||||||
|
const href = sp.link_url?.trim() || undefined;
|
||||||
|
const inner = (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={sp.logo_url} alt={sp.name} className="j13-sponsor-logo" loading="lazy" />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={sp.id} className="j13-sponsor">
|
||||||
|
{href ? (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer sponsored"
|
||||||
|
className="j13-sponsor-hit"
|
||||||
|
aria-label={sp.name}
|
||||||
|
data-tip={tip || undefined}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="j13-sponsor-hit"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={sp.name}
|
||||||
|
data-tip={tip || undefined}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CommunityPanelsBody({
|
function CommunityPanelsBody({
|
||||||
stats,
|
stats,
|
||||||
hot,
|
hot,
|
||||||
@@ -441,11 +596,19 @@ function CommunityPanelsBody({
|
|||||||
canManagePages,
|
canManagePages,
|
||||||
sidebarPages = [],
|
sidebarPages = [],
|
||||||
siteDescription,
|
siteDescription,
|
||||||
|
ads = [],
|
||||||
|
adsPanelTitle = "自助推广",
|
||||||
|
adsEnabled = true,
|
||||||
|
sponsors = [],
|
||||||
|
sponsorsPanelTitle = "赞助商",
|
||||||
|
sponsorsEnabled = true,
|
||||||
}: PanelsProps) {
|
}: PanelsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||||
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
||||||
|
<AdsPanel items={ads} title={adsPanelTitle} enabled={adsEnabled} />
|
||||||
|
<SponsorsPanel items={sponsors} title={sponsorsPanelTitle} enabled={sponsorsEnabled} />
|
||||||
<HotRanking posts={hot} allowComments={allowComments} />
|
<HotRanking posts={hot} allowComments={allowComments} />
|
||||||
<NewUsers users={newUsers} />
|
<NewUsers users={newUsers} />
|
||||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||||
|
|||||||
8
frontend/lib/ads.ts
Normal file
8
frontend/lib/ads.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** 首页侧栏图片广告展示尺寸(与 .j13-ad-promo 宽一致:248 = 两列文字 + 中间 6px 间距) */
|
||||||
|
export const AD_IMAGE_WIDTH = 248;
|
||||||
|
export const AD_IMAGE_HEIGHT = 60;
|
||||||
|
/** 单列文字槽宽度:(248 - 6) / 2 */
|
||||||
|
export const AD_TEXT_COL_WIDTH = 121;
|
||||||
|
/** 购买页建议文案 */
|
||||||
|
export const AD_IMAGE_SIZE_LABEL = `${AD_IMAGE_WIDTH}×${AD_IMAGE_HEIGHT}`;
|
||||||
|
export const AD_IMAGE_SIZE_LABEL_2X = `${AD_IMAGE_WIDTH * 2}×${AD_IMAGE_HEIGHT * 2}`;
|
||||||
@@ -213,6 +213,30 @@ export interface OverviewResponse {
|
|||||||
sidebar_pages?: SidebarPageItem[];
|
sidebar_pages?: SidebarPageItem[];
|
||||||
new_users: NewUser[];
|
new_users: NewUser[];
|
||||||
checkin?: CheckinStatus | null;
|
checkin?: CheckinStatus | null;
|
||||||
|
ads?: PublicAdItem[];
|
||||||
|
ads_panel_title?: string;
|
||||||
|
ads_enabled?: boolean;
|
||||||
|
sponsors?: PublicSponsorItem[];
|
||||||
|
sponsors_panel_title?: string;
|
||||||
|
sponsors_enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicAdItem {
|
||||||
|
id: number;
|
||||||
|
kind: "image" | "text" | string;
|
||||||
|
link_url: string;
|
||||||
|
image_url?: string;
|
||||||
|
title?: string;
|
||||||
|
text_color?: string;
|
||||||
|
bg_color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicSponsorItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo_url: string;
|
||||||
|
link_url?: string;
|
||||||
|
hover_text?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 板块右栏:管理员公开资料 */
|
/** 板块右栏:管理员公开资料 */
|
||||||
@@ -3031,3 +3055,164 @@ export async function apiOperations<T = Record<string, unknown>>(path: string, m
|
|||||||
if (!res.ok) throw new Error(data.error || "操作失败,请重试");
|
if (!res.ok) throw new Error(data.error || "操作失败,请重试");
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 自助推广 / 赞助商 ---------- */
|
||||||
|
|
||||||
|
export interface AdDurationOption {
|
||||||
|
days: number;
|
||||||
|
label: string;
|
||||||
|
price_hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdPaymentOption {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
kind: "alipay" | "wechat" | "other" | string;
|
||||||
|
qr_url: string;
|
||||||
|
hint: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
panel_title: string;
|
||||||
|
durations: AdDurationOption[];
|
||||||
|
payments: AdPaymentOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo_url: string;
|
||||||
|
link_url: string;
|
||||||
|
hover_text: string;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SponsorsConfig {
|
||||||
|
enabled: boolean;
|
||||||
|
panel_title: string;
|
||||||
|
items: SponsorItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdRecord {
|
||||||
|
id: number;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
contact_email: string;
|
||||||
|
link_url: string;
|
||||||
|
image_url?: string;
|
||||||
|
title?: string;
|
||||||
|
text_color?: string;
|
||||||
|
bg_color?: string;
|
||||||
|
duration_days: number;
|
||||||
|
payment_id: string;
|
||||||
|
buyer_note?: string;
|
||||||
|
starts_at?: string | null;
|
||||||
|
ends_at?: string | null;
|
||||||
|
reject_reason?: string;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
reviewed_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiFetchCaptcha(): Promise<{ id: string; image: string }> {
|
||||||
|
const res = await fetch("/api/captcha", { credentials: "include", cache: "no-store" });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || "获取验证码失败");
|
||||||
|
return data as { id: string; image: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdsPublicConfig(): Promise<AdConfig> {
|
||||||
|
const res = await fetch("/api/ads/config", { credentials: "include", cache: "no-store" });
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || "加载广告配置失败");
|
||||||
|
return data as AdConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiSubmitAd(body: Record<string, unknown>): Promise<{ ad: AdRecord; message: string }> {
|
||||||
|
const res = await fetch("/api/ads", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || "提交失败");
|
||||||
|
return data as { ad: AdRecord; message: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminSaveAdsConfig(cfg: AdConfig): Promise<AdConfig> {
|
||||||
|
return apiOperations<AdConfig>("/api/admin/ads/config", "PUT", cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminListAds(
|
||||||
|
status = "",
|
||||||
|
page = 1
|
||||||
|
): Promise<{ ads: AdRecord[]; total: number }> {
|
||||||
|
const q = new URLSearchParams({ page: String(page), size: "30" });
|
||||||
|
if (status) q.set("status", status);
|
||||||
|
return apiOperations(`/api/admin/ads?${q}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminAds(
|
||||||
|
cookie?: string,
|
||||||
|
status = ""
|
||||||
|
): Promise<{ ads: AdRecord[]; total: number }> {
|
||||||
|
const q = new URLSearchParams({ page: "1", size: "50" });
|
||||||
|
if (status) q.set("status", status);
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ads?${q}`, {
|
||||||
|
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("加载广告列表失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminAdsConfig(cookie?: string): Promise<AdConfig> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/ads/config`, {
|
||||||
|
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("加载广告配置失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminApproveAd(id: number) {
|
||||||
|
return apiOperations(`/api/admin/ads/${id}/approve`, "PUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminRejectAd(id: number, reason = "") {
|
||||||
|
return apiOperations(`/api/admin/ads/${id}/reject`, "PUT", { reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminDeleteAd(id: number) {
|
||||||
|
return apiOperations(`/api/admin/ads/${id}`, "DELETE");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminUploadAdQR(file: File): Promise<{ url: string }> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetchWithRefresh("/api/admin/upload/ad-qr", {
|
||||||
|
method: "POST",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error((data as { error?: string }).error || "上传失败");
|
||||||
|
return data as { url: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiAdminSaveSponsorsConfig(cfg: SponsorsConfig): Promise<SponsorsConfig> {
|
||||||
|
return apiOperations<SponsorsConfig>("/api/admin/sponsors/config", "PUT", cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminSponsorsConfig(cookie?: string): Promise<SponsorsConfig> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/sponsors/config`, {
|
||||||
|
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("加载赞助商配置失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user