mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-04 16:06:37 +00:00
Compare commits
78 Commits
v0.10.7-al
...
feature/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e967094348 | ||
|
|
47012e84c1 | ||
|
|
b8b40511f3 | ||
|
|
58afec3771 | ||
|
|
e48b74f469 | ||
|
|
c1061b2d18 | ||
|
|
4e9c5bb45b | ||
|
|
f578aa8e00 | ||
|
|
732484ceaa | ||
|
|
f521a430ce | ||
|
|
11eef1ce77 | ||
|
|
1e2c039f40 | ||
|
|
3d177f3020 | ||
|
|
0486a5d83b | ||
|
|
2cdc37fdc4 | ||
|
|
49ac355357 | ||
|
|
414f86fb4b | ||
|
|
6b694c9c94 | ||
|
|
cbebd15692 | ||
|
|
afa9efa037 | ||
|
|
760fbeb6e6 | ||
|
|
b942d4eebd | ||
|
|
ef44a341a8 | ||
|
|
70a8b30aab | ||
|
|
34e5720773 | ||
|
|
4057eedaff | ||
|
|
1fba3c064b | ||
|
|
ee0487806c | ||
|
|
c50eff53d4 | ||
|
|
16d8055397 | ||
|
|
5d2e45a147 | ||
|
|
1788fb290e | ||
|
|
4978fead3a | ||
|
|
120256a52c | ||
|
|
16349c98cb | ||
|
|
57b9905539 | ||
|
|
0d5ae12ebc | ||
|
|
b6dc75cb86 | ||
|
|
2c29993cfc | ||
|
|
540cf6c991 | ||
|
|
6c0e9403a2 | ||
|
|
76050e66ca | ||
|
|
99745e7e38 | ||
|
|
621938699b | ||
|
|
2d9b408fda | ||
|
|
63b642f39a | ||
|
|
a74cc93bbc | ||
|
|
ff41e65d9b | ||
|
|
e3f96120bc | ||
|
|
e8bd2e0d53 | ||
|
|
de90e11cdf | ||
|
|
f0e60df96e | ||
|
|
96caec1626 | ||
|
|
c22ca9cdb3 | ||
|
|
6300c31d70 | ||
|
|
b92a4ee987 | ||
|
|
cf67af3b14 | ||
|
|
2297af731c | ||
|
|
28c5feb570 | ||
|
|
354da6ea6b | ||
|
|
a0c23a0648 | ||
|
|
41489fc32a | ||
|
|
ffebb35499 | ||
|
|
5707ee3492 | ||
|
|
ecf50b754a | ||
|
|
697cbbf752 | ||
|
|
a60783e99f | ||
|
|
348ae6df73 | ||
|
|
ac8a92655e | ||
|
|
009910b960 | ||
|
|
c6c12d340f | ||
|
|
8c4d2f2c2f | ||
|
|
ca81de39c9 | ||
|
|
df465ca8fd | ||
|
|
65fd33e3ef | ||
|
|
d10f9126a4 | ||
|
|
94076def9c | ||
|
|
9c91b8fb18 |
@@ -85,3 +85,8 @@ LINUX_DO_USER_ENDPOINT=https://connect.linux.do/api/user
|
||||
# 节点类型
|
||||
# 如果是主节点则为master
|
||||
# NODE_TYPE=master
|
||||
|
||||
# 可信任重定向域名列表(逗号分隔,支持子域名匹配)
|
||||
# 用于验证支付成功/取消回调URL的域名安全性
|
||||
# 示例: example.com,myapp.io 将允许 example.com, sub.example.com, myapp.io 等
|
||||
# TRUSTED_REDIRECT_DOMAINS=example.com,myapp.io
|
||||
|
||||
@@ -159,4 +159,17 @@ func initConstantEnv() {
|
||||
}
|
||||
constant.TaskPricePatches = taskPricePatches
|
||||
}
|
||||
|
||||
// Initialize trusted redirect domains for URL validation
|
||||
trustedDomainsStr := GetEnvOrDefaultString("TRUSTED_REDIRECT_DOMAINS", "")
|
||||
var trustedDomains []string
|
||||
domains := strings.Split(trustedDomainsStr, ",")
|
||||
for _, domain := range domains {
|
||||
trimmedDomain := strings.TrimSpace(domain)
|
||||
if trimmedDomain != "" {
|
||||
// Normalize domain to lowercase
|
||||
trustedDomains = append(trustedDomains, strings.ToLower(trimmedDomain))
|
||||
}
|
||||
}
|
||||
constant.TrustedRedirectDomains = trustedDomains
|
||||
}
|
||||
|
||||
@@ -106,6 +106,16 @@ func GetJsonString(data any) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// NormalizeBillingPreference clamps the billing preference to valid values.
|
||||
func NormalizeBillingPreference(pref string) string {
|
||||
switch strings.TrimSpace(pref) {
|
||||
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||||
return strings.TrimSpace(pref)
|
||||
default:
|
||||
return "subscription_first"
|
||||
}
|
||||
}
|
||||
|
||||
// MaskEmail masks a user email to prevent PII leakage in logs
|
||||
// Returns "***masked***" if email is empty, otherwise shows only the domain part
|
||||
func MaskEmail(email string) string {
|
||||
|
||||
39
common/url_validator.go
Normal file
39
common/url_validator.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
// ValidateRedirectURL validates that a redirect URL is safe to use.
|
||||
// It checks that:
|
||||
// - The URL is properly formatted
|
||||
// - The scheme is either http or https
|
||||
// - The domain is in the trusted domains list (exact match or subdomain)
|
||||
//
|
||||
// Returns nil if the URL is valid and trusted, otherwise returns an error
|
||||
// describing why the validation failed.
|
||||
func ValidateRedirectURL(rawURL string) error {
|
||||
// Parse the URL
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL format: %s", err.Error())
|
||||
}
|
||||
|
||||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||||
return fmt.Errorf("invalid URL scheme: only http and https are allowed")
|
||||
}
|
||||
|
||||
domain := strings.ToLower(parsedURL.Hostname())
|
||||
|
||||
for _, trustedDomain := range constant.TrustedRedirectDomains {
|
||||
if domain == trustedDomain || strings.HasSuffix(domain, "."+trustedDomain) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("domain %s is not in the trusted domains list", domain)
|
||||
}
|
||||
134
common/url_validator_test.go
Normal file
134
common/url_validator_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/constant"
|
||||
)
|
||||
|
||||
func TestValidateRedirectURL(t *testing.T) {
|
||||
// Save original trusted domains and restore after test
|
||||
originalDomains := constant.TrustedRedirectDomains
|
||||
defer func() {
|
||||
constant.TrustedRedirectDomains = originalDomains
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
trustedDomains []string
|
||||
wantErr bool
|
||||
errContains string
|
||||
}{
|
||||
// Valid cases
|
||||
{
|
||||
name: "exact domain match with https",
|
||||
url: "https://example.com/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "exact domain match with http",
|
||||
url: "http://example.com/callback",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "subdomain match",
|
||||
url: "https://sub.example.com/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive domain",
|
||||
url: "https://EXAMPLE.COM/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: false,
|
||||
},
|
||||
|
||||
// Invalid cases - untrusted domain
|
||||
{
|
||||
name: "untrusted domain",
|
||||
url: "https://evil.com/phishing",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "not in the trusted domains list",
|
||||
},
|
||||
{
|
||||
name: "suffix attack - fakeexample.com",
|
||||
url: "https://fakeexample.com/success",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "not in the trusted domains list",
|
||||
},
|
||||
{
|
||||
name: "empty trusted domains list",
|
||||
url: "https://example.com/success",
|
||||
trustedDomains: []string{},
|
||||
wantErr: true,
|
||||
errContains: "not in the trusted domains list",
|
||||
},
|
||||
|
||||
// Invalid cases - scheme
|
||||
{
|
||||
name: "javascript scheme",
|
||||
url: "javascript:alert('xss')",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "invalid URL scheme",
|
||||
},
|
||||
{
|
||||
name: "data scheme",
|
||||
url: "data:text/html,<script>alert('xss')</script>",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "invalid URL scheme",
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "empty URL",
|
||||
url: "",
|
||||
trustedDomains: []string{"example.com"},
|
||||
wantErr: true,
|
||||
errContains: "invalid URL scheme",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up trusted domains for this test case
|
||||
constant.TrustedRedirectDomains = tt.trustedDomains
|
||||
|
||||
err := ValidateRedirectURL(tt.url)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateRedirectURL(%q) expected error containing %q, got nil", tt.url, tt.errContains)
|
||||
return
|
||||
}
|
||||
if tt.errContains != "" && !contains(err.Error(), tt.errContains) {
|
||||
t.Errorf("ValidateRedirectURL(%q) error = %q, want error containing %q", tt.url, err.Error(), tt.errContains)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("ValidateRedirectURL(%q) unexpected error: %v", tt.url, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && findSubstring(s, substr)))
|
||||
}
|
||||
|
||||
func findSubstring(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,3 +20,7 @@ var TaskQueryLimit int
|
||||
|
||||
// temporary variable for sora patch, will be removed in future
|
||||
var TaskPricePatches []string
|
||||
|
||||
// TrustedRedirectDomains is a list of trusted domains for redirect URL validation.
|
||||
// Domains support subdomain matching (e.g., "example.com" matches "sub.example.com").
|
||||
var TrustedRedirectDomains []string
|
||||
|
||||
@@ -58,3 +58,31 @@ func ClearChannelAffinityCache(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func GetChannelAffinityUsageCacheStats(c *gin.Context) {
|
||||
ruleName := strings.TrimSpace(c.Query("rule_name"))
|
||||
usingGroup := strings.TrimSpace(c.Query("using_group"))
|
||||
keyFp := strings.TrimSpace(c.Query("key_fp"))
|
||||
|
||||
if ruleName == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "missing param: rule_name",
|
||||
})
|
||||
return
|
||||
}
|
||||
if keyFp == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
"message": "missing param: key_fp",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
stats := service.GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": stats,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ func GetStatus(c *gin.Context) {
|
||||
"user_agreement_enabled": legalSetting.UserAgreement != "",
|
||||
"privacy_policy_enabled": legalSetting.PrivacyPolicy != "",
|
||||
"checkin_enabled": operation_setting.GetCheckinSetting().Enabled,
|
||||
"_qn": "new-api",
|
||||
"_qn": "new-api",
|
||||
}
|
||||
|
||||
// 根据启用状态注入可选内容
|
||||
|
||||
@@ -91,11 +91,11 @@ func GetPerformanceStats(c *gin.Context) {
|
||||
// 获取配置信息
|
||||
diskConfig := common.GetDiskCacheConfig()
|
||||
config := PerformanceConfig{
|
||||
DiskCacheEnabled: diskConfig.Enabled,
|
||||
DiskCacheThresholdMB: diskConfig.ThresholdMB,
|
||||
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
|
||||
DiskCachePath: diskConfig.Path,
|
||||
IsRunningInContainer: common.IsRunningInContainer(),
|
||||
DiskCacheEnabled: diskConfig.Enabled,
|
||||
DiskCacheThresholdMB: diskConfig.ThresholdMB,
|
||||
DiskCacheMaxSizeMB: diskConfig.MaxSizeMB,
|
||||
DiskCachePath: diskConfig.Path,
|
||||
IsRunningInContainer: common.IsRunningInContainer(),
|
||||
}
|
||||
|
||||
// 获取磁盘空间信息
|
||||
@@ -199,4 +199,3 @@ func getDiskCacheInfo() DiskCacheInfo {
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ func getDiskSpaceInfo() DiskSpaceInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// 计算磁盘空间
|
||||
info.Total = stat.Blocks * uint64(stat.Bsize)
|
||||
info.Free = stat.Bavail * uint64(stat.Bsize)
|
||||
info.Used = info.Total - stat.Bfree*uint64(stat.Bsize)
|
||||
// 计算磁盘空间 (显式转换以兼容 FreeBSD,其字段类型为 int64)
|
||||
bsize := uint64(stat.Bsize)
|
||||
info.Total = uint64(stat.Blocks) * bsize
|
||||
info.Free = uint64(stat.Bavail) * bsize
|
||||
info.Used = info.Total - uint64(stat.Bfree)*bsize
|
||||
|
||||
if info.Total > 0 {
|
||||
info.UsedPercent = float64(info.Used) / float64(info.Total) * 100
|
||||
|
||||
@@ -159,7 +159,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
||||
if priceData.FreeModel {
|
||||
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
|
||||
} else {
|
||||
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
|
||||
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
|
||||
if newAPIError != nil {
|
||||
return
|
||||
}
|
||||
@@ -311,6 +311,9 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
|
||||
if openaiErr == nil {
|
||||
return false
|
||||
}
|
||||
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
||||
return false
|
||||
}
|
||||
if types.IsChannelError(openaiErr) {
|
||||
return true
|
||||
}
|
||||
@@ -514,6 +517,9 @@ func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError,
|
||||
if taskErr == nil {
|
||||
return false
|
||||
}
|
||||
if service.ShouldSkipRetryAfterChannelAffinityFailure(c) {
|
||||
return false
|
||||
}
|
||||
if retryTimes <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
367
controller/subscription.go
Normal file
367
controller/subscription.go
Normal file
@@ -0,0 +1,367 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ---- Shared types ----
|
||||
|
||||
type SubscriptionPlanDTO struct {
|
||||
Plan model.SubscriptionPlan `json:"plan"`
|
||||
}
|
||||
|
||||
type BillingPreferenceRequest struct {
|
||||
BillingPreference string `json:"billing_preference"`
|
||||
}
|
||||
|
||||
// ---- User APIs ----
|
||||
|
||||
func GetSubscriptionPlans(c *gin.Context) {
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Where("enabled = ?", true).Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, result)
|
||||
}
|
||||
|
||||
func GetSubscriptionSelf(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
settingMap, _ := model.GetUserSetting(userId, false)
|
||||
pref := common.NormalizeBillingPreference(settingMap.BillingPreference)
|
||||
|
||||
// Get all subscriptions (including expired)
|
||||
allSubscriptions, err := model.GetAllUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
allSubscriptions = []model.SubscriptionSummary{}
|
||||
}
|
||||
|
||||
// Get active subscriptions for backward compatibility
|
||||
activeSubscriptions, err := model.GetAllActiveUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
activeSubscriptions = []model.SubscriptionSummary{}
|
||||
}
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"billing_preference": pref,
|
||||
"subscriptions": activeSubscriptions, // all active subscriptions
|
||||
"all_subscriptions": allSubscriptions, // all subscriptions including expired
|
||||
})
|
||||
}
|
||||
|
||||
func UpdateSubscriptionPreference(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var req BillingPreferenceRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
pref := common.NormalizeBillingPreference(req.BillingPreference)
|
||||
|
||||
user, err := model.GetUserById(userId, true)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
current := user.GetSetting()
|
||||
current.BillingPreference = pref
|
||||
user.SetSetting(current)
|
||||
if err := user.Update(false); err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"billing_preference": pref})
|
||||
}
|
||||
|
||||
// ---- Admin APIs ----
|
||||
|
||||
func AdminListSubscriptionPlans(c *gin.Context) {
|
||||
var plans []model.SubscriptionPlan
|
||||
if err := model.DB.Order("sort_order desc, id desc").Find(&plans).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
result := make([]SubscriptionPlanDTO, 0, len(plans))
|
||||
for _, p := range plans {
|
||||
result = append(result, SubscriptionPlanDTO{
|
||||
Plan: p,
|
||||
})
|
||||
}
|
||||
common.ApiSuccess(c, result)
|
||||
}
|
||||
|
||||
type AdminUpsertSubscriptionPlanRequest struct {
|
||||
Plan model.SubscriptionPlan `json:"plan"`
|
||||
}
|
||||
|
||||
func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = 0
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
return
|
||||
}
|
||||
if req.Plan.Currency == "" {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
}
|
||||
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||
if req.Plan.UpgradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "升级分组不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
return
|
||||
}
|
||||
err := model.DB.Create(&req.Plan).Error
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.InvalidateSubscriptionPlanCache(req.Plan.Id)
|
||||
common.ApiSuccess(c, req.Plan)
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpsertSubscriptionPlanRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Plan.Title) == "" {
|
||||
common.ApiErrorMsg(c, "套餐标题不能为空")
|
||||
return
|
||||
}
|
||||
req.Plan.Id = id
|
||||
if req.Plan.Currency == "" {
|
||||
req.Plan.Currency = "USD"
|
||||
}
|
||||
req.Plan.Currency = "USD"
|
||||
if req.Plan.DurationUnit == "" {
|
||||
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||
}
|
||||
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||
req.Plan.DurationValue = 1
|
||||
}
|
||||
if req.Plan.MaxPurchasePerUser < 0 {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
}
|
||||
req.Plan.UpgradeGroup = strings.TrimSpace(req.Plan.UpgradeGroup)
|
||||
if req.Plan.UpgradeGroup != "" {
|
||||
if _, ok := ratio_setting.GetGroupRatioCopy()[req.Plan.UpgradeGroup]; !ok {
|
||||
common.ApiErrorMsg(c, "升级分组不存在")
|
||||
return
|
||||
}
|
||||
}
|
||||
req.Plan.QuotaResetPeriod = model.NormalizeResetPeriod(req.Plan.QuotaResetPeriod)
|
||||
if req.Plan.QuotaResetPeriod == model.SubscriptionResetCustom && req.Plan.QuotaResetCustomSeconds <= 0 {
|
||||
common.ApiErrorMsg(c, "自定义重置周期需大于0秒")
|
||||
return
|
||||
}
|
||||
|
||||
err := model.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// update plan (allow zero values updates with map)
|
||||
updateMap := map[string]interface{}{
|
||||
"title": req.Plan.Title,
|
||||
"subtitle": req.Plan.Subtitle,
|
||||
"price_amount": req.Plan.PriceAmount,
|
||||
"currency": req.Plan.Currency,
|
||||
"duration_unit": req.Plan.DurationUnit,
|
||||
"duration_value": req.Plan.DurationValue,
|
||||
"custom_seconds": req.Plan.CustomSeconds,
|
||||
"enabled": req.Plan.Enabled,
|
||||
"sort_order": req.Plan.SortOrder,
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
"quota_reset_period": req.Plan.QuotaResetPeriod,
|
||||
"quota_reset_custom_seconds": req.Plan.QuotaResetCustomSeconds,
|
||||
"updated_at": common.GetTimestamp(),
|
||||
}
|
||||
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.InvalidateSubscriptionPlanCache(id)
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
type AdminUpdateSubscriptionPlanStatusRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func AdminUpdateSubscriptionPlanStatus(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
if id <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的ID")
|
||||
return
|
||||
}
|
||||
var req AdminUpdateSubscriptionPlanStatusRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Enabled == nil {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", *req.Enabled).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
model.InvalidateSubscriptionPlanCache(id)
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
type AdminBindSubscriptionRequest struct {
|
||||
UserId int `json:"user_id"`
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func AdminBindSubscription(c *gin.Context) {
|
||||
var req AdminBindSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserId <= 0 || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminBindSubscription(req.UserId, req.PlanId, "")
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// ---- Admin: user subscription management ----
|
||||
|
||||
func AdminListUserSubscriptions(c *gin.Context) {
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
subs, err := model.GetAllUserSubscriptions(userId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, subs)
|
||||
}
|
||||
|
||||
type AdminCreateUserSubscriptionRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
// AdminCreateUserSubscription creates a new user subscription from a plan (no payment).
|
||||
func AdminCreateUserSubscription(c *gin.Context) {
|
||||
userId, _ := strconv.Atoi(c.Param("id"))
|
||||
if userId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的用户ID")
|
||||
return
|
||||
}
|
||||
var req AdminCreateUserSubscriptionRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminBindSubscription(userId, req.PlanId, "")
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// AdminInvalidateUserSubscription cancels a user subscription immediately.
|
||||
func AdminInvalidateUserSubscription(c *gin.Context) {
|
||||
subId, _ := strconv.Atoi(c.Param("id"))
|
||||
if subId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminInvalidateUserSubscription(subId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
|
||||
// AdminDeleteUserSubscription hard-deletes a user subscription.
|
||||
func AdminDeleteUserSubscription(c *gin.Context) {
|
||||
subId, _ := strconv.Atoi(c.Param("id"))
|
||||
if subId <= 0 {
|
||||
common.ApiErrorMsg(c, "无效的订阅ID")
|
||||
return
|
||||
}
|
||||
msg, err := model.AdminDeleteUserSubscription(subId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if msg != "" {
|
||||
common.ApiSuccess(c, gin.H{"message": msg})
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, nil)
|
||||
}
|
||||
129
controller/subscription_payment_creem.go
Normal file
129
controller/subscription_payment_creem.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionCreemPayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestCreemPay(c *gin.Context) {
|
||||
var req SubscriptionCreemPayRequest
|
||||
|
||||
// Keep body for debugging consistency (like RequestCreemPay)
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
log.Printf("read subscription creem pay req body err: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "read query error"})
|
||||
return
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "参数错误"})
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.CreemProductId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 CreemProductId")
|
||||
return
|
||||
}
|
||||
if setting.CreemWebhookSecret == "" && !setting.CreemTestMode {
|
||||
common.ApiErrorMsg(c, "Creem Webhook 未配置")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reference := "sub-creem-ref-" + randstr.String(6)
|
||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference+time.Now().String()+user.Username))
|
||||
|
||||
// create pending order first
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodCreem,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(200, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reuse Creem checkout generator by building a lightweight product reference.
|
||||
currency := "USD"
|
||||
switch operation_setting.GetGeneralSetting().QuotaDisplayType {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
currency = "CNY"
|
||||
case operation_setting.QuotaDisplayTypeUSD:
|
||||
currency = "USD"
|
||||
default:
|
||||
currency = "USD"
|
||||
}
|
||||
product := &CreemProduct{
|
||||
ProductId: plan.CreemProductId,
|
||||
Name: plan.Title,
|
||||
Price: plan.PriceAmount,
|
||||
Currency: currency,
|
||||
Quota: 0,
|
||||
}
|
||||
|
||||
checkoutUrl, err := genCreemLink(referenceId, product, user.Email, user.Username)
|
||||
if err != nil {
|
||||
log.Printf("获取Creem支付链接失败: %v", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(200, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"checkout_url": checkoutUrl,
|
||||
"order_id": referenceId,
|
||||
},
|
||||
})
|
||||
}
|
||||
196
controller/subscription_payment_epay.go
Normal file
196
controller/subscription_payment_epay.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/Calcium-Ion/go-epay/epay"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type SubscriptionEpayPayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestEpay(c *gin.Context) {
|
||||
var req SubscriptionEpayPayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.PriceAmount < 0.01 {
|
||||
common.ApiErrorMsg(c, "套餐金额过低")
|
||||
return
|
||||
}
|
||||
if !operation_setting.ContainsPayMethod(req.PaymentMethod) {
|
||||
common.ApiErrorMsg(c, "支付方式不存在")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
callBackAddress := service.GetCallbackAddress()
|
||||
returnUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/return")
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "回调地址配置错误")
|
||||
return
|
||||
}
|
||||
notifyUrl, err := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
||||
if err != nil {
|
||||
common.ApiErrorMsg(c, "回调地址配置错误")
|
||||
return
|
||||
}
|
||||
|
||||
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
common.ApiErrorMsg(c, "当前管理员未配置支付信息")
|
||||
return
|
||||
}
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: tradeNo,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
common.ApiErrorMsg(c, "创建订单失败")
|
||||
return
|
||||
}
|
||||
uri, params, err := client.Purchase(&epay.PurchaseArgs{
|
||||
Type: req.PaymentMethod,
|
||||
ServiceTradeNo: tradeNo,
|
||||
Name: fmt.Sprintf("SUB:%s", plan.Title),
|
||||
Money: strconv.FormatFloat(plan.PriceAmount, 'f', 2, 64),
|
||||
Device: epay.PC,
|
||||
NotifyUrl: notifyUrl,
|
||||
ReturnUrl: returnUrl,
|
||||
})
|
||||
if err != nil {
|
||||
_ = model.ExpireSubscriptionOrder(tradeNo)
|
||||
common.ApiErrorMsg(c, "拉起支付失败")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"data": params, "url": uri})
|
||||
}
|
||||
|
||||
func SubscriptionEpayNotify(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
if len(params) == 0 {
|
||||
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
if verifyInfo.TradeStatus != epay.StatusTradeSuccess {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = c.Writer.Write([]byte("success"))
|
||||
}
|
||||
|
||||
// SubscriptionEpayReturn handles browser return after payment.
|
||||
// It verifies the payload and completes the order, then redirects to console.
|
||||
func SubscriptionEpayReturn(c *gin.Context) {
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
if len(params) == 0 {
|
||||
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
verifyInfo, err := client.Verify(params)
|
||||
if err != nil || !verifyInfo.VerifyStatus {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
if verifyInfo.TradeStatus == epay.StatusTradeSuccess {
|
||||
LockOrder(verifyInfo.ServiceTradeNo)
|
||||
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, common.GetJsonString(verifyInfo)); err != nil {
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=fail")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=success")
|
||||
return
|
||||
}
|
||||
c.Redirect(http.StatusFound, system_setting.ServerAddress+"/console/subscription?pay=pending")
|
||||
}
|
||||
138
controller/subscription_payment_stripe.go
Normal file
138
controller/subscription_payment_stripe.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/setting"
|
||||
"github.com/QuantumNous/new-api/setting/system_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
"github.com/thanhpk/randstr"
|
||||
)
|
||||
|
||||
type SubscriptionStripePayRequest struct {
|
||||
PlanId int `json:"plan_id"`
|
||||
}
|
||||
|
||||
func SubscriptionRequestStripePay(c *gin.Context) {
|
||||
var req SubscriptionStripePayRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.PlanId <= 0 {
|
||||
common.ApiErrorMsg(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
plan, err := model.GetSubscriptionPlanById(req.PlanId)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if !plan.Enabled {
|
||||
common.ApiErrorMsg(c, "套餐未启用")
|
||||
return
|
||||
}
|
||||
if plan.StripePriceId == "" {
|
||||
common.ApiErrorMsg(c, "该套餐未配置 StripePriceId")
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||
common.ApiErrorMsg(c, "Stripe 未配置或密钥无效")
|
||||
return
|
||||
}
|
||||
if setting.StripeWebhookSecret == "" {
|
||||
common.ApiErrorMsg(c, "Stripe Webhook 未配置")
|
||||
return
|
||||
}
|
||||
|
||||
userId := c.GetInt("id")
|
||||
user, err := model.GetUserById(userId, false)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if user == nil {
|
||||
common.ApiErrorMsg(c, "用户不存在")
|
||||
return
|
||||
}
|
||||
|
||||
if plan.MaxPurchasePerUser > 0 {
|
||||
count, err := model.CountUserSubscriptionsByPlan(userId, plan.Id)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if count >= int64(plan.MaxPurchasePerUser) {
|
||||
common.ApiErrorMsg(c, "已达到该套餐购买上限")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
reference := fmt.Sprintf("sub-stripe-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||
referenceId := "sub_ref_" + common.Sha1([]byte(reference))
|
||||
|
||||
payLink, err := genStripeSubscriptionLink(referenceId, user.StripeCustomer, user.Email, plan.StripePriceId)
|
||||
if err != nil {
|
||||
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
return
|
||||
}
|
||||
|
||||
order := &model.SubscriptionOrder{
|
||||
UserId: userId,
|
||||
PlanId: plan.Id,
|
||||
Money: plan.PriceAmount,
|
||||
TradeNo: referenceId,
|
||||
PaymentMethod: PaymentMethodStripe,
|
||||
CreateTime: time.Now().Unix(),
|
||||
Status: common.TopUpStatusPending,
|
||||
}
|
||||
if err := order.Insert(); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "success",
|
||||
"data": gin.H{
|
||||
"pay_link": payLink,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func genStripeSubscriptionLink(referenceId string, customerId string, email string, priceId string) (string, error) {
|
||||
stripe.Key = setting.StripeApiSecret
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(priceId),
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
|
||||
}
|
||||
|
||||
if "" == customerId {
|
||||
if "" != email {
|
||||
params.CustomerEmail = stripe.String(email)
|
||||
}
|
||||
params.CustomerCreation = stripe.String(string(stripe.CheckoutSessionCustomerCreationAlways))
|
||||
} else {
|
||||
params.Customer = stripe.String(customerId)
|
||||
}
|
||||
|
||||
result, err := session.New(params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result.URL, nil
|
||||
}
|
||||
@@ -65,12 +65,10 @@ func GetTopUpInfo(c *gin.Context) {
|
||||
type EpayRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
TopUpCode string `json:"top_up_code"`
|
||||
}
|
||||
|
||||
type AmountRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
TopUpCode string `json:"top_up_code"`
|
||||
Amount int64 `json:"amount"`
|
||||
}
|
||||
|
||||
func GetEpayClient() *epay.Client {
|
||||
@@ -230,10 +228,21 @@ func UnlockOrder(tradeNo string) {
|
||||
}
|
||||
|
||||
func EpayNotify(c *gin.Context) {
|
||||
params := lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
if err := c.Request.ParseForm(); err != nil {
|
||||
log.Println("易支付回调解析失败:", err)
|
||||
_, _ = c.Writer.Write([]byte("fail"))
|
||||
return
|
||||
}
|
||||
params := lo.Reduce(lo.Keys(c.Request.PostForm), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.PostForm.Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
if len(params) == 0 {
|
||||
params = lo.Reduce(lo.Keys(c.Request.URL.Query()), func(r map[string]string, t string, i int) map[string]string {
|
||||
r[t] = c.Request.URL.Query().Get(t)
|
||||
return r
|
||||
}, map[string]string{})
|
||||
}
|
||||
client := GetEpayClient()
|
||||
if client == nil {
|
||||
log.Println("易支付回调失败 未找到配置信息")
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
@@ -227,16 +228,6 @@ type CreemWebhookEvent struct {
|
||||
} `json:"object"`
|
||||
}
|
||||
|
||||
// 保留旧的结构体作为兼容
|
||||
type CreemWebhookData struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
RequestId string `json:"request_id"`
|
||||
Status string `json:"status"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func CreemWebhook(c *gin.Context) {
|
||||
// 读取body内容用于打印,同时保留原始数据供后续使用
|
||||
bodyBytes, err := io.ReadAll(c.Request.Body)
|
||||
@@ -308,7 +299,19 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款
|
||||
// Try complete subscription order first
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(event)); err == nil {
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// 验证订单类型,目前只处理一次性付款(充值)
|
||||
if event.Object.Order.Type != "onetime" {
|
||||
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -28,9 +29,18 @@ const (
|
||||
|
||||
var stripeAdaptor = &StripeAdaptor{}
|
||||
|
||||
// StripePayRequest represents a payment request for Stripe checkout.
|
||||
type StripePayRequest struct {
|
||||
Amount int64 `json:"amount"`
|
||||
// Amount is the quantity of units to purchase.
|
||||
Amount int64 `json:"amount"`
|
||||
// PaymentMethod specifies the payment method (e.g., "stripe").
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
// SuccessURL is the optional custom URL to redirect after successful payment.
|
||||
// If empty, defaults to the server's console log page.
|
||||
SuccessURL string `json:"success_url,omitempty"`
|
||||
// CancelURL is the optional custom URL to redirect when payment is canceled.
|
||||
// If empty, defaults to the server's console topup page.
|
||||
CancelURL string `json:"cancel_url,omitempty"`
|
||||
}
|
||||
|
||||
type StripeAdaptor struct {
|
||||
@@ -69,6 +79,16 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.SuccessURL != "" && common.ValidateRedirectURL(req.SuccessURL) != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "支付成功重定向URL不在可信任域名列表中", "data": ""})
|
||||
return
|
||||
}
|
||||
|
||||
if req.CancelURL != "" && common.ValidateRedirectURL(req.CancelURL) != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "支付取消重定向URL不在可信任域名列表中", "data": ""})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.GetInt("id")
|
||||
user, _ := model.GetUserById(id, false)
|
||||
chargedMoney := GetChargedAmount(float64(req.Amount), *user)
|
||||
@@ -76,7 +96,7 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
|
||||
reference := fmt.Sprintf("new-api-ref-%d-%d-%s", user.Id, time.Now().UnixMilli(), randstr.String(4))
|
||||
referenceId := "ref_" + common.Sha1([]byte(reference))
|
||||
|
||||
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount)
|
||||
payLink, err := genStripeLink(referenceId, user.StripeCustomer, user.Email, req.Amount, req.SuccessURL, req.CancelURL)
|
||||
if err != nil {
|
||||
log.Println("获取Stripe Checkout支付链接失败", err)
|
||||
c.JSON(200, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||
@@ -166,6 +186,22 @@ func sessionCompleted(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try complete subscription order first
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
payload := map[string]any{
|
||||
"customer": customerId,
|
||||
"amount_total": event.GetObjectValue("amount_total"),
|
||||
"currency": strings.ToUpper(event.GetObjectValue("currency")),
|
||||
"event_type": string(event.Type),
|
||||
}
|
||||
if err := model.CompleteSubscriptionOrder(referenceId, common.GetJsonString(payload)); err == nil {
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
||||
return
|
||||
}
|
||||
|
||||
err := model.Recharge(referenceId, customerId)
|
||||
if err != nil {
|
||||
log.Println(err.Error(), referenceId)
|
||||
@@ -190,6 +226,16 @@ func sessionExpired(event stripe.Event) {
|
||||
return
|
||||
}
|
||||
|
||||
// Subscription order expiration
|
||||
LockOrder(referenceId)
|
||||
defer UnlockOrder(referenceId)
|
||||
if err := model.ExpireSubscriptionOrder(referenceId); err == nil {
|
||||
return
|
||||
} else if err != nil && !errors.Is(err, model.ErrSubscriptionOrderNotFound) {
|
||||
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||
if topUp == nil {
|
||||
log.Println("充值订单不存在", referenceId)
|
||||
@@ -210,17 +256,37 @@ func sessionExpired(event stripe.Event) {
|
||||
log.Println("充值订单已过期", referenceId)
|
||||
}
|
||||
|
||||
func genStripeLink(referenceId string, customerId string, email string, amount int64) (string, error) {
|
||||
// genStripeLink generates a Stripe Checkout session URL for payment.
|
||||
// It creates a new checkout session with the specified parameters and returns the payment URL.
|
||||
//
|
||||
// Parameters:
|
||||
// - referenceId: unique reference identifier for the transaction
|
||||
// - customerId: existing Stripe customer ID (empty string if new customer)
|
||||
// - email: customer email address for new customer creation
|
||||
// - amount: quantity of units to purchase
|
||||
// - successURL: custom URL to redirect after successful payment (empty for default)
|
||||
// - cancelURL: custom URL to redirect when payment is canceled (empty for default)
|
||||
//
|
||||
// Returns the checkout session URL or an error if the session creation fails.
|
||||
func genStripeLink(referenceId string, customerId string, email string, amount int64, successURL string, cancelURL string) (string, error) {
|
||||
if !strings.HasPrefix(setting.StripeApiSecret, "sk_") && !strings.HasPrefix(setting.StripeApiSecret, "rk_") {
|
||||
return "", fmt.Errorf("无效的Stripe API密钥")
|
||||
}
|
||||
|
||||
stripe.Key = setting.StripeApiSecret
|
||||
|
||||
// Use custom URLs if provided, otherwise use defaults
|
||||
if successURL == "" {
|
||||
successURL = system_setting.ServerAddress + "/console/log"
|
||||
}
|
||||
if cancelURL == "" {
|
||||
cancelURL = system_setting.ServerAddress + "/console/topup"
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{
|
||||
ClientReferenceID: stripe.String(referenceId),
|
||||
SuccessURL: stripe.String(system_setting.ServerAddress + "/console/log"),
|
||||
CancelURL: stripe.String(system_setting.ServerAddress + "/console/topup"),
|
||||
SuccessURL: stripe.String(successURL),
|
||||
CancelURL: stripe.String(cancelURL),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
Price: stripe.String(setting.StripePriceId),
|
||||
|
||||
@@ -449,11 +449,12 @@ type GeminiChatResponse struct {
|
||||
}
|
||||
|
||||
type GeminiUsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
ThoughtsTokenCount int `json:"thoughtsTokenCount"`
|
||||
CachedContentTokenCount int `json:"cachedContentTokenCount"`
|
||||
PromptTokensDetails []GeminiPromptTokensDetails `json:"promptTokensDetails"`
|
||||
}
|
||||
|
||||
type GeminiPromptTokensDetails struct {
|
||||
|
||||
@@ -817,6 +817,10 @@ type OpenAIResponsesRequest struct {
|
||||
User string `json:"user,omitempty"`
|
||||
MaxToolCalls uint `json:"max_tool_calls,omitempty"`
|
||||
Prompt json.RawMessage `json:"prompt,omitempty"`
|
||||
// qwen
|
||||
EnableThinking json.RawMessage `json:"enable_thinking,omitempty"`
|
||||
// perplexity
|
||||
Preset json.RawMessage `json:"preset,omitempty"`
|
||||
}
|
||||
|
||||
func (r *OpenAIResponsesRequest) GetTokenCountMeta() *types.TokenCountMeta {
|
||||
|
||||
@@ -13,6 +13,7 @@ type UserSetting struct {
|
||||
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
||||
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
||||
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
||||
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
5
main.go
5
main.go
@@ -19,8 +19,8 @@ import (
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
"github.com/QuantumNous/new-api/router"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
_ "github.com/QuantumNous/new-api/setting/performance_setting"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
_ "github.com/QuantumNous/new-api/setting/performance_setting" // 注册性能设置
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -106,6 +106,9 @@ func main() {
|
||||
// Codex credential auto-refresh check every 10 minutes, refresh when expires within 1 day
|
||||
service.StartCodexCredentialAutoRefreshTask()
|
||||
|
||||
// Subscription quota reset task (daily/weekly/monthly/custom)
|
||||
service.StartSubscriptionQuotaResetTask()
|
||||
|
||||
if common.IsMasterNode && constant.UpdateTask {
|
||||
gopool.Go(func() {
|
||||
controller.UpdateMidjourneyTaskBulk()
|
||||
|
||||
22
model/db_time.go
Normal file
22
model/db_time.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "github.com/QuantumNous/new-api/common"
|
||||
|
||||
// GetDBTimestamp returns a UNIX timestamp from database time.
|
||||
// Falls back to application time on error.
|
||||
func GetDBTimestamp() int64 {
|
||||
var ts int64
|
||||
var err error
|
||||
switch {
|
||||
case common.UsingPostgreSQL:
|
||||
err = DB.Raw("SELECT EXTRACT(EPOCH FROM NOW())::bigint").Scan(&ts).Error
|
||||
case common.UsingSQLite:
|
||||
err = DB.Raw("SELECT strftime('%s','now')").Scan(&ts).Error
|
||||
default:
|
||||
err = DB.Raw("SELECT UNIX_TIMESTAMP()").Scan(&ts).Error
|
||||
}
|
||||
if err != nil || ts <= 0 {
|
||||
return common.GetTimestamp()
|
||||
}
|
||||
return ts
|
||||
}
|
||||
@@ -268,6 +268,10 @@ func migrateDB() error {
|
||||
&TwoFA{},
|
||||
&TwoFABackupCode{},
|
||||
&Checkin{},
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&SubscriptionPreConsumeRecord{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -302,6 +306,10 @@ func migrateDBFast() error {
|
||||
{&TwoFA{}, "TwoFA"},
|
||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||
{&Checkin{}, "Checkin"},
|
||||
{&SubscriptionPlan{}, "SubscriptionPlan"},
|
||||
{&SubscriptionOrder{}, "SubscriptionOrder"},
|
||||
{&UserSubscription{}, "UserSubscription"},
|
||||
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
|
||||
}
|
||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||
errChan := make(chan error, len(migrations))
|
||||
|
||||
1176
model/subscription.go
Normal file
1176
model/subscription.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -204,6 +204,10 @@ func updateUserGroupCache(userId int, group string) error {
|
||||
return common.RedisHSetField(getUserCacheKey(userId), "Group", group)
|
||||
}
|
||||
|
||||
func UpdateUserGroupCache(userId int, group string) error {
|
||||
return updateUserGroupCache(userId, group)
|
||||
}
|
||||
|
||||
func updateUserNameCache(userId int, username string) error {
|
||||
if !common.RedisEnabled {
|
||||
return nil
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/service"
|
||||
"github.com/QuantumNous/new-api/setting/model_setting"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -84,6 +84,8 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
fullRequestURL = fmt.Sprintf("%s/compatible-mode/v1/embeddings", info.ChannelBaseUrl)
|
||||
case constant.RelayModeRerank:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/rerank/text-rerank/text-rerank", info.ChannelBaseUrl)
|
||||
case constant.RelayModeResponses:
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v2/apps/protocols/compatible-mode/v1/responses", info.ChannelBaseUrl)
|
||||
case constant.RelayModeImagesGenerations:
|
||||
if isSyncImageModel(info.OriginModelName) {
|
||||
fullRequestURL = fmt.Sprintf("%s/api/v1/services/aigc/multimodal-generation/generation", info.ChannelBaseUrl)
|
||||
@@ -210,8 +212,7 @@ func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInf
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
|
||||
@@ -98,6 +98,19 @@ func processHeaderOverride(info *common.RelayInfo, c *gin.Context) (map[string]s
|
||||
return headerOverride, nil
|
||||
}
|
||||
|
||||
func applyHeaderOverrideToRequest(req *http.Request, headerOverride map[string]string) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
for key, value := range headerOverride {
|
||||
req.Header.Set(key, value)
|
||||
// set Host in req
|
||||
if strings.EqualFold(key, "Host") {
|
||||
req.Host = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody io.Reader) (*http.Response, error) {
|
||||
fullRequestURL, err := a.GetRequestURL(info)
|
||||
if err != nil {
|
||||
@@ -121,9 +134,7 @@ func DoApiRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBody
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headerOverride {
|
||||
headers.Set(key, value)
|
||||
}
|
||||
applyHeaderOverrideToRequest(req, headerOverride)
|
||||
resp, err := doRequest(c, req, info)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request failed: %w", err)
|
||||
@@ -156,9 +167,7 @@ func DoFormRequest(a Adaptor, c *gin.Context, info *common.RelayInfo, requestBod
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for key, value := range headerOverride {
|
||||
headers.Set(key, value)
|
||||
}
|
||||
applyHeaderOverrideToRequest(req, headerOverride)
|
||||
resp, err := doRequest(c, req, info)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("do request failed: %w", err)
|
||||
|
||||
@@ -437,8 +437,10 @@ func StreamResponseClaude2OpenAI(reqMode int, claudeResponse *dto.ClaudeResponse
|
||||
}
|
||||
} else {
|
||||
if claudeResponse.Type == "message_start" {
|
||||
response.Id = claudeResponse.Message.Id
|
||||
response.Model = claudeResponse.Message.Model
|
||||
if claudeResponse.Message != nil {
|
||||
response.Id = claudeResponse.Message.Id
|
||||
response.Model = claudeResponse.Message.Model
|
||||
}
|
||||
//claudeUsage = &claudeResponse.Message.Usage
|
||||
choice.Delta.SetContentString("")
|
||||
choice.Delta.Role = "assistant"
|
||||
@@ -589,35 +591,63 @@ type ClaudeResponseInfo struct {
|
||||
}
|
||||
|
||||
func FormatClaudeResponseInfo(requestMode int, claudeResponse *dto.ClaudeResponse, oaiResponse *dto.ChatCompletionsStreamResponse, claudeInfo *ClaudeResponseInfo) bool {
|
||||
if claudeInfo == nil {
|
||||
return false
|
||||
}
|
||||
if claudeInfo.Usage == nil {
|
||||
claudeInfo.Usage = &dto.Usage{}
|
||||
}
|
||||
if requestMode == RequestModeCompletion {
|
||||
claudeInfo.ResponseText.WriteString(claudeResponse.Completion)
|
||||
} else {
|
||||
if claudeResponse.Type == "message_start" {
|
||||
claudeInfo.ResponseId = claudeResponse.Message.Id
|
||||
claudeInfo.Model = claudeResponse.Message.Model
|
||||
if claudeResponse.Message != nil {
|
||||
claudeInfo.ResponseId = claudeResponse.Message.Id
|
||||
claudeInfo.Model = claudeResponse.Message.Model
|
||||
}
|
||||
|
||||
// message_start, 获取usage
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
|
||||
} else if claudeResponse.Type == "content_block_delta" {
|
||||
if claudeResponse.Delta.Text != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
|
||||
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
|
||||
}
|
||||
if claudeResponse.Delta.Thinking != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
||||
} else if claudeResponse.Type == "content_block_delta" {
|
||||
if claudeResponse.Delta != nil {
|
||||
if claudeResponse.Delta.Text != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Text)
|
||||
}
|
||||
if claudeResponse.Delta.Thinking != nil {
|
||||
claudeInfo.ResponseText.WriteString(*claudeResponse.Delta.Thinking)
|
||||
}
|
||||
}
|
||||
} else if claudeResponse.Type == "message_delta" {
|
||||
// 最终的usage获取
|
||||
if claudeResponse.Usage.InputTokens > 0 {
|
||||
// 不叠加,只取最新的
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
||||
if claudeResponse.Usage != nil {
|
||||
if claudeResponse.Usage.InputTokens > 0 {
|
||||
// 不叠加,只取最新的
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
||||
}
|
||||
if claudeResponse.Usage.CacheReadInputTokens > 0 {
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||
}
|
||||
if claudeResponse.Usage.CacheCreationInputTokens > 0 {
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||
}
|
||||
if cacheCreation5m := claudeResponse.Usage.GetCacheCreation5mTokens(); cacheCreation5m > 0 {
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = cacheCreation5m
|
||||
}
|
||||
if cacheCreation1h := claudeResponse.Usage.GetCacheCreation1hTokens(); cacheCreation1h > 0 {
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = cacheCreation1h
|
||||
}
|
||||
if claudeResponse.Usage.OutputTokens > 0 {
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||
}
|
||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||
}
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
|
||||
|
||||
// 判断是否完整
|
||||
claudeInfo.Done = true
|
||||
@@ -657,7 +687,9 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
} else {
|
||||
if claudeResponse.Type == "message_start" {
|
||||
// message_start, 获取usage
|
||||
info.UpstreamModelName = claudeResponse.Message.Model
|
||||
if claudeResponse.Message != nil {
|
||||
info.UpstreamModelName = claudeResponse.Message.Model
|
||||
}
|
||||
} else if claudeResponse.Type == "content_block_delta" {
|
||||
} else if claudeResponse.Type == "message_delta" {
|
||||
}
|
||||
@@ -745,13 +777,18 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
if requestMode == RequestModeCompletion {
|
||||
claudeInfo.Usage = service.ResponseText2Usage(c, claudeResponse.Completion, info.UpstreamModelName, info.GetEstimatePromptTokens())
|
||||
} else {
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
|
||||
if claudeInfo.Usage == nil {
|
||||
claudeInfo.Usage = &dto.Usage{}
|
||||
}
|
||||
if claudeResponse.Usage != nil {
|
||||
claudeInfo.Usage.PromptTokens = claudeResponse.Usage.InputTokens
|
||||
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
|
||||
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
|
||||
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
|
||||
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
|
||||
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Usage.GetCacheCreation1hTokens()
|
||||
}
|
||||
}
|
||||
var responseData []byte
|
||||
switch info.RelayFormat {
|
||||
@@ -766,7 +803,7 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
responseData = data
|
||||
}
|
||||
|
||||
if claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
|
||||
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
|
||||
c.Set("claude_web_search_requests", claudeResponse.Usage.ServerToolUse.WebSearchRequests)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
|
||||
}
|
||||
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
|
||||
@@ -1251,6 +1251,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
|
||||
usage.CompletionTokens = geminiResponse.UsageMetadata.CandidatesTokenCount + geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.TotalTokens = geminiResponse.UsageMetadata.TotalTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
@@ -1395,6 +1396,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
PromptTokens: geminiResponse.UsageMetadata.PromptTokenCount,
|
||||
}
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
if detail.Modality == "AUDIO" {
|
||||
usage.PromptTokensDetails.AudioTokens = detail.TokenCount
|
||||
@@ -1447,6 +1449,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
|
||||
}
|
||||
|
||||
usage.CompletionTokenDetails.ReasoningTokens = geminiResponse.UsageMetadata.ThoughtsTokenCount
|
||||
usage.PromptTokensDetails.CachedTokens = geminiResponse.UsageMetadata.CachedContentTokenCount
|
||||
usage.CompletionTokens = usage.TotalTokens - usage.PromptTokens
|
||||
|
||||
for _, detail := range geminiResponse.UsageMetadata.PromptTokensDetails {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/QuantumNous/new-api/relay/channel"
|
||||
"github.com/QuantumNous/new-api/relay/channel/openai"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
relayconstant "github.com/QuantumNous/new-api/relay/constant"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -42,6 +43,9 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
}
|
||||
|
||||
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
if info.RelayMode == relayconstant.RelayModeResponses {
|
||||
return fmt.Sprintf("%s/v1/responses", info.ChannelBaseUrl), nil
|
||||
}
|
||||
return fmt.Sprintf("%s/chat/completions", info.ChannelBaseUrl), nil
|
||||
}
|
||||
|
||||
@@ -71,8 +75,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
|
||||
// TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
|
||||
|
||||
@@ -30,6 +30,7 @@ type ContentItem struct {
|
||||
Text string `json:"text,omitempty"` // for text type
|
||||
ImageURL *ImageURL `json:"image_url,omitempty"` // for image_url type
|
||||
Video *VideoReference `json:"video,omitempty"` // for video (sample) type
|
||||
Role string `json:"role,omitempty"` // reference_image / first_frame / last_frame
|
||||
}
|
||||
|
||||
type ImageURL struct {
|
||||
|
||||
@@ -24,9 +24,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
RequestModeClaude = 1
|
||||
RequestModeGemini = 2
|
||||
RequestModeLlama = 3
|
||||
RequestModeClaude = 1
|
||||
RequestModeGemini = 2
|
||||
RequestModeOpenSource = 3
|
||||
)
|
||||
|
||||
var claudeModelMap = map[string]string{
|
||||
@@ -115,7 +115,7 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
|
||||
} else if strings.Contains(info.UpstreamModelName, "llama") ||
|
||||
// open source models
|
||||
strings.Contains(info.UpstreamModelName, "-maas") {
|
||||
a.RequestMode = RequestModeLlama
|
||||
a.RequestMode = RequestModeOpenSource
|
||||
} else {
|
||||
a.RequestMode = RequestModeGemini
|
||||
}
|
||||
@@ -166,10 +166,9 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
|
||||
suffix,
|
||||
), nil
|
||||
}
|
||||
} else if a.RequestMode == RequestModeLlama {
|
||||
} else if a.RequestMode == RequestModeOpenSource {
|
||||
return fmt.Sprintf(
|
||||
"https://%s-aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
|
||||
region,
|
||||
"https://aiplatform.googleapis.com/v1beta1/projects/%s/locations/%s/endpoints/openapi/chat/completions",
|
||||
adc.ProjectID,
|
||||
region,
|
||||
), nil
|
||||
@@ -242,7 +241,7 @@ func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
|
||||
model = v
|
||||
}
|
||||
return a.getRequestUrl(info, model, suffix)
|
||||
} else if a.RequestMode == RequestModeLlama {
|
||||
} else if a.RequestMode == RequestModeOpenSource {
|
||||
return a.getRequestUrl(info, "", "")
|
||||
}
|
||||
return "", errors.New("unsupported request mode")
|
||||
@@ -340,7 +339,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
|
||||
}
|
||||
c.Set("request_model", request.Model)
|
||||
return geminiRequest, nil
|
||||
} else if a.RequestMode == RequestModeLlama {
|
||||
} else if a.RequestMode == RequestModeOpenSource {
|
||||
return request, nil
|
||||
}
|
||||
return nil, errors.New("unsupported request mode")
|
||||
@@ -375,7 +374,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
} else {
|
||||
return gemini.GeminiChatStreamHandler(c, info, resp)
|
||||
}
|
||||
case RequestModeLlama:
|
||||
case RequestModeOpenSource:
|
||||
return openai.OaiStreamHandler(c, info, resp)
|
||||
}
|
||||
} else {
|
||||
@@ -391,7 +390,7 @@ func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycom
|
||||
}
|
||||
return gemini.GeminiChatHandler(c, info, resp)
|
||||
}
|
||||
case RequestModeLlama:
|
||||
case RequestModeOpenSource:
|
||||
return openai.OpenaiHandler(c, info, resp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,26 @@ type RelayInfo struct {
|
||||
UserQuota int
|
||||
RelayFormat types.RelayFormat
|
||||
SendResponseCount int
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||
IsChannelTest bool // channel test request
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
// BillingSource indicates whether this request is billed from wallet quota or subscription.
|
||||
// "" or "wallet" => wallet; "subscription" => subscription
|
||||
BillingSource string
|
||||
// SubscriptionId is the user_subscriptions.id used when BillingSource == "subscription"
|
||||
SubscriptionId int
|
||||
// SubscriptionPreConsumed is the amount pre-consumed on subscription item (quota units or 1)
|
||||
SubscriptionPreConsumed int64
|
||||
// SubscriptionPostDelta is the post-consume delta applied to amount_used (quota units; can be negative).
|
||||
SubscriptionPostDelta int64
|
||||
// SubscriptionPlanId / SubscriptionPlanTitle are used for logging/UI display.
|
||||
SubscriptionPlanId int
|
||||
SubscriptionPlanTitle string
|
||||
// RequestId is used for idempotent pre-consume/refund
|
||||
RequestId string
|
||||
// SubscriptionAmountTotal / SubscriptionAmountUsedAfterPreConsume are used to compute remaining in logs.
|
||||
SubscriptionAmountTotal int64
|
||||
SubscriptionAmountUsedAfterPreConsume int64
|
||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||
IsChannelTest bool // channel test request
|
||||
|
||||
PriceData types.PriceData
|
||||
|
||||
@@ -400,9 +417,14 @@ func genBaseRelayInfo(c *gin.Context, request dto.Request) *RelayInfo {
|
||||
|
||||
// firstResponseTime = time.Now() - 1 second
|
||||
|
||||
reqId := common.GetContextKeyString(c, common.RequestIdKey)
|
||||
if reqId == "" {
|
||||
reqId = common.GetTimeString() + common.GetRandomString(8)
|
||||
}
|
||||
info := &RelayInfo{
|
||||
Request: request,
|
||||
|
||||
RequestId: reqId,
|
||||
UserId: common.GetContextKeyInt(c, constant.ContextKeyUserId),
|
||||
UsingGroup: common.GetContextKeyString(c, constant.ContextKeyUsingGroup),
|
||||
UserGroup: common.GetContextKeyString(c, constant.ContextKeyUserGroup),
|
||||
|
||||
@@ -219,6 +219,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
|
||||
}
|
||||
|
||||
func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage *dto.Usage, extraContent ...string) {
|
||||
originUsage := usage
|
||||
if usage == nil {
|
||||
usage = &dto.Usage{
|
||||
PromptTokens: relayInfo.GetEstimatePromptTokens(),
|
||||
@@ -228,6 +229,10 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
|
||||
extraContent = append(extraContent, "上游无计费信息")
|
||||
}
|
||||
|
||||
if originUsage != nil {
|
||||
service.ObserveChannelAffinityUsageCacheFromContext(ctx, usage)
|
||||
}
|
||||
|
||||
adminRejectReason := common.GetContextKeyString(ctx, constant.ContextKeyAdminRejectReason)
|
||||
|
||||
useTimeSeconds := time.Now().Unix() - relayInfo.StartTime.Unix()
|
||||
|
||||
@@ -144,7 +144,7 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (taskErr *dto.
|
||||
if !success {
|
||||
defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[modelName]
|
||||
if !ok {
|
||||
modelPrice = 0.1
|
||||
modelPrice = float64(common.PreConsumedQuota) / common.QuotaPerUnit
|
||||
} else {
|
||||
modelPrice = defaultPrice
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
userRoute.POST("/passkey/login/finish", middleware.CriticalRateLimit(), controller.PasskeyLoginFinish)
|
||||
//userRoute.POST("/tokenlog", middleware.CriticalRateLimit(), controller.TokenLog)
|
||||
userRoute.GET("/logout", controller.Logout)
|
||||
userRoute.GET("/epay/notify", controller.EpayNotify)
|
||||
userRoute.POST("/epay/notify", controller.EpayNotify)
|
||||
userRoute.GET("/groups", controller.GetUserGroups)
|
||||
|
||||
selfRoute := userRoute.Group("/")
|
||||
@@ -119,6 +119,38 @@ func SetApiRouter(router *gin.Engine) {
|
||||
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription billing (plans, purchase, admin management)
|
||||
subscriptionRoute := apiRouter.Group("/subscription")
|
||||
subscriptionRoute.Use(middleware.UserAuth())
|
||||
{
|
||||
subscriptionRoute.GET("/plans", controller.GetSubscriptionPlans)
|
||||
subscriptionRoute.GET("/self", controller.GetSubscriptionSelf)
|
||||
subscriptionRoute.PUT("/self/preference", controller.UpdateSubscriptionPreference)
|
||||
subscriptionRoute.POST("/epay/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestEpay)
|
||||
subscriptionRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestStripePay)
|
||||
subscriptionRoute.POST("/creem/pay", middleware.CriticalRateLimit(), controller.SubscriptionRequestCreemPay)
|
||||
}
|
||||
subscriptionAdminRoute := apiRouter.Group("/subscription/admin")
|
||||
subscriptionAdminRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
subscriptionAdminRoute.GET("/plans", controller.AdminListSubscriptionPlans)
|
||||
subscriptionAdminRoute.POST("/plans", controller.AdminCreateSubscriptionPlan)
|
||||
subscriptionAdminRoute.PUT("/plans/:id", controller.AdminUpdateSubscriptionPlan)
|
||||
subscriptionAdminRoute.PATCH("/plans/:id", controller.AdminUpdateSubscriptionPlanStatus)
|
||||
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
|
||||
|
||||
// User subscription management (admin)
|
||||
subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions)
|
||||
subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription)
|
||||
subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription)
|
||||
subscriptionAdminRoute.DELETE("/user_subscriptions/:id", controller.AdminDeleteUserSubscription)
|
||||
}
|
||||
|
||||
// Subscription payment callbacks (no auth)
|
||||
apiRouter.POST("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||
apiRouter.GET("/subscription/epay/return", controller.SubscriptionEpayReturn)
|
||||
apiRouter.POST("/subscription/epay/return", controller.SubscriptionEpayReturn)
|
||||
optionRoute := apiRouter.Group("/option")
|
||||
optionRoute.Use(middleware.RootAuth())
|
||||
{
|
||||
@@ -220,6 +252,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
logRoute.DELETE("/", middleware.AdminAuth(), controller.DeleteHistoryLogs)
|
||||
logRoute.GET("/stat", middleware.AdminAuth(), controller.GetLogsStat)
|
||||
logRoute.GET("/self/stat", middleware.UserAuth(), controller.GetLogsSelfStat)
|
||||
logRoute.GET("/channel_affinity_usage_cache", middleware.AdminAuth(), controller.GetChannelAffinityUsageCacheStats)
|
||||
logRoute.GET("/search", middleware.AdminAuth(), controller.SearchAllLogs)
|
||||
logRoute.GET("/self", middleware.UserAuth(), controller.GetUserLogs)
|
||||
logRoute.GET("/self/search", middleware.UserAuth(), controller.SearchUserLogs)
|
||||
|
||||
106
service/billing.go
Normal file
106
service/billing.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||
"github.com/QuantumNous/new-api/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
BillingSourceWallet = "wallet"
|
||||
BillingSourceSubscription = "subscription"
|
||||
)
|
||||
|
||||
// PreConsumeBilling decides whether to pre-consume from subscription or wallet based on user preference.
|
||||
// It also always pre-consumes token quota in quota units (same as legacy flow).
|
||||
func PreConsumeBilling(c *gin.Context, preConsumedQuota int, relayInfo *relaycommon.RelayInfo) *types.NewAPIError {
|
||||
if relayInfo == nil {
|
||||
return types.NewError(fmt.Errorf("relayInfo is nil"), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry())
|
||||
}
|
||||
|
||||
pref := common.NormalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
||||
trySubscription := func() *types.NewAPIError {
|
||||
quotaType := 0
|
||||
// For total quota: consume preConsumedQuota quota units.
|
||||
subConsume := int64(preConsumedQuota)
|
||||
if subConsume <= 0 {
|
||||
subConsume = 1
|
||||
}
|
||||
|
||||
// Pre-consume token quota in quota units to keep token limits consistent.
|
||||
if preConsumedQuota > 0 {
|
||||
if err := PreConsumeTokenQuota(relayInfo, preConsumedQuota); err != nil {
|
||||
return types.NewErrorWithStatusCode(err, types.ErrorCodePreConsumeTokenQuotaFailed, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
}
|
||||
|
||||
res, err := model.PreConsumeUserSubscription(relayInfo.RequestId, relayInfo.UserId, relayInfo.OriginModelName, quotaType, subConsume)
|
||||
if err != nil {
|
||||
// revert token pre-consume when subscription fails
|
||||
if preConsumedQuota > 0 && !relayInfo.IsPlayground {
|
||||
_ = model.IncreaseTokenQuota(relayInfo.TokenId, relayInfo.TokenKey, preConsumedQuota)
|
||||
}
|
||||
errMsg := err.Error()
|
||||
if strings.Contains(errMsg, "no active subscription") || strings.Contains(errMsg, "subscription quota insufficient") {
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", errMsg), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||
}
|
||||
return types.NewErrorWithStatusCode(fmt.Errorf("订阅预扣失败: %s", errMsg), types.ErrorCodeQueryDataError, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
relayInfo.BillingSource = BillingSourceSubscription
|
||||
relayInfo.SubscriptionId = res.UserSubscriptionId
|
||||
relayInfo.SubscriptionPreConsumed = res.PreConsumed
|
||||
relayInfo.SubscriptionPostDelta = 0
|
||||
relayInfo.SubscriptionAmountTotal = res.AmountTotal
|
||||
relayInfo.SubscriptionAmountUsedAfterPreConsume = res.AmountUsedAfter
|
||||
if planInfo, err := model.GetSubscriptionPlanInfoByUserSubscriptionId(res.UserSubscriptionId); err == nil && planInfo != nil {
|
||||
relayInfo.SubscriptionPlanId = planInfo.PlanId
|
||||
relayInfo.SubscriptionPlanTitle = planInfo.PlanTitle
|
||||
}
|
||||
relayInfo.FinalPreConsumedQuota = preConsumedQuota
|
||||
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 使用订阅计费预扣:订阅=%d,token_quota=%d", relayInfo.UserId, res.PreConsumed, preConsumedQuota))
|
||||
return nil
|
||||
}
|
||||
|
||||
tryWallet := func() *types.NewAPIError {
|
||||
relayInfo.BillingSource = BillingSourceWallet
|
||||
relayInfo.SubscriptionId = 0
|
||||
relayInfo.SubscriptionPreConsumed = 0
|
||||
return PreConsumeQuota(c, preConsumedQuota, relayInfo)
|
||||
}
|
||||
|
||||
switch pref {
|
||||
case "subscription_only":
|
||||
return trySubscription()
|
||||
case "wallet_only":
|
||||
return tryWallet()
|
||||
case "wallet_first":
|
||||
if err := tryWallet(); err != nil {
|
||||
// only fallback for insufficient wallet quota
|
||||
if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
|
||||
return trySubscription()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case "subscription_first":
|
||||
fallthrough
|
||||
default:
|
||||
if err := trySubscription(); err != nil {
|
||||
// fallback only when subscription not available/insufficient
|
||||
if err.GetErrorCode() == types.ErrorCodeInsufficientUserQuota {
|
||||
return tryWallet()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/dto"
|
||||
"github.com/QuantumNous/new-api/pkg/cachex"
|
||||
"github.com/QuantumNous/new-api/setting/operation_setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -21,14 +23,19 @@ const (
|
||||
ginKeyChannelAffinityTTLSeconds = "channel_affinity_ttl_seconds"
|
||||
ginKeyChannelAffinityMeta = "channel_affinity_meta"
|
||||
ginKeyChannelAffinityLogInfo = "channel_affinity_log_info"
|
||||
ginKeyChannelAffinitySkipRetry = "channel_affinity_skip_retry_on_failure"
|
||||
|
||||
channelAffinityCacheNamespace = "new-api:channel_affinity:v1"
|
||||
channelAffinityCacheNamespace = "new-api:channel_affinity:v1"
|
||||
channelAffinityUsageCacheStatsNamespace = "new-api:channel_affinity_usage_cache_stats:v1"
|
||||
)
|
||||
|
||||
var (
|
||||
channelAffinityCacheOnce sync.Once
|
||||
channelAffinityCache *cachex.HybridCache[int]
|
||||
|
||||
channelAffinityUsageCacheStatsOnce sync.Once
|
||||
channelAffinityUsageCacheStatsCache *cachex.HybridCache[ChannelAffinityUsageCacheCounters]
|
||||
|
||||
channelAffinityRegexCache sync.Map // map[string]*regexp.Regexp
|
||||
)
|
||||
|
||||
@@ -36,15 +43,24 @@ type channelAffinityMeta struct {
|
||||
CacheKey string
|
||||
TTLSeconds int
|
||||
RuleName string
|
||||
SkipRetry bool
|
||||
KeySourceType string
|
||||
KeySourceKey string
|
||||
KeySourcePath string
|
||||
KeyHint string
|
||||
KeyFingerprint string
|
||||
UsingGroup string
|
||||
ModelName string
|
||||
RequestPath string
|
||||
}
|
||||
|
||||
type ChannelAffinityStatsContext struct {
|
||||
RuleName string
|
||||
UsingGroup string
|
||||
KeyFingerprint string
|
||||
TTLSeconds int64
|
||||
}
|
||||
|
||||
type ChannelAffinityCacheStats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Total int `json:"total"`
|
||||
@@ -338,6 +354,32 @@ func getChannelAffinityMeta(c *gin.Context) (channelAffinityMeta, bool) {
|
||||
return meta, true
|
||||
}
|
||||
|
||||
func GetChannelAffinityStatsContext(c *gin.Context) (ChannelAffinityStatsContext, bool) {
|
||||
if c == nil {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
meta, ok := getChannelAffinityMeta(c)
|
||||
if !ok {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
ruleName := strings.TrimSpace(meta.RuleName)
|
||||
keyFp := strings.TrimSpace(meta.KeyFingerprint)
|
||||
usingGroup := strings.TrimSpace(meta.UsingGroup)
|
||||
if ruleName == "" || keyFp == "" {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
ttlSeconds := int64(meta.TTLSeconds)
|
||||
if ttlSeconds <= 0 {
|
||||
return ChannelAffinityStatsContext{}, false
|
||||
}
|
||||
return ChannelAffinityStatsContext{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
TTLSeconds: ttlSeconds,
|
||||
}, true
|
||||
}
|
||||
|
||||
func affinityFingerprint(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
@@ -349,6 +391,19 @@ func affinityFingerprint(s string) string {
|
||||
return hex
|
||||
}
|
||||
|
||||
func buildChannelAffinityKeyHint(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\r", " ")
|
||||
if len(s) <= 12 {
|
||||
return s
|
||||
}
|
||||
return s[:4] + "..." + s[len(s)-4:]
|
||||
}
|
||||
|
||||
func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup string) (int, bool) {
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
if setting == nil || !setting.Enabled {
|
||||
@@ -399,9 +454,11 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
||||
CacheKey: cacheKeyFull,
|
||||
TTLSeconds: ttlSeconds,
|
||||
RuleName: rule.Name,
|
||||
SkipRetry: rule.SkipRetryOnFailure,
|
||||
KeySourceType: strings.TrimSpace(usedSource.Type),
|
||||
KeySourceKey: strings.TrimSpace(usedSource.Key),
|
||||
KeySourcePath: strings.TrimSpace(usedSource.Path),
|
||||
KeyHint: buildChannelAffinityKeyHint(affinityValue),
|
||||
KeyFingerprint: affinityFingerprint(affinityValue),
|
||||
UsingGroup: usingGroup,
|
||||
ModelName: modelName,
|
||||
@@ -422,6 +479,21 @@ func GetPreferredChannelByAffinity(c *gin.Context, modelName string, usingGroup
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ShouldSkipRetryAfterChannelAffinityFailure(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
v, ok := c.Get(ginKeyChannelAffinitySkipRetry)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int) {
|
||||
if c == nil || channelID <= 0 {
|
||||
return
|
||||
@@ -430,6 +502,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Set(ginKeyChannelAffinitySkipRetry, meta.SkipRetry)
|
||||
info := map[string]interface{}{
|
||||
"reason": meta.RuleName,
|
||||
"rule_name": meta.RuleName,
|
||||
@@ -441,6 +514,7 @@ func MarkChannelAffinityUsed(c *gin.Context, selectedGroup string, channelID int
|
||||
"key_source": meta.KeySourceType,
|
||||
"key_key": meta.KeySourceKey,
|
||||
"key_path": meta.KeySourcePath,
|
||||
"key_hint": meta.KeyHint,
|
||||
"key_fp": meta.KeyFingerprint,
|
||||
}
|
||||
c.Set(ginKeyChannelAffinityLogInfo, info)
|
||||
@@ -485,3 +559,225 @@ func RecordChannelAffinity(c *gin.Context, channelID int) {
|
||||
common.SysError(fmt.Sprintf("channel affinity cache set failed: key=%s, err=%v", cacheKey, err))
|
||||
}
|
||||
}
|
||||
|
||||
type ChannelAffinityUsageCacheStats struct {
|
||||
RuleName string `json:"rule_name"`
|
||||
UsingGroup string `json:"using_group"`
|
||||
KeyFingerprint string `json:"key_fp"`
|
||||
|
||||
Hit int64 `json:"hit"`
|
||||
Total int64 `json:"total"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
type ChannelAffinityUsageCacheCounters struct {
|
||||
Hit int64 `json:"hit"`
|
||||
Total int64 `json:"total"`
|
||||
WindowSeconds int64 `json:"window_seconds"`
|
||||
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
CachedTokens int64 `json:"cached_tokens"`
|
||||
PromptCacheHitTokens int64 `json:"prompt_cache_hit_tokens"`
|
||||
LastSeenAt int64 `json:"last_seen_at"`
|
||||
}
|
||||
|
||||
var channelAffinityUsageCacheStatsLocks [64]sync.Mutex
|
||||
|
||||
func ObserveChannelAffinityUsageCacheFromContext(c *gin.Context, usage *dto.Usage) {
|
||||
statsCtx, ok := GetChannelAffinityStatsContext(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
observeChannelAffinityUsageCache(statsCtx, usage)
|
||||
}
|
||||
|
||||
func GetChannelAffinityUsageCacheStats(ruleName, usingGroup, keyFp string) ChannelAffinityUsageCacheStats {
|
||||
ruleName = strings.TrimSpace(ruleName)
|
||||
usingGroup = strings.TrimSpace(usingGroup)
|
||||
keyFp = strings.TrimSpace(keyFp)
|
||||
|
||||
entryKey := channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp)
|
||||
if entryKey == "" {
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
}
|
||||
}
|
||||
|
||||
cache := getChannelAffinityUsageCacheStatsCache()
|
||||
v, found, err := cache.Get(entryKey)
|
||||
if err != nil || !found {
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
}
|
||||
}
|
||||
return ChannelAffinityUsageCacheStats{
|
||||
RuleName: ruleName,
|
||||
UsingGroup: usingGroup,
|
||||
KeyFingerprint: keyFp,
|
||||
Hit: v.Hit,
|
||||
Total: v.Total,
|
||||
WindowSeconds: v.WindowSeconds,
|
||||
PromptTokens: v.PromptTokens,
|
||||
CompletionTokens: v.CompletionTokens,
|
||||
TotalTokens: v.TotalTokens,
|
||||
CachedTokens: v.CachedTokens,
|
||||
PromptCacheHitTokens: v.PromptCacheHitTokens,
|
||||
LastSeenAt: v.LastSeenAt,
|
||||
}
|
||||
}
|
||||
|
||||
func observeChannelAffinityUsageCache(statsCtx ChannelAffinityStatsContext, usage *dto.Usage) {
|
||||
entryKey := channelAffinityUsageCacheEntryKey(statsCtx.RuleName, statsCtx.UsingGroup, statsCtx.KeyFingerprint)
|
||||
if entryKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
windowSeconds := statsCtx.TTLSeconds
|
||||
if windowSeconds <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
cache := getChannelAffinityUsageCacheStatsCache()
|
||||
ttl := time.Duration(windowSeconds) * time.Second
|
||||
|
||||
lock := channelAffinityUsageCacheStatsLock(entryKey)
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
prev, found, err := cache.Get(entryKey)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
next := prev
|
||||
if !found {
|
||||
next = ChannelAffinityUsageCacheCounters{}
|
||||
}
|
||||
next.Total++
|
||||
hit, cachedTokens, promptCacheHitTokens := usageCacheSignals(usage)
|
||||
if hit {
|
||||
next.Hit++
|
||||
}
|
||||
next.WindowSeconds = windowSeconds
|
||||
next.LastSeenAt = time.Now().Unix()
|
||||
next.CachedTokens += cachedTokens
|
||||
next.PromptCacheHitTokens += promptCacheHitTokens
|
||||
next.PromptTokens += int64(usagePromptTokens(usage))
|
||||
next.CompletionTokens += int64(usageCompletionTokens(usage))
|
||||
next.TotalTokens += int64(usageTotalTokens(usage))
|
||||
_ = cache.SetWithTTL(entryKey, next, ttl)
|
||||
}
|
||||
|
||||
func channelAffinityUsageCacheEntryKey(ruleName, usingGroup, keyFp string) string {
|
||||
ruleName = strings.TrimSpace(ruleName)
|
||||
usingGroup = strings.TrimSpace(usingGroup)
|
||||
keyFp = strings.TrimSpace(keyFp)
|
||||
if ruleName == "" || keyFp == "" {
|
||||
return ""
|
||||
}
|
||||
return ruleName + "\n" + usingGroup + "\n" + keyFp
|
||||
}
|
||||
|
||||
func usageCacheSignals(usage *dto.Usage) (hit bool, cachedTokens int64, promptCacheHitTokens int64) {
|
||||
if usage == nil {
|
||||
return false, 0, 0
|
||||
}
|
||||
|
||||
cached := int64(0)
|
||||
if usage.PromptTokensDetails.CachedTokens > 0 {
|
||||
cached = int64(usage.PromptTokensDetails.CachedTokens)
|
||||
} else if usage.InputTokensDetails != nil && usage.InputTokensDetails.CachedTokens > 0 {
|
||||
cached = int64(usage.InputTokensDetails.CachedTokens)
|
||||
}
|
||||
pcht := int64(0)
|
||||
if usage.PromptCacheHitTokens > 0 {
|
||||
pcht = int64(usage.PromptCacheHitTokens)
|
||||
}
|
||||
return cached > 0 || pcht > 0, cached, pcht
|
||||
}
|
||||
|
||||
func usagePromptTokens(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
if usage.PromptTokens > 0 {
|
||||
return usage.PromptTokens
|
||||
}
|
||||
return usage.InputTokens
|
||||
}
|
||||
|
||||
func usageCompletionTokens(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
if usage.CompletionTokens > 0 {
|
||||
return usage.CompletionTokens
|
||||
}
|
||||
return usage.OutputTokens
|
||||
}
|
||||
|
||||
func usageTotalTokens(usage *dto.Usage) int {
|
||||
if usage == nil {
|
||||
return 0
|
||||
}
|
||||
if usage.TotalTokens > 0 {
|
||||
return usage.TotalTokens
|
||||
}
|
||||
pt := usagePromptTokens(usage)
|
||||
ct := usageCompletionTokens(usage)
|
||||
if pt > 0 || ct > 0 {
|
||||
return pt + ct
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func getChannelAffinityUsageCacheStatsCache() *cachex.HybridCache[ChannelAffinityUsageCacheCounters] {
|
||||
channelAffinityUsageCacheStatsOnce.Do(func() {
|
||||
setting := operation_setting.GetChannelAffinitySetting()
|
||||
capacity := 100_000
|
||||
defaultTTLSeconds := 3600
|
||||
if setting != nil {
|
||||
if setting.MaxEntries > 0 {
|
||||
capacity = setting.MaxEntries
|
||||
}
|
||||
if setting.DefaultTTLSeconds > 0 {
|
||||
defaultTTLSeconds = setting.DefaultTTLSeconds
|
||||
}
|
||||
}
|
||||
|
||||
channelAffinityUsageCacheStatsCache = cachex.NewHybridCache[ChannelAffinityUsageCacheCounters](cachex.HybridCacheConfig[ChannelAffinityUsageCacheCounters]{
|
||||
Namespace: cachex.Namespace(channelAffinityUsageCacheStatsNamespace),
|
||||
Redis: common.RDB,
|
||||
RedisEnabled: func() bool {
|
||||
return common.RedisEnabled && common.RDB != nil
|
||||
},
|
||||
RedisCodec: cachex.JSONCodec[ChannelAffinityUsageCacheCounters]{},
|
||||
Memory: func() *hot.HotCache[string, ChannelAffinityUsageCacheCounters] {
|
||||
return hot.NewHotCache[string, ChannelAffinityUsageCacheCounters](hot.LRU, capacity).
|
||||
WithTTL(time.Duration(defaultTTLSeconds) * time.Second).
|
||||
WithJanitor().
|
||||
Build()
|
||||
},
|
||||
})
|
||||
})
|
||||
return channelAffinityUsageCacheStatsCache
|
||||
}
|
||||
|
||||
func channelAffinityUsageCacheStatsLock(key string) *sync.Mutex {
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(key))
|
||||
idx := h.Sum32() % uint32(len(channelAffinityUsageCacheStatsLocks))
|
||||
return &channelAffinityUsageCacheStatsLocks[idx]
|
||||
}
|
||||
|
||||
@@ -231,6 +231,8 @@ func GetMimeTypeByExtension(ext string) string {
|
||||
return "image/png"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "jfif":
|
||||
return "image/jpeg"
|
||||
|
||||
// Audio files
|
||||
case "mp3":
|
||||
|
||||
@@ -73,9 +73,64 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
||||
other["admin_info"] = adminInfo
|
||||
appendRequestPath(ctx, relayInfo, other)
|
||||
appendRequestConversionChain(relayInfo, other)
|
||||
appendBillingInfo(relayInfo, other)
|
||||
return other
|
||||
}
|
||||
|
||||
func appendBillingInfo(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
}
|
||||
// billing_source: "wallet" or "subscription"
|
||||
if relayInfo.BillingSource != "" {
|
||||
other["billing_source"] = relayInfo.BillingSource
|
||||
}
|
||||
if relayInfo.UserSetting.BillingPreference != "" {
|
||||
other["billing_preference"] = relayInfo.UserSetting.BillingPreference
|
||||
}
|
||||
if relayInfo.BillingSource == "subscription" {
|
||||
if relayInfo.SubscriptionId != 0 {
|
||||
other["subscription_id"] = relayInfo.SubscriptionId
|
||||
}
|
||||
if relayInfo.SubscriptionPreConsumed > 0 {
|
||||
other["subscription_pre_consumed"] = relayInfo.SubscriptionPreConsumed
|
||||
}
|
||||
// post_delta: settlement delta applied after actual usage is known (can be negative for refund)
|
||||
if relayInfo.SubscriptionPostDelta != 0 {
|
||||
other["subscription_post_delta"] = relayInfo.SubscriptionPostDelta
|
||||
}
|
||||
if relayInfo.SubscriptionPlanId != 0 {
|
||||
other["subscription_plan_id"] = relayInfo.SubscriptionPlanId
|
||||
}
|
||||
if relayInfo.SubscriptionPlanTitle != "" {
|
||||
other["subscription_plan_title"] = relayInfo.SubscriptionPlanTitle
|
||||
}
|
||||
// Compute "this request" subscription consumed + remaining
|
||||
consumed := relayInfo.SubscriptionPreConsumed + relayInfo.SubscriptionPostDelta
|
||||
usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume + relayInfo.SubscriptionPostDelta
|
||||
if consumed < 0 {
|
||||
consumed = 0
|
||||
}
|
||||
if usedFinal < 0 {
|
||||
usedFinal = 0
|
||||
}
|
||||
if relayInfo.SubscriptionAmountTotal > 0 {
|
||||
remain := relayInfo.SubscriptionAmountTotal - usedFinal
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
other["subscription_total"] = relayInfo.SubscriptionAmountTotal
|
||||
other["subscription_used"] = usedFinal
|
||||
other["subscription_remain"] = remain
|
||||
}
|
||||
if consumed > 0 {
|
||||
other["subscription_consumed"] = consumed
|
||||
}
|
||||
// Wallet quota is not deducted when billed from subscription.
|
||||
other["wallet_quota_deducted"] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||
if relayInfo == nil || other == nil {
|
||||
return
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
@@ -15,17 +16,61 @@ import (
|
||||
)
|
||||
|
||||
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||
if relayInfo.FinalPreConsumedQuota != 0 {
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
// Always refund subscription pre-consumed (can be non-zero even when FinalPreConsumedQuota is 0)
|
||||
needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionId != 0 && relayInfo.SubscriptionPreConsumed > 0
|
||||
needRefundToken := relayInfo.FinalPreConsumedQuota != 0
|
||||
if !needRefundSub && !needRefundToken {
|
||||
return
|
||||
}
|
||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费(token_quota=%s, subscription=%d)",
|
||||
relayInfo.UserId,
|
||||
logger.FormatQuota(relayInfo.FinalPreConsumedQuota),
|
||||
relayInfo.SubscriptionPreConsumed,
|
||||
))
|
||||
gopool.Go(func() {
|
||||
relayInfoCopy := *relayInfo
|
||||
if relayInfoCopy.BillingSource == BillingSourceSubscription {
|
||||
if needRefundSub {
|
||||
if err := refundWithRetry(func() error {
|
||||
return model.RefundSubscriptionPreConsume(relayInfoCopy.RequestId)
|
||||
}); err != nil {
|
||||
common.SysLog("error refund subscription pre-consume: " + err.Error())
|
||||
}
|
||||
}
|
||||
// refund token quota only
|
||||
if needRefundToken && !relayInfoCopy.IsPlayground {
|
||||
_ = model.IncreaseTokenQuota(relayInfoCopy.TokenId, relayInfoCopy.TokenKey, relayInfoCopy.FinalPreConsumedQuota)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// wallet refund uses existing path (user quota + token quota)
|
||||
if needRefundToken {
|
||||
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||
if err != nil {
|
||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func refundWithRetry(fn func() error) error {
|
||||
if fn == nil {
|
||||
return nil
|
||||
}
|
||||
const maxAttempts = 3
|
||||
var lastErr error
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if err := fn(); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
if i < maxAttempts-1 {
|
||||
time.Sleep(time.Duration(200*(i+1)) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||
|
||||
@@ -503,13 +503,28 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
|
||||
|
||||
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) {
|
||||
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
// 1) Consume from wallet quota OR subscription item
|
||||
if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription {
|
||||
if relayInfo.SubscriptionId == 0 {
|
||||
return errors.New("subscription id is missing")
|
||||
}
|
||||
delta := int64(quota)
|
||||
if delta != 0 {
|
||||
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionId, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
relayInfo.SubscriptionPostDelta += delta
|
||||
}
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
// Wallet
|
||||
if quota > 0 {
|
||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||
} else {
|
||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !relayInfo.IsPlayground {
|
||||
|
||||
93
service/subscription_reset_task.go
Normal file
93
service/subscription_reset_task.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
"github.com/QuantumNous/new-api/logger"
|
||||
"github.com/QuantumNous/new-api/model"
|
||||
|
||||
"github.com/bytedance/gopkg/util/gopool"
|
||||
)
|
||||
|
||||
const (
|
||||
subscriptionResetTickInterval = 1 * time.Minute
|
||||
subscriptionResetBatchSize = 300
|
||||
subscriptionCleanupInterval = 30 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
subscriptionResetOnce sync.Once
|
||||
subscriptionResetRunning atomic.Bool
|
||||
subscriptionCleanupLast atomic.Int64
|
||||
)
|
||||
|
||||
func StartSubscriptionQuotaResetTask() {
|
||||
subscriptionResetOnce.Do(func() {
|
||||
if !common.IsMasterNode {
|
||||
return
|
||||
}
|
||||
gopool.Go(func() {
|
||||
logger.LogInfo(context.Background(), fmt.Sprintf("subscription quota reset task started: tick=%s", subscriptionResetTickInterval))
|
||||
ticker := time.NewTicker(subscriptionResetTickInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
runSubscriptionQuotaResetOnce()
|
||||
for range ticker.C {
|
||||
runSubscriptionQuotaResetOnce()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func runSubscriptionQuotaResetOnce() {
|
||||
if !subscriptionResetRunning.CompareAndSwap(false, true) {
|
||||
return
|
||||
}
|
||||
defer subscriptionResetRunning.Store(false)
|
||||
|
||||
ctx := context.Background()
|
||||
totalReset := 0
|
||||
totalExpired := 0
|
||||
for {
|
||||
n, err := model.ExpireDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("subscription expire task failed: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
totalExpired += n
|
||||
if n < subscriptionResetBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
for {
|
||||
n, err := model.ResetDueSubscriptions(subscriptionResetBatchSize)
|
||||
if err != nil {
|
||||
logger.LogWarn(ctx, fmt.Sprintf("subscription quota reset task failed: %v", err))
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
totalReset += n
|
||||
if n < subscriptionResetBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
lastCleanup := time.Unix(subscriptionCleanupLast.Load(), 0)
|
||||
if time.Since(lastCleanup) >= subscriptionCleanupInterval {
|
||||
if _, err := model.CleanupSubscriptionPreConsumeRecords(7 * 24 * 3600); err == nil {
|
||||
subscriptionCleanupLast.Store(time.Now().Unix())
|
||||
}
|
||||
}
|
||||
if common.DebugEnabled && (totalReset > 0 || totalExpired > 0) {
|
||||
logger.LogDebug(ctx, "subscription maintenance: reset_count=%d, expired_count=%d", totalReset, totalExpired)
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ type ChannelAffinityRule struct {
|
||||
ValueRegex string `json:"value_regex"`
|
||||
TTLSeconds int `json:"ttl_seconds"`
|
||||
|
||||
SkipRetryOnFailure bool `json:"skip_retry_on_failure,omitempty"`
|
||||
|
||||
IncludeUsingGroup bool `json:"include_using_group"`
|
||||
IncludeRuleName bool `json:"include_rule_name"`
|
||||
}
|
||||
@@ -31,11 +33,40 @@ type ChannelAffinitySetting struct {
|
||||
}
|
||||
|
||||
var channelAffinitySetting = ChannelAffinitySetting{
|
||||
Enabled: false,
|
||||
Enabled: true,
|
||||
SwitchOnSuccess: true,
|
||||
MaxEntries: 100_000,
|
||||
DefaultTTLSeconds: 3600,
|
||||
Rules: []ChannelAffinityRule{},
|
||||
Rules: []ChannelAffinityRule{
|
||||
{
|
||||
Name: "codex trace",
|
||||
ModelRegex: []string{"^gpt-.*$"},
|
||||
PathRegex: []string{"/v1/responses"},
|
||||
KeySources: []ChannelAffinityKeySource{
|
||||
{Type: "gjson", Path: "prompt_cache_key"},
|
||||
},
|
||||
ValueRegex: "",
|
||||
TTLSeconds: 0,
|
||||
SkipRetryOnFailure: false,
|
||||
IncludeUsingGroup: true,
|
||||
IncludeRuleName: true,
|
||||
UserAgentInclude: nil,
|
||||
},
|
||||
{
|
||||
Name: "claude code trace",
|
||||
ModelRegex: []string{"^claude-.*$"},
|
||||
PathRegex: []string{"/v1/messages"},
|
||||
KeySources: []ChannelAffinityKeySource{
|
||||
{Type: "gjson", Path: "metadata.user_id"},
|
||||
},
|
||||
ValueRegex: "",
|
||||
TTLSeconds: 0,
|
||||
SkipRetryOnFailure: false,
|
||||
IncludeUsingGroup: true,
|
||||
IncludeRuleName: true,
|
||||
UserAgentInclude: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
)
|
||||
|
||||
var defaultCacheRatio = map[string]float64{
|
||||
"gemini-3-flash-preview": 0.25,
|
||||
"gemini-3-pro-preview": 0.25,
|
||||
"gpt-4": 0.5,
|
||||
"o1": 0.5,
|
||||
"o1-2024-12-17": 0.5,
|
||||
|
||||
@@ -21,77 +21,66 @@ import { defineConfig } from 'i18next-cli';
|
||||
|
||||
/** @type {import('i18next-cli').I18nextToolkitConfig} */
|
||||
export default defineConfig({
|
||||
locales: [
|
||||
"zh",
|
||||
"en",
|
||||
"fr",
|
||||
"ru",
|
||||
"ja",
|
||||
"vi"
|
||||
],
|
||||
locales: ['zh', 'en', 'fr', 'ru', 'ja', 'vi'],
|
||||
extract: {
|
||||
input: [
|
||||
"src/**/*.{js,jsx,ts,tsx}"
|
||||
],
|
||||
ignore: [
|
||||
"src/i18n/**/*"
|
||||
],
|
||||
output: "src/i18n/locales/{{language}}.json",
|
||||
input: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
ignore: ['src/i18n/**/*'],
|
||||
output: 'src/i18n/locales/{{language}}.json',
|
||||
ignoredAttributes: [
|
||||
"accept",
|
||||
"align",
|
||||
"aria-label",
|
||||
"autoComplete",
|
||||
"className",
|
||||
"clipRule",
|
||||
"color",
|
||||
"crossOrigin",
|
||||
"data-index",
|
||||
"data-name",
|
||||
"data-testid",
|
||||
"data-type",
|
||||
"defaultActiveKey",
|
||||
"direction",
|
||||
"editorType",
|
||||
"field",
|
||||
"fill",
|
||||
"fillRule",
|
||||
"height",
|
||||
"hoverStyle",
|
||||
"htmlType",
|
||||
"id",
|
||||
"itemKey",
|
||||
"key",
|
||||
"keyPrefix",
|
||||
"layout",
|
||||
"margin",
|
||||
"maxHeight",
|
||||
"mode",
|
||||
"name",
|
||||
"overflow",
|
||||
"placement",
|
||||
"position",
|
||||
"rel",
|
||||
"role",
|
||||
"rowKey",
|
||||
"searchPosition",
|
||||
"selectedStyle",
|
||||
"shape",
|
||||
"size",
|
||||
"style",
|
||||
"theme",
|
||||
"trigger",
|
||||
"uploadTrigger",
|
||||
"validateStatus",
|
||||
"value",
|
||||
"viewBox",
|
||||
"width"
|
||||
'accept',
|
||||
'align',
|
||||
'aria-label',
|
||||
'autoComplete',
|
||||
'className',
|
||||
'clipRule',
|
||||
'color',
|
||||
'crossOrigin',
|
||||
'data-index',
|
||||
'data-name',
|
||||
'data-testid',
|
||||
'data-type',
|
||||
'defaultActiveKey',
|
||||
'direction',
|
||||
'editorType',
|
||||
'field',
|
||||
'fill',
|
||||
'fillRule',
|
||||
'height',
|
||||
'hoverStyle',
|
||||
'htmlType',
|
||||
'id',
|
||||
'itemKey',
|
||||
'key',
|
||||
'keyPrefix',
|
||||
'layout',
|
||||
'margin',
|
||||
'maxHeight',
|
||||
'mode',
|
||||
'name',
|
||||
'overflow',
|
||||
'placement',
|
||||
'position',
|
||||
'rel',
|
||||
'role',
|
||||
'rowKey',
|
||||
'searchPosition',
|
||||
'selectedStyle',
|
||||
'shape',
|
||||
'size',
|
||||
'style',
|
||||
'theme',
|
||||
'trigger',
|
||||
'uploadTrigger',
|
||||
'validateStatus',
|
||||
'value',
|
||||
'viewBox',
|
||||
'width',
|
||||
],
|
||||
sort: true,
|
||||
disablePlurals: false,
|
||||
removeUnusedKeys: false,
|
||||
nsSeparator: false,
|
||||
keySeparator: false,
|
||||
mergeNamespaces: true
|
||||
}
|
||||
});
|
||||
mergeNamespaces: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,7 @@ import Task from './pages/Task';
|
||||
import ModelPage from './pages/Model';
|
||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||
import Playground from './pages/Playground';
|
||||
import Subscription from './pages/Subscription';
|
||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||
import PersonalSetting from './components/settings/PersonalSetting';
|
||||
import Setup from './pages/Setup';
|
||||
@@ -117,6 +118,14 @@ function App() {
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/subscription'
|
||||
element={
|
||||
<AdminRoute>
|
||||
<Subscription />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/console/channel'
|
||||
element={
|
||||
|
||||
@@ -39,7 +39,15 @@ import {
|
||||
isPasskeySupported,
|
||||
} from '../../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
import { Button, Card, Checkbox, Divider, Form, Icon, Modal } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Form,
|
||||
Icon,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import Title from '@douyinfe/semi-ui/lib/es/typography/title';
|
||||
import Text from '@douyinfe/semi-ui/lib/es/typography/text';
|
||||
import TelegramLoginButton from 'react-telegram-login';
|
||||
@@ -55,7 +63,7 @@ import WeChatIcon from '../common/logo/WeChatIcon';
|
||||
import LinuxDoIcon from '../common/logo/LinuxDoIcon';
|
||||
import TwoFAVerification from './TwoFAVerification';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SiDiscord }from 'react-icons/si';
|
||||
import { SiDiscord } from 'react-icons/si';
|
||||
|
||||
const LoginForm = () => {
|
||||
let navigate = useNavigate();
|
||||
@@ -126,7 +134,7 @@ const LoginForm = () => {
|
||||
setTurnstileEnabled(true);
|
||||
setTurnstileSiteKey(status.turnstile_site_key);
|
||||
}
|
||||
|
||||
|
||||
// 从 status 获取用户协议和隐私政策的启用状态
|
||||
setHasUserAgreement(status?.user_agreement_enabled || false);
|
||||
setHasPrivacyPolicy(status?.privacy_policy_enabled || false);
|
||||
@@ -514,7 +522,15 @@ const LoginForm = () => {
|
||||
theme='outline'
|
||||
className='w-full h-12 flex items-center justify-center !rounded-full border border-gray-200 hover:bg-gray-50 transition-colors'
|
||||
type='tertiary'
|
||||
icon={<SiDiscord style={{ color: '#5865F2', width: '20px', height: '20px' }} />}
|
||||
icon={
|
||||
<SiDiscord
|
||||
style={{
|
||||
color: '#5865F2',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={handleDiscordClick}
|
||||
loading={discordLoading}
|
||||
>
|
||||
@@ -626,11 +642,11 @@ const LoginForm = () => {
|
||||
{t('隐私政策')}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!status.self_use_mode_enabled && (
|
||||
<div className='mt-6 text-center text-sm'>
|
||||
@@ -746,7 +762,9 @@ const LoginForm = () => {
|
||||
htmlType='submit'
|
||||
onClick={handleSubmit}
|
||||
loading={loginLoading}
|
||||
disabled={(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms}
|
||||
disabled={
|
||||
(hasUserAgreement || hasPrivacyPolicy) && !agreedToTerms
|
||||
}
|
||||
>
|
||||
{t('继续')}
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,7 @@ const isUrl = (content) => {
|
||||
// 检查是否为 HTML 内容
|
||||
const isHtmlContent = (content) => {
|
||||
if (!content || typeof content !== 'string') return false;
|
||||
|
||||
|
||||
// 检查是否包含HTML标签
|
||||
const htmlTagRegex = /<\/?[a-z][\s\S]*>/i;
|
||||
return htmlTagRegex.test(content);
|
||||
@@ -52,16 +52,16 @@ const sanitizeHtml = (html) => {
|
||||
// 创建一个临时元素来解析HTML
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = html;
|
||||
|
||||
|
||||
// 提取样式
|
||||
const styles = Array.from(tempDiv.querySelectorAll('style'))
|
||||
.map(style => style.innerHTML)
|
||||
.map((style) => style.innerHTML)
|
||||
.join('\n');
|
||||
|
||||
|
||||
// 提取body内容,如果没有body标签则使用全部内容
|
||||
const bodyContent = tempDiv.querySelector('body');
|
||||
const content = bodyContent ? bodyContent.innerHTML : html;
|
||||
|
||||
|
||||
return { content, styles };
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 处理HTML样式注入
|
||||
useEffect(() => {
|
||||
const styleId = `document-renderer-styles-${cacheKey}`;
|
||||
|
||||
|
||||
if (htmlStyles) {
|
||||
let styleEl = document.getElementById(styleId);
|
||||
if (!styleEl) {
|
||||
@@ -165,8 +165,12 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50'>
|
||||
<Empty
|
||||
title={t('管理员未设置' + title + '内容')}
|
||||
image={<IllustrationConstruction style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 150, height: 150 }} />}
|
||||
image={
|
||||
<IllustrationConstruction style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationConstructionDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
className='p-8'
|
||||
/>
|
||||
</div>
|
||||
@@ -179,7 +183,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
<div className='flex justify-center items-center min-h-screen bg-gray-50 p-4'>
|
||||
<Card className='max-w-md w-full'>
|
||||
<div className='text-center'>
|
||||
<Title heading={4} className='mb-4'>{title}</Title>
|
||||
<Title heading={4} className='mb-4'>
|
||||
{title}
|
||||
</Title>
|
||||
<p className='text-gray-600 mb-4'>
|
||||
{t('管理员设置了外部链接,点击下方按钮访问')}
|
||||
</p>
|
||||
@@ -202,20 +208,22 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
// 如果是 HTML 内容,直接渲染
|
||||
if (isHtmlContent(content)) {
|
||||
const { content: htmlContent, styles } = sanitizeHtml(content);
|
||||
|
||||
|
||||
// 设置样式(如果有的话)
|
||||
useEffect(() => {
|
||||
if (styles && styles !== htmlStyles) {
|
||||
setHtmlStyles(styles);
|
||||
}
|
||||
}, [content, styles, htmlStyles]);
|
||||
|
||||
|
||||
return (
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<div
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
{title}
|
||||
</Title>
|
||||
<div
|
||||
className='prose prose-lg max-w-none'
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
@@ -230,7 +238,9 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
<div className='min-h-screen bg-gray-50'>
|
||||
<div className='max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8'>
|
||||
<div className='bg-white rounded-lg shadow-sm p-8'>
|
||||
<Title heading={2} className='text-center mb-8'>{title}</Title>
|
||||
<Title heading={2} className='text-center mb-8'>
|
||||
{title}
|
||||
</Title>
|
||||
<div className='prose prose-lg max-w-none'>
|
||||
<MarkdownRenderer content={content} />
|
||||
</div>
|
||||
@@ -240,4 +250,4 @@ const DocumentRenderer = ({ apiEndpoint, title, cacheKey, emptyMessage }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentRenderer;
|
||||
export default DocumentRenderer;
|
||||
|
||||
@@ -37,6 +37,7 @@ const routerMap = {
|
||||
redemption: '/console/redemption',
|
||||
topup: '/console/topup',
|
||||
user: '/console/user',
|
||||
subscription: '/console/subscription',
|
||||
log: '/console/log',
|
||||
midjourney: '/console/midjourney',
|
||||
setting: '/console/setting',
|
||||
@@ -152,6 +153,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
||||
to: '/channel',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('订阅管理'),
|
||||
itemKey: 'subscription',
|
||||
to: '/subscription',
|
||||
className: isAdmin() ? '' : 'tableHiddle',
|
||||
},
|
||||
{
|
||||
text: t('模型管理'),
|
||||
itemKey: 'models',
|
||||
|
||||
@@ -136,9 +136,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
style={{ width, height, borderRadius: 9999 }}
|
||||
/>
|
||||
<Skeleton.Title style={{ width, height, borderRadius: 9999 }} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +184,9 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title style={{ width: width || 60, height: height || 12 }} />
|
||||
<Skeleton.Title
|
||||
style={{ width: width || 60, height: height || 12 }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -221,9 +221,7 @@ const SkeletonWrapper = ({
|
||||
loading={true}
|
||||
active
|
||||
placeholder={
|
||||
<Skeleton.Title
|
||||
style={{ width: labelWidth, height: TEXT_HEIGHT }}
|
||||
/>
|
||||
<Skeleton.Title style={{ width: labelWidth, height: TEXT_HEIGHT }} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -106,6 +106,21 @@ const highlightJson = (str) => {
|
||||
);
|
||||
};
|
||||
|
||||
const linkRegex = /(https?:\/\/[^\s<"'\]),;}]+)/g;
|
||||
|
||||
const linkifyHtml = (html) => {
|
||||
const parts = html.split(/(<[^>]+>)/g);
|
||||
return parts
|
||||
.map((part) => {
|
||||
if (part.startsWith('<')) return part;
|
||||
return part.replace(
|
||||
linkRegex,
|
||||
(url) => `<a href="${url}" target="_blank" rel="noreferrer">${url}</a>`,
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
};
|
||||
|
||||
const isJsonLike = (content, language) => {
|
||||
if (language === 'json') return true;
|
||||
const trimmed = content.trim();
|
||||
@@ -179,6 +194,10 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
return displayContent;
|
||||
}, [displayContent, language, contentMetrics.isVeryLarge, isExpanded]);
|
||||
|
||||
const renderedContent = useMemo(() => {
|
||||
return linkifyHtml(highlightedContent);
|
||||
}, [highlightedContent]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
const textToCopy =
|
||||
@@ -276,6 +295,8 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
style={{
|
||||
...codeThemeStyles.content,
|
||||
paddingTop: contentPadding,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
className='model-settings-scroll'
|
||||
>
|
||||
@@ -303,7 +324,7 @@ const CodeViewer = ({ content, title, language = 'json' }) => {
|
||||
{t('正在处理大内容...')}
|
||||
</div>
|
||||
) : (
|
||||
<div dangerouslySetInnerHTML={{ __html: highlightedContent }} />
|
||||
<div dangerouslySetInnerHTML={{ __html: renderedContent }} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -30,64 +30,67 @@ const CustomInputRender = (props) => {
|
||||
detailProps;
|
||||
const containerRef = useRef(null);
|
||||
|
||||
const handlePaste = useCallback(async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const handlePaste = useCallback(
|
||||
async (e) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
if (!imageEnabled) {
|
||||
Toast.warning({
|
||||
content: t('请先在设置中启用图片功能'),
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target.result;
|
||||
|
||||
if (onPasteImage) {
|
||||
onPasteImage(base64);
|
||||
Toast.success({
|
||||
content: t('图片已添加'),
|
||||
duration: 2,
|
||||
});
|
||||
} else {
|
||||
Toast.error({
|
||||
content: t('无法添加图片'),
|
||||
duration: 2,
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
e.preventDefault();
|
||||
const file = item.getAsFile();
|
||||
|
||||
if (file) {
|
||||
try {
|
||||
if (!imageEnabled) {
|
||||
Toast.warning({
|
||||
content: t('请先在设置中启用图片功能'),
|
||||
duration: 3,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read image file:', reader.error);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const base64 = event.target.result;
|
||||
|
||||
if (onPasteImage) {
|
||||
onPasteImage(base64);
|
||||
Toast.success({
|
||||
content: t('图片已添加'),
|
||||
duration: 2,
|
||||
});
|
||||
} else {
|
||||
Toast.error({
|
||||
content: t('无法添加图片'),
|
||||
duration: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('Failed to read image file:', reader.error);
|
||||
Toast.error({
|
||||
content: t('粘贴图片失败'),
|
||||
duration: 2,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Failed to paste image:', error);
|
||||
Toast.error({
|
||||
content: t('粘贴图片失败'),
|
||||
duration: 2,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('Failed to paste image:', error);
|
||||
Toast.error({
|
||||
content: t('粘贴图片失败'),
|
||||
duration: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [onPasteImage, imageEnabled, t]);
|
||||
},
|
||||
[onPasteImage, imageEnabled, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
@@ -140,7 +140,9 @@ const CustomRequestEditor = ({
|
||||
{/* 提示信息 */}
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。')}
|
||||
description={t(
|
||||
'启用此模式后,将使用您自定义的请求体发送API请求,模型配置面板的参数设置将被忽略。',
|
||||
)}
|
||||
icon={<AlertTriangle size={16} />}
|
||||
className='!rounded-lg'
|
||||
closeIcon={null}
|
||||
@@ -201,7 +203,9 @@ const CustomRequestEditor = ({
|
||||
)}
|
||||
|
||||
<Typography.Text className='text-xs text-gray-500 mt-2 block'>
|
||||
{t('请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。')}
|
||||
{t(
|
||||
'请输入有效的JSON格式的请求体。您可以参考预览面板中的默认请求体格式。',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -191,10 +191,7 @@ const DebugPanel = ({
|
||||
itemKey='response'
|
||||
>
|
||||
{debugData.sseMessages && debugData.sseMessages.length > 0 ? (
|
||||
<SSEViewer
|
||||
sseData={debugData.sseMessages}
|
||||
title='response'
|
||||
/>
|
||||
<SSEViewer sseData={debugData.sseMessages} title='response' />
|
||||
) : (
|
||||
<CodeViewer
|
||||
content={debugData.response}
|
||||
|
||||
@@ -18,8 +18,22 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Button, Tooltip, Toast, Collapse, Badge, Typography } from '@douyinfe/semi-ui';
|
||||
import { Copy, ChevronDown, ChevronUp, Zap, CheckCircle, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Toast,
|
||||
Collapse,
|
||||
Badge,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { copy } from '../../helpers';
|
||||
|
||||
@@ -67,19 +81,19 @@ const SSEViewer = ({ sseData }) => {
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = parsedSSEData.length;
|
||||
const errors = parsedSSEData.filter(item => item.error).length;
|
||||
const done = parsedSSEData.filter(item => item.isDone).length;
|
||||
const errors = parsedSSEData.filter((item) => item.error).length;
|
||||
const done = parsedSSEData.filter((item) => item.isDone).length;
|
||||
const valid = total - errors - done;
|
||||
|
||||
return { total, errors, done, valid };
|
||||
}, [parsedSSEData]);
|
||||
|
||||
const handleToggleAll = useCallback(() => {
|
||||
setExpandedKeys(prev => {
|
||||
setExpandedKeys((prev) => {
|
||||
if (prev.length === parsedSSEData.length) {
|
||||
return [];
|
||||
} else {
|
||||
return parsedSSEData.map(item => item.key);
|
||||
return parsedSSEData.map((item) => item.key);
|
||||
}
|
||||
});
|
||||
}, [parsedSSEData]);
|
||||
@@ -87,7 +101,9 @@ const SSEViewer = ({ sseData }) => {
|
||||
const handleCopyAll = useCallback(async () => {
|
||||
try {
|
||||
const allData = parsedSSEData
|
||||
.map(item => (item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw))
|
||||
.map((item) =>
|
||||
item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw,
|
||||
)
|
||||
.join('\n\n');
|
||||
|
||||
await copy(allData);
|
||||
@@ -100,15 +116,20 @@ const SSEViewer = ({ sseData }) => {
|
||||
}
|
||||
}, [parsedSSEData, t]);
|
||||
|
||||
const handleCopySingle = useCallback(async (item) => {
|
||||
try {
|
||||
const textToCopy = item.parsed ? JSON.stringify(item.parsed, null, 2) : item.raw;
|
||||
await copy(textToCopy);
|
||||
Toast.success(t('已复制'));
|
||||
} catch (err) {
|
||||
Toast.error(t('复制失败'));
|
||||
}
|
||||
}, [t]);
|
||||
const handleCopySingle = useCallback(
|
||||
async (item) => {
|
||||
try {
|
||||
const textToCopy = item.parsed
|
||||
? JSON.stringify(item.parsed, null, 2)
|
||||
: item.raw;
|
||||
await copy(textToCopy);
|
||||
Toast.success(t('已复制'));
|
||||
} catch (err) {
|
||||
Toast.error(t('复制失败'));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const renderSSEItem = (item) => {
|
||||
if (item.isDone) {
|
||||
@@ -158,18 +179,24 @@ const SSEViewer = ({ sseData }) => {
|
||||
{item.parsed?.choices?.[0] && (
|
||||
<div className='flex flex-wrap gap-2 text-xs'>
|
||||
{item.parsed.choices[0].delta?.content && (
|
||||
<Badge count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`} type='primary' />
|
||||
<Badge
|
||||
count={`${t('内容')}: "${String(item.parsed.choices[0].delta.content).substring(0, 20)}..."`}
|
||||
type='primary'
|
||||
/>
|
||||
)}
|
||||
{item.parsed.choices[0].delta?.reasoning_content && (
|
||||
<Badge count={t('有 Reasoning')} type='warning' />
|
||||
)}
|
||||
{item.parsed.choices[0].finish_reason && (
|
||||
<Badge count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`} type='success' />
|
||||
<Badge
|
||||
count={`${t('完成')}: ${item.parsed.choices[0].finish_reason}`}
|
||||
type='success'
|
||||
/>
|
||||
)}
|
||||
{item.parsed.usage && (
|
||||
<Badge
|
||||
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
||||
type='tertiary'
|
||||
<Badge
|
||||
count={`${t('令牌')}: ${item.parsed.usage.prompt_tokens || 0}/${item.parsed.usage.completion_tokens || 0}`}
|
||||
type='tertiary'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -194,7 +221,9 @@ const SSEViewer = ({ sseData }) => {
|
||||
<Zap size={16} className='text-blue-500' />
|
||||
<Typography.Text strong>{t('SSE数据流')}</Typography.Text>
|
||||
<Badge count={stats.total} type='primary' />
|
||||
{stats.errors > 0 && <Badge count={`${stats.errors} ${t('错误')}`} type='danger' />}
|
||||
{stats.errors > 0 && (
|
||||
<Badge count={`${stats.errors} ${t('错误')}`} type='danger' />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
@@ -208,14 +237,28 @@ const SSEViewer = ({ sseData }) => {
|
||||
{copied ? t('已复制') : t('复制全部')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={expandedKeys.length === parsedSSEData.length ? t('全部收起') : t('全部展开')}>
|
||||
<Tooltip
|
||||
content={
|
||||
expandedKeys.length === parsedSSEData.length
|
||||
? t('全部收起')
|
||||
: t('全部展开')
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={expandedKeys.length === parsedSSEData.length ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
icon={
|
||||
expandedKeys.length === parsedSSEData.length ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)
|
||||
}
|
||||
size='small'
|
||||
onClick={handleToggleAll}
|
||||
theme='borderless'
|
||||
>
|
||||
{expandedKeys.length === parsedSSEData.length ? t('收起') : t('展开')}
|
||||
{expandedKeys.length === parsedSSEData.length
|
||||
? t('收起')
|
||||
: t('展开')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -242,11 +285,16 @@ const SSEViewer = ({ sseData }) => {
|
||||
) : (
|
||||
<>
|
||||
<span className='text-gray-600'>
|
||||
{item.parsed?.id || item.parsed?.object || t('SSE 事件')}
|
||||
{item.parsed?.id ||
|
||||
item.parsed?.object ||
|
||||
t('SSE 事件')}
|
||||
</span>
|
||||
{item.parsed?.choices?.[0]?.delta && (
|
||||
<span className='text-xs text-gray-400'>
|
||||
• {Object.keys(item.parsed.choices[0].delta).filter(k => item.parsed.choices[0].delta[k]).join(', ')}
|
||||
•{' '}
|
||||
{Object.keys(item.parsed.choices[0].delta)
|
||||
.filter((k) => item.parsed.choices[0].delta[k])
|
||||
.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -68,4 +68,3 @@ export default function HttpStatusCodeRulesInput(props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const ModelDeploymentSetting = () => {
|
||||
'model_deployment.ionet.api_key': '',
|
||||
'model_deployment.ionet.enabled': false,
|
||||
};
|
||||
|
||||
|
||||
data.forEach((item) => {
|
||||
if (item.key.endsWith('Enabled') || item.key.endsWith('enabled')) {
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
@@ -82,4 +82,4 @@ const ModelDeploymentSetting = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelDeploymentSetting;
|
||||
export default ModelDeploymentSetting;
|
||||
|
||||
@@ -71,7 +71,8 @@ const OperationSetting = () => {
|
||||
AutomaticEnableChannelEnabled: false,
|
||||
AutomaticDisableKeywords: '',
|
||||
AutomaticDisableStatusCodes: '401',
|
||||
AutomaticRetryStatusCodes: '100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
AutomaticRetryStatusCodes:
|
||||
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
|
||||
'monitor_setting.auto_test_channel_enabled': false,
|
||||
'monitor_setting.auto_test_channel_minutes': 10 /* 签到设置 */,
|
||||
'checkin_setting.enabled': false,
|
||||
|
||||
@@ -378,13 +378,15 @@ const OtherSetting = () => {
|
||||
<Form.TextArea
|
||||
label={t('用户协议')}
|
||||
placeholder={t(
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
'在此输入用户协议内容,支持 Markdown & HTML 代码',
|
||||
)}
|
||||
field={LEGAL_USER_AGREEMENT_KEY}
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议')}
|
||||
helpText={t(
|
||||
'填写用户协议内容后,用户注册时将被要求勾选已阅读用户协议',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitUserAgreement}
|
||||
@@ -401,7 +403,9 @@ const OtherSetting = () => {
|
||||
onChange={handleInputChange}
|
||||
style={{ fontFamily: 'JetBrains Mono, Consolas' }}
|
||||
autosize={{ minRows: 6, maxRows: 12 }}
|
||||
helpText={t('填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策')}
|
||||
helpText={t(
|
||||
'填写隐私政策内容后,用户注册时将被要求勾选已阅读隐私政策',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={submitPrivacyPolicy}
|
||||
|
||||
@@ -57,9 +57,7 @@ const RatioSetting = () => {
|
||||
if (success) {
|
||||
let newInputs = {};
|
||||
data.forEach((item) => {
|
||||
if (
|
||||
item.value.startsWith('{') || item.value.startsWith('[')
|
||||
) {
|
||||
if (item.value.startsWith('{') || item.value.startsWith('[')) {
|
||||
try {
|
||||
item.value = JSON.stringify(JSON.parse(item.value), null, 2);
|
||||
} catch (e) {
|
||||
|
||||
@@ -481,10 +481,14 @@ const SystemSetting = () => {
|
||||
const options = [];
|
||||
|
||||
if (originInputs['discord.client_id'] !== inputs['discord.client_id']) {
|
||||
options.push({ key: 'discord.client_id', value: inputs['discord.client_id'] });
|
||||
options.push({
|
||||
key: 'discord.client_id',
|
||||
value: inputs['discord.client_id'],
|
||||
});
|
||||
}
|
||||
if (
|
||||
originInputs['discord.client_secret'] !== inputs['discord.client_secret'] &&
|
||||
originInputs['discord.client_secret'] !==
|
||||
inputs['discord.client_secret'] &&
|
||||
inputs['discord.client_secret'] !== ''
|
||||
) {
|
||||
options.push({
|
||||
@@ -745,8 +749,8 @@ const SystemSetting = () => {
|
||||
rel='noreferrer'
|
||||
>
|
||||
new-api-worker
|
||||
</a>
|
||||
{' '}{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</a>{' '}
|
||||
{t('或其兼容new-api-worker格式的其他版本')}
|
||||
</Text>
|
||||
<Row
|
||||
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
|
||||
|
||||
@@ -109,7 +109,9 @@ const renderType = (type, record = {}, t) => {
|
||||
<Tooltip
|
||||
content={
|
||||
<div className='max-w-xs'>
|
||||
<div className='text-xs text-gray-600'>{t('来源于 IO.NET 部署')}</div>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('来源于 IO.NET 部署')}
|
||||
</div>
|
||||
{ionetMeta?.deployment_id && (
|
||||
<div className='text-xs text-gray-500 mt-1'>
|
||||
{t('部署 ID')}: {ionetMeta.deployment_id}
|
||||
|
||||
@@ -19,7 +19,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal, Button, Space, Typography, Input, Banner } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Input,
|
||||
Banner,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, copy, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -33,14 +40,21 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
const startOAuth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/channel/codex/oauth/start', {}, { skipErrorHandler: true });
|
||||
const res = await API.post(
|
||||
'/api/channel/codex/oauth/start',
|
||||
{},
|
||||
{ skipErrorHandler: true },
|
||||
);
|
||||
if (!res?.data?.success) {
|
||||
console.error('Codex OAuth start failed:', res?.data?.message);
|
||||
throw new Error(t('启动授权失败'));
|
||||
}
|
||||
const url = res?.data?.data?.authorize_url || '';
|
||||
if (!url) {
|
||||
console.error('Codex OAuth start response missing authorize_url:', res?.data);
|
||||
console.error(
|
||||
'Codex OAuth start response missing authorize_url:',
|
||||
res?.data,
|
||||
);
|
||||
throw new Error(t('响应缺少授权链接'));
|
||||
}
|
||||
setAuthorizeUrl(url);
|
||||
@@ -106,7 +120,12 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
<Button theme='borderless' onClick={onCancel} disabled={loading}>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
<Button theme='solid' type='primary' onClick={completeOAuth} loading={loading}>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={completeOAuth}
|
||||
loading={loading}
|
||||
>
|
||||
{t('生成并填入')}
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -141,7 +160,9 @@ const CodexOAuthModal = ({ visible, onCancel, onSuccess }) => {
|
||||
/>
|
||||
|
||||
<Text type='tertiary' size='small'>
|
||||
{t('说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。')}
|
||||
{t(
|
||||
'说明:生成结果是可直接粘贴到渠道密钥里的 JSON(包含 access_token / refresh_token / account_id)。',
|
||||
)}
|
||||
</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
@@ -18,7 +18,14 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Modal, Button, Progress, Tag, Typography, Spin } from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Modal,
|
||||
Button,
|
||||
Progress,
|
||||
Tag,
|
||||
Typography,
|
||||
Spin,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -134,7 +141,12 @@ const CodexUsageView = ({ t, record, payload, onCopy, onRefresh }) => {
|
||||
</Text>
|
||||
<div className='flex items-center gap-2'>
|
||||
{statusTag}
|
||||
<Button size='small' type='tertiary' theme='borderless' onClick={onRefresh}>
|
||||
<Button
|
||||
size='small'
|
||||
type='tertiary'
|
||||
theme='borderless'
|
||||
onClick={onRefresh}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -243,7 +255,12 @@ const CodexUsageLoader = ({ t, record, initialPayload, onCopy }) => {
|
||||
<div className='flex flex-col gap-3'>
|
||||
<Text type='danger'>{tt('获取用量失败')}</Text>
|
||||
<div className='flex justify-end'>
|
||||
<Button size='small' type='primary' theme='outline' onClick={fetchUsage}>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={fetchUsage}
|
||||
>
|
||||
{tt('刷新')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2000,171 +2000,180 @@ const EditChannelModal = (props) => {
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
<Text type='warning' size='small'>
|
||||
{t(
|
||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||
)}
|
||||
</Text>
|
||||
extraText={
|
||||
<div className='flex items-center gap-2 flex-wrap'>
|
||||
{isEdit &&
|
||||
isMultiKeyChannel &&
|
||||
keyMode === 'append' && (
|
||||
<Text type='warning' size='small'>
|
||||
{t(
|
||||
'追加模式:新密钥将添加到现有密钥列表的末尾',
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 57 ? (
|
||||
<>
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={
|
||||
isEdit
|
||||
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
||||
: t('密钥')
|
||||
}
|
||||
placeholder={t(
|
||||
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
||||
)}
|
||||
rules={
|
||||
isEdit
|
||||
? []
|
||||
: [{ required: true, message: t('请输入密钥') }]
|
||||
}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) => handleInputChange('key', value)}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
|
||||
)}
|
||||
</Text>
|
||||
{batchExtra}
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{inputs.type === 57 ? (
|
||||
<>
|
||||
<Form.TextArea
|
||||
field='key'
|
||||
label={
|
||||
isEdit
|
||||
? t('密钥(编辑模式下,保存的密钥不会显示)')
|
||||
: t('密钥')
|
||||
}
|
||||
placeholder={t(
|
||||
'请输入 JSON 格式的 OAuth 凭据,例如:\n{\n "access_token": "...",\n "account_id": "..." \n}',
|
||||
)}
|
||||
rules={
|
||||
isEdit
|
||||
? []
|
||||
: [
|
||||
{
|
||||
required: true,
|
||||
message: t('请输入密钥'),
|
||||
},
|
||||
]
|
||||
}
|
||||
autoComplete='new-password'
|
||||
onChange={(value) =>
|
||||
handleInputChange('key', value)
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
extraText={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'仅支持 JSON 对象,必须包含 access_token 与 account_id',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Space wrap spacing='tight'>
|
||||
<Space wrap spacing='tight'>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('格式化')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
/>
|
||||
|
||||
<CodexOAuthModal
|
||||
visible={codexOAuthModalVisible}
|
||||
onCancel={() => setCodexOAuthModalVisible(false)}
|
||||
onSuccess={handleCodexOAuthGenerated}
|
||||
/>
|
||||
</>
|
||||
) : inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('密钥输入方式')}
|
||||
</Text>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() =>
|
||||
setCodexOAuthModalVisible(true)
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
disabled={isIonetLocked}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('Codex 授权')}
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleRefreshCodexCredential}
|
||||
loading={codexCredentialRefreshing}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('刷新凭证')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={() => formatJsonField('key')}
|
||||
disabled={isIonetLocked}
|
||||
type={
|
||||
useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('格式化')}
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
{isEdit && (
|
||||
<Button
|
||||
size='small'
|
||||
type='primary'
|
||||
theme='outline'
|
||||
onClick={handleShow2FAModal}
|
||||
disabled={isIonetLocked}
|
||||
>
|
||||
{t('查看密钥')}
|
||||
</Button>
|
||||
)}
|
||||
{batchExtra}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
autosize
|
||||
showClear
|
||||
/>
|
||||
|
||||
<CodexOAuthModal
|
||||
visible={codexOAuthModalVisible}
|
||||
onCancel={() => setCodexOAuthModalVisible(false)}
|
||||
onSuccess={handleCodexOAuthGenerated}
|
||||
/>
|
||||
</>
|
||||
) : inputs.type === 41 &&
|
||||
(inputs.vertex_key_type || 'json') === 'json' ? (
|
||||
<>
|
||||
{!batch && (
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Text className='text-sm font-medium'>
|
||||
{t('密钥输入方式')}
|
||||
</Text>
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type={
|
||||
!useManualInput ? 'primary' : 'tertiary'
|
||||
}
|
||||
onClick={() => {
|
||||
setUseManualInput(false);
|
||||
// 切换到文件上传模式时清空手动输入的密钥
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue('key', '');
|
||||
}
|
||||
handleInputChange('key', '');
|
||||
}}
|
||||
>
|
||||
{t('文件上传')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type={useManualInput ? 'primary' : 'tertiary'}
|
||||
onClick={() => {
|
||||
setUseManualInput(true);
|
||||
// 切换到手动输入模式时清空文件上传相关状态
|
||||
setVertexKeys([]);
|
||||
setVertexFileList([]);
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValue(
|
||||
'vertex_files',
|
||||
[],
|
||||
);
|
||||
}
|
||||
setInputs((prev) => ({
|
||||
...prev,
|
||||
vertex_files: [],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{t('手动输入')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{batch && (
|
||||
<Banner
|
||||
|
||||
@@ -533,7 +533,11 @@ const EditTagModal = (props) => {
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
|
||||
{/* Header: Advanced Settings */}
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconSetting size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
@@ -549,9 +553,7 @@ const EditTagModal = (props) => {
|
||||
field='param_override'
|
||||
label={t('参数覆盖')}
|
||||
placeholder={
|
||||
t(
|
||||
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
|
||||
) +
|
||||
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
|
||||
'\n' +
|
||||
t('旧格式(直接覆盖):') +
|
||||
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
|
||||
|
||||
@@ -104,7 +104,9 @@ const ModelSelectModal = ({
|
||||
}, [normalizedRedirectModels, normalizedSelectedSet]);
|
||||
|
||||
const filteredModels = models.filter((m) =>
|
||||
String(m || '').toLowerCase().includes(keyword.toLowerCase()),
|
||||
String(m || '')
|
||||
.toLowerCase()
|
||||
.includes(keyword.toLowerCase()),
|
||||
);
|
||||
|
||||
// 分类模型:新获取的模型和已有模型
|
||||
|
||||
@@ -30,7 +30,7 @@ const ConfirmationDialog = ({
|
||||
type = 'danger',
|
||||
deployment,
|
||||
t,
|
||||
loading = false
|
||||
loading = false,
|
||||
}) => {
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
@@ -66,17 +66,17 @@ const ConfirmationDialog = ({
|
||||
okButtonProps={{
|
||||
disabled: !isConfirmed,
|
||||
type: type === 'danger' ? 'danger' : 'primary',
|
||||
loading
|
||||
loading,
|
||||
}}
|
||||
width={480}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Text type="danger" strong>
|
||||
<div className='space-y-4'>
|
||||
<Text type='danger' strong>
|
||||
{t('此操作具有风险,请确认要继续执行')}。
|
||||
</Text>
|
||||
<Text>
|
||||
{t('请输入部署名称以完成二次确认')}:
|
||||
<Text code className="ml-1">
|
||||
<Text code className='ml-1'>
|
||||
{requiredText || t('未知部署')}
|
||||
</Text>
|
||||
</Text>
|
||||
@@ -87,7 +87,7 @@ const ConfirmationDialog = ({
|
||||
autoFocus
|
||||
/>
|
||||
{!isConfirmed && confirmText && (
|
||||
<Text type="danger" size="small">
|
||||
<Text type='danger' size='small'>
|
||||
{t('部署名称不匹配,请检查后重新输入')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -130,9 +130,7 @@ const ExtendDurationModal = ({
|
||||
? details.locations
|
||||
.map((location) =>
|
||||
Number(
|
||||
location?.id ??
|
||||
location?.location_id ??
|
||||
location?.locationId,
|
||||
location?.id ?? location?.location_id ?? location?.locationId,
|
||||
),
|
||||
)
|
||||
.filter((id) => Number.isInteger(id) && id > 0)
|
||||
@@ -181,9 +179,7 @@ const ExtendDurationModal = ({
|
||||
} else {
|
||||
const message = response.data.message || '';
|
||||
setPriceEstimation(null);
|
||||
setPriceError(
|
||||
t('价格计算失败') + (message ? `: ${message}` : ''),
|
||||
);
|
||||
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||
}
|
||||
} catch (error) {
|
||||
if (costRequestIdRef.current !== requestId) {
|
||||
@@ -192,9 +188,7 @@ const ExtendDurationModal = ({
|
||||
|
||||
const message = error?.response?.data?.message || error.message || '';
|
||||
setPriceEstimation(null);
|
||||
setPriceError(
|
||||
t('价格计算失败') + (message ? `: ${message}` : ''),
|
||||
);
|
||||
setPriceError(t('价格计算失败') + (message ? `: ${message}` : ''));
|
||||
} finally {
|
||||
if (costRequestIdRef.current === requestId) {
|
||||
setCostLoading(false);
|
||||
@@ -269,11 +263,8 @@ const ExtendDurationModal = ({
|
||||
const newTotalTime = `${currentRemainingTime} + ${durationHours}${t('小时')}`;
|
||||
|
||||
const priceData = priceEstimation || {};
|
||||
const breakdown =
|
||||
priceData.price_breakdown || priceData.PriceBreakdown || {};
|
||||
const currencyLabel = (
|
||||
priceData.currency || priceData.Currency || 'USDC'
|
||||
)
|
||||
const breakdown = priceData.price_breakdown || priceData.PriceBreakdown || {};
|
||||
const currencyLabel = (priceData.currency || priceData.Currency || 'USDC')
|
||||
.toString()
|
||||
.toUpperCase();
|
||||
|
||||
@@ -316,7 +307,10 @@ const ExtendDurationModal = ({
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{
|
||||
disabled:
|
||||
!deployment?.id || detailsLoading || !durationHours || durationHours < 1,
|
||||
!deployment?.id ||
|
||||
detailsLoading ||
|
||||
!durationHours ||
|
||||
durationHours < 1,
|
||||
}}
|
||||
width={600}
|
||||
className='extend-duration-modal'
|
||||
@@ -357,9 +351,7 @@ const ExtendDurationModal = ({
|
||||
<p>
|
||||
{t('延长容器时长将会产生额外费用,请确认您有足够的账户余额。')}
|
||||
</p>
|
||||
<p>
|
||||
{t('延长操作一旦确认无法撤销,费用将立即扣除。')}
|
||||
</p>
|
||||
<p>{t('延长操作一旦确认无法撤销,费用将立即扣除。')}</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
@@ -370,7 +362,9 @@ const ExtendDurationModal = ({
|
||||
onValueChange={(values) => {
|
||||
if (values.duration_hours !== undefined) {
|
||||
const numericValue = Number(values.duration_hours);
|
||||
setDurationHours(Number.isFinite(numericValue) ? numericValue : 0);
|
||||
setDurationHours(
|
||||
Number.isFinite(numericValue) ? numericValue : 0,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
return (
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
type='primary'
|
||||
className='w-full md:w-auto'
|
||||
onClick={openCreate}
|
||||
size='small'
|
||||
>
|
||||
{t('新建套餐')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsActions;
|
||||
@@ -0,0 +1,357 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Popover,
|
||||
Divider,
|
||||
Badge,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import { convertUSDToCurrency } from '../../../helpers/render';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatDuration(plan, t) {
|
||||
if (!plan) return '';
|
||||
const u = plan.duration_unit || 'month';
|
||||
if (u === 'custom') {
|
||||
return `${t('自定义')} ${plan.custom_seconds || 0}s`;
|
||||
}
|
||||
const unitMap = {
|
||||
year: t('年'),
|
||||
month: t('月'),
|
||||
day: t('日'),
|
||||
hour: t('小时'),
|
||||
};
|
||||
return `${plan.duration_value || 0}${unitMap[u] || u}`;
|
||||
}
|
||||
|
||||
function formatResetPeriod(plan, t) {
|
||||
const period = plan?.quota_reset_period || 'never';
|
||||
if (period === 'daily') return t('每天');
|
||||
if (period === 'weekly') return t('每周');
|
||||
if (period === 'monthly') return t('每月');
|
||||
if (period === 'custom') {
|
||||
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return t('不重置');
|
||||
}
|
||||
|
||||
const renderPlanTitle = (text, record, t) => {
|
||||
const subtitle = record?.plan?.subtitle;
|
||||
const plan = record?.plan;
|
||||
const popoverContent = (
|
||||
<div style={{ width: 260 }}>
|
||||
<Text strong>{text}</Text>
|
||||
{subtitle && (
|
||||
<Text type='tertiary' style={{ display: 'block', marginTop: 4 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
<Divider margin={12} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<Text type='tertiary'>{t('价格')}</Text>
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(plan?.price_amount || 0), 2)}
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('总额度')}</Text>
|
||||
{plan?.total_amount > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${plan.total_amount}`}>
|
||||
<Text>{renderQuota(plan.total_amount)}</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text>{t('不限')}</Text>
|
||||
)}
|
||||
<Text type='tertiary'>{t('升级分组')}</Text>
|
||||
<Text>{plan?.upgrade_group ? plan.upgrade_group : t('不升级')}</Text>
|
||||
<Text type='tertiary'>{t('购买上限')}</Text>
|
||||
<Text>
|
||||
{plan?.max_purchase_per_user > 0
|
||||
? plan.max_purchase_per_user
|
||||
: t('不限')}
|
||||
</Text>
|
||||
<Text type='tertiary'>{t('有效期')}</Text>
|
||||
<Text>{formatDuration(plan, t)}</Text>
|
||||
<Text type='tertiary'>{t('重置')}</Text>
|
||||
<Text>{formatResetPeriod(plan, t)}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover content={popoverContent} position='rightTop' showArrow>
|
||||
<div style={{ cursor: 'pointer', maxWidth: 180 }}>
|
||||
<Text strong ellipsis={{ showTooltip: false }}>
|
||||
{text}
|
||||
</Text>
|
||||
{subtitle && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
ellipsis={{ showTooltip: false }}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPrice = (text) => {
|
||||
return (
|
||||
<Text strong style={{ color: 'var(--semi-color-success)' }}>
|
||||
{convertUSDToCurrency(Number(text || 0), 2)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPurchaseLimit = (text, record, t) => {
|
||||
const limit = Number(record?.plan?.max_purchase_per_user || 0);
|
||||
return (
|
||||
<Text type={limit > 0 ? 'secondary' : 'tertiary'}>
|
||||
{limit > 0 ? limit : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderDuration = (text, record, t) => {
|
||||
return <Text type='secondary'>{formatDuration(record?.plan, t)}</Text>;
|
||||
};
|
||||
|
||||
const renderEnabled = (text, record, t) => {
|
||||
return text ? (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{t('启用')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
type='light'
|
||||
prefixIcon={<Badge dot type='danger' />}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
const renderTotalAmount = (text, record, t) => {
|
||||
const total = Number(record?.plan?.total_amount || 0);
|
||||
return (
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${total}`}>
|
||||
<span>{renderQuota(total)}</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderUpgradeGroup = (text, record, t) => {
|
||||
const group = record?.plan?.upgrade_group || '';
|
||||
return (
|
||||
<Text type={group ? 'secondary' : 'tertiary'}>
|
||||
{group ? group : t('不升级')}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderResetPeriod = (text, record, t) => {
|
||||
const period = record?.plan?.quota_reset_period || 'never';
|
||||
const isNever = period === 'never';
|
||||
return (
|
||||
<Text type={isNever ? 'tertiary' : 'secondary'}>
|
||||
{formatResetPeriod(record?.plan, t)}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const renderPaymentConfig = (text, record, t, enableEpay) => {
|
||||
const hasStripe = !!record?.plan?.stripe_price_id;
|
||||
const hasCreem = !!record?.plan?.creem_product_id;
|
||||
const hasEpay = !!enableEpay;
|
||||
|
||||
return (
|
||||
<Space spacing={4}>
|
||||
{hasStripe && (
|
||||
<Tag color='violet' shape='circle'>
|
||||
Stripe
|
||||
</Tag>
|
||||
)}
|
||||
{hasCreem && (
|
||||
<Tag color='cyan' shape='circle'>
|
||||
Creem
|
||||
</Tag>
|
||||
)}
|
||||
{hasEpay && (
|
||||
<Tag color='light-green' shape='circle'>
|
||||
{t('易支付')}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
const isEnabled = record?.plan?.enabled;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isEnabled) {
|
||||
Modal.confirm({
|
||||
title: t('确认禁用'),
|
||||
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => setPlanEnabled(record, false),
|
||||
});
|
||||
} else {
|
||||
Modal.confirm({
|
||||
title: t('确认启用'),
|
||||
content: t('启用后套餐将在用户端展示。是否继续?'),
|
||||
centered: true,
|
||||
onOk: () => setPlanEnabled(record, true),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space spacing={8}>
|
||||
<Button
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export const getSubscriptionsColumns = ({
|
||||
t,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['plan', 'id'],
|
||||
width: 60,
|
||||
render: (text) => <Text type='tertiary'>#{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('套餐'),
|
||||
dataIndex: ['plan', 'title'],
|
||||
width: 200,
|
||||
render: (text, record) => renderPlanTitle(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('价格'),
|
||||
dataIndex: ['plan', 'price_amount'],
|
||||
width: 100,
|
||||
render: (text) => renderPrice(text),
|
||||
},
|
||||
{
|
||||
title: t('购买上限'),
|
||||
width: 90,
|
||||
render: (text, record) => renderPurchaseLimit(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('优先级'),
|
||||
dataIndex: ['plan', 'sort_order'],
|
||||
width: 80,
|
||||
render: (text) => <Text type='tertiary'>{Number(text || 0)}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
width: 100,
|
||||
render: (text, record) => renderDuration(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('重置'),
|
||||
width: 80,
|
||||
render: (text, record) => renderResetPeriod(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
dataIndex: ['plan', 'enabled'],
|
||||
width: 80,
|
||||
render: (text, record) => renderEnabled(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('支付渠道'),
|
||||
width: 180,
|
||||
render: (text, record) =>
|
||||
renderPaymentConfig(text, record, t, enableEpay),
|
||||
},
|
||||
{
|
||||
title: t('总额度'),
|
||||
width: 100,
|
||||
render: (text, record) => renderTotalAmount(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('升级分组'),
|
||||
width: 100,
|
||||
render: (text, record) => renderUpgradeGroup(text, record, t),
|
||||
},
|
||||
{
|
||||
title: t('操作'),
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
render: (text, record) =>
|
||||
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { CalendarClock } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
<div className='flex items-center text-blue-500'>
|
||||
<CalendarClock size={16} className='mr-2' />
|
||||
<Text>{t('订阅管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsDescription;
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getSubscriptionsColumns } from './SubscriptionsColumnDefs';
|
||||
|
||||
const SubscriptionsTable = (subscriptionsData) => {
|
||||
const {
|
||||
plans,
|
||||
loading,
|
||||
compactMode,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
t,
|
||||
enableEpay,
|
||||
} = subscriptionsData;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return getSubscriptionsColumns({
|
||||
t,
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
});
|
||||
}, [t, openEdit, setPlanEnabled, enableEpay]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
? columns.map((col) => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
})
|
||||
: columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={plans}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={false}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowKey={(row) => row?.plan?.id}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅套餐')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className='overflow-hidden'
|
||||
size='middle'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsTable;
|
||||
103
web/src/components/table/subscriptions/index.jsx
Normal file
103
web/src/components/table/subscriptions/index.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import SubscriptionsTable from './SubscriptionsTable';
|
||||
import SubscriptionsActions from './SubscriptionsActions';
|
||||
import SubscriptionsDescription from './SubscriptionsDescription';
|
||||
import AddEditSubscriptionModal from './modals/AddEditSubscriptionModal';
|
||||
import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscriptionsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
|
||||
const SubscriptionsPage = () => {
|
||||
const subscriptionsData = useSubscriptionsData();
|
||||
const isMobile = useIsMobile();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const enableEpay = !!statusState?.status?.enable_online_topup;
|
||||
|
||||
const {
|
||||
showEdit,
|
||||
editingPlan,
|
||||
sheetPlacement,
|
||||
closeEdit,
|
||||
refresh,
|
||||
openCreate,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddEditSubscriptionModal
|
||||
visible={showEdit}
|
||||
handleClose={closeEdit}
|
||||
editingPlan={editingPlan}
|
||||
placement={sheetPlacement}
|
||||
refresh={refresh}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type='type1'
|
||||
descriptionArea={
|
||||
<SubscriptionsDescription
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
}
|
||||
actionsArea={
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
{/* Mobile: actions first; Desktop: actions left */}
|
||||
<div className='order-1 md:order-0 w-full md:w-auto'>
|
||||
<SubscriptionsActions openCreate={openCreate} t={t} />
|
||||
</div>
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('Stripe/Creem 需在第三方平台创建商品并填入 ID')}
|
||||
closeIcon={null}
|
||||
// Mobile: banner below; Desktop: banner right
|
||||
className='!rounded-lg order-2 md:order-1'
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: subscriptionsData.activePage,
|
||||
pageSize: subscriptionsData.pageSize,
|
||||
total: subscriptionsData.planCount,
|
||||
onPageChange: subscriptionsData.handlePageChange,
|
||||
onPageSizeChange: subscriptionsData.handlePageSizeChange,
|
||||
isMobile,
|
||||
t: subscriptionsData.t,
|
||||
})}
|
||||
t={t}
|
||||
>
|
||||
<SubscriptionsTable {...subscriptionsData} enableEpay={enableEpay} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionsPage;
|
||||
@@ -0,0 +1,553 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Form,
|
||||
Row,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCalendarClock,
|
||||
IconClose,
|
||||
IconCreditCard,
|
||||
IconSave,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { Clock, RefreshCw } from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import {
|
||||
quotaToDisplayAmount,
|
||||
displayAmountToQuota,
|
||||
} from '../../../../helpers/quota';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const durationUnitOptions = [
|
||||
{ value: 'year', label: '年' },
|
||||
{ value: 'month', label: '月' },
|
||||
{ value: 'day', label: '日' },
|
||||
{ value: 'hour', label: '小时' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const resetPeriodOptions = [
|
||||
{ value: 'never', label: '不重置' },
|
||||
{ value: 'daily', label: '每天' },
|
||||
{ value: 'weekly', label: '每周' },
|
||||
{ value: 'monthly', label: '每月' },
|
||||
{ value: 'custom', label: '自定义(秒)' },
|
||||
];
|
||||
|
||||
const AddEditSubscriptionModal = ({
|
||||
visible,
|
||||
handleClose,
|
||||
editingPlan,
|
||||
placement = 'left',
|
||||
refresh,
|
||||
t,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [groupOptions, setGroupOptions] = useState([]);
|
||||
const [groupLoading, setGroupLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = editingPlan?.plan?.id !== undefined;
|
||||
const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create';
|
||||
|
||||
const getInitValues = () => ({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
price_amount: 0,
|
||||
currency: 'USD',
|
||||
duration_unit: 'month',
|
||||
duration_value: 1,
|
||||
custom_seconds: 0,
|
||||
quota_reset_period: 'never',
|
||||
quota_reset_custom_seconds: 0,
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
creem_product_id: '',
|
||||
});
|
||||
|
||||
const buildFormValues = () => {
|
||||
const base = getInitValues();
|
||||
if (editingPlan?.plan?.id === undefined) return base;
|
||||
const p = editingPlan.plan || {};
|
||||
return {
|
||||
...base,
|
||||
title: p.title || '',
|
||||
subtitle: p.subtitle || '',
|
||||
price_amount: Number(p.price_amount || 0),
|
||||
currency: 'USD',
|
||||
duration_unit: p.duration_unit || 'month',
|
||||
duration_value: Number(p.duration_value || 1),
|
||||
custom_seconds: Number(p.custom_seconds || 0),
|
||||
quota_reset_period: p.quota_reset_period || 'never',
|
||||
quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0),
|
||||
enabled: p.enabled !== false,
|
||||
sort_order: Number(p.sort_order || 0),
|
||||
max_purchase_per_user: Number(p.max_purchase_per_user || 0),
|
||||
total_amount: Number(
|
||||
quotaToDisplayAmount(p.total_amount || 0).toFixed(2),
|
||||
),
|
||||
upgrade_group: p.upgrade_group || '',
|
||||
stripe_price_id: p.stripe_price_id || '',
|
||||
creem_product_id: p.creem_product_id || '',
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setGroupLoading(true);
|
||||
API.get('/api/group')
|
||||
.then((res) => {
|
||||
if (res.data?.success) {
|
||||
setGroupOptions(res.data?.data || []);
|
||||
} else {
|
||||
setGroupOptions([]);
|
||||
}
|
||||
})
|
||||
.catch(() => setGroupOptions([]))
|
||||
.finally(() => setGroupLoading(false));
|
||||
}, [visible]);
|
||||
|
||||
const submit = async (values) => {
|
||||
if (!values.title || values.title.trim() === '') {
|
||||
showError(t('套餐标题不能为空'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
plan: {
|
||||
...values,
|
||||
price_amount: Number(values.price_amount || 0),
|
||||
currency: 'USD',
|
||||
duration_value: Number(values.duration_value || 0),
|
||||
custom_seconds: Number(values.custom_seconds || 0),
|
||||
quota_reset_period: values.quota_reset_period || 'never',
|
||||
quota_reset_custom_seconds:
|
||||
values.quota_reset_period === 'custom'
|
||||
? Number(values.quota_reset_custom_seconds || 0)
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
total_amount: displayAmountToQuota(values.total_amount),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
};
|
||||
if (editingPlan?.plan?.id) {
|
||||
const res = await API.put(
|
||||
`/api/subscription/admin/plans/${editingPlan.plan.id}`,
|
||||
payload,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/subscription/admin/plans', payload);
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('创建成功'));
|
||||
handleClose();
|
||||
refresh?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('创建失败'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={visible}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
type='primary'
|
||||
onClick={handleClose}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={formKey}
|
||||
initValues={buildFormValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='blue'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCalendarClock size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('基本信息')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('套餐的基本信息和定价')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='title'
|
||||
label={t('套餐标题')}
|
||||
placeholder={t('例如:基础套餐')}
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: t('请输入套餐标题') },
|
||||
]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='subtitle'
|
||||
label={t('套餐副标题')}
|
||||
placeholder={t('例如:适合轻度使用')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='price_amount'
|
||||
label={t('实付金额')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入金额') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='total_amount'
|
||||
label={t('总额度')}
|
||||
required
|
||||
min={0}
|
||||
precision={2}
|
||||
rules={[{ required: true, message: t('请输入总额度') }]}
|
||||
extraText={`${t('0 表示不限')} · ${t('原生额度')}:${displayAmountToQuota(
|
||||
values.total_amount,
|
||||
)}`}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='upgrade_group'
|
||||
label={t('升级分组')}
|
||||
showClear
|
||||
loading={groupLoading}
|
||||
placeholder={t('不升级')}
|
||||
extraText={t(
|
||||
'购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。',
|
||||
)}
|
||||
>
|
||||
<Select.Option value=''>{t('不升级')}</Select.Option>
|
||||
{(groupOptions || []).map((g) => (
|
||||
<Select.Option key={g} value={g}>
|
||||
{g}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Input
|
||||
field='currency'
|
||||
label={t('币种')}
|
||||
disabled
|
||||
extraText={t('由全站货币展示设置统一控制')}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='sort_order'
|
||||
label={t('排序')}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.InputNumber
|
||||
field='max_purchase_per_user'
|
||||
label={t('购买上限')}
|
||||
min={0}
|
||||
precision={0}
|
||||
extraText={t('0 表示不限')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Switch
|
||||
field='enabled'
|
||||
label={t('启用状态')}
|
||||
size='large'
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 有效期设置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='green'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<Clock size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('有效期设置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('配置套餐的有效时长')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='duration_unit'
|
||||
label={t('有效期单位')}
|
||||
required
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
{durationUnitOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
{values.duration_unit === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='duration_value'
|
||||
label={t('有效期数值')}
|
||||
required
|
||||
min={1}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入数值') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 额度重置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='orange'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('额度重置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('支持周期性重置套餐权益额度')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Select
|
||||
field='quota_reset_period'
|
||||
label={t('重置周期')}
|
||||
>
|
||||
{resetPeriodOptions.map((o) => (
|
||||
<Select.Option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
{values.quota_reset_period === 'custom' ? (
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
required
|
||||
min={60}
|
||||
precision={0}
|
||||
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<Form.InputNumber
|
||||
field='quota_reset_custom_seconds'
|
||||
label={t('自定义秒数')}
|
||||
min={0}
|
||||
precision={0}
|
||||
style={{ width: '100%' }}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 第三方支付配置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar
|
||||
size='small'
|
||||
color='purple'
|
||||
className='mr-2 shadow-md'
|
||||
>
|
||||
<IconCreditCard size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>
|
||||
{t('第三方支付配置')}
|
||||
</Text>
|
||||
<div className='text-xs text-gray-600'>
|
||||
{t('Stripe/Creem 商品ID(可选)')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='stripe_price_id'
|
||||
label='Stripe PriceId'
|
||||
placeholder='price_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='creem_product_id'
|
||||
label='Creem ProductId'
|
||||
placeholder='prod_...'
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddEditSubscriptionModal;
|
||||
@@ -120,7 +120,7 @@ const ContentModal = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: 'relative', height: '100%' }}>
|
||||
{isLoading && (
|
||||
<div
|
||||
style={{
|
||||
@@ -137,7 +137,13 @@ const ContentModal = ({
|
||||
<video
|
||||
src={modalContent}
|
||||
controls
|
||||
style={{ width: '100%' }}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
autoPlay
|
||||
crossOrigin='anonymous'
|
||||
onError={handleVideoError}
|
||||
@@ -155,11 +161,13 @@ const ContentModal = ({
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
closable={null}
|
||||
bodyStyle={{
|
||||
height: isVideo ? '450px' : '400px',
|
||||
height: isVideo ? '70vh' : '400px',
|
||||
maxHeight: '80vh',
|
||||
overflow: 'auto',
|
||||
padding: isVideo && videoError ? '0' : '24px',
|
||||
}}
|
||||
width={800}
|
||||
width={isVideo ? '90vw' : 800}
|
||||
style={isVideo ? { maxWidth: 960 } : undefined}
|
||||
>
|
||||
{isVideo ? (
|
||||
renderVideoContent()
|
||||
|
||||
@@ -378,7 +378,12 @@ const EditTokenModal = (props) => {
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
<Col span={24} style={{ display: values.group === 'auto' ? 'block' : 'none' }}>
|
||||
<Col
|
||||
span={24}
|
||||
style={{
|
||||
display: values.group === 'auto' ? 'block' : 'none',
|
||||
}}
|
||||
>
|
||||
<Form.Switch
|
||||
field='cross_group_retry'
|
||||
label={t('跨分组重试')}
|
||||
@@ -561,7 +566,9 @@ const EditTokenModal = (props) => {
|
||||
placeholder={t('允许的IP,一行一个,不填写则不限制')}
|
||||
autosize
|
||||
rows={1}
|
||||
extraText={t('请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用')}
|
||||
extraText={t(
|
||||
'请勿过度信任此功能,IP可能被伪造,请配合nginx和cdn等网关使用',
|
||||
)}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
@@ -71,6 +72,34 @@ function formatRatio(ratio) {
|
||||
return String(ratio);
|
||||
}
|
||||
|
||||
function buildChannelAffinityTooltip(affinity, t) {
|
||||
if (!affinity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keySource = affinity.key_source || '-';
|
||||
const keyPath = affinity.key_path || affinity.key_key || '-';
|
||||
const keyHint = affinity.key_hint || '';
|
||||
const keyFp = affinity.key_fp ? `#${affinity.key_fp}` : '';
|
||||
const keyText = `${keySource}:${keyPath}${keyFp}`;
|
||||
|
||||
const lines = [
|
||||
t('渠道亲和性'),
|
||||
`${t('规则')}:${affinity.rule_name || '-'}`,
|
||||
`${t('分组')}:${affinity.selected_group || '-'}`,
|
||||
`${t('Key')}:${keyText}`,
|
||||
...(keyHint ? [`${t('Key 摘要')}:${keyHint}`] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ lineHeight: 1.6, display: 'flex', flexDirection: 'column' }}>
|
||||
{lines.map((line, i) => (
|
||||
<div key={i}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render functions
|
||||
function renderType(type, t) {
|
||||
switch (type) {
|
||||
@@ -182,6 +211,18 @@ function renderFirstUseTime(type, t) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderBillingTag(record, t) {
|
||||
const other = getLogOther(record.other);
|
||||
if (other?.billing_source === 'subscription') {
|
||||
return (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('订阅抵扣')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderModelName(record, copyText, t) {
|
||||
let other = getLogOther(record.other);
|
||||
let modelMapped =
|
||||
@@ -250,6 +291,7 @@ export const getLogsColumns = ({
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
}) => {
|
||||
return [
|
||||
@@ -457,11 +499,20 @@ export const getLogsColumns = ({
|
||||
title: t('花费'),
|
||||
dataIndex: 'quota',
|
||||
render: (text, record, index) => {
|
||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
||||
<>{renderQuota(text, 6)}</>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
|
||||
return <></>;
|
||||
}
|
||||
const other = getLogOther(record.other);
|
||||
const isSubscription = other?.billing_source === 'subscription';
|
||||
if (isSubscription) {
|
||||
// Subscription billed: show only tag (no $0), but keep tooltip for equivalent cost.
|
||||
return (
|
||||
<Tooltip content={`${t('由订阅抵扣')}:${renderQuota(text, 6)}`}>
|
||||
<span>{renderBillingTag(record, t)}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <>{renderQuota(text, 6)}</>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -532,42 +583,39 @@ export const getLogsColumns = ({
|
||||
return isAdminUser ? (
|
||||
<Space>
|
||||
<div>{content}</div>
|
||||
{affinity ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div style={{ lineHeight: 1.6 }}>
|
||||
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('规则')}:{affinity.rule_name || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('分组')}:{affinity.selected_group || '-'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type='secondary'>
|
||||
{t('Key')}:
|
||||
{(affinity.key_source || '-') +
|
||||
':' +
|
||||
(affinity.key_path || affinity.key_key || '-') +
|
||||
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
|
||||
</Typography.Text>
|
||||
{affinity ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<div>
|
||||
{buildChannelAffinityTooltip(affinity, t)}
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Button
|
||||
theme='borderless'
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openChannelAffinityUsageCacheModal?.(affinity);
|
||||
}}
|
||||
>
|
||||
{t('查看详情')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
||||
<span className='channel-affinity-tag-content'>
|
||||
<IconStarStroked style={{ fontSize: 13 }} />
|
||||
{t('优选')}
|
||||
</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<Tag
|
||||
className='channel-affinity-tag'
|
||||
color='cyan'
|
||||
shape='circle'
|
||||
>
|
||||
<span className='channel-affinity-tag-content'>
|
||||
<IconStarStroked style={{ fontSize: 13 }} />
|
||||
{t('优选')}
|
||||
</span>
|
||||
</Tag>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Space>
|
||||
) : (
|
||||
@@ -671,6 +719,10 @@ export const getLogsColumns = ({
|
||||
other?.is_system_prompt_overwritten,
|
||||
'openai',
|
||||
);
|
||||
// Do not add billing source here; keep details clean.
|
||||
const summary = [content, text ? `${t('详情')}:${text}` : null]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
return (
|
||||
<Typography.Paragraph
|
||||
ellipsis={{
|
||||
@@ -678,7 +730,7 @@ export const getLogsColumns = ({
|
||||
}}
|
||||
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{content}
|
||||
{summary}
|
||||
</Typography.Paragraph>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -40,6 +40,7 @@ const LogsTable = (logsData) => {
|
||||
handlePageSizeChange,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
hasExpandableRows,
|
||||
isAdminUser,
|
||||
t,
|
||||
@@ -53,9 +54,17 @@ const LogsTable = (logsData) => {
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
});
|
||||
}, [t, COLUMN_KEYS, copyText, showUserInfoFunc, isAdminUser]);
|
||||
}, [
|
||||
t,
|
||||
COLUMN_KEYS,
|
||||
copyText,
|
||||
showUserInfoFunc,
|
||||
openChannelAffinityUsageCacheModal,
|
||||
isAdminUser,
|
||||
]);
|
||||
|
||||
// Filter columns based on visibility settings
|
||||
const getVisibleColumns = () => {
|
||||
|
||||
@@ -24,6 +24,7 @@ import LogsActions from './UsageLogsActions';
|
||||
import LogsFilters from './UsageLogsFilters';
|
||||
import ColumnSelectorModal from './modals/ColumnSelectorModal';
|
||||
import UserInfoModal from './modals/UserInfoModal';
|
||||
import ChannelAffinityUsageCacheModal from './modals/ChannelAffinityUsageCacheModal';
|
||||
import { useLogsData } from '../../../hooks/usage-logs/useUsageLogsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
@@ -37,6 +38,7 @@ const LogsPage = () => {
|
||||
{/* Modals */}
|
||||
<ColumnSelectorModal {...logsData} />
|
||||
<UserInfoModal {...logsData} />
|
||||
<ChannelAffinityUsageCacheModal {...logsData} />
|
||||
|
||||
{/* Main Content */}
|
||||
<CardPro
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Modal, Descriptions, Spin, Typography } from '@douyinfe/semi-ui';
|
||||
import { API, showError, timestamp2string } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatRate(hit, total) {
|
||||
if (!total || total <= 0) return '-';
|
||||
const r = (Number(hit || 0) / Number(total || 0)) * 100;
|
||||
if (!Number.isFinite(r)) return '-';
|
||||
return `${r.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function formatTokenRate(n, d) {
|
||||
const nn = Number(n || 0);
|
||||
const dd = Number(d || 0);
|
||||
if (!dd || dd <= 0) return '-';
|
||||
const r = (nn / dd) * 100;
|
||||
if (!Number.isFinite(r)) return '-';
|
||||
return `${r.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
const ChannelAffinityUsageCacheModal = ({
|
||||
t,
|
||||
showChannelAffinityUsageCacheModal,
|
||||
setShowChannelAffinityUsageCacheModal,
|
||||
channelAffinityUsageCacheTarget,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState(null);
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
const params = useMemo(() => {
|
||||
const x = channelAffinityUsageCacheTarget || {};
|
||||
return {
|
||||
rule_name: (x.rule_name || '').trim(),
|
||||
using_group: (x.using_group || '').trim(),
|
||||
key_hint: (x.key_hint || '').trim(),
|
||||
key_fp: (x.key_fp || '').trim(),
|
||||
};
|
||||
}, [channelAffinityUsageCacheTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showChannelAffinityUsageCacheModal) {
|
||||
requestSeqRef.current += 1; // invalidate inflight request
|
||||
setLoading(false);
|
||||
setStats(null);
|
||||
return;
|
||||
}
|
||||
if (!params.rule_name || !params.key_fp) {
|
||||
setLoading(false);
|
||||
setStats(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqSeq = (requestSeqRef.current += 1);
|
||||
setStats(null);
|
||||
setLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const res = await API.get('/api/log/channel_affinity_usage_cache', {
|
||||
params,
|
||||
disableDuplicate: true,
|
||||
});
|
||||
if (reqSeq !== requestSeqRef.current) return;
|
||||
const { success, message, data } = res.data || {};
|
||||
if (!success) {
|
||||
setStats(null);
|
||||
showError(t(message || '请求失败'));
|
||||
return;
|
||||
}
|
||||
setStats(data || {});
|
||||
} catch (e) {
|
||||
if (reqSeq !== requestSeqRef.current) return;
|
||||
setStats(null);
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
if (reqSeq !== requestSeqRef.current) return;
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [
|
||||
showChannelAffinityUsageCacheModal,
|
||||
params.rule_name,
|
||||
params.using_group,
|
||||
params.key_hint,
|
||||
params.key_fp,
|
||||
t,
|
||||
]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const s = stats || {};
|
||||
const hit = Number(s.hit || 0);
|
||||
const total = Number(s.total || 0);
|
||||
const windowSeconds = Number(s.window_seconds || 0);
|
||||
const lastSeenAt = Number(s.last_seen_at || 0);
|
||||
const promptTokens = Number(s.prompt_tokens || 0);
|
||||
const completionTokens = Number(s.completion_tokens || 0);
|
||||
const totalTokens = Number(s.total_tokens || 0);
|
||||
const cachedTokens = Number(s.cached_tokens || 0);
|
||||
const promptCacheHitTokens = Number(s.prompt_cache_hit_tokens || 0);
|
||||
|
||||
return [
|
||||
{ key: t('规则'), value: s.rule_name || params.rule_name || '-' },
|
||||
{ key: t('分组'), value: s.using_group || params.using_group || '-' },
|
||||
{
|
||||
key: t('Key 摘要'),
|
||||
value: params.key_hint || '-',
|
||||
},
|
||||
{
|
||||
key: t('Key 指纹'),
|
||||
value: s.key_fp || params.key_fp || '-',
|
||||
},
|
||||
{ key: t('TTL(秒)'), value: windowSeconds > 0 ? windowSeconds : '-' },
|
||||
{
|
||||
key: t('命中率'),
|
||||
value: `${hit}/${total} (${formatRate(hit, total)})`,
|
||||
},
|
||||
{
|
||||
key: t('Prompt tokens'),
|
||||
value: promptTokens,
|
||||
},
|
||||
{
|
||||
key: t('Cached tokens'),
|
||||
value: `${cachedTokens} (${formatTokenRate(cachedTokens, promptTokens)})`,
|
||||
},
|
||||
{
|
||||
key: t('Prompt cache hit tokens'),
|
||||
value: promptCacheHitTokens,
|
||||
},
|
||||
{
|
||||
key: t('Completion tokens'),
|
||||
value: completionTokens,
|
||||
},
|
||||
{
|
||||
key: t('Total tokens'),
|
||||
value: totalTokens,
|
||||
},
|
||||
{
|
||||
key: t('最近一次'),
|
||||
value: lastSeenAt > 0 ? timestamp2string(lastSeenAt) : '-',
|
||||
},
|
||||
];
|
||||
}, [stats, params, t]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('渠道亲和性:上游缓存命中')}
|
||||
visible={showChannelAffinityUsageCacheModal}
|
||||
onCancel={() => setShowChannelAffinityUsageCacheModal(false)}
|
||||
footer={null}
|
||||
centered
|
||||
closable
|
||||
maskClosable
|
||||
width={640}
|
||||
>
|
||||
<div style={{ padding: 16 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text type='tertiary' size='small'>
|
||||
{t(
|
||||
'命中判定:usage 中存在 cached tokens(例如 cached_tokens/prompt_cache_hit_tokens)即视为命中。',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<Spin spinning={loading} tip={t('加载中...')}>
|
||||
{stats ? (
|
||||
<Descriptions data={rows} />
|
||||
) : (
|
||||
<div style={{ padding: '24px 0' }}>
|
||||
<Text type='tertiary' size='small'>
|
||||
{loading ? t('加载中...') : t('暂无数据')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelAffinityUsageCacheModal;
|
||||
@@ -208,6 +208,7 @@ const renderOperations = (
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showUserSubscriptionsModal,
|
||||
t,
|
||||
},
|
||||
) => {
|
||||
@@ -216,6 +217,14 @@ const renderOperations = (
|
||||
}
|
||||
|
||||
const moreMenu = [
|
||||
{
|
||||
node: 'item',
|
||||
name: t('订阅管理'),
|
||||
onClick: () => showUserSubscriptionsModal(record),
|
||||
},
|
||||
{
|
||||
node: 'divider',
|
||||
},
|
||||
{
|
||||
node: 'item',
|
||||
name: t('重置 Passkey'),
|
||||
@@ -299,6 +308,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showUserSubscriptionsModal,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -355,6 +365,7 @@ export const getUsersColumns = ({
|
||||
showDeleteModal,
|
||||
showResetPasskeyModal,
|
||||
showResetTwoFAModal,
|
||||
showUserSubscriptionsModal,
|
||||
t,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -31,6 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
||||
import DeleteUserModal from './modals/DeleteUserModal';
|
||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||
import UserSubscriptionsModal from './modals/UserSubscriptionsModal';
|
||||
|
||||
const UsersTable = (usersData) => {
|
||||
const {
|
||||
@@ -61,6 +62,8 @@ const UsersTable = (usersData) => {
|
||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||
const [showUserSubscriptionsModal, setShowUserSubscriptionsModal] =
|
||||
useState(false);
|
||||
|
||||
// Modal handlers
|
||||
const showPromoteUserModal = (user) => {
|
||||
@@ -94,6 +97,11 @@ const UsersTable = (usersData) => {
|
||||
setShowResetTwoFAModal(true);
|
||||
};
|
||||
|
||||
const showUserSubscriptionsUserModal = (user) => {
|
||||
setModalUser(user);
|
||||
setShowUserSubscriptionsModal(true);
|
||||
};
|
||||
|
||||
// Modal confirm handlers
|
||||
const handlePromoteConfirm = () => {
|
||||
manageUser(modalUser.id, 'promote', modalUser);
|
||||
@@ -132,6 +140,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteModal: showDeleteUserModal,
|
||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||
showUserSubscriptionsModal: showUserSubscriptionsUserModal,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
@@ -143,6 +152,7 @@ const UsersTable = (usersData) => {
|
||||
showDeleteUserModal,
|
||||
showResetPasskeyUserModal,
|
||||
showResetTwoFAUserModal,
|
||||
showUserSubscriptionsUserModal,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
@@ -242,6 +252,14 @@ const UsersTable = (usersData) => {
|
||||
user={modalUser}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<UserSubscriptionsModal
|
||||
visible={showUserSubscriptionsModal}
|
||||
onCancel={() => setShowUserSubscriptionsModal(false)}
|
||||
user={modalUser}
|
||||
t={t}
|
||||
onSuccess={() => refresh?.()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
123
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
123
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Select, Space, Typography } from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const BindSubscriptionModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState(null);
|
||||
|
||||
const loadPlans = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedPlanId(null);
|
||||
loadPlans();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const planOptions = useMemo(() => {
|
||||
return (plans || []).map((p) => ({
|
||||
label: `${p?.plan?.title || ''} (${p?.plan?.currency || 'USD'} ${Number(p?.plan?.price_amount || 0)})`,
|
||||
value: p?.plan?.id,
|
||||
}));
|
||||
}, [plans]);
|
||||
|
||||
const bind = async () => {
|
||||
if (!user?.id) {
|
||||
showError(t('用户信息缺失'));
|
||||
return;
|
||||
}
|
||||
if (!selectedPlanId) {
|
||||
showError(t('请选择订阅套餐'));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/admin/bind', {
|
||||
user_id: user.id,
|
||||
plan_id: selectedPlanId,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('绑定成功'));
|
||||
onSuccess?.();
|
||||
onCancel?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('绑定失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('绑定订阅套餐')}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={bind}
|
||||
confirmLoading={loading}
|
||||
maskClosable={false}
|
||||
centered
|
||||
>
|
||||
<Space vertical style={{ width: '100%' }} spacing='medium'>
|
||||
<div className='text-sm'>
|
||||
<Text strong>{t('用户')}:</Text>
|
||||
<Text>{user?.username}</Text>
|
||||
<Text type='tertiary'> (ID: {user?.id})</Text>
|
||||
</div>
|
||||
<Select
|
||||
placeholder={t('选择订阅套餐')}
|
||||
optionList={planOptions}
|
||||
value={selectedPlanId}
|
||||
onChange={setSelectedPlanId}
|
||||
loading={loading}
|
||||
filter
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('绑定后会立即生成用户订阅(无需支付),有效期按套餐配置计算。')}
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BindSubscriptionModal;
|
||||
433
web/src/components/table/users/modals/UserSubscriptionsModal.jsx
Normal file
433
web/src/components/table/users/modals/UserSubscriptionsModal.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Empty,
|
||||
Modal,
|
||||
Select,
|
||||
SideSheet,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { convertUSDToCurrency } from '../../../../helpers/render';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
import CardTable from '../../../common/ui/CardTable';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function formatTs(ts) {
|
||||
if (!ts) return '-';
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
function renderStatusTag(sub, t) {
|
||||
const now = Date.now() / 1000;
|
||||
const end = sub?.end_time || 0;
|
||||
const status = sub?.status || '';
|
||||
|
||||
const isExpiredByTime = end > 0 && end < now;
|
||||
const isActive = status === 'active' && !isExpiredByTime;
|
||||
if (isActive) {
|
||||
return (
|
||||
<Tag color='green' shape='circle' size='small'>
|
||||
{t('生效')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('已作废')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tag color='grey' shape='circle' size='small'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
const UserSubscriptionsModal = ({ visible, onCancel, user, t, onSuccess }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [plansLoading, setPlansLoading] = useState(false);
|
||||
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlanId, setSelectedPlanId] = useState(null);
|
||||
|
||||
const [subs, setSubs] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(plans || []).forEach((p) => {
|
||||
const id = p?.plan?.id;
|
||||
const title = p?.plan?.title;
|
||||
if (id) map.set(id, title || `#${id}`);
|
||||
});
|
||||
return map;
|
||||
}, [plans]);
|
||||
|
||||
const pagedSubs = useMemo(() => {
|
||||
const start = Math.max(0, (Number(currentPage || 1) - 1) * pageSize);
|
||||
const end = start + pageSize;
|
||||
return (subs || []).slice(start, end);
|
||||
}, [subs, currentPage]);
|
||||
|
||||
const planOptions = useMemo(() => {
|
||||
return (plans || []).map((p) => ({
|
||||
label: `${p?.plan?.title || ''} (${convertUSDToCurrency(
|
||||
Number(p?.plan?.price_amount || 0),
|
||||
2,
|
||||
)})`,
|
||||
value: p?.plan?.id,
|
||||
}));
|
||||
}, [plans]);
|
||||
|
||||
const loadPlans = async () => {
|
||||
setPlansLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/admin/plans');
|
||||
if (res.data?.success) {
|
||||
setPlans(res.data.data || []);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setPlansLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadUserSubscriptions = async () => {
|
||||
if (!user?.id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(
|
||||
`/api/subscription/admin/users/${user.id}/subscriptions`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const next = res.data.data || [];
|
||||
setSubs(next);
|
||||
setCurrentPage(1);
|
||||
} else {
|
||||
showError(res.data?.message || t('加载失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setSelectedPlanId(null);
|
||||
setCurrentPage(1);
|
||||
loadPlans();
|
||||
loadUserSubscriptions();
|
||||
}, [visible]);
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const createSubscription = async () => {
|
||||
if (!user?.id) {
|
||||
showError(t('用户信息缺失'));
|
||||
return;
|
||||
}
|
||||
if (!selectedPlanId) {
|
||||
showError(t('请选择订阅套餐'));
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await API.post(
|
||||
`/api/subscription/admin/users/${user.id}/subscriptions`,
|
||||
{
|
||||
plan_id: selectedPlanId,
|
||||
},
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('新增成功'));
|
||||
setSelectedPlanId(null);
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('新增失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateSubscription = (subId) => {
|
||||
Modal.confirm({
|
||||
title: t('确认作废'),
|
||||
content: t('作废后该订阅将立即失效,历史记录不受影响。是否继续?'),
|
||||
centered: true,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await API.post(
|
||||
`/api/subscription/admin/user_subscriptions/${subId}/invalidate`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已作废'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('操作失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const deleteSubscription = (subId) => {
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('删除会彻底移除该订阅记录(含权益明细)。是否继续?'),
|
||||
centered: true,
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res = await API.delete(
|
||||
`/api/subscription/admin/user_subscriptions/${subId}`,
|
||||
);
|
||||
if (res.data?.success) {
|
||||
const msg = res.data?.data?.message;
|
||||
showSuccess(msg ? msg : t('已删除'));
|
||||
await loadUserSubscriptions();
|
||||
onSuccess?.();
|
||||
} else {
|
||||
showError(res.data?.message || t('删除失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: ['subscription', 'id'],
|
||||
key: 'id',
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: t('套餐'),
|
||||
key: 'plan',
|
||||
width: 180,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const planId = sub?.plan_id;
|
||||
const title =
|
||||
planTitleMap.get(planId) || (planId ? `#${planId}` : '-');
|
||||
return (
|
||||
<div className='min-w-0'>
|
||||
<div className='font-medium truncate'>{title}</div>
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('来源')}: {sub?.source || '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('状态'),
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (_, record) => renderStatusTag(record?.subscription, t),
|
||||
},
|
||||
{
|
||||
title: t('有效期'),
|
||||
key: 'validity',
|
||||
width: 200,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
return (
|
||||
<div className='text-xs text-gray-600'>
|
||||
<div>
|
||||
{t('开始')}: {formatTs(sub?.start_time)}
|
||||
</div>
|
||||
<div>
|
||||
{t('结束')}: {formatTs(sub?.end_time)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('总额度'),
|
||||
key: 'total',
|
||||
width: 120,
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const total = Number(sub?.amount_total || 0);
|
||||
const used = Number(sub?.amount_used || 0);
|
||||
return (
|
||||
<Text type={total > 0 ? 'secondary' : 'tertiary'}>
|
||||
{total > 0 ? `${used}/${total}` : t('不限')}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'operate',
|
||||
width: 140,
|
||||
fixed: 'right',
|
||||
render: (_, record) => {
|
||||
const sub = record?.subscription;
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired =
|
||||
(sub?.end_time || 0) > 0 && (sub?.end_time || 0) < now;
|
||||
const isActive = sub?.status === 'active' && !isExpired;
|
||||
const isCancelled = sub?.status === 'cancelled';
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size='small'
|
||||
type='warning'
|
||||
theme='light'
|
||||
disabled={!isActive || isCancelled}
|
||||
onClick={() => invalidateSubscription(sub?.id)}
|
||||
>
|
||||
{t('作废')}
|
||||
</Button>
|
||||
<Button
|
||||
size='small'
|
||||
type='danger'
|
||||
theme='light'
|
||||
onClick={() => deleteSubscription(sub?.id)}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}, [t, planTitleMap]);
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
visible={visible}
|
||||
placement='right'
|
||||
width={isMobile ? '100%' : 920}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
onCancel={onCancel}
|
||||
title={
|
||||
<Space>
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('管理')}
|
||||
</Tag>
|
||||
<Typography.Title heading={4} className='m-0'>
|
||||
{t('用户订阅管理')}
|
||||
</Typography.Title>
|
||||
<Text type='tertiary' className='ml-2'>
|
||||
{user?.username || '-'} (ID: {user?.id || '-'})
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className='p-4'>
|
||||
{/* 顶部操作栏:新增订阅 */}
|
||||
<div className='flex flex-col md:flex-row md:items-center md:justify-between gap-3 mb-4'>
|
||||
<div className='flex gap-2 flex-1'>
|
||||
<Select
|
||||
placeholder={t('选择订阅套餐')}
|
||||
optionList={planOptions}
|
||||
value={selectedPlanId}
|
||||
onChange={setSelectedPlanId}
|
||||
loading={plansLoading}
|
||||
filter
|
||||
style={{ minWidth: isMobile ? undefined : 300, flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
icon={<IconPlusCircle />}
|
||||
loading={creating}
|
||||
onClick={createSubscription}
|
||||
>
|
||||
{t('新增订阅')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订阅列表 */}
|
||||
<CardTable
|
||||
columns={columns}
|
||||
dataSource={pagedSubs}
|
||||
rowKey={(row) => row?.subscription?.id}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
hidePagination={false}
|
||||
pagination={{
|
||||
currentPage,
|
||||
pageSize,
|
||||
total: subs.length,
|
||||
pageSizeOpts: [10, 20, 50],
|
||||
showSizeChanger: false,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
empty={
|
||||
<Empty
|
||||
image={
|
||||
<IllustrationNoResult style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
darkModeImage={
|
||||
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
|
||||
}
|
||||
description={t('暂无订阅记录')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
size='middle'
|
||||
/>
|
||||
</div>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSubscriptionsModal;
|
||||
@@ -87,7 +87,12 @@ const RechargeCard = ({
|
||||
const onlineFormApiRef = useRef(null);
|
||||
const redeemFormApiRef = useRef(null);
|
||||
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
|
||||
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
|
||||
console.log(
|
||||
' enabled screem ?',
|
||||
enableCreemTopUp,
|
||||
' products ?',
|
||||
creemProducts,
|
||||
);
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
@@ -503,7 +508,8 @@ const RechargeCard = ({
|
||||
{t('充值额度')}: {product.quota}
|
||||
</div>
|
||||
<div className='text-lg font-semibold text-blue-600'>
|
||||
{product.currency === 'EUR' ? '€' : '$'}{product.price}
|
||||
{product.currency === 'EUR' ? '€' : '$'}
|
||||
{product.price}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
629
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
629
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
@@ -0,0 +1,629 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess, renderQuota } from '../../helpers';
|
||||
import { getCurrencyConfig } from '../../helpers/render';
|
||||
import { Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||
import {
|
||||
formatSubscriptionDuration,
|
||||
formatSubscriptionResetPeriod,
|
||||
} from '../../helpers/subscriptionFormat';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// 过滤易支付方式
|
||||
function getEpayMethods(payMethods = []) {
|
||||
return (payMethods || []).filter(
|
||||
(m) => m?.type && m.type !== 'stripe' && m.type !== 'creem',
|
||||
);
|
||||
}
|
||||
|
||||
// 提交易支付表单
|
||||
function submitEpayForm({ url, params }) {
|
||||
const form = document.createElement('form');
|
||||
form.action = url;
|
||||
form.method = 'POST';
|
||||
const isSafari =
|
||||
navigator.userAgent.indexOf('Safari') > -1 &&
|
||||
navigator.userAgent.indexOf('Chrome') < 1;
|
||||
if (!isSafari) form.target = '_blank';
|
||||
Object.keys(params || {}).forEach((key) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = params[key];
|
||||
form.appendChild(input);
|
||||
});
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
const SubscriptionPlansCard = ({
|
||||
t,
|
||||
loading = false,
|
||||
plans = [],
|
||||
payMethods = [],
|
||||
enableOnlineTopUp = false,
|
||||
enableStripeTopUp = false,
|
||||
enableCreemTopUp = false,
|
||||
billingPreference,
|
||||
onChangeBillingPreference,
|
||||
activeSubscriptions = [],
|
||||
allSubscriptions = [],
|
||||
reloadSubscriptionSelf,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [selectedEpayMethod, setSelectedEpayMethod] = useState('');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const epayMethods = useMemo(() => getEpayMethods(payMethods), [payMethods]);
|
||||
|
||||
const openBuy = (p) => {
|
||||
setSelectedPlan(p);
|
||||
setSelectedEpayMethod(epayMethods?.[0]?.type || '');
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const closeBuy = () => {
|
||||
setOpen(false);
|
||||
setSelectedPlan(null);
|
||||
setPaying(false);
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await reloadSubscriptionSelf?.();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payStripe = async () => {
|
||||
if (!selectedPlan?.plan?.stripe_price_id) {
|
||||
showError(t('该套餐未配置 Stripe'));
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/stripe/pay', {
|
||||
plan_id: selectedPlan.plan.id,
|
||||
});
|
||||
if (res.data?.message === 'success') {
|
||||
window.open(res.data.data?.pay_link, '_blank');
|
||||
showSuccess(t('已打开支付页面'));
|
||||
closeBuy();
|
||||
} else {
|
||||
showError(res.data?.data || res.data?.message || t('支付失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payCreem = async () => {
|
||||
if (!selectedPlan?.plan?.creem_product_id) {
|
||||
showError(t('该套餐未配置 Creem'));
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/creem/pay', {
|
||||
plan_id: selectedPlan.plan.id,
|
||||
});
|
||||
if (res.data?.message === 'success') {
|
||||
window.open(res.data.data?.checkout_url, '_blank');
|
||||
showSuccess(t('已打开支付页面'));
|
||||
closeBuy();
|
||||
} else {
|
||||
showError(res.data?.data || res.data?.message || t('支付失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const payEpay = async () => {
|
||||
if (!selectedEpayMethod) {
|
||||
showError(t('请选择支付方式'));
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
try {
|
||||
const res = await API.post('/api/subscription/epay/pay', {
|
||||
plan_id: selectedPlan.plan.id,
|
||||
payment_method: selectedEpayMethod,
|
||||
});
|
||||
if (res.data?.message === 'success') {
|
||||
submitEpayForm({ url: res.data.url, params: res.data.data });
|
||||
showSuccess(t('已发起支付'));
|
||||
closeBuy();
|
||||
} else {
|
||||
showError(res.data?.data || res.data?.message || t('支付失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('支付请求失败'));
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当前订阅信息 - 支持多个订阅
|
||||
const hasActiveSubscription = activeSubscriptions.length > 0;
|
||||
const hasAnySubscription = allSubscriptions.length > 0;
|
||||
|
||||
const planPurchaseCountMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(allSubscriptions || []).forEach((sub) => {
|
||||
const planId = sub?.subscription?.plan_id;
|
||||
if (!planId) return;
|
||||
map.set(planId, (map.get(planId) || 0) + 1);
|
||||
});
|
||||
return map;
|
||||
}, [allSubscriptions]);
|
||||
|
||||
const planTitleMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(plans || []).forEach((p) => {
|
||||
const plan = p?.plan;
|
||||
if (!plan?.id) return;
|
||||
map.set(plan.id, plan.title || '');
|
||||
});
|
||||
return map;
|
||||
}, [plans]);
|
||||
|
||||
const getPlanPurchaseCount = (planId) =>
|
||||
planPurchaseCountMap.get(planId) || 0;
|
||||
|
||||
// 计算单个订阅的剩余天数
|
||||
const getRemainingDays = (sub) => {
|
||||
if (!sub?.subscription?.end_time) return 0;
|
||||
const now = Date.now() / 1000;
|
||||
const remaining = sub.subscription.end_time - now;
|
||||
return Math.max(0, Math.ceil(remaining / 86400));
|
||||
};
|
||||
|
||||
// 计算单个订阅的使用进度
|
||||
const getUsagePercent = (sub) => {
|
||||
const total = Number(sub?.subscription?.amount_total || 0);
|
||||
const used = Number(sub?.subscription?.amount_used || 0);
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((used / total) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
{/* 卡片头部 */}
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<div className='flex items-center'>
|
||||
<Avatar size='small' color='violet' className='mr-3 shadow-md'>
|
||||
<Crown size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('订阅套餐')}</Text>
|
||||
<div className='text-xs'>{t('购买订阅获得模型额度/次数')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 扣费策略 - 右上角 */}
|
||||
<Select
|
||||
value={billingPreference}
|
||||
onChange={onChangeBillingPreference}
|
||||
size='small'
|
||||
optionList={[
|
||||
{ value: 'subscription_first', label: t('优先订阅') },
|
||||
{ value: 'wallet_first', label: t('优先钱包') },
|
||||
{ value: 'subscription_only', label: t('仅用订阅') },
|
||||
{ value: 'wallet_only', label: t('仅用钱包') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className='space-y-4'>
|
||||
{/* 我的订阅骨架屏 */}
|
||||
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
|
||||
<div className='flex items-center justify-between mb-3'>
|
||||
<Skeleton.Title active style={{ width: 100, height: 20 }} />
|
||||
<Skeleton.Button active style={{ width: 24, height: 24 }} />
|
||||
</div>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton.Paragraph active rows={2} />
|
||||
</div>
|
||||
</Card>
|
||||
{/* 套餐列表骨架屏 */}
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i} className='!rounded-xl' bodyStyle={{ padding: 16 }}>
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: '60%', height: 24, marginBottom: 8 }}
|
||||
/>
|
||||
<Skeleton.Paragraph
|
||||
active
|
||||
rows={1}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<div className='text-center py-4'>
|
||||
<Skeleton.Title
|
||||
active
|
||||
style={{ width: '40%', height: 32, margin: '0 auto' }}
|
||||
/>
|
||||
</div>
|
||||
<Skeleton.Paragraph active rows={3} style={{ marginTop: 12 }} />
|
||||
<Skeleton.Button
|
||||
active
|
||||
block
|
||||
style={{ marginTop: 16, height: 32 }}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Space vertical style={{ width: '100%' }} spacing={8}>
|
||||
{/* 当前订阅状态 */}
|
||||
<Card className='!rounded-xl w-full' bodyStyle={{ padding: '12px' }}>
|
||||
<div className='flex items-center justify-between mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Text strong>{t('我的订阅')}</Text>
|
||||
{hasActiveSubscription ? (
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
shape='circle'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{activeSubscriptions.length} {t('个生效中')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{t('无生效')}
|
||||
</Tag>
|
||||
)}
|
||||
{allSubscriptions.length > activeSubscriptions.length && (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{allSubscriptions.length - activeSubscriptions.length}{' '}
|
||||
{t('个已过期')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
theme='light'
|
||||
type='tertiary'
|
||||
icon={
|
||||
<RefreshCw
|
||||
size={12}
|
||||
className={refreshing ? 'animate-spin' : ''}
|
||||
/>
|
||||
}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasAnySubscription ? (
|
||||
<>
|
||||
<Divider margin={8} />
|
||||
<div className='max-h-64 overflow-y-auto pr-1 semi-table-body'>
|
||||
{allSubscriptions.map((sub, subIndex) => {
|
||||
const isLast = subIndex === allSubscriptions.length - 1;
|
||||
const subscription = sub.subscription;
|
||||
const totalAmount = Number(subscription?.amount_total || 0);
|
||||
const usedAmount = Number(subscription?.amount_used || 0);
|
||||
const remainAmount =
|
||||
totalAmount > 0
|
||||
? Math.max(0, totalAmount - usedAmount)
|
||||
: 0;
|
||||
const planTitle =
|
||||
planTitleMap.get(subscription?.plan_id) || '';
|
||||
const remainDays = getRemainingDays(sub);
|
||||
const usagePercent = getUsagePercent(sub);
|
||||
const now = Date.now() / 1000;
|
||||
const isExpired = (subscription?.end_time || 0) < now;
|
||||
const isActive =
|
||||
subscription?.status === 'active' && !isExpired;
|
||||
|
||||
return (
|
||||
<div key={subscription?.id || subIndex}>
|
||||
{/* 订阅概要 */}
|
||||
<div className='flex items-center justify-between text-xs mb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='font-medium'>
|
||||
{planTitle
|
||||
? `${planTitle} · ${t('订阅')} #${subscription?.id}`
|
||||
: `${t('订阅')} #${subscription?.id}`}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Tag
|
||||
color='white'
|
||||
size='small'
|
||||
shape='circle'
|
||||
prefixIcon={<Badge dot type='success' />}
|
||||
>
|
||||
{t('生效')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='white' size='small' shape='circle'>
|
||||
{t('已过期')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
{isActive && (
|
||||
<span className='text-gray-500'>
|
||||
{t('剩余')} {remainDays} {t('天')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{isActive ? t('至') : t('过期于')}{' '}
|
||||
{new Date(
|
||||
(subscription?.end_time || 0) * 1000,
|
||||
).toLocaleString()}
|
||||
</div>
|
||||
<div className='text-xs text-gray-500 mb-2'>
|
||||
{t('总额度')}:{' '}
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip
|
||||
content={`${t('原生额度')}:${usedAmount}/${totalAmount} · ${t('剩余')} ${remainAmount}`}
|
||||
>
|
||||
<span>
|
||||
{renderQuota(usedAmount)}/
|
||||
{renderQuota(totalAmount)} · {t('剩余')}{' '}
|
||||
{renderQuota(remainAmount)}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
t('不限')
|
||||
)}
|
||||
{totalAmount > 0 && (
|
||||
<span className='ml-2'>
|
||||
{t('已用')} {usagePercent}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && <Divider margin={12} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-xs text-gray-500'>
|
||||
{t('购买套餐后即可享受模型权益')}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 可购买套餐 - 标准定价卡片 */}
|
||||
{plans.length > 0 ? (
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||
{plans.map((p, index) => {
|
||||
const plan = p?.plan;
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = Number(plan?.price_amount || 0);
|
||||
const convertedPrice = price * rate;
|
||||
const displayPrice = convertedPrice.toFixed(
|
||||
Number.isInteger(convertedPrice) ? 0 : 2,
|
||||
);
|
||||
const isPopular = index === 0 && plans.length > 1;
|
||||
const limit = Number(plan?.max_purchase_per_user || 0);
|
||||
const limitLabel = limit > 0 ? `${t('限购')} ${limit}` : null;
|
||||
const totalLabel =
|
||||
totalAmount > 0
|
||||
? `${t('总额度')}: ${renderQuota(totalAmount)}`
|
||||
: `${t('总额度')}: ${t('不限')}`;
|
||||
const upgradeLabel = plan?.upgrade_group
|
||||
? `${t('升级分组')}: ${plan.upgrade_group}`
|
||||
: null;
|
||||
const resetLabel =
|
||||
formatSubscriptionResetPeriod(plan, t) === t('不重置')
|
||||
? null
|
||||
: `${t('额度重置')}: ${formatSubscriptionResetPeriod(plan, t)}`;
|
||||
const planBenefits = [
|
||||
{
|
||||
label: `${t('有效期')}: ${formatSubscriptionDuration(plan, t)}`,
|
||||
},
|
||||
resetLabel ? { label: resetLabel } : null,
|
||||
totalAmount > 0
|
||||
? {
|
||||
label: totalLabel,
|
||||
tooltip: `${t('原生额度')}:${totalAmount}`,
|
||||
}
|
||||
: { label: totalLabel },
|
||||
limitLabel ? { label: limitLabel } : null,
|
||||
upgradeLabel ? { label: upgradeLabel } : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan?.id}
|
||||
className={`!rounded-xl transition-all hover:shadow-lg ${
|
||||
isPopular ? 'ring-2 ring-purple-500' : ''
|
||||
}`}
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<div className='p-4'>
|
||||
{/* 推荐标签 */}
|
||||
{isPopular && (
|
||||
<div className='text-center mb-2'>
|
||||
<Tag color='purple' shape='circle' size='small'>
|
||||
<Sparkles size={10} className='mr-1' />
|
||||
{t('推荐')}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
{/* 套餐名称 */}
|
||||
<div className='text-center mb-3'>
|
||||
<Typography.Title
|
||||
heading={5}
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
style={{ margin: 0 }}
|
||||
>
|
||||
{plan?.title || t('订阅套餐')}
|
||||
</Typography.Title>
|
||||
{plan?.subtitle && (
|
||||
<Text
|
||||
type='tertiary'
|
||||
size='small'
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{plan.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 价格区域 */}
|
||||
<div className='text-center py-2'>
|
||||
<div className='flex items-baseline justify-center'>
|
||||
<span className='text-xl font-bold text-purple-600'>
|
||||
{symbol}
|
||||
</span>
|
||||
<span className='text-3xl font-bold text-purple-600'>
|
||||
{displayPrice}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 套餐权益描述 */}
|
||||
<div className='flex flex-col items-center gap-1 pb-2'>
|
||||
{planBenefits.map((item) => {
|
||||
const content = (
|
||||
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||
<Badge dot type='tertiary' />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
);
|
||||
if (!item.tooltip) {
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className='w-full flex justify-center'
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip key={item.label} content={item.tooltip}>
|
||||
<div className='w-full flex justify-center'>
|
||||
{content}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Divider margin={12} />
|
||||
|
||||
{/* 购买按钮 */}
|
||||
{(() => {
|
||||
const count = getPlanPurchaseCount(p?.plan?.id);
|
||||
const reached = limit > 0 && count >= limit;
|
||||
const tip = reached
|
||||
? t('已达到购买上限') + ` (${count}/${limit})`
|
||||
: '';
|
||||
const buttonEl = (
|
||||
<Button
|
||||
theme='outline'
|
||||
type='tertiary'
|
||||
block
|
||||
disabled={reached}
|
||||
onClick={() => {
|
||||
if (!reached) openBuy(p);
|
||||
}}
|
||||
>
|
||||
{reached ? t('已达上限') : t('立即订阅')}
|
||||
</Button>
|
||||
);
|
||||
return reached ? (
|
||||
<Tooltip content={tip} position='top'>
|
||||
{buttonEl}
|
||||
</Tooltip>
|
||||
) : (
|
||||
buttonEl
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center text-gray-400 text-sm py-4'>
|
||||
{t('暂无可购买套餐')}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{/* 购买确认弹窗 */}
|
||||
<SubscriptionPurchaseModal
|
||||
t={t}
|
||||
visible={open}
|
||||
onCancel={closeBuy}
|
||||
selectedPlan={selectedPlan}
|
||||
paying={paying}
|
||||
selectedEpayMethod={selectedEpayMethod}
|
||||
setSelectedEpayMethod={setSelectedEpayMethod}
|
||||
epayMethods={epayMethods}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
purchaseLimitInfo={
|
||||
selectedPlan?.plan?.id
|
||||
? {
|
||||
limit: Number(selectedPlan?.plan?.max_purchase_per_user || 0),
|
||||
count: getPlanPurchaseCount(selectedPlan?.plan?.id),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onPayStripe={payStripe}
|
||||
onPayCreem={payCreem}
|
||||
onPayEpay={payEpay}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPlansCard;
|
||||
@@ -35,6 +35,7 @@ import { StatusContext } from '../../context/Status';
|
||||
|
||||
import RechargeCard from './RechargeCard';
|
||||
import InvitationCard from './InvitationCard';
|
||||
import SubscriptionPlansCard from './SubscriptionPlansCard';
|
||||
import TransferModal from './modals/TransferModal';
|
||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||
@@ -87,6 +88,14 @@ const TopUp = () => {
|
||||
// 账单Modal状态
|
||||
const [openHistory, setOpenHistory] = useState(false);
|
||||
|
||||
// 订阅相关
|
||||
const [subscriptionPlans, setSubscriptionPlans] = useState([]);
|
||||
const [subscriptionLoading, setSubscriptionLoading] = useState(true);
|
||||
const [billingPreference, setBillingPreference] =
|
||||
useState('subscription_first');
|
||||
const [activeSubscriptions, setActiveSubscriptions] = useState([]);
|
||||
const [allSubscriptions, setAllSubscriptions] = useState([]);
|
||||
|
||||
// 预设充值额度选项
|
||||
const [presetAmounts, setPresetAmounts] = useState([]);
|
||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||
@@ -313,6 +322,61 @@ const TopUp = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionPlans = async () => {
|
||||
setSubscriptionLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/subscription/plans');
|
||||
if (res.data?.success) {
|
||||
setSubscriptionPlans(res.data.data || []);
|
||||
}
|
||||
} catch (e) {
|
||||
setSubscriptionPlans([]);
|
||||
} finally {
|
||||
setSubscriptionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscriptionSelf = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/subscription/self');
|
||||
if (res.data?.success) {
|
||||
setBillingPreference(
|
||||
res.data.data?.billing_preference || 'subscription_first',
|
||||
);
|
||||
// Active subscriptions
|
||||
const activeSubs = res.data.data?.subscriptions || [];
|
||||
setActiveSubscriptions(activeSubs);
|
||||
// All subscriptions (including expired)
|
||||
const allSubs = res.data.data?.all_subscriptions || [];
|
||||
setAllSubscriptions(allSubs);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const updateBillingPreference = async (pref) => {
|
||||
const previousPref = billingPreference;
|
||||
setBillingPreference(pref);
|
||||
try {
|
||||
const res = await API.put('/api/subscription/self/preference', {
|
||||
billing_preference: pref,
|
||||
});
|
||||
if (res.data?.success) {
|
||||
showSuccess(t('更新成功'));
|
||||
const normalizedPref =
|
||||
res.data?.data?.billing_preference || pref || previousPref;
|
||||
setBillingPreference(normalizedPref);
|
||||
} else {
|
||||
showError(res.data?.message || t('更新失败'));
|
||||
setBillingPreference(previousPref);
|
||||
}
|
||||
} catch (e) {
|
||||
showError(t('请求失败'));
|
||||
setBillingPreference(previousPref);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取充值配置信息
|
||||
const getTopupInfo = async () => {
|
||||
try {
|
||||
@@ -479,6 +543,8 @@ const TopUp = () => {
|
||||
// 在 statusState 可用时获取充值信息
|
||||
useEffect(() => {
|
||||
getTopupInfo().then();
|
||||
getSubscriptionPlans().then();
|
||||
getSubscriptionSelf().then();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -651,7 +717,8 @@ const TopUp = () => {
|
||||
{t('产品名称')}:{selectedCreemProduct.name}
|
||||
</p>
|
||||
<p>
|
||||
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
|
||||
{t('价格')}:{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}
|
||||
{selectedCreemProduct.price}
|
||||
</p>
|
||||
<p>
|
||||
{t('充值额度')}:{selectedCreemProduct.quota}
|
||||
@@ -661,60 +728,72 @@ const TopUp = () => {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* 用户信息头部 */}
|
||||
<div className='space-y-6'>
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧充值区域 */}
|
||||
<div className='lg:col-span-7 space-y-6 w-full'>
|
||||
<RechargeCard
|
||||
t={t}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
creemProducts={creemProducts}
|
||||
creemPreTopUp={creemPreTopUp}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
selectPresetAmount={selectPresetAmount}
|
||||
formatLargeNumber={formatLargeNumber}
|
||||
priceRatio={priceRatio}
|
||||
topUpCount={topUpCount}
|
||||
minTopUp={minTopUp}
|
||||
renderQuotaWithAmount={renderQuotaWithAmount}
|
||||
getAmount={getAmount}
|
||||
setTopUpCount={setTopUpCount}
|
||||
setSelectedPreset={setSelectedPreset}
|
||||
renderAmount={renderAmount}
|
||||
amountLoading={amountLoading}
|
||||
payMethods={payMethods}
|
||||
preTopUp={preTopUp}
|
||||
paymentLoading={paymentLoading}
|
||||
payWay={payWay}
|
||||
redemptionCode={redemptionCode}
|
||||
setRedemptionCode={setRedemptionCode}
|
||||
topUp={topUp}
|
||||
isSubmitting={isSubmitting}
|
||||
topUpLink={topUpLink}
|
||||
openTopUpLink={openTopUpLink}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
</div>
|
||||
{/* 主布局区域 */}
|
||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||
{/* 左侧 - 订阅套餐 */}
|
||||
<div className='lg:col-span-7'>
|
||||
<SubscriptionPlansCard
|
||||
t={t}
|
||||
loading={subscriptionLoading}
|
||||
plans={subscriptionPlans}
|
||||
payMethods={payMethods}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
billingPreference={billingPreference}
|
||||
onChangeBillingPreference={updateBillingPreference}
|
||||
activeSubscriptions={activeSubscriptions}
|
||||
allSubscriptions={allSubscriptions}
|
||||
reloadSubscriptionSelf={getSubscriptionSelf}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 右侧信息区域 */}
|
||||
<div className='lg:col-span-5'>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
/>
|
||||
</div>
|
||||
{/* 右侧 - 账户充值 + 邀请奖励 */}
|
||||
<div className='lg:col-span-5 flex flex-col gap-6'>
|
||||
<RechargeCard
|
||||
t={t}
|
||||
enableOnlineTopUp={enableOnlineTopUp}
|
||||
enableStripeTopUp={enableStripeTopUp}
|
||||
enableCreemTopUp={enableCreemTopUp}
|
||||
creemProducts={creemProducts}
|
||||
creemPreTopUp={creemPreTopUp}
|
||||
presetAmounts={presetAmounts}
|
||||
selectedPreset={selectedPreset}
|
||||
selectPresetAmount={selectPresetAmount}
|
||||
formatLargeNumber={formatLargeNumber}
|
||||
priceRatio={priceRatio}
|
||||
topUpCount={topUpCount}
|
||||
minTopUp={minTopUp}
|
||||
renderQuotaWithAmount={renderQuotaWithAmount}
|
||||
getAmount={getAmount}
|
||||
setTopUpCount={setTopUpCount}
|
||||
setSelectedPreset={setSelectedPreset}
|
||||
renderAmount={renderAmount}
|
||||
amountLoading={amountLoading}
|
||||
payMethods={payMethods}
|
||||
preTopUp={preTopUp}
|
||||
paymentLoading={paymentLoading}
|
||||
payWay={payWay}
|
||||
redemptionCode={redemptionCode}
|
||||
setRedemptionCode={setRedemptionCode}
|
||||
topUp={topUp}
|
||||
isSubmitting={isSubmitting}
|
||||
topUpLink={topUpLink}
|
||||
openTopUpLink={openTopUpLink}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
statusLoading={statusLoading}
|
||||
topupInfo={topupInfo}
|
||||
onOpenHistory={handleOpenHistory}
|
||||
/>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
userState={userState}
|
||||
renderQuota={renderQuota}
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
259
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
259
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
Banner,
|
||||
Modal,
|
||||
Typography,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Divider,
|
||||
Tooltip,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { Crown, CalendarClock, Package } from 'lucide-react';
|
||||
import { SiStripe } from 'react-icons/si';
|
||||
import { IconCreditCard } from '@douyinfe/semi-icons';
|
||||
import { renderQuota } from '../../../helpers';
|
||||
import { getCurrencyConfig } from '../../../helpers/render';
|
||||
import {
|
||||
formatSubscriptionDuration,
|
||||
formatSubscriptionResetPeriod,
|
||||
} from '../../../helpers/subscriptionFormat';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SubscriptionPurchaseModal = ({
|
||||
t,
|
||||
visible,
|
||||
onCancel,
|
||||
selectedPlan,
|
||||
paying,
|
||||
selectedEpayMethod,
|
||||
setSelectedEpayMethod,
|
||||
epayMethods = [],
|
||||
enableOnlineTopUp = false,
|
||||
enableStripeTopUp = false,
|
||||
enableCreemTopUp = false,
|
||||
purchaseLimitInfo = null,
|
||||
onPayStripe,
|
||||
onPayCreem,
|
||||
onPayEpay,
|
||||
}) => {
|
||||
const plan = selectedPlan?.plan;
|
||||
const totalAmount = Number(plan?.total_amount || 0);
|
||||
const { symbol, rate } = getCurrencyConfig();
|
||||
const price = plan ? Number(plan.price_amount || 0) : 0;
|
||||
const convertedPrice = price * rate;
|
||||
const displayPrice = convertedPrice.toFixed(
|
||||
Number.isInteger(convertedPrice) ? 0 : 2,
|
||||
);
|
||||
// 只有当管理员开启支付网关 AND 套餐配置了对应的支付ID时才显示
|
||||
const hasStripe = enableStripeTopUp && !!plan?.stripe_price_id;
|
||||
const hasCreem = enableCreemTopUp && !!plan?.creem_product_id;
|
||||
const hasEpay = enableOnlineTopUp && epayMethods.length > 0;
|
||||
const hasAnyPayment = hasStripe || hasCreem || hasEpay;
|
||||
const purchaseLimit = Number(purchaseLimitInfo?.limit || 0);
|
||||
const purchaseCount = Number(purchaseLimitInfo?.count || 0);
|
||||
const purchaseLimitReached =
|
||||
purchaseLimit > 0 && purchaseCount >= purchaseLimit;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<Crown className='mr-2' size={18} />
|
||||
{t('购买订阅套餐')}
|
||||
</div>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
size='small'
|
||||
centered
|
||||
>
|
||||
{plan ? (
|
||||
<div className='space-y-4 pb-10'>
|
||||
{/* 套餐信息 */}
|
||||
<Card className='!rounded-xl !border-0 bg-slate-50 dark:bg-slate-800'>
|
||||
<div className='space-y-3'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('套餐名称')}:
|
||||
</Text>
|
||||
<Typography.Text
|
||||
ellipsis={{ rows: 1, showTooltip: true }}
|
||||
className='text-slate-900 dark:text-slate-100'
|
||||
style={{ maxWidth: 200 }}
|
||||
>
|
||||
{plan.title}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('有效期')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<CalendarClock size={14} className='mr-1 text-slate-500' />
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{formatSubscriptionDuration(plan, t)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
{formatSubscriptionResetPeriod(plan, t) !== t('不重置') && (
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('重置周期')}:
|
||||
</Text>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{formatSubscriptionResetPeriod(plan, t)}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('总额度')}:
|
||||
</Text>
|
||||
<div className='flex items-center'>
|
||||
<Package size={14} className='mr-1 text-slate-500' />
|
||||
{totalAmount > 0 ? (
|
||||
<Tooltip content={`${t('原生额度')}:${totalAmount}`}>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{renderQuota(totalAmount)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{t('不限')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{plan?.upgrade_group ? (
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('升级分组')}:
|
||||
</Text>
|
||||
<Text className='text-slate-900 dark:text-slate-100'>
|
||||
{plan.upgrade_group}
|
||||
</Text>
|
||||
</div>
|
||||
) : null}
|
||||
<Divider margin={8} />
|
||||
<div className='flex justify-between items-center'>
|
||||
<Text strong className='text-slate-700 dark:text-slate-200'>
|
||||
{t('应付金额')}:
|
||||
</Text>
|
||||
<Text strong className='text-xl text-purple-600'>
|
||||
{symbol}
|
||||
{displayPrice}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 支付方式 */}
|
||||
{purchaseLimitReached && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={`${t('已达到购买上限')} (${purchaseCount}/${purchaseLimit})`}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{hasAnyPayment ? (
|
||||
<div className='space-y-3'>
|
||||
<Text size='small' type='tertiary'>
|
||||
{t('选择支付方式')}:
|
||||
</Text>
|
||||
|
||||
{/* Stripe / Creem */}
|
||||
{(hasStripe || hasCreem) && (
|
||||
<div className='flex gap-2'>
|
||||
{hasStripe && (
|
||||
<Button
|
||||
theme='light'
|
||||
className='flex-1'
|
||||
icon={<SiStripe size={14} color='#635BFF' />}
|
||||
onClick={onPayStripe}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Stripe
|
||||
</Button>
|
||||
)}
|
||||
{hasCreem && (
|
||||
<Button
|
||||
theme='light'
|
||||
className='flex-1'
|
||||
icon={<IconCreditCard />}
|
||||
onClick={onPayCreem}
|
||||
loading={paying}
|
||||
disabled={purchaseLimitReached}
|
||||
>
|
||||
Creem
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 易支付 */}
|
||||
{hasEpay && (
|
||||
<div className='flex gap-2'>
|
||||
<Select
|
||||
value={selectedEpayMethod}
|
||||
onChange={setSelectedEpayMethod}
|
||||
style={{ flex: 1 }}
|
||||
size='default'
|
||||
placeholder={t('选择支付方式')}
|
||||
optionList={epayMethods.map((m) => ({
|
||||
value: m.type,
|
||||
label: m.name || m.type,
|
||||
}))}
|
||||
disabled={purchaseLimitReached}
|
||||
/>
|
||||
<Button
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={onPayEpay}
|
||||
loading={paying}
|
||||
disabled={!selectedEpayMethod || purchaseLimitReached}
|
||||
>
|
||||
{t('支付')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Banner
|
||||
type='info'
|
||||
description={t('管理员未开启在线支付功能,请联系管理员配置。')}
|
||||
className='!rounded-xl'
|
||||
closeIcon={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubscriptionPurchaseModal;
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Empty,
|
||||
Button,
|
||||
Input,
|
||||
Tag,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
@@ -49,6 +50,7 @@ const STATUS_CONFIG = {
|
||||
// 支付方式映射
|
||||
const PAYMENT_METHOD_MAP = {
|
||||
stripe: 'Stripe',
|
||||
creem: 'Creem',
|
||||
alipay: '支付宝',
|
||||
wxpay: '微信',
|
||||
};
|
||||
@@ -150,6 +152,11 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
|
||||
};
|
||||
|
||||
const isSubscriptionTopup = (record) => {
|
||||
const tradeNo = (record?.trade_no || '').toLowerCase();
|
||||
return Number(record?.amount || 0) === 0 && tradeNo.startsWith('sub');
|
||||
};
|
||||
|
||||
// 检查是否为管理员
|
||||
const userIsAdmin = useMemo(() => isAdmin(), []);
|
||||
|
||||
@@ -171,12 +178,21 @@ const TopupHistoryModal = ({ visible, onCancel, t }) => {
|
||||
title: t('充值额度'),
|
||||
dataIndex: 'amount',
|
||||
key: 'amount',
|
||||
render: (amount) => (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
),
|
||||
render: (amount, record) => {
|
||||
if (isSubscriptionTopup(record)) {
|
||||
return (
|
||||
<Tag color='purple' shape='circle' size='small'>
|
||||
{t('订阅套餐')}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className='flex items-center gap-1'>
|
||||
<Coins size={16} />
|
||||
<Text>{amount}</Text>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('支付金额'),
|
||||
|
||||
@@ -236,9 +236,7 @@ async function prepareOAuthState(options = {}) {
|
||||
if (shouldLogout) {
|
||||
try {
|
||||
await API.get('/api/user/logout', { skipErrorHandler: true });
|
||||
} catch (err) {
|
||||
|
||||
}
|
||||
} catch (err) {}
|
||||
localStorage.removeItem('user');
|
||||
updateAPI();
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ export const processRawData = (
|
||||
};
|
||||
|
||||
// 检查数据是否跨年
|
||||
const showYear = isDataCrossYear(data.map(item => item.created_at));
|
||||
const showYear = isDataCrossYear(data.map((item) => item.created_at));
|
||||
|
||||
data.forEach((item) => {
|
||||
result.uniqueModels.add(item.model_name);
|
||||
@@ -269,7 +269,11 @@ export const processRawData = (
|
||||
result.totalQuota += item.quota;
|
||||
result.totalTimes += item.count;
|
||||
|
||||
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear);
|
||||
const timeKey = timestamp2string1(
|
||||
item.created_at,
|
||||
dataExportDefaultTime,
|
||||
showYear,
|
||||
);
|
||||
if (!result.timePoints.includes(timeKey)) {
|
||||
result.timePoints.push(timeKey);
|
||||
}
|
||||
@@ -328,10 +332,14 @@ export const aggregateDataByTimeAndModel = (data, dataExportDefaultTime) => {
|
||||
const aggregatedData = new Map();
|
||||
|
||||
// 检查数据是否跨年
|
||||
const showYear = isDataCrossYear(data.map(item => item.created_at));
|
||||
const showYear = isDataCrossYear(data.map((item) => item.created_at));
|
||||
|
||||
data.forEach((item) => {
|
||||
const timeKey = timestamp2string1(item.created_at, dataExportDefaultTime, showYear);
|
||||
const timeKey = timestamp2string1(
|
||||
item.created_at,
|
||||
dataExportDefaultTime,
|
||||
showYear,
|
||||
);
|
||||
const modelKey = item.model_name;
|
||||
const key = `${timeKey}-${modelKey}`;
|
||||
|
||||
@@ -372,7 +380,7 @@ export const generateChartTimePoints = (
|
||||
);
|
||||
const showYear = isDataCrossYear(generatedTimestamps);
|
||||
|
||||
chartTimePoints = generatedTimestamps.map(ts =>
|
||||
chartTimePoints = generatedTimestamps.map((ts) =>
|
||||
timestamp2string1(ts, dataExportDefaultTime, showYear),
|
||||
);
|
||||
}
|
||||
|
||||
25
web/src/helpers/quota.js
Normal file
25
web/src/helpers/quota.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { getCurrencyConfig } from './render';
|
||||
|
||||
export const getQuotaPerUnit = () => {
|
||||
const raw = parseFloat(localStorage.getItem('quota_per_unit') || '1');
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
||||
};
|
||||
|
||||
export const quotaToDisplayAmount = (quota) => {
|
||||
const q = Number(quota || 0);
|
||||
if (!Number.isFinite(q) || q <= 0) return 0;
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return q;
|
||||
const usd = q / getQuotaPerUnit();
|
||||
if (type === 'USD') return usd;
|
||||
return usd * (rate || 1);
|
||||
};
|
||||
|
||||
export const displayAmountToQuota = (amount) => {
|
||||
const val = Number(amount || 0);
|
||||
if (!Number.isFinite(val) || val <= 0) return 0;
|
||||
const { type, rate } = getCurrencyConfig();
|
||||
if (type === 'TOKENS') return Math.round(val);
|
||||
const usd = type === 'USD' ? val : val / (rate || 1);
|
||||
return Math.round(usd * getQuotaPerUnit());
|
||||
};
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
CircleUser,
|
||||
Package,
|
||||
Server,
|
||||
CalendarClock,
|
||||
} from 'lucide-react';
|
||||
|
||||
// 获取侧边栏Lucide图标组件
|
||||
@@ -117,6 +118,8 @@ export function getLucideIcon(key, selected = false) {
|
||||
return <Package {...commonProps} color={iconColor} />;
|
||||
case 'deployment':
|
||||
return <Server {...commonProps} color={iconColor} />;
|
||||
case 'subscription':
|
||||
return <CalendarClock {...commonProps} color={iconColor} />;
|
||||
case 'setting':
|
||||
return <Settings {...commonProps} color={iconColor} />;
|
||||
default:
|
||||
@@ -167,21 +170,21 @@ export const getModelCategories = (() => {
|
||||
gemini: {
|
||||
label: 'Gemini',
|
||||
icon: <Gemini.Color />,
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('gemini') ||
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('gemini') ||
|
||||
model.model_name.toLowerCase().includes('gemma') ||
|
||||
model.model_name.toLowerCase().includes('learnlm') ||
|
||||
model.model_name.toLowerCase().includes('learnlm') ||
|
||||
model.model_name.toLowerCase().startsWith('embedding-') ||
|
||||
model.model_name.toLowerCase().includes('text-embedding-004') ||
|
||||
model.model_name.toLowerCase().includes('imagen-4') ||
|
||||
model.model_name.toLowerCase().includes('veo-') ||
|
||||
model.model_name.toLowerCase().includes('aqa') ,
|
||||
model.model_name.toLowerCase().includes('imagen-4') ||
|
||||
model.model_name.toLowerCase().includes('veo-') ||
|
||||
model.model_name.toLowerCase().includes('aqa'),
|
||||
},
|
||||
moonshot: {
|
||||
label: 'Moonshot',
|
||||
icon: <Moonshot />,
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('moonshot') ||
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('moonshot') ||
|
||||
model.model_name.toLowerCase().includes('kimi'),
|
||||
},
|
||||
zhipu: {
|
||||
@@ -189,8 +192,8 @@ export const getModelCategories = (() => {
|
||||
icon: <Zhipu.Color />,
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('chatglm') ||
|
||||
model.model_name.toLowerCase().includes('glm-') ||
|
||||
model.model_name.toLowerCase().includes('cogview') ||
|
||||
model.model_name.toLowerCase().includes('glm-') ||
|
||||
model.model_name.toLowerCase().includes('cogview') ||
|
||||
model.model_name.toLowerCase().includes('cogvideo'),
|
||||
},
|
||||
qwen: {
|
||||
@@ -206,8 +209,8 @@ export const getModelCategories = (() => {
|
||||
minimax: {
|
||||
label: 'MiniMax',
|
||||
icon: <Minimax.Color />,
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('abab') ||
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('abab') ||
|
||||
model.model_name.toLowerCase().includes('minimax'),
|
||||
},
|
||||
baidu: {
|
||||
@@ -233,7 +236,7 @@ export const getModelCategories = (() => {
|
||||
cohere: {
|
||||
label: 'Cohere',
|
||||
icon: <Cohere.Color />,
|
||||
filter: (model) =>
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('command') ||
|
||||
model.model_name.toLowerCase().includes('c4ai-') ||
|
||||
model.model_name.toLowerCase().includes('embed-'),
|
||||
@@ -256,7 +259,7 @@ export const getModelCategories = (() => {
|
||||
mistral: {
|
||||
label: 'Mistral AI',
|
||||
icon: <Mistral.Color />,
|
||||
filter: (model) =>
|
||||
filter: (model) =>
|
||||
model.model_name.toLowerCase().includes('mistral') ||
|
||||
model.model_name.toLowerCase().includes('codestral') ||
|
||||
model.model_name.toLowerCase().includes('pixtral') ||
|
||||
@@ -602,6 +605,34 @@ export function stringToColor(str) {
|
||||
return colors[i];
|
||||
}
|
||||
|
||||
// High-contrast color palette for group tags (avoids similar blue/teal shades)
|
||||
const groupColors = [
|
||||
'red',
|
||||
'orange',
|
||||
'yellow',
|
||||
'lime',
|
||||
'green',
|
||||
'cyan',
|
||||
'blue',
|
||||
'indigo',
|
||||
'violet',
|
||||
'purple',
|
||||
'pink',
|
||||
'amber',
|
||||
'grey',
|
||||
];
|
||||
|
||||
export function groupToColor(str) {
|
||||
// Use a better hash algorithm for more even distribution
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash = hash & hash;
|
||||
}
|
||||
hash = Math.abs(hash);
|
||||
return groupColors[hash % groupColors.length];
|
||||
}
|
||||
|
||||
// 渲染带有模型图标的标签
|
||||
export function renderModelTag(modelName, options = {}) {
|
||||
const {
|
||||
@@ -670,7 +701,7 @@ export function renderGroup(group) {
|
||||
<span key={group}>
|
||||
{groups.map((group) => (
|
||||
<Tag
|
||||
color={tagColors[group] || stringToColor(group)}
|
||||
color={tagColors[group] || groupToColor(group)}
|
||||
key={group}
|
||||
shape='circle'
|
||||
onClick={async (event) => {
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
export function parseHttpStatusCodeRules(input) {
|
||||
const raw = (input ?? '').toString().trim();
|
||||
if (raw.length === 0) {
|
||||
@@ -35,7 +53,9 @@ export function parseHttpStatusCodeRules(input) {
|
||||
}
|
||||
|
||||
const merged = mergeRanges(ranges);
|
||||
const tokens = merged.map((r) => (r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`));
|
||||
const tokens = merged.map((r) =>
|
||||
r.start === r.end ? `${r.start}` : `${r.start}-${r.end}`,
|
||||
);
|
||||
const normalized = tokens.join(',');
|
||||
|
||||
return {
|
||||
@@ -78,7 +98,9 @@ function isNumber(s) {
|
||||
function mergeRanges(ranges) {
|
||||
if (!Array.isArray(ranges) || ranges.length === 0) return [];
|
||||
|
||||
const sorted = [...ranges].sort((a, b) => (a.start !== b.start ? a.start - b.start : a.end - b.end));
|
||||
const sorted = [...ranges].sort((a, b) =>
|
||||
a.start !== b.start ? a.start - b.start : a.end - b.end,
|
||||
);
|
||||
const merged = [sorted[0]];
|
||||
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
|
||||
34
web/src/helpers/subscriptionFormat.js
Normal file
34
web/src/helpers/subscriptionFormat.js
Normal file
@@ -0,0 +1,34 @@
|
||||
export function formatSubscriptionDuration(plan, t) {
|
||||
const unit = plan?.duration_unit || 'month';
|
||||
const value = plan?.duration_value || 1;
|
||||
const unitLabels = {
|
||||
year: t('年'),
|
||||
month: t('个月'),
|
||||
day: t('天'),
|
||||
hour: t('小时'),
|
||||
custom: t('自定义'),
|
||||
};
|
||||
if (unit === 'custom') {
|
||||
const seconds = plan?.custom_seconds || 0;
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return `${value} ${unitLabels[unit] || unit}`;
|
||||
}
|
||||
|
||||
export function formatSubscriptionResetPeriod(plan, t) {
|
||||
const period = plan?.quota_reset_period || 'never';
|
||||
if (period === 'never') return t('不重置');
|
||||
if (period === 'daily') return t('每天');
|
||||
if (period === 'weekly') return t('每周');
|
||||
if (period === 'monthly') return t('每月');
|
||||
if (period === 'custom') {
|
||||
const seconds = Number(plan?.quota_reset_custom_seconds || 0);
|
||||
if (seconds >= 86400) return `${Math.floor(seconds / 86400)} ${t('天')}`;
|
||||
if (seconds >= 3600) return `${Math.floor(seconds / 3600)} ${t('小时')}`;
|
||||
if (seconds >= 60) return `${Math.floor(seconds / 60)} ${t('分钟')}`;
|
||||
return `${seconds} ${t('秒')}`;
|
||||
}
|
||||
return t('不重置');
|
||||
}
|
||||
@@ -217,7 +217,11 @@ export function timestamp2string(timestamp) {
|
||||
);
|
||||
}
|
||||
|
||||
export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', showYear = false) {
|
||||
export function timestamp2string1(
|
||||
timestamp,
|
||||
dataExportDefaultTime = 'hour',
|
||||
showYear = false,
|
||||
) {
|
||||
let date = new Date(timestamp * 1000);
|
||||
let year = date.getFullYear();
|
||||
let month = (date.getMonth() + 1).toString();
|
||||
@@ -248,7 +252,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
|
||||
nextDay = '0' + nextDay;
|
||||
}
|
||||
// 周视图结束日期也仅在跨年时显示年份
|
||||
let nextStr = showYear ? nextWeekYear + '-' + nextMonth + '-' + nextDay : nextMonth + '-' + nextDay;
|
||||
let nextStr = showYear
|
||||
? nextWeekYear + '-' + nextMonth + '-' + nextDay
|
||||
: nextMonth + '-' + nextDay;
|
||||
str += ' - ' + nextStr;
|
||||
}
|
||||
return str;
|
||||
@@ -257,7 +263,9 @@ export function timestamp2string1(timestamp, dataExportDefaultTime = 'hour', sho
|
||||
// 检查时间戳数组是否跨年
|
||||
export function isDataCrossYear(timestamps) {
|
||||
if (!timestamps || timestamps.length === 0) return false;
|
||||
const years = new Set(timestamps.map(ts => new Date(ts * 1000).getFullYear()));
|
||||
const years = new Set(
|
||||
timestamps.map((ts) => new Date(ts * 1000).getFullYear()),
|
||||
);
|
||||
return years.size > 1;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user