Merge pull request #2202 from Michael-Jetson/main

新增三大功能:兑换码邀请返利、批量修改用户并发数、Markdown页面渲染
This commit is contained in:
Wesley Liddick 2026-05-07 09:35:14 +08:00 committed by GitHub
commit d52da45363
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 765 additions and 18 deletions

View File

@ -74,7 +74,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService) authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache) userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient) redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator) redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
secretEncryptor, err := repository.NewAESEncryptor(configConfig) secretEncryptor, err := repository.NewAESEncryptor(configConfig)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -175,6 +175,10 @@ func (s *stubAdminService) UpdateUserBalance(ctx context.Context, userID int64,
return &user, nil return &user, nil
} }
func (s *stubAdminService) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
return len(userIDs), nil
}
func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) { func (s *stubAdminService) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil return s.apiKeys, int64(len(s.apiKeys)), nil
} }

View File

@ -998,17 +998,27 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Custom menu item label is too long (max 50 characters)") response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
return return
} }
if strings.TrimSpace(item.URL) == "" { urlTrimmed := strings.TrimSpace(item.URL)
response.BadRequest(c, "Custom menu item URL is required") if strings.HasPrefix(urlTrimmed, "md:") {
return // Markdown page mode: URL = "md:<slug>"
} slug := strings.TrimPrefix(urlTrimmed, "md:")
if len(item.URL) > maxMenuItemURLLen { if slug == "" {
response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)") response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)")
return return
} }
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil { } else {
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL") if urlTrimmed == "" {
return response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)")
return
}
if len(item.URL) > maxMenuItemURLLen {
response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
return
}
if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil {
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:<slug>")
return
}
} }
if item.Visibility != "user" && item.Visibility != "admin" { if item.Visibility != "user" && item.Visibility != "admin" {
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'") response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")

View File

@ -477,3 +477,63 @@ func (h *UserHandler) GetUserRPMStatus(c *gin.Context) {
response.Success(c, status) response.Success(c, status)
} }
// BatchUpdateConcurrency 批量修改用户并发数
// POST /api/v1/admin/users/batch-concurrency
type BatchUpdateConcurrencyRequest struct {
UserIDs []int64 `json:"user_ids"`
All bool `json:"all"`
Concurrency int `json:"concurrency"`
Mode string `json:"mode" binding:"required,oneof=set add"`
}
func (h *UserHandler) BatchUpdateConcurrency(c *gin.Context) {
var req BatchUpdateConcurrencyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if !req.All && len(req.UserIDs) == 0 {
response.BadRequest(c, "user_ids is required unless all=true")
return
}
if len(req.UserIDs) > 500 {
response.BadRequest(c, "user_ids cannot exceed 500")
return
}
var userIDs []int64
if req.All {
// Fetch all user IDs via pagination
page := 1
const pageSize = 500
for {
users, _, err := h.adminService.ListUsers(c.Request.Context(), page, pageSize, service.UserListFilters{}, "id", "asc")
if err != nil {
response.ErrorFrom(c, err)
return
}
for _, u := range users {
userIDs = append(userIDs, u.ID)
}
if len(users) < pageSize {
break
}
page++
}
} else {
userIDs = req.UserIDs
}
if len(userIDs) == 0 {
response.Success(c, gin.H{"affected": 0})
return
}
affected, err := h.adminService.BatchUpdateConcurrency(c.Request.Context(), userIDs, req.Concurrency, req.Mode)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"affected": affected})
}

View File

@ -2798,6 +2798,14 @@ func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int
panic("unexpected UpdateConcurrency call") panic("unexpected UpdateConcurrency call")
} }
func (r *oauthPendingFlowUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
panic("unexpected BatchSetConcurrency call")
}
func (r *oauthPendingFlowUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
panic("unexpected BatchAddConcurrency call")
}
func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) { func (r *oauthPendingFlowUserRepo) GetLatestUsedAtByUserIDs(context.Context, []int64) (map[int64]*time.Time, error) {
return map[int64]*time.Time{}, nil return map[int64]*time.Time{}, nil
} }

View File

