feat: custom menu pages with iframe embedding and CSP injection
Add configurable custom menu items that appear in sidebar, each rendering an iframe-embedded external page. Includes shared URL builder with src_host/src_url tracking, CSP frame-src multi-origin deduplication, admin settings UI, and i18n support. chore: bump version to 0.1.87.19 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -20,6 +23,27 @@ import (
|
||||
// semverPattern 预编译 semver 格式校验正则
|
||||
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||
|
||||
// generateMenuItemID generates a short random hex ID for a custom menu item.
|
||||
func generateMenuItemID() string {
|
||||
b := make([]byte, 8)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// parseCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
|
||||
// Returns empty slice on empty/invalid input.
|
||||
func parseCustomMenuItems(raw string) []dto.CustomMenuItem {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return []dto.CustomMenuItem{}
|
||||
}
|
||||
var items []dto.CustomMenuItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return []dto.CustomMenuItem{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// SettingHandler 系统设置处理器
|
||||
type SettingHandler struct {
|
||||
settingService *service.SettingService
|
||||
@@ -92,6 +116,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
CustomMenuItems: parseCustomMenuItems(settings.CustomMenuItems),
|
||||
DefaultConcurrency: settings.DefaultConcurrency,
|
||||
DefaultBalance: settings.DefaultBalance,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
@@ -152,6 +177,7 @@ type UpdateSettingsRequest struct {
|
||||
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
|
||||
|
||||
// 默认配置
|
||||
DefaultConcurrency int `json:"default_concurrency"`
|
||||
@@ -299,6 +325,40 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义菜单项验证
|
||||
customMenuJSON := previousSettings.CustomMenuItems
|
||||
if req.CustomMenuItems != nil {
|
||||
items := *req.CustomMenuItems
|
||||
for i, item := range items {
|
||||
if strings.TrimSpace(item.Label) == "" {
|
||||
response.BadRequest(c, "Custom menu item label is required")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(item.URL) == "" {
|
||||
response.BadRequest(c, "Custom menu item URL is required")
|
||||
return
|
||||
}
|
||||
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(item.URL)); err != nil {
|
||||
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL")
|
||||
return
|
||||
}
|
||||
if item.Visibility != "user" && item.Visibility != "admin" {
|
||||
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
||||
return
|
||||
}
|
||||
// Auto-generate ID if missing
|
||||
if strings.TrimSpace(item.ID) == "" {
|
||||
items[i].ID = generateMenuItemID()
|
||||
}
|
||||
}
|
||||
menuBytes, err := json.Marshal(items)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Failed to serialize custom menu items")
|
||||
return
|
||||
}
|
||||
customMenuJSON = string(menuBytes)
|
||||
}
|
||||
|
||||
// Ops metrics collector interval validation (seconds).
|
||||
if req.OpsMetricsIntervalSeconds != nil {
|
||||
v := *req.OpsMetricsIntervalSeconds
|
||||
@@ -358,6 +418,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||
PurchaseSubscriptionURL: purchaseURL,
|
||||
SoraClientEnabled: req.SoraClientEnabled,
|
||||
CustomMenuItems: customMenuJSON,
|
||||
DefaultConcurrency: req.DefaultConcurrency,
|
||||
DefaultBalance: req.DefaultBalance,
|
||||
DefaultSubscriptions: defaultSubscriptions,
|
||||
@@ -449,6 +510,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
|
||||
SoraClientEnabled: updatedSettings.SoraClientEnabled,
|
||||
CustomMenuItems: parseCustomMenuItems(updatedSettings.CustomMenuItems),
|
||||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||||
DefaultBalance: updatedSettings.DefaultBalance,
|
||||
DefaultSubscriptions: updatedDefaultSubscriptions,
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
package dto
|
||||
|
||||
// CustomMenuItem represents a user-configured custom menu entry.
|
||||
type CustomMenuItem struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
IconSVG string `json:"icon_svg"`
|
||||
URL string `json:"url"`
|
||||
Visibility string `json:"visibility"` // "user" or "admin"
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// SystemSettings represents the admin settings API response payload.
|
||||
type SystemSettings struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
@@ -38,6 +48,7 @@ type SystemSettings struct {
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||
|
||||
DefaultConcurrency int `json:"default_concurrency"`
|
||||
DefaultBalance float64 `json:"default_balance"`
|
||||
@@ -87,6 +98,7 @@ type PublicSettings struct {
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
SoraClientEnabled bool `json:"sora_client_enabled"`
|
||||
Version string `json:"version"`
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -50,8 +53,22 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
CustomMenuItems: parsePublicCustomMenuItems(settings.CustomMenuItems),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
SoraClientEnabled: settings.SoraClientEnabled,
|
||||
Version: h.version,
|
||||
})
|
||||
}
|
||||
|
||||
// parsePublicCustomMenuItems parses a JSON string into a slice of CustomMenuItem.
|
||||
func parsePublicCustomMenuItems(raw string) []dto.CustomMenuItem {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return []dto.CustomMenuItem{}
|
||||
}
|
||||
var items []dto.CustomMenuItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return []dto.CustomMenuItem{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ func GetNonceFromContext(c *gin.Context) string {
|
||||
}
|
||||
|
||||
// SecurityHeaders sets baseline security headers for all responses.
|
||||
func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
|
||||
// getFrameSrcOrigins is an optional function that returns extra origins to inject into frame-src;
|
||||
// pass nil to disable dynamic frame-src injection.
|
||||
func SecurityHeaders(cfg config.CSPConfig, getFrameSrcOrigins func() []string) gin.HandlerFunc {
|
||||
policy := strings.TrimSpace(cfg.Policy)
|
||||
if policy == "" {
|
||||
policy = config.DefaultCSPPolicy
|
||||
@@ -51,6 +53,15 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
|
||||
policy = enhanceCSPPolicy(policy)
|
||||
|
||||
return func(c *gin.Context) {
|
||||
finalPolicy := policy
|
||||
if getFrameSrcOrigins != nil {
|
||||
for _, origin := range getFrameSrcOrigins() {
|
||||
if origin != "" {
|
||||
finalPolicy = addToDirective(finalPolicy, "frame-src", origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
@@ -65,12 +76,10 @@ func SecurityHeaders(cfg config.CSPConfig) gin.HandlerFunc {
|
||||
if err != nil {
|
||||
// crypto/rand 失败时降级为无 nonce 的 CSP 策略
|
||||
log.Printf("[SecurityHeaders] %v — 降级为无 nonce 的 CSP", err)
|
||||
finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'unsafe-inline'")
|
||||
c.Header("Content-Security-Policy", finalPolicy)
|
||||
c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'unsafe-inline'"))
|
||||
} else {
|
||||
c.Set(CSPNonceKey, nonce)
|
||||
finalPolicy := strings.ReplaceAll(policy, NonceTemplate, "'nonce-"+nonce+"'")
|
||||
c.Header("Content-Security-Policy", finalPolicy)
|
||||
c.Header("Content-Security-Policy", strings.ReplaceAll(finalPolicy, NonceTemplate, "'nonce-"+nonce+"'"))
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
@@ -14,6 +20,25 @@ import (
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// extractOrigin returns the scheme+host origin from rawURL, or "" on error.
|
||||
// Only http and https schemes are accepted; other values (e.g. "//host/path") return "".
|
||||
func extractOrigin(rawURL string) string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
const paymentOriginFetchTimeout = 5 * time.Second
|
||||
|
||||
// SetupRouter 配置路由器中间件和路由
|
||||
func SetupRouter(
|
||||
r *gin.Engine,
|
||||
@@ -28,11 +53,65 @@ func SetupRouter(
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
) *gin.Engine {
|
||||
// 缓存 iframe 页面的 origin 列表,用于动态注入 CSP frame-src
|
||||
// 包含 purchase_subscription_url 和所有 custom_menu_items 的 origin(去重)
|
||||
var cachedFrameOrigins atomic.Pointer[[]string]
|
||||
emptyOrigins := []string{}
|
||||
cachedFrameOrigins.Store(&emptyOrigins)
|
||||
|
||||
refreshFrameOrigins := func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), paymentOriginFetchTimeout)
|
||||
defer cancel()
|
||||
settings, err := settingService.GetPublicSettings(ctx)
|
||||
if err != nil {
|
||||
// 获取失败时保留已有缓存,避免 frame-src 被意外清空
|
||||
return
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var origins []string
|
||||
|
||||
// purchase subscription URL
|
||||
if settings.PurchaseSubscriptionEnabled {
|
||||
if origin := extractOrigin(settings.PurchaseSubscriptionURL); origin != "" {
|
||||
if _, ok := seen[origin]; !ok {
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// custom menu items
|
||||
if raw := strings.TrimSpace(settings.CustomMenuItems); raw != "" && raw != "[]" {
|
||||
var items []struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &items); err == nil {
|
||||
for _, item := range items {
|
||||
if origin := extractOrigin(item.URL); origin != "" {
|
||||
if _, ok := seen[origin]; !ok {
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedFrameOrigins.Store(&origins)
|
||||
}
|
||||
refreshFrameOrigins() // 启动时初始化
|
||||
|
||||
// 应用中间件
|
||||
r.Use(middleware2.RequestLogger())
|
||||
r.Use(middleware2.Logger())
|
||||
r.Use(middleware2.CORS(cfg.CORS))
|
||||
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP))
|
||||
r.Use(middleware2.SecurityHeaders(cfg.Security.CSP, func() []string {
|
||||
if p := cachedFrameOrigins.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
// Serve embedded frontend with settings injection if available
|
||||
if web.HasEmbeddedFrontend() {
|
||||
@@ -40,11 +119,17 @@ func SetupRouter(
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
|
||||
r.Use(web.ServeEmbeddedFrontend())
|
||||
settingService.SetOnUpdateCallback(refreshFrameOrigins)
|
||||
} else {
|
||||
// Register cache invalidation callback
|
||||
settingService.SetOnUpdateCallback(frontendServer.InvalidateCache)
|
||||
// Register combined callback: invalidate HTML cache + refresh frame origins
|
||||
settingService.SetOnUpdateCallback(func() {
|
||||
frontendServer.InvalidateCache()
|
||||
refreshFrameOrigins()
|
||||
})
|
||||
r.Use(frontendServer.Middleware())
|
||||
}
|
||||
} else {
|
||||
settingService.SetOnUpdateCallback(refreshFrameOrigins)
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
|
||||
@@ -113,8 +113,9 @@ const (
|
||||
SettingKeyDocURL = "doc_url" // 文档链接
|
||||
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
|
||||
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
|
||||
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示“购买订阅”页面入口
|
||||
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // “购买订阅”页面 URL(作为 iframe src)
|
||||
SettingKeyPurchaseSubscriptionEnabled = “purchase_subscription_enabled” // 是否展示”购买订阅”页面入口
|
||||
SettingKeyPurchaseSubscriptionURL = “purchase_subscription_url” // “购买订阅”页面 URL(作为 iframe src)
|
||||
SettingKeyCustomMenuItems = “custom_menu_items” // 自定义菜单项(JSON 数组)
|
||||
|
||||
// 默认配置
|
||||
SettingKeyDefaultConcurrency = "default_concurrency" // 新用户默认并发量
|
||||
|
||||
@@ -124,6 +124,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyPurchaseSubscriptionEnabled,
|
||||
SettingKeyPurchaseSubscriptionURL,
|
||||
SettingKeySoraClientEnabled,
|
||||
SettingKeyCustomMenuItems,
|
||||
SettingKeyLinuxDoConnectEnabled,
|
||||
}
|
||||
|
||||
@@ -163,6 +164,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
}, nil
|
||||
}
|
||||
@@ -293,6 +295,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet
|
||||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
||||
updates[SettingKeySoraClientEnabled] = strconv.FormatBool(settings.SoraClientEnabled)
|
||||
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
||||
|
||||
// 默认配置
|
||||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
||||
@@ -509,6 +512,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyPurchaseSubscriptionEnabled: "false",
|
||||
SettingKeyPurchaseSubscriptionURL: "",
|
||||
SettingKeySoraClientEnabled: "false",
|
||||
SettingKeyCustomMenuItems: "[]",
|
||||
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
|
||||
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
|
||||
SettingKeyDefaultSubscriptions: "[]",
|
||||
@@ -567,6 +571,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
SoraClientEnabled: settings[SettingKeySoraClientEnabled] == "true",
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
}
|
||||
|
||||
// 解析整数类型
|
||||
|
||||
@@ -40,6 +40,7 @@ type SystemSettings struct {
|
||||
PurchaseSubscriptionEnabled bool
|
||||
PurchaseSubscriptionURL string
|
||||
SoraClientEnabled bool
|
||||
CustomMenuItems string // JSON array of custom menu items
|
||||
|
||||
DefaultConcurrency int
|
||||
DefaultBalance float64
|
||||
@@ -92,6 +93,7 @@ type PublicSettings struct {
|
||||
PurchaseSubscriptionEnabled bool
|
||||
PurchaseSubscriptionURL string
|
||||
SoraClientEnabled bool
|
||||
CustomMenuItems string // JSON array of custom menu items
|
||||
|
||||
LinuxDoOAuthEnabled bool
|
||||
Version string
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client'
|
||||
import type { CustomMenuItem } from '@/types'
|
||||
|
||||
export interface DefaultSubscriptionSetting {
|
||||
group_id: number
|
||||
@@ -38,6 +39,7 @@ export interface SystemSettings {
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
sora_client_enabled: boolean
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
// SMTP settings
|
||||
smtp_host: string
|
||||
smtp_port: number
|
||||
@@ -99,6 +101,7 @@ export interface UpdateSettingsRequest {
|
||||
purchase_subscription_enabled?: boolean
|
||||
purchase_subscription_url?: string
|
||||
sora_client_enabled?: boolean
|
||||
custom_menu_items?: CustomMenuItem[]
|
||||
smtp_host?: string
|
||||
smtp_port?: number
|
||||
smtp_username?: string
|
||||
|
||||
@@ -47,7 +47,8 @@
|
||||
"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -71,7 +72,8 @@
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -92,7 +94,8 @@
|
||||
:data-tour="item.path === '/keys' ? 'sidebar-my-keys' : undefined"
|
||||
@click="handleMenuItemClick(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<span v-if="item.iconSvg" class="h-5 w-5 flex-shrink-0 sidebar-svg-icon" v-html="item.iconSvg"></span>
|
||||
<component v-else :is="item.icon" class="h-5 w-5 flex-shrink-0" />
|
||||
<transition name="fade">
|
||||
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
|
||||
</transition>
|
||||
@@ -150,6 +153,14 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
|
||||
interface NavItem {
|
||||
path: string
|
||||
label: string
|
||||
icon: unknown
|
||||
iconSvg?: string
|
||||
hideInSimpleMode?: boolean
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
@@ -496,8 +507,8 @@ const ChevronDoubleRightIcon = {
|
||||
}
|
||||
|
||||
// User navigation items (for regular users)
|
||||
const userNavItems = computed(() => {
|
||||
const items = [
|
||||
const userNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
@@ -515,6 +526,13 @@ const userNavItems = computed(() => {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
hideInSimpleMode: true,
|
||||
})),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
]
|
||||
@@ -522,8 +540,8 @@ const userNavItems = computed(() => {
|
||||
})
|
||||
|
||||
// Personal navigation items (for admin's "My Account" section, without Dashboard)
|
||||
const personalNavItems = computed(() => {
|
||||
const items = [
|
||||
const personalNavItems = computed((): NavItem[] => {
|
||||
const items: NavItem[] = [
|
||||
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
|
||||
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
|
||||
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
@@ -540,15 +558,37 @@ const personalNavItems = computed(() => {
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...customMenuItemsForUser.value.map((item): NavItem => ({
|
||||
path: `/custom/${item.id}`,
|
||||
label: item.label,
|
||||
icon: null,
|
||||
iconSvg: item.icon_svg,
|
||||
hideInSimpleMode: true,
|
||||
})),
|
||||
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
|
||||
{ path: '/profile', label: t('nav.profile'), icon: UserIcon }
|
||||
]
|
||||
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
|
||||
})
|
||||
|
||||
// Custom menu items filtered by visibility
|
||||
const customMenuItemsForUser = computed(() => {
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
return items
|
||||
.filter((item) => item.visibility === 'user')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
})
|
||||
|
||||
const customMenuItemsForAdmin = computed(() => {
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
return items
|
||||
.filter((item) => item.visibility === 'admin')
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
})
|
||||
|
||||
// Admin navigation items
|
||||
const adminNavItems = computed(() => {
|
||||
const baseItems = [
|
||||
const adminNavItems = computed((): NavItem[] => {
|
||||
const baseItems: NavItem[] = [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
...(adminSettingsStore.opsMonitoringEnabled
|
||||
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
|
||||
@@ -567,6 +607,10 @@ const adminNavItems = computed(() => {
|
||||
// 简单模式下,在系统设置前插入 API密钥
|
||||
if (authStore.isSimpleMode) {
|
||||
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
|
||||
// Add admin custom menu items
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
}
|
||||
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
|
||||
filtered.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
|
||||
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
@@ -574,6 +618,10 @@ const adminNavItems = computed(() => {
|
||||
}
|
||||
|
||||
baseItems.push({ path: '/admin/data-management', label: t('nav.dataManagement'), icon: DatabaseIcon })
|
||||
// Add admin custom menu items before settings
|
||||
for (const cm of customMenuItemsForAdmin.value) {
|
||||
baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
|
||||
}
|
||||
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
|
||||
return baseItems
|
||||
})
|
||||
@@ -654,4 +702,12 @@ onMounted(() => {
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Custom SVG icon in sidebar: inherit color, constrain size */
|
||||
.sidebar-svg-icon :deep(svg) {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3625,6 +3625,25 @@ export default {
|
||||
enabled: 'Enable Sora Client',
|
||||
enabledHint: 'When enabled, the Sora entry will be shown in the sidebar for users to access Sora features'
|
||||
},
|
||||
customMenu: {
|
||||
title: 'Custom Menu Pages',
|
||||
description: 'Add custom iframe pages to the sidebar navigation. Each page can be visible to regular users or administrators.',
|
||||
itemLabel: 'Menu Item #{n}',
|
||||
name: 'Menu Name',
|
||||
namePlaceholder: 'e.g. Help Center',
|
||||
url: 'Page URL',
|
||||
urlPlaceholder: 'https://example.com/page',
|
||||
iconSvg: 'SVG Icon',
|
||||
iconSvgPlaceholder: '<svg>...</svg>',
|
||||
iconPreview: 'Icon Preview',
|
||||
visibility: 'Visible To',
|
||||
visibilityUser: 'Regular Users',
|
||||
visibilityAdmin: 'Administrators',
|
||||
add: 'Add Menu Item',
|
||||
remove: 'Remove',
|
||||
moveUp: 'Move Up',
|
||||
moveDown: 'Move Down',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP Settings',
|
||||
description: 'Configure email sending for verification codes',
|
||||
@@ -3913,6 +3932,16 @@ export default {
|
||||
'The administrator enabled the entry but has not configured a recharge/subscription URL. Please contact admin.'
|
||||
},
|
||||
|
||||
// Custom Page (iframe embed)
|
||||
customPage: {
|
||||
title: 'Custom Page',
|
||||
openInNewTab: 'Open in new tab',
|
||||
notFoundTitle: 'Page not found',
|
||||
notFoundDesc: 'This custom page does not exist or has been removed.',
|
||||
notConfiguredTitle: 'Page URL not configured',
|
||||
notConfiguredDesc: 'The URL for this custom page has not been properly configured.',
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: 'Announcements',
|
||||
|
||||
@@ -3795,6 +3795,25 @@ export default {
|
||||
enabled: '启用 Sora 客户端',
|
||||
enabledHint: '开启后,侧边栏将显示 Sora 入口,用户可访问 Sora 功能'
|
||||
},
|
||||
customMenu: {
|
||||
title: '自定义菜单页面',
|
||||
description: '添加自定义 iframe 页面到侧边栏导航。每个页面可以设置为普通用户或管理员可见。',
|
||||
itemLabel: '菜单项 #{n}',
|
||||
name: '菜单名称',
|
||||
namePlaceholder: '如:帮助中心',
|
||||
url: '页面 URL',
|
||||
urlPlaceholder: 'https://example.com/page',
|
||||
iconSvg: 'SVG 图标',
|
||||
iconSvgPlaceholder: '<svg>...</svg>',
|
||||
iconPreview: '图标预览',
|
||||
visibility: '可见角色',
|
||||
visibilityUser: '普通用户',
|
||||
visibilityAdmin: '管理员',
|
||||
add: '添加菜单项',
|
||||
remove: '删除',
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
},
|
||||
smtp: {
|
||||
title: 'SMTP 设置',
|
||||
description: '配置用于发送验证码的邮件服务',
|
||||
@@ -4081,6 +4100,16 @@ export default {
|
||||
notConfiguredDesc: '管理员已开启入口,但尚未配置充值/订阅链接,请联系管理员。'
|
||||
},
|
||||
|
||||
// Custom Page (iframe embed)
|
||||
customPage: {
|
||||
title: '自定义页面',
|
||||
openInNewTab: '新窗口打开',
|
||||
notFoundTitle: '页面不存在',
|
||||
notFoundDesc: '该自定义页面不存在或已被删除。',
|
||||
notConfiguredTitle: '页面链接未配置',
|
||||
notConfiguredDesc: '该自定义页面的 URL 未正确配置。',
|
||||
},
|
||||
|
||||
// Announcements Page
|
||||
announcements: {
|
||||
title: '公告',
|
||||
|
||||
@@ -203,6 +203,17 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'sora.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/custom/:id',
|
||||
name: 'CustomPage',
|
||||
component: () => import('@/views/user/CustomPageView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: false,
|
||||
title: 'Custom Page',
|
||||
titleKey: 'customPage.title',
|
||||
}
|
||||
},
|
||||
|
||||
// ==================== Admin Routes ====================
|
||||
{
|
||||
|
||||
@@ -327,6 +327,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
hide_ccs_import_button: false,
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
custom_menu_items: [],
|
||||
linuxdo_oauth_enabled: false,
|
||||
sora_client_enabled: false,
|
||||
version: siteVersion.value
|
||||
|
||||
@@ -75,6 +75,15 @@ export interface SendVerifyCodeResponse {
|
||||
countdown: number
|
||||
}
|
||||
|
||||
export interface CustomMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
icon_svg: string
|
||||
url: string
|
||||
visibility: 'user' | 'admin'
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface PublicSettings {
|
||||
registration_enabled: boolean
|
||||
email_verify_enabled: boolean
|
||||
@@ -93,6 +102,7 @@ export interface PublicSettings {
|
||||
hide_ccs_import_button: boolean
|
||||
purchase_subscription_enabled: boolean
|
||||
purchase_subscription_url: string
|
||||
custom_menu_items: CustomMenuItem[]
|
||||
linuxdo_oauth_enabled: boolean
|
||||
sora_client_enabled: boolean
|
||||
version: string
|
||||
|
||||
46
frontend/src/utils/embedded-url.ts
Normal file
46
frontend/src/utils/embedded-url.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared URL builder for iframe-embedded pages.
|
||||
* Used by PurchaseSubscriptionView and CustomPageView to build consistent URLs
|
||||
* with user_id, token, theme, ui_mode, src_host, and src parameters.
|
||||
*/
|
||||
|
||||
const EMBEDDED_USER_ID_QUERY_KEY = 'user_id'
|
||||
const EMBEDDED_AUTH_TOKEN_QUERY_KEY = 'token'
|
||||
const EMBEDDED_THEME_QUERY_KEY = 'theme'
|
||||
const EMBEDDED_UI_MODE_QUERY_KEY = 'ui_mode'
|
||||
const EMBEDDED_UI_MODE_VALUE = 'embedded'
|
||||
const EMBEDDED_SRC_HOST_QUERY_KEY = 'src_host'
|
||||
const EMBEDDED_SRC_QUERY_KEY = 'src_url'
|
||||
|
||||
export function buildEmbeddedUrl(
|
||||
baseUrl: string,
|
||||
userId?: number,
|
||||
authToken?: string | null,
|
||||
theme: 'light' | 'dark' = 'light',
|
||||
): string {
|
||||
if (!baseUrl) return baseUrl
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (userId) {
|
||||
url.searchParams.set(EMBEDDED_USER_ID_QUERY_KEY, String(userId))
|
||||
}
|
||||
if (authToken) {
|
||||
url.searchParams.set(EMBEDDED_AUTH_TOKEN_QUERY_KEY, authToken)
|
||||
}
|
||||
url.searchParams.set(EMBEDDED_THEME_QUERY_KEY, theme)
|
||||
url.searchParams.set(EMBEDDED_UI_MODE_QUERY_KEY, EMBEDDED_UI_MODE_VALUE)
|
||||
// Source tracking: let the embedded page know where it's being loaded from
|
||||
if (typeof window !== 'undefined') {
|
||||
url.searchParams.set(EMBEDDED_SRC_HOST_QUERY_KEY, window.location.origin)
|
||||
url.searchParams.set(EMBEDDED_SRC_QUERY_KEY, window.location.href)
|
||||
}
|
||||
return url.toString()
|
||||
} catch {
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
export function detectTheme(): 'light' | 'dark' {
|
||||
if (typeof document === 'undefined') return 'light'
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
}
|
||||
@@ -1160,6 +1160,135 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Menu Items -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.customMenu.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.customMenu.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4 p-6">
|
||||
<!-- Existing menu items -->
|
||||
<div
|
||||
v-for="(item, index) in form.custom_menu_items"
|
||||
:key="item.id || index"
|
||||
class="rounded-lg border border-gray-200 p-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.customMenu.itemLabel', { n: index + 1 }) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Move up -->
|
||||
<button
|
||||
v-if="index > 0"
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
|
||||
:title="t('admin.settings.customMenu.moveUp')"
|
||||
@click="moveMenuItem(index, -1)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" /></svg>
|
||||
</button>
|
||||
<!-- Move down -->
|
||||
<button
|
||||
v-if="index < form.custom_menu_items.length - 1"
|
||||
type="button"
|
||||
class="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
|
||||
:title="t('admin.settings.customMenu.moveDown')"
|
||||
@click="moveMenuItem(index, 1)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" /></svg>
|
||||
</button>
|
||||
<!-- Delete -->
|
||||
<button
|
||||
type="button"
|
||||
class="rounded p-1 text-red-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
|
||||
:title="t('admin.settings.customMenu.remove')"
|
||||
@click="removeMenuItem(index)"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<!-- Label -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.customMenu.name') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="item.label"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.settings.customMenu.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Visibility -->
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.customMenu.visibility') }}
|
||||
</label>
|
||||
<select v-model="item.visibility" class="input text-sm">
|
||||
<option value="user">{{ t('admin.settings.customMenu.visibilityUser') }}</option>
|
||||
<option value="admin">{{ t('admin.settings.customMenu.visibilityAdmin') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- URL (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.customMenu.url') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="item.url"
|
||||
type="url"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.settings.customMenu.urlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- SVG Icon (full width) -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.settings.customMenu.iconSvg') }}
|
||||
</label>
|
||||
<div class="flex items-start gap-3">
|
||||
<textarea
|
||||
v-model="item.icon_svg"
|
||||
rows="2"
|
||||
class="input flex-1 font-mono text-xs"
|
||||
:placeholder="t('admin.settings.customMenu.iconSvgPlaceholder')"
|
||||
></textarea>
|
||||
<!-- SVG Preview -->
|
||||
<div
|
||||
v-if="item.icon_svg"
|
||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 dark:border-dark-600 dark:bg-dark-800"
|
||||
:title="t('admin.settings.customMenu.iconPreview')"
|
||||
>
|
||||
<span class="h-5 w-5 text-gray-600 dark:text-gray-300 [&>svg]:h-5 [&>svg]:w-5 [&>svg]:stroke-current" v-html="item.icon_svg"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed border-gray-300 py-3 text-sm text-gray-500 transition-colors hover:border-primary-400 hover:text-primary-600 dark:border-dark-600 dark:text-gray-400 dark:hover:border-primary-500 dark:hover:text-primary-400"
|
||||
@click="addMenuItem"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /></svg>
|
||||
{{ t('admin.settings.customMenu.add') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Test Email - Only show when email verification is enabled -->
|
||||
<div v-if="form.email_verify_enabled" class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
@@ -1332,6 +1461,7 @@ const form = reactive<SettingsForm>({
|
||||
purchase_subscription_enabled: false,
|
||||
purchase_subscription_url: '',
|
||||
sora_client_enabled: false,
|
||||
custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
@@ -1396,6 +1526,39 @@ async function setAndCopyLinuxdoRedirectUrl() {
|
||||
await copyToClipboard(url, t('admin.settings.linuxdo.redirectUrlSetAndCopied'))
|
||||
}
|
||||
|
||||
// Custom menu item management
|
||||
function addMenuItem() {
|
||||
form.custom_menu_items.push({
|
||||
id: '',
|
||||
label: '',
|
||||
icon_svg: '',
|
||||
url: '',
|
||||
visibility: 'user',
|
||||
sort_order: form.custom_menu_items.length,
|
||||
})
|
||||
}
|
||||
|
||||
function removeMenuItem(index: number) {
|
||||
form.custom_menu_items.splice(index, 1)
|
||||
// Re-index sort_order
|
||||
form.custom_menu_items.forEach((item, i) => {
|
||||
item.sort_order = i
|
||||
})
|
||||
}
|
||||
|
||||
function moveMenuItem(index: number, direction: -1 | 1) {
|
||||
const targetIndex = index + direction
|
||||
if (targetIndex < 0 || targetIndex >= form.custom_menu_items.length) return
|
||||
const items = form.custom_menu_items
|
||||
const temp = items[index]
|
||||
items[index] = items[targetIndex]
|
||||
items[targetIndex] = temp
|
||||
// Re-index sort_order
|
||||
items.forEach((item, i) => {
|
||||
item.sort_order = i
|
||||
})
|
||||
}
|
||||
|
||||
function handleLogoUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -1534,6 +1697,7 @@ async function saveSettings() {
|
||||
purchase_subscription_enabled: form.purchase_subscription_enabled,
|
||||
purchase_subscription_url: form.purchase_subscription_url,
|
||||
sora_client_enabled: form.sora_client_enabled,
|
||||
custom_menu_items: form.custom_menu_items,
|
||||
smtp_host: form.smtp_host,
|
||||
smtp_port: form.smtp_port,
|
||||
smtp_username: form.smtp_username,
|
||||
|
||||
166
frontend/src/views/user/CustomPageView.vue
Normal file
166
frontend/src/views/user/CustomPageView.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="custom-page-layout">
|
||||
<div class="card flex-1 min-h-0 overflow-hidden">
|
||||
<div v-if="loading" class="flex h-full items-center justify-center py-12">
|
||||
<div
|
||||
class="h-8 w-8 animate-spin rounded-full border-2 border-primary-500 border-t-transparent"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!menuItem"
|
||||
class="flex h-full items-center justify-center p-10 text-center"
|
||||
>
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="link" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('customPage.notFoundTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('customPage.notFoundDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isValidUrl" class="flex h-full items-center justify-center p-10 text-center">
|
||||
<div class="max-w-md">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700"
|
||||
>
|
||||
<Icon name="link" size="lg" class="text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('customPage.notConfiguredTitle') }}
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dark-400">
|
||||
{{ t('customPage.notConfiguredDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="custom-embed-shell">
|
||||
<a
|
||||
:href="embeddedUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-secondary btn-sm custom-open-fab"
|
||||
>
|
||||
<Icon name="externalLink" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('customPage.openInNewTab') }}
|
||||
</a>
|
||||
<iframe
|
||||
:src="embeddedUrl"
|
||||
class="custom-embed-frame"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const pageTheme = ref<'light' | 'dark'>('light')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
const menuItemId = computed(() => route.params.id as string)
|
||||
|
||||
const menuItem = computed(() => {
|
||||
const items = appStore.cachedPublicSettings?.custom_menu_items ?? []
|
||||
return items.find((item) => item.id === menuItemId.value) ?? null
|
||||
})
|
||||
|
||||
const embeddedUrl = computed(() => {
|
||||
if (!menuItem.value) return ''
|
||||
return buildEmbeddedUrl(
|
||||
menuItem.value.url,
|
||||
authStore.user?.id,
|
||||
authStore.token,
|
||||
pageTheme.value,
|
||||
)
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
const url = embeddedUrl.value
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
pageTheme.value = detectTheme()
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
themeObserver = new MutationObserver(() => {
|
||||
pageTheme.value = detectTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
}
|
||||
|
||||
if (appStore.publicSettingsLoaded) return
|
||||
loading.value = true
|
||||
try {
|
||||
await appStore.fetchPublicSettings()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (themeObserver) {
|
||||
themeObserver.disconnect()
|
||||
themeObserver = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-page-layout {
|
||||
@apply flex flex-col;
|
||||
height: calc(100vh - 64px - 4rem);
|
||||
}
|
||||
|
||||
.custom-embed-shell {
|
||||
@apply relative;
|
||||
@apply h-full w-full overflow-hidden rounded-2xl;
|
||||
@apply bg-gradient-to-b from-gray-50 to-white dark:from-dark-900 dark:to-dark-950;
|
||||
@apply p-0;
|
||||
}
|
||||
|
||||
.custom-open-fab {
|
||||
@apply absolute right-3 top-3 z-10;
|
||||
@apply shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/80;
|
||||
}
|
||||
|
||||
.custom-embed-frame {
|
||||
display: block;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -74,17 +74,12 @@ import { useAppStore } from '@/stores'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { buildEmbeddedUrl, detectTheme } from '@/utils/embedded-url'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const PURCHASE_USER_ID_QUERY_KEY = 'user_id'
|
||||
const PURCHASE_AUTH_TOKEN_QUERY_KEY = 'token'
|
||||
const PURCHASE_THEME_QUERY_KEY = 'theme'
|
||||
const PURCHASE_UI_MODE_QUERY_KEY = 'ui_mode'
|
||||
const PURCHASE_UI_MODE_EMBEDDED = 'embedded'
|
||||
|
||||
const loading = ref(false)
|
||||
const purchaseTheme = ref<'light' | 'dark'>('light')
|
||||
let themeObserver: MutationObserver | null = null
|
||||
@@ -93,37 +88,9 @@ const purchaseEnabled = computed(() => {
|
||||
return appStore.cachedPublicSettings?.purchase_subscription_enabled ?? false
|
||||
})
|
||||
|
||||
function detectTheme(): 'light' | 'dark' {
|
||||
if (typeof document === 'undefined') return 'light'
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
function buildPurchaseUrl(
|
||||
baseUrl: string,
|
||||
userId?: number,
|
||||
authToken?: string | null,
|
||||
theme: 'light' | 'dark' = 'light',
|
||||
): string {
|
||||
if (!baseUrl) return baseUrl
|
||||
try {
|
||||
const url = new URL(baseUrl)
|
||||
if (userId) {
|
||||
url.searchParams.set(PURCHASE_USER_ID_QUERY_KEY, String(userId))
|
||||
}
|
||||
if (authToken) {
|
||||
url.searchParams.set(PURCHASE_AUTH_TOKEN_QUERY_KEY, authToken)
|
||||
}
|
||||
url.searchParams.set(PURCHASE_THEME_QUERY_KEY, theme)
|
||||
url.searchParams.set(PURCHASE_UI_MODE_QUERY_KEY, PURCHASE_UI_MODE_EMBEDDED)
|
||||
return url.toString()
|
||||
} catch {
|
||||
return baseUrl
|
||||
}
|
||||
}
|
||||
|
||||
const purchaseUrl = computed(() => {
|
||||
const baseUrl = (appStore.cachedPublicSettings?.purchase_subscription_url || '').trim()
|
||||
return buildPurchaseUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value)
|
||||
return buildEmbeddedUrl(baseUrl, authStore.user?.id, authStore.token, purchaseTheme.value)
|
||||
})
|
||||
|
||||
const isValidUrl = computed(() => {
|
||||
|
||||
25
tmp_api_admin_orders/[id]/cancel/route.ts
Normal file
25
tmp_api_admin_orders/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { adminCancelOrder, OrderError } from '@/lib/order/service';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
await adminCancelOrder(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
}
|
||||
console.error('Admin cancel order error:', error);
|
||||
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
25
tmp_api_admin_orders/[id]/retry/route.ts
Normal file
25
tmp_api_admin_orders/[id]/retry/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { retryRecharge, OrderError } from '@/lib/order/service';
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
await retryRecharge(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
}
|
||||
console.error('Retry recharge error:', error);
|
||||
return NextResponse.json({ error: '重试充值失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
31
tmp_api_admin_orders/[id]/route.ts
Normal file
31
tmp_api_admin_orders/[id]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
auditLogs: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...order,
|
||||
amount: Number(order.amount),
|
||||
refundAmount: order.refundAmount ? Number(order.refundAmount) : null,
|
||||
});
|
||||
}
|
||||
60
tmp_api_admin_orders/route.ts
Normal file
60
tmp_api_admin_orders/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!verifyAdminToken(request)) return unauthorizedResponse();
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = Math.max(1, Number(searchParams.get('page') || '1'));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('page_size') || '20')));
|
||||
const status = searchParams.get('status');
|
||||
const userId = searchParams.get('user_id');
|
||||
const dateFrom = searchParams.get('date_from');
|
||||
const dateTo = searchParams.get('date_to');
|
||||
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
if (status) where.status = status as any;
|
||||
if (userId) where.userId = Number(userId);
|
||||
if (dateFrom || dateTo) {
|
||||
where.createdAt = {};
|
||||
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
|
||||
if (dateTo) where.createdAt.lte = new Date(dateTo);
|
||||
}
|
||||
|
||||
const [orders, total] = await Promise.all([
|
||||
prisma.order.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
userName: true,
|
||||
userEmail: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
paymentType: true,
|
||||
createdAt: true,
|
||||
paidAt: true,
|
||||
completedAt: true,
|
||||
failedReason: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.order.count({ where }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
orders: orders.map(o => ({
|
||||
...o,
|
||||
amount: Number(o.amount),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: Math.ceil(total / pageSize),
|
||||
});
|
||||
}
|
||||
37
tmp_api_orders/[id]/cancel/route.ts
Normal file
37
tmp_api_orders/[id]/cancel/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { cancelOrder, OrderError } from '@/lib/order/service';
|
||||
|
||||
const cancelSchema = z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const parsed = cancelSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await cancelOrder(id, parsed.data.user_id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
}
|
||||
console.error('Cancel order error:', error);
|
||||
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
50
tmp_api_orders/[id]/route.ts
Normal file
50
tmp_api_orders/[id]/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
userName: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
paymentType: true,
|
||||
payUrl: true,
|
||||
qrCode: true,
|
||||
qrCodeImg: true,
|
||||
expiresAt: true,
|
||||
paidAt: true,
|
||||
completedAt: true,
|
||||
failedReason: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
order_id: order.id,
|
||||
user_id: order.userId,
|
||||
user_name: order.userName,
|
||||
amount: Number(order.amount),
|
||||
status: order.status,
|
||||
payment_type: order.paymentType,
|
||||
pay_url: order.payUrl,
|
||||
qr_code: order.qrCode,
|
||||
qr_code_img: order.qrCodeImg,
|
||||
expires_at: order.expiresAt,
|
||||
paid_at: order.paidAt,
|
||||
completed_at: order.completedAt,
|
||||
failed_reason: order.failedReason,
|
||||
created_at: order.createdAt,
|
||||
});
|
||||
}
|
||||
46
tmp_api_orders/my/route.ts
Normal file
46
tmp_api_orders/my/route.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getCurrentUserByToken(token);
|
||||
const orders = await prisma.order.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
status: true,
|
||||
paymentType: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
displayName: user.username || user.email || `用户 #${user.id}`,
|
||||
balance: user.balance,
|
||||
},
|
||||
orders: orders.map((item) => ({
|
||||
id: item.id,
|
||||
amount: Number(item.amount),
|
||||
status: item.status,
|
||||
paymentType: item.paymentType,
|
||||
createdAt: item.createdAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get my orders error:', error);
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
68
tmp_api_orders/route.ts
Normal file
68
tmp_api_orders/route.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createOrder, OrderError } from '@/lib/order/service';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
amount: z.number().positive(),
|
||||
payment_type: z.enum(['alipay', 'wxpay']),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const env = getEnv();
|
||||
const body = await request.json();
|
||||
const parsed = createOrderSchema.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: '参数错误', details: parsed.error.flatten().fieldErrors },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const { user_id, amount, payment_type } = parsed.data;
|
||||
|
||||
// Validate amount range
|
||||
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Validate payment type is enabled
|
||||
if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `不支持的支付方式: ${payment_type}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const clientIp = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| request.headers.get('x-real-ip')
|
||||
|| '127.0.0.1';
|
||||
|
||||
const result = await createOrder({
|
||||
userId: user_id,
|
||||
amount,
|
||||
paymentType: payment_type,
|
||||
clientIp,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof OrderError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode },
|
||||
);
|
||||
}
|
||||
console.error('Create order error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '创建订单失败,请稍后重试' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user