@ -11,6 +11,7 @@ type CustomMenuItem struct {
Label string `json:"label"` Label string `json:"label"`
IconSVG string `json:"icon_svg"` IconSVG string `json:"icon_svg"`
URL string `json:"url"` URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin" Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }

View File

@ -0,0 +1,215 @@
package handler
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
var validSlugPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)
const maxPageFileSize = 1 << 20 // 1MB
type PageHandler struct {
pagesDir string
settingService *service.SettingService
}
func NewPageHandler(dataDir string, settingService *service.SettingService) *PageHandler {
pagesDir := filepath.Join(dataDir, "pages")
_ = os.MkdirAll(pagesDir, 0755)
return &PageHandler{pagesDir: pagesDir, settingService: settingService}
}
// GetPageContent serves raw markdown content for a given slug.
// GET /api/v1/pages/:slug
func (h *PageHandler) GetPageContent(c *gin.Context) {
slug := c.Param("slug")
if !validSlugPattern.MatchString(slug) || len(slug) > 64 {
response.BadRequest(c, "Invalid page slug")
return
}
// Visibility check: slug must be configured in custom_menu_items
// and the user must have permission based on visibility setting
if !h.checkSlugVisibility(c, slug) {
c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
return
}
filePath := filepath.Join(h.pagesDir, slug+".md")
cleaned := filepath.Clean(filePath)
if !strings.HasPrefix(cleaned, filepath.Clean(h.pagesDir)) {
response.BadRequest(c, "Invalid page slug")
return
}
info, err := os.Stat(cleaned)
if err != nil || info.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "page not found"})
return
}
if info.Size() > maxPageFileSize {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "page too large"})
return
}
content, err := os.ReadFile(cleaned)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read page"})
return
}
c.Data(http.StatusOK, "text/markdown; charset=utf-8", content)
}
// ListPages returns available page slugs.
// GET /api/v1/pages
func (h *PageHandler) ListPages(c *gin.Context) {
entries, err := os.ReadDir(h.pagesDir)
if err != nil {
response.Success(c, []string{})
return
}
slugs := make([]string, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".md") {
slugs = append(slugs, strings.TrimSuffix(name, ".md"))
}
}
response.Success(c, slugs)
}
// ServePageImage serves images from data/pages/{slug}/ directory.
// GET /api/v1/pages/:slug/images/*filename
// No JWT required (browser img tags can't carry tokens), but visibility is checked.
func (h *PageHandler) ServePageImage(c *gin.Context) {
slug := c.Param("slug")
filename := c.Param("filename")
filename = strings.TrimPrefix(filename, "/")
if !validSlugPattern.MatchString(slug) || len(slug) > 64 {
c.Status(http.StatusNotFound)
return
}
if !h.checkImageSlugVisibility(c, slug) {
c.Status(http.StatusNotFound)
return
}
if filename == "" || strings.Contains(filename, "..") || strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
c.Status(http.StatusNotFound)
return
}
imagesDir := filepath.Join(h.pagesDir, slug)
filePath := filepath.Join(imagesDir, filename)
cleaned := filepath.Clean(filePath)
if !strings.HasPrefix(cleaned, filepath.Clean(imagesDir)) {
c.Status(http.StatusNotFound)
return
}
info, err := os.Stat(cleaned)
if err != nil || info.IsDir() {
c.Status(http.StatusNotFound)
return
}
c.File(cleaned)
}
// findSlugVisibility looks up the slug in custom_menu_items and returns (visibility, found).
func (h *PageHandler) findSlugVisibility(c *gin.Context, slug string) (string, bool) {
if h.settingService == nil {
return "", false
}
raw := h.settingService.GetCustomMenuItemsRaw(c.Request.Context())
if raw == "" || raw == "[]" {
return "", false
}
var items []struct {
URL string `json:"url"`
PageSlug string `json:"page_slug"`
Visibility string `json:"visibility"`
}
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return "", false
}
for _, item := range items {
itemSlug := item.PageSlug
if itemSlug == "" && strings.HasPrefix(item.URL, "md:") {
itemSlug = strings.TrimPrefix(item.URL, "md:")
}
if itemSlug == slug {
return item.Visibility, true
}
}
return "", false
}
// checkSlugVisibility verifies the slug is configured in custom_menu_items
// and the authenticated user has permission to view it.
func (h *PageHandler) checkSlugVisibility(c *gin.Context, slug string) bool {
visibility, found := h.findSlugVisibility(c, slug)
if !found {
return false
}
if visibility == "admin" {
role, _ := middleware2.GetUserRoleFromContext(c)
return role == "admin"
}
return true
}
// checkImageSlugVisibility checks visibility for image requests (no JWT available).
// Only allows user-visible pages; admin-only pages are blocked.
func (h *PageHandler) checkImageSlugVisibility(c *gin.Context, slug string) bool {
visibility, found := h.findSlugVisibility(c, slug)
if !found {
return false
}
return visibility != "admin"
}
// RegisterPageRoutes registers page routes on a router group.
func RegisterPageRoutes(v1 *gin.RouterGroup, dataDir string, jwtAuth gin.HandlerFunc, adminAuth gin.HandlerFunc, settingService *service.SettingService) {
h := NewPageHandler(dataDir, settingService)
// Authenticated page content (JWT required + visibility check)
pages := v1.Group("/pages")
pages.Use(jwtAuth)
{
pages.GET("/:slug", h.GetPageContent)
}
// Images: no JWT (browser img tags can't carry tokens), visibility check in handler
pageImages := v1.Group("/pages")
{
pageImages.GET("/:slug/images/*filename", h.ServePageImage)
}
// Admin-only: list all available pages
adminPages := v1.Group("/pages")
adminPages.Use(adminAuth)
{
adminPages.GET("", h.ListPages)
}
}

View File

@ -87,6 +87,8 @@ func (s *userHandlerRepoStub) ListWithFilters(context.Context, pagination.Pagina
func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil } func (s *userHandlerRepoStub) UpdateBalance(context.Context, int64, float64) error { return nil }
func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil } func (s *userHandlerRepoStub) DeductBalance(context.Context, int64, float64) error { return nil }
func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil } func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) error { return nil }
func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil return 0, nil

View File

@ -737,6 +737,37 @@ func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount
return nil return nil
} }
func (r *userRepository) BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error) {
if len(userIDs) == 0 {
return 0, nil
}
if value < 0 {
value = 0
}
res, err := r.sql.ExecContext(ctx,
"UPDATE users SET concurrency = $1, updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
value, pq.Array(userIDs))
if err != nil {
return 0, fmt.Errorf("batch set concurrency: %w", err)
}
affected, _ := res.RowsAffected()
return int(affected), nil
}
func (r *userRepository) BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error) {
if len(userIDs) == 0 {
return 0, nil
}
res, err := r.sql.ExecContext(ctx,
"UPDATE users SET concurrency = GREATEST(concurrency + $1, 0), updated_at = NOW() WHERE id = ANY($2) AND deleted_at IS NULL",
delta, pq.Array(userIDs))
if err != nil {
return 0, fmt.Errorf("batch add concurrency: %w", err)
}
affected, _ := res.RowsAffected()
return int(affected), nil
}
func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) { func (r *userRepository) ExistsByEmail(ctx context.Context, email string) (bool, error) {
return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx) return r.client.User.Query().Where(userEmailLookupPredicate(email)).Exist(ctx)
} }

View File

@ -1125,7 +1125,7 @@ func newContractDeps(t *testing.T) *contractDeps {
subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg) subscriptionService := service.NewSubscriptionService(groupRepo, userSubRepo, nil, nil, cfg)
subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService) subscriptionHandler := handler.NewSubscriptionHandler(subscriptionService)
redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil) redeemService := service.NewRedeemService(redeemRepo, userRepo, subscriptionService, nil, nil, nil, nil, nil)
redeemHandler := handler.NewRedeemHandler(redeemService) redeemHandler := handler.NewRedeemHandler(redeemService)
settingRepo := newStubSettingRepo() settingRepo := newStubSettingRepo()
@ -1296,6 +1296,9 @@ func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
return errors.New("not implemented") return errors.New("not implemented")
} }
func (r *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (r *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { func (r *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
return false, errors.New("not implemented") return false, errors.New("not implemented")
} }

View File

@ -198,6 +198,9 @@ func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount i
panic("unexpected UpdateConcurrency call") panic("unexpected UpdateConcurrency call")
} }
func (s *stubUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *stubUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) { func (s *stubUserRepo) ExistsByEmail(ctx context.Context, email string) (bool, error) {
panic("unexpected ExistsByEmail call") panic("unexpected ExistsByEmail call")
} }

View File

@ -112,4 +112,6 @@ func registerRoutes(
routes.RegisterAdminRoutes(v1, h, adminAuth) routes.RegisterAdminRoutes(v1, h, adminAuth)
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg) routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg)
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService) routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, settingService)
handler.RegisterPageRoutes(v1, cfg.Pricing.DataDir, gin.HandlerFunc(jwtAuth), gin.HandlerFunc(adminAuth), settingService)
} }

View File

@ -245,6 +245,7 @@ func registerUserManagementRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory) users.GET("/:id/balance-history", h.Admin.User.GetBalanceHistory)
users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup) users.POST("/:id/replace-group", h.Admin.User.ReplaceGroup)
users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus) users.GET("/:id/rpm-status", h.Admin.User.GetUserRPMStatus)
users.POST("/batch-concurrency", h.Admin.User.BatchUpdateConcurrency)
// User attribute values // User attribute values
users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes) users.GET("/:id/attributes", h.Admin.UserAttribute.GetUserAttributes)

View File

@ -33,6 +33,7 @@ type AdminService interface {
UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error)
DeleteUser(ctx context.Context, id int64) error DeleteUser(ctx context.Context, id int64) error
UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error)
BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error)
GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error)
GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error)
GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error)
@ -817,6 +818,39 @@ func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error {
return nil return nil
} }
func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) {
cleaned := make([]int64, 0, len(userIDs))
for _, uid := range userIDs {
if uid > 0 {
cleaned = append(cleaned, uid)
}
}
if len(cleaned) == 0 {
return 0, nil
}
var affected int
var err error
switch mode {
case "set":
affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value)
case "add":
affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value)
default:
return 0, errors.New("invalid mode: must be 'set' or 'add'")
}
if err != nil {
return 0, err
}
if s.authCacheInvalidator != nil {
for _, uid := range cleaned {
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid)
}
}
return affected, nil
}
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
user, err := s.userRepo.GetByID(ctx, userID) user, err := s.userRepo.GetByID(ctx, userID)
if err != nil { if err != nil {

View File

@ -68,6 +68,9 @@ func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error { func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
panic("unexpected") panic("unexpected")
} }
func (s *userRepoStubForGroupUpdate) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStubForGroupUpdate) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) { func (s *userRepoStubForGroupUpdate) ExistsByEmail(context.Context, string) (bool, error) {
panic("unexpected") panic("unexpected")
} }

View File

@ -131,6 +131,9 @@ func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount i
panic("unexpected UpdateConcurrency call") panic("unexpected UpdateConcurrency call")
} }
func (s *userRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) { func (s *userRepoStub) ExistsByEmail(ctx context.Context, email string) (bool, error) {
if s.existsErr != nil { if s.existsErr != nil {
return false, s.existsErr return false, s.existsErr

View File

@ -113,6 +113,9 @@ func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64)
return 0, nil return 0, nil
} }
func (s *emailSyncRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *emailSyncRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (s *emailSyncRepoStub) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error { func (s *emailSyncRepoStub) RemoveGroupFromUserAllowedGroups(context.Context, int64, int64) error {

View File

@ -820,6 +820,9 @@ func (s *emailBindUserRepoStub) ExistsByEmail(_ context.Context, email string) (
return ok, nil return ok, nil
} }
func (s *emailBindUserRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *emailBindUserRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (s *emailBindUserRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }

View File

@ -282,7 +282,7 @@ func (s *PaymentService) doBalance(ctx context.Context, o *dbent.PaymentOrder) e
case redeemActionRedeem: case redeemActionRedeem:
// Code exists but unused — skip creation, proceed to redeem // Code exists but unused — skip creation, proceed to redeem
} }
if _, err := s.redeemService.Redeem(ctx, o.UserID, o.RechargeCode); err != nil { if _, err := s.redeemService.Redeem(ContextSkipRedeemAffiliate(ctx), o.UserID, o.RechargeCode); err != nil {
return fmt.Errorf("redeem balance: %w", err) return fmt.Errorf("redeem balance: %w", err)
} }
if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil { if err := s.applyAffiliateRebateForOrder(ctx, o); err != nil {

View File

@ -208,6 +208,7 @@ func TestVerifyOrderByOutTradeNoBackfillsTradeNoFromPaidQuery(t *testing.T) {
nil, nil,
client, client,
nil, nil,
nil,
) )
registry := payment.NewRegistry() registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{ provider := &paymentOrderLifecycleQueryProvider{
@ -308,6 +309,7 @@ func TestVerifyOrderByOutTradeNoRetriesZeroAmountPaidQueryOnce(t *testing.T) {
nil, nil,
client, client,
nil, nil,
nil,
) )
registry := payment.NewRegistry() registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{ provider := &paymentOrderLifecycleQueryProvider{
@ -398,6 +400,7 @@ func TestVerifyOrderByOutTradeNoRejectsPaidQueryWithZeroAmount(t *testing.T) {
nil, nil,
client, client,
nil, nil,
nil,
) )
registry := payment.NewRegistry() registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{ provider := &paymentOrderLifecycleQueryProvider{
@ -496,6 +499,7 @@ func TestVerifyOrderByOutTradeNoUsesOutTradeNoWhenPaymentTradeNoAlreadyExistsFor
nil, nil,
client, client,
nil, nil,
nil,
) )
registry := payment.NewRegistry() registry := payment.NewRegistry()
provider := &paymentOrderLifecycleQueryProvider{ provider := &paymentOrderLifecycleQueryProvider{

View File

@ -11,6 +11,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
) )
@ -28,6 +29,15 @@ const (
redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁 redeemLockDuration = 10 * time.Second // 锁超时时间,防止死锁
) )
type ctxKeySkipRedeemAffiliate struct{}
// ContextSkipRedeemAffiliate returns a context that suppresses the redeem-level
// affiliate rebate. Used by payment fulfillment which handles rebate separately
// via applyAffiliateRebateForOrder (with audit-log deduplication).
func ContextSkipRedeemAffiliate(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxKeySkipRedeemAffiliate{}, true)
}
// RedeemCache defines cache operations for redeem service // RedeemCache defines cache operations for redeem service
type RedeemCache interface { type RedeemCache interface {
GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error) GetRedeemAttemptCount(ctx context.Context, userID int64) (int, error)
@ -80,6 +90,7 @@ type RedeemService struct {
billingCacheService *BillingCacheService billingCacheService *BillingCacheService
entClient *dbent.Client entClient *dbent.Client
authCacheInvalidator APIKeyAuthCacheInvalidator authCacheInvalidator APIKeyAuthCacheInvalidator
affiliateService *AffiliateService
} }
// NewRedeemService 创建兑换码服务实例 // NewRedeemService 创建兑换码服务实例
@ -91,6 +102,7 @@ func NewRedeemService(
billingCacheService *BillingCacheService, billingCacheService *BillingCacheService,
entClient *dbent.Client, entClient *dbent.Client,
authCacheInvalidator APIKeyAuthCacheInvalidator, authCacheInvalidator APIKeyAuthCacheInvalidator,
affiliateService *AffiliateService,
) *RedeemService { ) *RedeemService {
return &RedeemService{ return &RedeemService{
redeemRepo: redeemRepo, redeemRepo: redeemRepo,
@ -100,6 +112,7 @@ func NewRedeemService(
billingCacheService: billingCacheService, billingCacheService: billingCacheService,
entClient: entClient, entClient: entClient,
authCacheInvalidator: authCacheInvalidator, authCacheInvalidator: authCacheInvalidator,
affiliateService: affiliateService,
} }
} }
@ -369,6 +382,11 @@ func (s *RedeemService) Redeem(ctx context.Context, userID int64, code string) (
// 事务提交成功后失效缓存 // 事务提交成功后失效缓存
s.invalidateRedeemCaches(ctx, userID, redeemCode) s.invalidateRedeemCaches(ctx, userID, redeemCode)
// 余额类正数兑换码触发邀请返利best-effort失败不影响兑换结果
if redeemCode.Type == RedeemTypeBalance && redeemCode.Value > 0 {
s.tryAccrueAffiliateRebateForRedeem(ctx, userID, redeemCode.Value)
}
// 重新获取更新后的兑换码 // 重新获取更新后的兑换码
redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID) redeemCode, err = s.redeemRepo.GetByID(ctx, redeemCode.ID)
if err != nil { if err != nil {
@ -418,6 +436,26 @@ func (s *RedeemService) invalidateRedeemCaches(ctx context.Context, userID int64
} }
} }
func (s *RedeemService) tryAccrueAffiliateRebateForRedeem(ctx context.Context, userID int64, amount float64) {
if ctx.Value(ctxKeySkipRedeemAffiliate{}) != nil {
return
}
if s.affiliateService == nil {
return
}
if !s.affiliateService.IsEnabled(ctx) {
return
}
rebate, err := s.affiliateService.AccrueInviteRebate(ctx, userID, amount)
if err != nil {
logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate failed for user %d amount %.2f: %v", userID, amount, err)
return
}
if rebate > 0 {
logger.LegacyPrintf("service.redeem", "[Redeem] affiliate rebate accrued %.8f for inviter of user %d", rebate, userID)
}
}
// GetByID 根据ID获取兑换码 // GetByID 根据ID获取兑换码
func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) { func (s *RedeemService) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
code, err := s.redeemRepo.GetByID(ctx, id) code, err := s.redeemRepo.GetByID(ctx, id)

View File

@ -1542,6 +1542,15 @@ func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
return value == "true" return value == "true"
} }
// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
if err != nil {
return "[]"
}
return value
}
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关) // IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool { func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled) value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)

View File

@ -96,6 +96,8 @@ type UserRepository interface {
UpdateBalance(ctx context.Context, id int64, amount float64) error UpdateBalance(ctx context.Context, id int64, amount float64) error
DeductBalance(ctx context.Context, id int64, amount float64) error DeductBalance(ctx context.Context, id int64, amount float64) error
UpdateConcurrency(ctx context.Context, id int64, amount int) error UpdateConcurrency(ctx context.Context, id int64, amount int) error
BatchSetConcurrency(ctx context.Context, userIDs []int64, value int) (int, error)
BatchAddConcurrency(ctx context.Context, userIDs []int64, delta int) (int, error)
ExistsByEmail(ctx context.Context, email string) (bool, error) ExistsByEmail(ctx context.Context, email string) (bool, error)
RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error) RemoveGroupFromAllowedGroups(ctx context.Context, groupID int64) (int64, error)
// AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups幂等冲突忽略 // AddGroupToAllowedGroups 将指定分组增量添加到用户的 allowed_groups幂等冲突忽略

View File

@ -199,6 +199,9 @@ func (m *mockUserRepo) ExistsByEmail(context.Context, string) (bool, error) { re
func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { func (m *mockUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil return 0, nil
} }
func (m *mockUserRepo) BatchSetConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (m *mockUserRepo) BatchAddConcurrency(context.Context, []int64, int) (int, error) { return 0, nil }
func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil } func (m *mockUserRepo) AddGroupToAllowedGroups(context.Context, int64, int64) error { return nil }
func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) { func (m *mockUserRepo) ListUserAuthIdentities(context.Context, int64) ([]UserAuthIdentityRecord, error) {
out := make([]UserAuthIdentityRecord, len(m.identities)) out := make([]UserAuthIdentityRecord, len(m.identities))

View File

@ -168,6 +168,7 @@ export interface CustomMenuItem {
label: string label: string
icon_svg: string icon_svg: string
url: string url: string
page_slug?: string
visibility: 'user' | 'admin' visibility: 'user' | 'admin'
sort_order: number sort_order: number
} }

View File

@ -27,6 +27,56 @@
</div> </div>
</div> </div>
<!-- Markdown mode with TOC -->
<div v-else-if="isMarkdownMode" class="flex h-full overflow-hidden">
<!-- TOC Sidebar -->
<aside
v-show="tocVisible"
class="toc-sidebar"
>
<div class="toc-header">
<span class="toc-title">目录</span>
<button class="toc-close-btn" @click="tocVisible = false">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
</button>
</div>
<nav class="toc-nav">
<a
v-for="item in tocItems"
:key="item.id"
:href="'#' + item.id"
class="toc-item"
:class="[
`toc-level-${item.level}`,
{ 'toc-active': activeHeadingId === item.id }
]"
@click.prevent="scrollToHeading(item.id)"
>
{{ item.text }}
</a>
</nav>
</aside>
<!-- TOC Toggle Button (when collapsed) -->
<button
v-show="!tocVisible && tocItems.length > 0"
class="toc-toggle-btn"
@click="tocVisible = true"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
<span class="ml-1 text-xs">目录</span>
</button>
<!-- Content -->
<div
ref="markdownContainer"
class="markdown-page-content flex-1 h-full overflow-auto p-6 md:p-10"
v-html="renderedHtml"
@scroll="onContentScroll"
></div>
</div>
<!-- URL not configured -->
<div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center"> <div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center">
<div class="max-w-md"> <div class="max-w-md">
<div <div
@ -43,6 +93,7 @@
</div> </div>
</div> </div>
<!-- Iframe embed mode -->
<div v-else class="custom-embed-shell"> <div v-else class="custom-embed-shell">
<a <a
:href="embeddedUrl" :href="embeddedUrl"
@ -65,7 +116,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores' import { useAppStore } from '@/stores'
@ -74,6 +125,14 @@ import { useAdminSettingsStore } from '@/stores/adminSettings'
import AppLayout from '@/components/layout/AppLayout.vue' import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue' import Icon from '@/components/icons/Icon.vue'
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url' import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
import { marked } from 'marked'
import DOMPurify from 'dompurify'
interface TocItem {
id: string
text: string
level: number
}
const { t, locale } = useI18n() const { t, locale } = useI18n()
const route = useRoute() const route = useRoute()
@ -83,25 +142,38 @@ const adminSettingsStore = useAdminSettingsStore()
const loading = ref(false) const loading = ref(false)
const pageTheme = ref<'light' | 'dark'>('light') const pageTheme = ref<'light' | 'dark'>('light')
const renderedHtml = ref('')
const markdownContainer = ref<HTMLElement | null>(null)
const tocItems = ref<TocItem[]>([])
const tocVisible = ref(typeof window !== 'undefined' ? window.innerWidth > 768 : true)
const activeHeadingId = ref('')
let themeObserver: MutationObserver | null = null let themeObserver: MutationObserver | null = null
const menuItemId = computed(() => route.params.id as string) const menuItemId = computed(() => route.params.id as string)
const menuItem = computed(() => { const menuItem = computed(() => {
const id = menuItemId.value const id = menuItemId.value
// Try public settings first (contains user-visible items)
const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? [] const publicItems = appStore.cachedPublicSettings?.custom_menu_items ?? []
const found = publicItems.find((item) => item.id === id) ?? null const found = publicItems.find((item) => item.id === id) ?? null
if (found) return found if (found) return found
// For admin users, also check admin settings (contains admin-only items)
if (authStore.isAdmin) { if (authStore.isAdmin) {
return adminSettingsStore.customMenuItems.find((item) => item.id === id) ?? null return adminSettingsStore.customMenuItems.find((item) => item.id === id) ?? null
} }
return null return null
}) })
const markdownSlug = computed(() => {
const item = menuItem.value
if (!item) return ''
if (item.page_slug) return item.page_slug
if (item.url?.startsWith('md:')) return item.url.slice(3)
return ''
})
const isMarkdownMode = computed(() => !!markdownSlug.value)
const embeddedUrl = computed(() => { const embeddedUrl = computed(() => {
if (!menuItem.value) return '' if (!menuItem.value || isMarkdownMode.value) return ''
return buildEmbeddedUrl( return buildEmbeddedUrl(
menuItem.value.url, menuItem.value.url,
authStore.user?.id, authStore.user?.id,
@ -112,10 +184,141 @@ const embeddedUrl = computed(() => {
}) })
const isValidUrl = computed(() => { const isValidUrl = computed(() => {
if (isMarkdownMode.value) return false
const url = embeddedUrl.value const url = embeddedUrl.value
return url.startsWith('http://') || url.startsWith('https://') return url.startsWith('http://') || url.startsWith('https://')
}) })
function generateHeadingId(text: string, index: number): string {
const base = text
.toLowerCase()
.replace(/[^\w一-鿿]+/g, '-')
.replace(/^-+|-+$/g, '')
return base ? `${base}-${index}` : `heading-${index}`
}
async function fetchAndRenderMarkdown(slug: string) {
loading.value = true
tocItems.value = []
activeHeadingId.value = ''
try {
const resp = await fetch(`/api/v1/pages/${encodeURIComponent(slug)}`, {
headers: authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {},
})
if (!resp.ok) {
renderedHtml.value = '<p class="text-red-500">Page not found</p>'
return
}
let raw = await resp.text()
raw = raw.replace(
/!\[([^\]]*)\]\((?!https?:\/\/)([^)]+)\)/g,
(_, alt, src) => `![${alt}](/api/v1/pages/${slug}/images/${src})`
)
const html = marked.parse(raw) as string
const sanitized = DOMPurify.sanitize(html, {
ADD_TAGS: ['iframe'],
ADD_ATTR: ['allowfullscreen', 'frameborder', 'src'],
})
// Inject IDs into headings and build TOC
const toc: TocItem[] = []
let headingIndex = 0
const withIds = sanitized.replace(
/<(h[1-4])[^>]*>(.*?)<\/h[1-4]>/gi,
(_, tag: string, content: string) => {
const level = parseInt(tag[1])
const text = content.replace(/<[^>]+>/g, '').trim()
const id = generateHeadingId(text, headingIndex++)
toc.push({ id, text, level })
return `<${tag} id="${id}">${content}</${tag}>`
}
)
renderedHtml.value = withIds
tocItems.value = toc
} catch {
renderedHtml.value = '<p class="text-red-500">Failed to load page</p>'
} finally {
loading.value = false
await nextTick()
await nextTick()
injectCopyButtons()
}
}
function scrollToHeading(id: string) {
const container = markdownContainer.value
if (!container) return
const el = container.querySelector(`#${CSS.escape(id)}`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
activeHeadingId.value = id
if (window.innerWidth <= 640) {
tocVisible.value = false
}
}
}
let scrollRafId = 0
function onContentScroll() {
if (scrollRafId) return
scrollRafId = requestAnimationFrame(() => {
scrollRafId = 0
const container = markdownContainer.value
if (!container || tocItems.value.length === 0) return
const containerRect = container.getBoundingClientRect()
let current = ''
for (const item of tocItems.value) {
const el = container.querySelector(`#${CSS.escape(item.id)}`) as HTMLElement | null
if (el) {
const elRect = el.getBoundingClientRect()
if (elRect.top - containerRect.top <= 100) {
current = item.id
}
}
}
activeHeadingId.value = current
})
}
function injectCopyButtons() {
const container = markdownContainer.value
if (!container) return
container.querySelectorAll('pre').forEach((pre) => {
if (pre.querySelector('.copy-btn')) return
const btn = document.createElement('button')
btn.className = 'copy-btn'
btn.textContent = '复制'
btn.addEventListener('click', async () => {
const code = pre.querySelector('code')?.textContent ?? pre.textContent ?? ''
try {
await navigator.clipboard.writeText(code)
btn.textContent = '已复制 ✓'
setTimeout(() => { btn.textContent = '复制' }, 2000)
} catch {
btn.textContent = '失败'
setTimeout(() => { btn.textContent = '复制' }, 2000)
}
})
pre.style.position = 'relative'
pre.appendChild(btn)
})
}
watch(markdownSlug, (slug) => {
if (slug) {
fetchAndRenderMarkdown(slug)
} else {
renderedHtml.value = ''
tocItems.value = []
}
}, { immediate: true })
onMounted(async () => { onMounted(async () => {
pageTheme.value = detectTheme() pageTheme.value = detectTheme()
@ -152,6 +355,64 @@ onUnmounted(() => {
height: calc(100vh - 64px - 4rem); height: calc(100vh - 64px - 4rem);
} }
.toc-sidebar {
@apply flex flex-col h-full border-r border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-800;
width: min(240px, 30%);
min-width: 160px;
max-width: 280px;
overflow: hidden;
}
@media (max-width: 640px) {
.toc-sidebar {
position: absolute;
left: 0;
top: 0;
z-index: 20;
width: 70%;
max-width: 240px;
height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
}
.toc-header {
@apply flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-dark-600;
}
.toc-title {
@apply text-sm font-semibold text-gray-700 dark:text-dark-200;
}
.toc-close-btn {
@apply p-1 rounded text-gray-400 hover:text-gray-600 dark:hover:text-dark-200 hover:bg-gray-200 dark:hover:bg-dark-600 transition-colors;
}
.toc-nav {
@apply flex-1 overflow-y-auto py-2 px-2;
}
.toc-item {
@apply block px-2 py-1.5 text-sm rounded transition-colors truncate;
@apply text-gray-600 dark:text-dark-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-dark-600;
}
.toc-item.toc-active {
@apply text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 font-medium;
}
.toc-level-1 { padding-left: 8px; }
.toc-level-2 { padding-left: 20px; }
.toc-level-3 { padding-left: 32px; }
.toc-level-4 { padding-left: 44px; }
.toc-toggle-btn {
@apply absolute left-2 top-2 z-10 flex items-center px-2 py-1.5 rounded-md text-sm;
@apply bg-white dark:bg-dark-700 border border-gray-200 dark:border-dark-500;
@apply text-gray-600 dark:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-600;
@apply shadow-sm transition-colors cursor-pointer;
}
.custom-embed-shell { .custom-embed-shell {
@apply relative; @apply relative;
@apply h-full w-full overflow-hidden rounded-2xl; @apply h-full w-full overflow-hidden rounded-2xl;
@ -175,3 +436,46 @@ onUnmounted(() => {
background: transparent; background: transparent;
} }
</style> </style>
<style>
.markdown-page-content {
line-height: 1.7;
color: inherit;
}
.markdown-page-content h1 { @apply text-3xl font-bold mt-8 mb-4 pb-2 border-b border-gray-200 dark:border-dark-600; }
.markdown-page-content h2 { @apply text-2xl font-bold mt-6 mb-3; }
.markdown-page-content h3 { @apply text-xl font-semibold mt-5 mb-2; }
.markdown-page-content h4 { @apply text-lg font-semibold mt-4 mb-2; }
.markdown-page-content p { @apply mb-4; }
.markdown-page-content ul { @apply list-disc pl-6 mb-4; }
.markdown-page-content ol { @apply list-decimal pl-6 mb-4; }
.markdown-page-content li { @apply mb-1; }
.markdown-page-content a { @apply text-primary-500 hover:text-primary-600 underline; }
.markdown-page-content blockquote { @apply border-l-4 border-gray-300 dark:border-dark-500 pl-4 italic text-gray-600 dark:text-dark-300 my-4; }
.markdown-page-content img { @apply max-w-full h-auto rounded-lg my-4; }
.markdown-page-content table { @apply w-full border-collapse my-4; }
.markdown-page-content th { @apply border border-gray-300 dark:border-dark-500 px-3 py-2 bg-gray-50 dark:bg-dark-700 font-semibold text-left; }
.markdown-page-content td { @apply border border-gray-300 dark:border-dark-500 px-3 py-2; }
.markdown-page-content code { @apply bg-gray-100 dark:bg-dark-700 px-1.5 py-0.5 rounded text-sm font-mono; }
.markdown-page-content pre { @apply bg-gray-900 dark:bg-dark-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4 relative; }
.markdown-page-content pre code { @apply bg-transparent p-0 text-inherit; }
.markdown-page-content hr { @apply my-6 border-gray-200 dark:border-dark-600; }
.copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 10px;
font-size: 12px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.15);
color: #e2e8f0;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s, background 0.2s;
font-family: inherit;
}
.copy-btn:hover { background: rgba(255, 255, 255, 0.25); }
pre:hover .copy-btn { opacity: 1; }
</style>