mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 09:38:39 +00:00
✨ feat: add subscription billing system with admin management and user purchase flow
Implement a new subscription-based billing model alongside existing metered/per-request billing: Backend: - Add subscription plan models (SubscriptionPlan, SubscriptionPlanItem, UserSubscription, etc.) - Implement CRUD APIs for subscription plan management (admin only) - Add user subscription queries with support for multiple active/expired subscriptions - Integrate payment gateways (Stripe, Creem, Epay) for subscription purchases - Implement pre-consume and post-consume billing logic for subscription quota tracking - Add billing preference settings (subscription_first, wallet_first, etc.) - Enhance usage logs with subscription deduction details Frontend - Admin: - Add subscription management page with table view and drawer-based edit form - Match UI/UX style with existing admin pages (redemption codes, users) - Support enabling/disabling plans, configuring payment IDs, and model quotas - Add user subscription binding modal in user management Frontend - Wallet: - Add subscription plans card with current subscription status display - Show all subscriptions (active and expired) with remaining days/usage percentage - Display purchasable plans with pricing cards following SaaS best practices - Extract purchase modal to separate component matching payment confirm modal style - Add skeleton loading states with active animation - Implement billing preference selector in card header - Handle payment gateway availability based on admin configuration Frontend - Usage Logs: - Display subscription deduction details in log entries - Show step-by-step breakdown of subscription usage (pre-consumed, delta, final, remaining) - Add subscription deduction tag for subscription-covered requests
This commit is contained in:
@@ -159,7 +159,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
|
|||||||
if priceData.FreeModel {
|
if priceData.FreeModel {
|
||||||
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
|
logger.LogInfo(c, fmt.Sprintf("模型 %s 免费,跳过预扣费", relayInfo.OriginModelName))
|
||||||
} else {
|
} else {
|
||||||
newAPIError = service.PreConsumeQuota(c, priceData.QuotaToPreConsume, relayInfo)
|
newAPIError = service.PreConsumeBilling(c, priceData.QuotaToPreConsume, relayInfo)
|
||||||
if newAPIError != nil {
|
if newAPIError != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
303
controller/subscription.go
Normal file
303
controller/subscription.go
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- Shared types ----
|
||||||
|
|
||||||
|
type SubscriptionPlanDTO struct {
|
||||||
|
Plan model.SubscriptionPlan `json:"plan"`
|
||||||
|
Items []model.SubscriptionPlanItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BillingPreferenceRequest struct {
|
||||||
|
BillingPreference string `json:"billing_preference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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 {
|
||||||
|
items, _ := model.GetSubscriptionPlanItems(p.Id)
|
||||||
|
result = append(result, SubscriptionPlanDTO{
|
||||||
|
Plan: p,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubscriptionSelf(c *gin.Context) {
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
settingMap, _ := model.GetUserSetting(userId, false)
|
||||||
|
pref := 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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For backward compatibility, also return the first active subscription as "subscription"
|
||||||
|
var summary *model.SubscriptionSummary
|
||||||
|
if len(activeSubscriptions) > 0 {
|
||||||
|
summary = &activeSubscriptions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
common.ApiSuccess(c, gin.H{
|
||||||
|
"billing_preference": pref,
|
||||||
|
"subscription": summary, // backward compatibility (first active)
|
||||||
|
"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 := 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 {
|
||||||
|
items, _ := model.GetSubscriptionPlanItems(p.Id)
|
||||||
|
result = append(result, SubscriptionPlanDTO{
|
||||||
|
Plan: p,
|
||||||
|
Items: items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminUpsertSubscriptionPlanRequest struct {
|
||||||
|
Plan model.SubscriptionPlan `json:"plan"`
|
||||||
|
Items []model.SubscriptionPlanItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
if req.Plan.DurationUnit == "" {
|
||||||
|
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||||
|
}
|
||||||
|
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||||
|
req.Plan.DurationValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
db := model.DB
|
||||||
|
err := db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&req.Plan).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := make([]model.SubscriptionPlanItem, 0, len(req.Items))
|
||||||
|
for _, it := range req.Items {
|
||||||
|
if strings.TrimSpace(it.ModelName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.AmountTotal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
it.Id = 0
|
||||||
|
it.PlanId = req.Plan.Id
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return errors.New("无有效的模型权益配置")
|
||||||
|
}
|
||||||
|
return tx.Create(&items).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
if req.Plan.DurationUnit == "" {
|
||||||
|
req.Plan.DurationUnit = model.SubscriptionDurationMonth
|
||||||
|
}
|
||||||
|
if req.Plan.DurationValue <= 0 && req.Plan.DurationUnit != model.SubscriptionDurationCustom {
|
||||||
|
req.Plan.DurationValue = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
common.ApiErrorMsg(c, "套餐至少需要配置一个模型权益")
|
||||||
|
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,
|
||||||
|
"updated_at": common.GetTimestamp(),
|
||||||
|
}
|
||||||
|
if err := tx.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Updates(updateMap).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// replace items
|
||||||
|
if err := tx.Where("plan_id = ?", id).Delete(&model.SubscriptionPlanItem{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
items := make([]model.SubscriptionPlanItem, 0, len(req.Items))
|
||||||
|
for _, it := range req.Items {
|
||||||
|
if strings.TrimSpace(it.ModelName) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if it.AmountTotal <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
it.Id = 0
|
||||||
|
it.PlanId = id
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return errors.New("无有效的模型权益配置")
|
||||||
|
}
|
||||||
|
return tx.Create(&items).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AdminDeleteSubscriptionPlan(c *gin.Context) {
|
||||||
|
id, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
if id <= 0 {
|
||||||
|
common.ApiErrorMsg(c, "无效的ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// best practice: disable instead of hard delete to avoid breaking past orders
|
||||||
|
if err := model.DB.Model(&model.SubscriptionPlan{}).Where("id = ?", id).Update("enabled", false).Error; err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if err := model.AdminBindSubscription(req.UserId, req.PlanId, ""); err != nil {
|
||||||
|
common.ApiError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
common.ApiSuccess(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helper: serialize provider payload safely ----
|
||||||
|
|
||||||
|
func jsonString(v any) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
95
controller/subscription_payment_creem.go
Normal file
95
controller/subscription_payment_creem.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
user, _ := model.GetUserById(userId, false)
|
||||||
|
|
||||||
|
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.
|
||||||
|
product := &CreemProduct{
|
||||||
|
ProductId: plan.CreemProductId,
|
||||||
|
Name: plan.Title,
|
||||||
|
Price: plan.PriceAmount,
|
||||||
|
Currency: plan.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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
126
controller/subscription_payment_epay.go
Normal file
126
controller/subscription_payment_epay.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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/Calcium-Ion/go-epay/epay"
|
||||||
|
"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")
|
||||||
|
|
||||||
|
callBackAddress := service.GetCallbackAddress()
|
||||||
|
returnUrl, _ := url.Parse(system_setting.ServerAddress + "/console/topup")
|
||||||
|
notifyUrl, _ := url.Parse(callBackAddress + "/api/subscription/epay/notify")
|
||||||
|
|
||||||
|
tradeNo := fmt.Sprintf("%s%d", common.GetRandomString(6), time.Now().Unix())
|
||||||
|
tradeNo = fmt.Sprintf("SUBUSR%dNO%s", userId, tradeNo)
|
||||||
|
|
||||||
|
client := GetEpayClient()
|
||||||
|
if client == nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "当前管理员未配置支付信息"})
|
||||||
|
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 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "拉起支付失败"})
|
||||||
|
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 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "error", "data": "创建订单失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "success", "data": params, "url": uri})
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubscriptionEpayNotify(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)
|
||||||
|
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("success"))
|
||||||
|
} else {
|
||||||
|
_, _ = c.Writer.Write([]byte("fail"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if verifyInfo.TradeStatus != epay.StatusTradeSuccess {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LockOrder(verifyInfo.ServiceTradeNo)
|
||||||
|
defer UnlockOrder(verifyInfo.ServiceTradeNo)
|
||||||
|
|
||||||
|
if err := model.CompleteSubscriptionOrder(verifyInfo.ServiceTradeNo, jsonString(verifyInfo)); err != nil {
|
||||||
|
// do not fail webhook response after signature verified
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
115
controller/subscription_payment_stripe.go
Normal file
115
controller/subscription_payment_stripe.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
userId := c.GetInt("id")
|
||||||
|
user, _ := model.GetUserById(userId, false)
|
||||||
|
|
||||||
|
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.CheckoutSessionModePayment)),
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -308,7 +308,18 @@ func handleCheckoutCompleted(c *gin.Context, event *CreemWebhookEvent) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证订单类型,目前只处理一次性付款
|
// Subscription order takes precedence (accept both onetime/subscription types)
|
||||||
|
if model.GetSubscriptionOrderByTradeNo(referenceId) != nil {
|
||||||
|
if err := model.CompleteSubscriptionOrder(referenceId, jsonString(event)); err != nil {
|
||||||
|
log.Printf("Creem订阅订单处理失败: %s, 订单号: %s", err.Error(), referenceId)
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证订单类型,目前只处理一次性付款(充值)
|
||||||
if event.Object.Order.Type != "onetime" {
|
if event.Object.Order.Type != "onetime" {
|
||||||
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
log.Printf("暂不支持的订单类型: %s, 跳过处理", event.Object.Order.Type)
|
||||||
c.Status(http.StatusOK)
|
c.Status(http.StatusOK)
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ func sessionCompleted(event stripe.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription order takes precedence
|
||||||
|
if model.GetSubscriptionOrderByTradeNo(referenceId) != nil {
|
||||||
|
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, jsonString(payload)); err != nil {
|
||||||
|
log.Println("complete subscription order failed:", err.Error(), referenceId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err := model.Recharge(referenceId, customerId)
|
err := model.Recharge(referenceId, customerId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err.Error(), referenceId)
|
log.Println(err.Error(), referenceId)
|
||||||
@@ -190,6 +204,14 @@ func sessionExpired(event stripe.Event) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscription order expiration
|
||||||
|
if model.GetSubscriptionOrderByTradeNo(referenceId) != nil {
|
||||||
|
if err := model.ExpireSubscriptionOrder(referenceId); err != nil {
|
||||||
|
log.Println("过期订阅订单失败", referenceId, ", err:", err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
topUp := model.GetTopUpByTradeNo(referenceId)
|
topUp := model.GetTopUpByTradeNo(referenceId)
|
||||||
if topUp == nil {
|
if topUp == nil {
|
||||||
log.Println("充值订单不存在", referenceId)
|
log.Println("充值订单不存在", referenceId)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ type UserSetting struct {
|
|||||||
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
|
||||||
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
|
||||||
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
|
||||||
|
BillingPreference string `json:"billing_preference,omitempty"` // BillingPreference 扣费策略(订阅/钱包)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -268,6 +268,11 @@ func migrateDB() error {
|
|||||||
&TwoFA{},
|
&TwoFA{},
|
||||||
&TwoFABackupCode{},
|
&TwoFABackupCode{},
|
||||||
&Checkin{},
|
&Checkin{},
|
||||||
|
&SubscriptionPlan{},
|
||||||
|
&SubscriptionPlanItem{},
|
||||||
|
&SubscriptionOrder{},
|
||||||
|
&UserSubscription{},
|
||||||
|
&UserSubscriptionItem{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -302,6 +307,11 @@ func migrateDBFast() error {
|
|||||||
{&TwoFA{}, "TwoFA"},
|
{&TwoFA{}, "TwoFA"},
|
||||||
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
{&TwoFABackupCode{}, "TwoFABackupCode"},
|
||||||
{&Checkin{}, "Checkin"},
|
{&Checkin{}, "Checkin"},
|
||||||
|
{&SubscriptionPlan{}, "SubscriptionPlan"},
|
||||||
|
{&SubscriptionPlanItem{}, "SubscriptionPlanItem"},
|
||||||
|
{&SubscriptionOrder{}, "SubscriptionOrder"},
|
||||||
|
{&UserSubscription{}, "UserSubscription"},
|
||||||
|
{&UserSubscriptionItem{}, "UserSubscriptionItem"},
|
||||||
}
|
}
|
||||||
// 动态计算migration数量,确保errChan缓冲区足够大
|
// 动态计算migration数量,确保errChan缓冲区足够大
|
||||||
errChan := make(chan error, len(migrations))
|
errChan := make(chan error, len(migrations))
|
||||||
|
|||||||
515
model/subscription.go
Normal file
515
model/subscription.go
Normal file
@@ -0,0 +1,515 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/QuantumNous/new-api/common"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscription duration units
|
||||||
|
const (
|
||||||
|
SubscriptionDurationYear = "year"
|
||||||
|
SubscriptionDurationMonth = "month"
|
||||||
|
SubscriptionDurationDay = "day"
|
||||||
|
SubscriptionDurationHour = "hour"
|
||||||
|
SubscriptionDurationCustom = "custom"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Subscription plan
|
||||||
|
type SubscriptionPlan struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
|
||||||
|
Title string `json:"title" gorm:"type:varchar(128);not null"`
|
||||||
|
Subtitle string `json:"subtitle" gorm:"type:varchar(255);default:''"`
|
||||||
|
|
||||||
|
// Display money amount (follow existing code style: float64 for money)
|
||||||
|
PriceAmount float64 `json:"price_amount" gorm:"type:double;not null;default:0"`
|
||||||
|
Currency string `json:"currency" gorm:"type:varchar(8);not null;default:'USD'"`
|
||||||
|
|
||||||
|
DurationUnit string `json:"duration_unit" gorm:"type:varchar(16);not null;default:'month'"`
|
||||||
|
DurationValue int `json:"duration_value" gorm:"type:int;not null;default:1"`
|
||||||
|
CustomSeconds int64 `json:"custom_seconds" gorm:"type:bigint;not null;default:0"`
|
||||||
|
|
||||||
|
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||||
|
SortOrder int `json:"sort_order" gorm:"type:int;default:0"`
|
||||||
|
|
||||||
|
StripePriceId string `json:"stripe_price_id" gorm:"type:varchar(128);default:''"`
|
||||||
|
CreemProductId string `json:"creem_product_id" gorm:"type:varchar(128);default:''"`
|
||||||
|
|
||||||
|
CreatedAt int64 `json:"created_at" gorm:"bigint"`
|
||||||
|
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SubscriptionPlan) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
p.CreatedAt = now
|
||||||
|
p.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *SubscriptionPlan) BeforeUpdate(tx *gorm.DB) error {
|
||||||
|
p.UpdatedAt = common.GetTimestamp()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionPlanItem struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
PlanId int `json:"plan_id" gorm:"index"`
|
||||||
|
|
||||||
|
ModelName string `json:"model_name" gorm:"type:varchar(128);index"`
|
||||||
|
// 0=按量(额度), 1=按次(次数)
|
||||||
|
QuotaType int `json:"quota_type" gorm:"type:int;index"`
|
||||||
|
|
||||||
|
// If quota_type=0 => amount in quota units; if quota_type=1 => request count.
|
||||||
|
AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription order (payment -> webhook -> create UserSubscription)
|
||||||
|
type SubscriptionOrder struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
UserId int `json:"user_id" gorm:"index"`
|
||||||
|
PlanId int `json:"plan_id" gorm:"index"`
|
||||||
|
Money float64 `json:"money"`
|
||||||
|
|
||||||
|
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
|
||||||
|
PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
CreateTime int64 `json:"create_time"`
|
||||||
|
CompleteTime int64 `json:"complete_time"`
|
||||||
|
|
||||||
|
ProviderPayload string `json:"provider_payload" gorm:"type:text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *SubscriptionOrder) Insert() error {
|
||||||
|
if o.CreateTime == 0 {
|
||||||
|
o.CreateTime = common.GetTimestamp()
|
||||||
|
}
|
||||||
|
return DB.Create(o).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *SubscriptionOrder) Update() error {
|
||||||
|
return DB.Save(o).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubscriptionOrderByTradeNo(tradeNo string) *SubscriptionOrder {
|
||||||
|
if tradeNo == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var order SubscriptionOrder
|
||||||
|
if err := DB.Where("trade_no = ?", tradeNo).First(&order).Error; err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &order
|
||||||
|
}
|
||||||
|
|
||||||
|
// User subscription instance
|
||||||
|
type UserSubscription struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
UserId int `json:"user_id" gorm:"index"`
|
||||||
|
PlanId int `json:"plan_id" gorm:"index"`
|
||||||
|
|
||||||
|
StartTime int64 `json:"start_time" gorm:"bigint"`
|
||||||
|
EndTime int64 `json:"end_time" gorm:"bigint;index"`
|
||||||
|
Status string `json:"status" gorm:"type:varchar(32);index"` // active/expired/cancelled
|
||||||
|
|
||||||
|
Source string `json:"source" gorm:"type:varchar(32);default:'order'"` // order/admin
|
||||||
|
|
||||||
|
CreatedAt int64 `json:"created_at" gorm:"bigint"`
|
||||||
|
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserSubscription) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
s.CreatedAt = now
|
||||||
|
s.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserSubscription) BeforeUpdate(tx *gorm.DB) error {
|
||||||
|
s.UpdatedAt = common.GetTimestamp()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserSubscriptionItem struct {
|
||||||
|
Id int `json:"id"`
|
||||||
|
UserSubscriptionId int `json:"user_subscription_id" gorm:"index"`
|
||||||
|
ModelName string `json:"model_name" gorm:"type:varchar(128);index"`
|
||||||
|
QuotaType int `json:"quota_type" gorm:"type:int;index"`
|
||||||
|
AmountTotal int64 `json:"amount_total" gorm:"type:bigint;not null;default:0"`
|
||||||
|
AmountUsed int64 `json:"amount_used" gorm:"type:bigint;not null;default:0"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionSummary struct {
|
||||||
|
Subscription *UserSubscription `json:"subscription"`
|
||||||
|
Items []UserSubscriptionItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func calcPlanEndTime(start time.Time, plan *SubscriptionPlan) (int64, error) {
|
||||||
|
if plan == nil {
|
||||||
|
return 0, errors.New("plan is nil")
|
||||||
|
}
|
||||||
|
if plan.DurationValue <= 0 && plan.DurationUnit != SubscriptionDurationCustom {
|
||||||
|
return 0, errors.New("duration_value must be > 0")
|
||||||
|
}
|
||||||
|
switch plan.DurationUnit {
|
||||||
|
case SubscriptionDurationYear:
|
||||||
|
return start.AddDate(plan.DurationValue, 0, 0).Unix(), nil
|
||||||
|
case SubscriptionDurationMonth:
|
||||||
|
return start.AddDate(0, plan.DurationValue, 0).Unix(), nil
|
||||||
|
case SubscriptionDurationDay:
|
||||||
|
return start.Add(time.Duration(plan.DurationValue) * 24 * time.Hour).Unix(), nil
|
||||||
|
case SubscriptionDurationHour:
|
||||||
|
return start.Add(time.Duration(plan.DurationValue) * time.Hour).Unix(), nil
|
||||||
|
case SubscriptionDurationCustom:
|
||||||
|
if plan.CustomSeconds <= 0 {
|
||||||
|
return 0, errors.New("custom_seconds must be > 0")
|
||||||
|
}
|
||||||
|
return start.Add(time.Duration(plan.CustomSeconds) * time.Second).Unix(), nil
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("invalid duration_unit: %s", plan.DurationUnit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubscriptionPlanById(id int) (*SubscriptionPlan, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, errors.New("invalid plan id")
|
||||||
|
}
|
||||||
|
var plan SubscriptionPlan
|
||||||
|
if err := DB.Where("id = ?", id).First(&plan).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &plan, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubscriptionPlanItems(planId int) ([]SubscriptionPlanItem, error) {
|
||||||
|
if planId <= 0 {
|
||||||
|
return nil, errors.New("invalid plan id")
|
||||||
|
}
|
||||||
|
var items []SubscriptionPlanItem
|
||||||
|
if err := DB.Where("plan_id = ?", planId).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateUserSubscriptionFromPlanTx(tx *gorm.DB, userId int, plan *SubscriptionPlan, source string) (*UserSubscription, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return nil, errors.New("tx is nil")
|
||||||
|
}
|
||||||
|
if plan == nil || plan.Id == 0 {
|
||||||
|
return nil, errors.New("invalid plan")
|
||||||
|
}
|
||||||
|
if userId <= 0 {
|
||||||
|
return nil, errors.New("invalid user id")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
endUnix, err := calcPlanEndTime(now, plan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sub := &UserSubscription{
|
||||||
|
UserId: userId,
|
||||||
|
PlanId: plan.Id,
|
||||||
|
StartTime: now.Unix(),
|
||||||
|
EndTime: endUnix,
|
||||||
|
Status: "active",
|
||||||
|
Source: source,
|
||||||
|
CreatedAt: common.GetTimestamp(),
|
||||||
|
UpdatedAt: common.GetTimestamp(),
|
||||||
|
}
|
||||||
|
if err := tx.Create(sub).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := GetSubscriptionPlanItems(plan.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, errors.New("plan has no items")
|
||||||
|
}
|
||||||
|
userItems := make([]UserSubscriptionItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
userItems = append(userItems, UserSubscriptionItem{
|
||||||
|
UserSubscriptionId: sub.Id,
|
||||||
|
ModelName: it.ModelName,
|
||||||
|
QuotaType: it.QuotaType,
|
||||||
|
AmountTotal: it.AmountTotal,
|
||||||
|
AmountUsed: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := tx.Create(&userItems).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return sub, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete a subscription order (idempotent). Creates a UserSubscription snapshot from the plan.
|
||||||
|
func CompleteSubscriptionOrder(tradeNo string, providerPayload string) error {
|
||||||
|
if tradeNo == "" {
|
||||||
|
return errors.New("tradeNo is empty")
|
||||||
|
}
|
||||||
|
refCol := "`trade_no`"
|
||||||
|
if common.UsingPostgreSQL {
|
||||||
|
refCol = `"trade_no"`
|
||||||
|
}
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order SubscriptionOrder
|
||||||
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
||||||
|
return errors.New("subscription order not found")
|
||||||
|
}
|
||||||
|
if order.Status == common.TopUpStatusSuccess {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if order.Status != common.TopUpStatusPending {
|
||||||
|
return errors.New("subscription order status invalid")
|
||||||
|
}
|
||||||
|
plan, err := GetSubscriptionPlanById(order.PlanId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !plan.Enabled {
|
||||||
|
// still allow completion for already purchased orders
|
||||||
|
}
|
||||||
|
_, err = CreateUserSubscriptionFromPlanTx(tx, order.UserId, plan, "order")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
order.Status = common.TopUpStatusSuccess
|
||||||
|
order.CompleteTime = common.GetTimestamp()
|
||||||
|
if providerPayload != "" {
|
||||||
|
order.ProviderPayload = providerPayload
|
||||||
|
}
|
||||||
|
return tx.Save(&order).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExpireSubscriptionOrder(tradeNo string) error {
|
||||||
|
if tradeNo == "" {
|
||||||
|
return errors.New("tradeNo is empty")
|
||||||
|
}
|
||||||
|
refCol := "`trade_no`"
|
||||||
|
if common.UsingPostgreSQL {
|
||||||
|
refCol = `"trade_no"`
|
||||||
|
}
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var order SubscriptionOrder
|
||||||
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(&order).Error; err != nil {
|
||||||
|
return errors.New("subscription order not found")
|
||||||
|
}
|
||||||
|
if order.Status != common.TopUpStatusPending {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
order.Status = common.TopUpStatusExpired
|
||||||
|
order.CompleteTime = common.GetTimestamp()
|
||||||
|
return tx.Save(&order).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bind (no payment). Creates a UserSubscription from a plan.
|
||||||
|
func AdminBindSubscription(userId int, planId int, sourceNote string) error {
|
||||||
|
if userId <= 0 || planId <= 0 {
|
||||||
|
return errors.New("invalid userId or planId")
|
||||||
|
}
|
||||||
|
plan, err := GetSubscriptionPlanById(planId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
_, err := CreateUserSubscriptionFromPlanTx(tx, userId, plan, "admin")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current active subscription (best-effort: latest end_time)
|
||||||
|
func GetActiveUserSubscription(userId int) (*SubscriptionSummary, error) {
|
||||||
|
if userId <= 0 {
|
||||||
|
return nil, errors.New("invalid userId")
|
||||||
|
}
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
var sub UserSubscription
|
||||||
|
err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
|
||||||
|
Order("end_time desc, id desc").
|
||||||
|
First(&sub).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var items []UserSubscriptionItem
|
||||||
|
if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SubscriptionSummary{Subscription: &sub, Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllActiveUserSubscriptions returns all active subscriptions for a user.
|
||||||
|
func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||||
|
if userId <= 0 {
|
||||||
|
return nil, errors.New("invalid userId")
|
||||||
|
}
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
var subs []UserSubscription
|
||||||
|
err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
|
||||||
|
Order("end_time desc, id desc").
|
||||||
|
Find(&subs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]SubscriptionSummary, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
var items []UserSubscriptionItem
|
||||||
|
if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subCopy := sub
|
||||||
|
result = append(result, SubscriptionSummary{Subscription: &subCopy, Items: items})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
|
||||||
|
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||||
|
if userId <= 0 {
|
||||||
|
return nil, errors.New("invalid userId")
|
||||||
|
}
|
||||||
|
var subs []UserSubscription
|
||||||
|
err := DB.Where("user_id = ?", userId).
|
||||||
|
Order("end_time desc, id desc").
|
||||||
|
Find(&subs).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]SubscriptionSummary, 0, len(subs))
|
||||||
|
for _, sub := range subs {
|
||||||
|
var items []UserSubscriptionItem
|
||||||
|
if err := DB.Where("user_subscription_id = ?", sub.Id).Find(&items).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
subCopy := sub
|
||||||
|
result = append(result, SubscriptionSummary{Subscription: &subCopy, Items: items})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionPreConsumeResult struct {
|
||||||
|
UserSubscriptionId int
|
||||||
|
ItemId int
|
||||||
|
QuotaType int
|
||||||
|
PreConsumed int64
|
||||||
|
AmountTotal int64
|
||||||
|
AmountUsedBefore int64
|
||||||
|
AmountUsedAfter int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreConsumeUserSubscription finds a valid active subscription item and increments amount_used.
|
||||||
|
// quotaType=0 => consume quota units; quotaType=1 => consume request count (usually 1).
|
||||||
|
func PreConsumeUserSubscription(userId int, modelName string, quotaType int, amount int64) (*SubscriptionPreConsumeResult, error) {
|
||||||
|
if userId <= 0 {
|
||||||
|
return nil, errors.New("invalid userId")
|
||||||
|
}
|
||||||
|
if modelName == "" {
|
||||||
|
return nil, errors.New("modelName is empty")
|
||||||
|
}
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil, errors.New("amount must be > 0")
|
||||||
|
}
|
||||||
|
now := common.GetTimestamp()
|
||||||
|
|
||||||
|
returnValue := &SubscriptionPreConsumeResult{}
|
||||||
|
err := DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var item UserSubscriptionItem
|
||||||
|
// lock item row; join to ensure subscription still active
|
||||||
|
q := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||||
|
Table("user_subscription_items").
|
||||||
|
Select("user_subscription_items.*").
|
||||||
|
Joins("JOIN user_subscriptions ON user_subscriptions.id = user_subscription_items.user_subscription_id").
|
||||||
|
Where("user_subscriptions.user_id = ? AND user_subscriptions.status = ? AND user_subscriptions.end_time > ?", userId, "active", now).
|
||||||
|
Where("user_subscription_items.model_name = ? AND user_subscription_items.quota_type = ?", modelName, quotaType).
|
||||||
|
Order("user_subscriptions.end_time desc, user_subscriptions.id desc, user_subscription_items.id desc")
|
||||||
|
if err := q.First(&item).Error; err != nil {
|
||||||
|
return errors.New("no active subscription item for this model")
|
||||||
|
}
|
||||||
|
usedBefore := item.AmountUsed
|
||||||
|
remain := item.AmountTotal - usedBefore
|
||||||
|
if remain < amount {
|
||||||
|
return fmt.Errorf("subscription quota insufficient, remain=%d need=%d", remain, amount)
|
||||||
|
}
|
||||||
|
item.AmountUsed += amount
|
||||||
|
if err := tx.Save(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
returnValue.UserSubscriptionId = item.UserSubscriptionId
|
||||||
|
returnValue.ItemId = item.Id
|
||||||
|
returnValue.QuotaType = item.QuotaType
|
||||||
|
returnValue.PreConsumed = amount
|
||||||
|
returnValue.AmountTotal = item.AmountTotal
|
||||||
|
returnValue.AmountUsedBefore = usedBefore
|
||||||
|
returnValue.AmountUsedAfter = item.AmountUsed
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return returnValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubscriptionPlanInfo struct {
|
||||||
|
PlanId int
|
||||||
|
PlanTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubscriptionPlanInfoByUserSubscriptionId(userSubscriptionId int) (*SubscriptionPlanInfo, error) {
|
||||||
|
if userSubscriptionId <= 0 {
|
||||||
|
return nil, errors.New("invalid userSubscriptionId")
|
||||||
|
}
|
||||||
|
var sub UserSubscription
|
||||||
|
if err := DB.Where("id = ?", userSubscriptionId).First(&sub).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var plan SubscriptionPlan
|
||||||
|
if err := DB.Where("id = ?", sub.PlanId).First(&plan).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &SubscriptionPlanInfo{
|
||||||
|
PlanId: sub.PlanId,
|
||||||
|
PlanTitle: plan.Title,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubscriptionPlanInfoBySubscriptionItemId(itemId int) (*SubscriptionPlanInfo, error) {
|
||||||
|
if itemId <= 0 {
|
||||||
|
return nil, errors.New("invalid itemId")
|
||||||
|
}
|
||||||
|
var item UserSubscriptionItem
|
||||||
|
if err := DB.Where("id = ?", itemId).First(&item).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return GetSubscriptionPlanInfoByUserSubscriptionId(item.UserSubscriptionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update subscription used amount by delta (positive consume more, negative refund).
|
||||||
|
func PostConsumeUserSubscriptionDelta(itemId int, delta int64) error {
|
||||||
|
if itemId <= 0 {
|
||||||
|
return errors.New("invalid itemId")
|
||||||
|
}
|
||||||
|
if delta == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return DB.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var item UserSubscriptionItem
|
||||||
|
if err := tx.Set("gorm:query_option", "FOR UPDATE").Where("id = ?", itemId).First(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newUsed := item.AmountUsed + delta
|
||||||
|
if newUsed < 0 {
|
||||||
|
newUsed = 0
|
||||||
|
}
|
||||||
|
if newUsed > item.AmountTotal {
|
||||||
|
return fmt.Errorf("subscription used exceeds total, used=%d total=%d", newUsed, item.AmountTotal)
|
||||||
|
}
|
||||||
|
item.AmountUsed = newUsed
|
||||||
|
return tx.Save(&item).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -114,6 +114,24 @@ type RelayInfo struct {
|
|||||||
RelayFormat types.RelayFormat
|
RelayFormat types.RelayFormat
|
||||||
SendResponseCount int
|
SendResponseCount int
|
||||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||||
|
// BillingSource indicates whether this request is billed from wallet quota or subscription.
|
||||||
|
// "" or "wallet" => wallet; "subscription" => subscription
|
||||||
|
BillingSource string
|
||||||
|
// SubscriptionItemId is the user_subscription_items.id used when BillingSource == "subscription"
|
||||||
|
SubscriptionItemId int
|
||||||
|
// SubscriptionQuotaType is the plan item quota type: 0=quota units, 1=request count
|
||||||
|
SubscriptionQuotaType 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).
|
||||||
|
// Only meaningful when SubscriptionQuotaType == 0.
|
||||||
|
SubscriptionPostDelta int64
|
||||||
|
// SubscriptionPlanId / SubscriptionPlanTitle are used for logging/UI display.
|
||||||
|
SubscriptionPlanId int
|
||||||
|
SubscriptionPlanTitle string
|
||||||
|
// SubscriptionAmountTotal / SubscriptionAmountUsedAfterPreConsume are used to compute remaining in logs.
|
||||||
|
SubscriptionAmountTotal int64
|
||||||
|
SubscriptionAmountUsedAfterPreConsume int64
|
||||||
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
IsClaudeBetaQuery bool // /v1/messages?beta=true
|
||||||
IsChannelTest bool // channel test request
|
IsChannelTest bool // channel test request
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,30 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
adminRoute.DELETE("/:id/2fa", controller.AdminDisable2FA)
|
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.DELETE("/plans/:id", controller.AdminDeleteSubscriptionPlan)
|
||||||
|
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription payment callbacks (no auth)
|
||||||
|
apiRouter.GET("/subscription/epay/notify", controller.SubscriptionEpayNotify)
|
||||||
optionRoute := apiRouter.Group("/option")
|
optionRoute := apiRouter.Group("/option")
|
||||||
optionRoute.Use(middleware.RootAuth())
|
optionRoute.Use(middleware.RootAuth())
|
||||||
{
|
{
|
||||||
|
|||||||
117
service/billing.go
Normal file
117
service/billing.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func normalizeBillingPreference(pref string) string {
|
||||||
|
switch pref {
|
||||||
|
case "subscription_first", "wallet_first", "subscription_only", "wallet_only":
|
||||||
|
return pref
|
||||||
|
default:
|
||||||
|
return "subscription_first"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 := normalizeBillingPreference(relayInfo.UserSetting.BillingPreference)
|
||||||
|
trySubscription := func() *types.NewAPIError {
|
||||||
|
quotaTypes := model.GetModelQuotaTypes(relayInfo.OriginModelName)
|
||||||
|
quotaType := 0
|
||||||
|
if len(quotaTypes) > 0 {
|
||||||
|
quotaType = quotaTypes[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// For subscription item: per-request consumes 1, per-quota consumes preConsumedQuota quota units.
|
||||||
|
subConsume := int64(preConsumedQuota)
|
||||||
|
if quotaType == 1 {
|
||||||
|
subConsume = 1
|
||||||
|
}
|
||||||
|
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.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)
|
||||||
|
}
|
||||||
|
return types.NewErrorWithStatusCode(fmt.Errorf("订阅额度不足或未配置订阅: %s", err.Error()), types.ErrorCodeInsufficientUserQuota, http.StatusForbidden, types.ErrOptionWithSkipRetry(), types.ErrOptionWithNoRecordErrorLog())
|
||||||
|
}
|
||||||
|
|
||||||
|
relayInfo.BillingSource = BillingSourceSubscription
|
||||||
|
relayInfo.SubscriptionItemId = res.ItemId
|
||||||
|
relayInfo.SubscriptionQuotaType = quotaType
|
||||||
|
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.SubscriptionItemId = 0
|
||||||
|
relayInfo.SubscriptionQuotaType = 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
|
||||||
|
return tryWallet()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
"github.com/QuantumNous/new-api/constant"
|
"github.com/QuantumNous/new-api/constant"
|
||||||
"github.com/QuantumNous/new-api/dto"
|
"github.com/QuantumNous/new-api/dto"
|
||||||
|
"github.com/QuantumNous/new-api/model"
|
||||||
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
relaycommon "github.com/QuantumNous/new-api/relay/common"
|
||||||
"github.com/QuantumNous/new-api/types"
|
"github.com/QuantumNous/new-api/types"
|
||||||
|
|
||||||
@@ -73,9 +74,76 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
|
|||||||
other["admin_info"] = adminInfo
|
other["admin_info"] = adminInfo
|
||||||
appendRequestPath(ctx, relayInfo, other)
|
appendRequestPath(ctx, relayInfo, other)
|
||||||
appendRequestConversionChain(relayInfo, other)
|
appendRequestConversionChain(relayInfo, other)
|
||||||
|
appendBillingInfo(relayInfo, other)
|
||||||
return 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.SubscriptionItemId != 0 {
|
||||||
|
other["subscription_item_id"] = relayInfo.SubscriptionItemId
|
||||||
|
}
|
||||||
|
other["subscription_quota_type"] = relayInfo.SubscriptionQuotaType
|
||||||
|
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.SubscriptionQuotaType == 0 && 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
|
||||||
|
usedFinal := relayInfo.SubscriptionAmountUsedAfterPreConsume
|
||||||
|
if relayInfo.SubscriptionQuotaType == 0 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// Fallback: if plan info missing (older requests), best-effort fetch by item id.
|
||||||
|
if relayInfo.SubscriptionPlanId == 0 && relayInfo.SubscriptionItemId != 0 {
|
||||||
|
if info, err := model.GetSubscriptionPlanInfoBySubscriptionItemId(relayInfo.SubscriptionItemId); err == nil && info != nil {
|
||||||
|
other["subscription_plan_id"] = info.PlanId
|
||||||
|
other["subscription_plan_title"] = info.PlanTitle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Wallet quota is not deducted when billed from subscription.
|
||||||
|
other["wallet_quota_deducted"] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
func appendRequestConversionChain(relayInfo *relaycommon.RelayInfo, other map[string]interface{}) {
|
||||||
if relayInfo == nil || other == nil {
|
if relayInfo == nil || other == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -15,17 +15,38 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
func ReturnPreConsumedQuota(c *gin.Context, relayInfo *relaycommon.RelayInfo) {
|
||||||
if relayInfo.FinalPreConsumedQuota != 0 {
|
// Always refund subscription pre-consumed (can be non-zero even when FinalPreConsumedQuota is 0)
|
||||||
logger.LogInfo(c, fmt.Sprintf("用户 %d 请求失败, 返还预扣费额度 %s", relayInfo.UserId, logger.FormatQuota(relayInfo.FinalPreConsumedQuota)))
|
needRefundSub := relayInfo.BillingSource == BillingSourceSubscription && relayInfo.SubscriptionItemId != 0 && relayInfo.SubscriptionPreConsumed > 0
|
||||||
gopool.Go(func() {
|
needRefundToken := relayInfo.FinalPreConsumedQuota != 0
|
||||||
relayInfoCopy := *relayInfo
|
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 {
|
||||||
|
_ = model.PostConsumeUserSubscriptionDelta(relayInfoCopy.SubscriptionItemId, -relayInfoCopy.SubscriptionPreConsumed)
|
||||||
|
}
|
||||||
|
// 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)
|
err := PostConsumeQuota(&relayInfoCopy, -relayInfoCopy.FinalPreConsumedQuota, 0, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysLog("error return pre-consumed quota: " + err.Error())
|
common.SysLog("error return pre-consumed quota: " + err.Error())
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
// PreConsumeQuota checks if the user has enough quota to pre-consume.
|
||||||
|
|||||||
@@ -503,13 +503,29 @@ func PreConsumeTokenQuota(relayInfo *relaycommon.RelayInfo, quota int) error {
|
|||||||
|
|
||||||
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) {
|
func PostConsumeQuota(relayInfo *relaycommon.RelayInfo, quota int, preConsumedQuota int, sendEmail bool) (err error) {
|
||||||
|
|
||||||
if quota > 0 {
|
// 1) Consume from wallet quota OR subscription item
|
||||||
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
if relayInfo != nil && relayInfo.BillingSource == BillingSourceSubscription {
|
||||||
|
// For subscription: quotaType=0 uses quota units delta; quotaType=1 uses fixed 0 delta (pre-consumed 1 on request begin)
|
||||||
|
if relayInfo.SubscriptionQuotaType == 0 {
|
||||||
|
if relayInfo.SubscriptionItemId == 0 {
|
||||||
|
return errors.New("subscription item id is missing")
|
||||||
|
}
|
||||||
|
if err := model.PostConsumeUserSubscriptionDelta(relayInfo.SubscriptionItemId, int64(quota)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Track delta for logging/UI (net consumed = preConsumed + postDelta)
|
||||||
|
relayInfo.SubscriptionPostDelta += int64(quota)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
// Wallet
|
||||||
}
|
if quota > 0 {
|
||||||
if err != nil {
|
err = model.DecreaseUserQuota(relayInfo.UserId, quota)
|
||||||
return err
|
} else {
|
||||||
|
err = model.IncreaseUserQuota(relayInfo.UserId, -quota, false)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !relayInfo.IsPlayground {
|
if !relayInfo.IsPlayground {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import Task from './pages/Task';
|
|||||||
import ModelPage from './pages/Model';
|
import ModelPage from './pages/Model';
|
||||||
import ModelDeploymentPage from './pages/ModelDeployment';
|
import ModelDeploymentPage from './pages/ModelDeployment';
|
||||||
import Playground from './pages/Playground';
|
import Playground from './pages/Playground';
|
||||||
|
import Subscription from './pages/Subscription';
|
||||||
import OAuth2Callback from './components/auth/OAuth2Callback';
|
import OAuth2Callback from './components/auth/OAuth2Callback';
|
||||||
import PersonalSetting from './components/settings/PersonalSetting';
|
import PersonalSetting from './components/settings/PersonalSetting';
|
||||||
import Setup from './pages/Setup';
|
import Setup from './pages/Setup';
|
||||||
@@ -117,6 +118,14 @@ function App() {
|
|||||||
</AdminRoute>
|
</AdminRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path='/console/subscription'
|
||||||
|
element={
|
||||||
|
<AdminRoute>
|
||||||
|
<Subscription />
|
||||||
|
</AdminRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path='/console/channel'
|
path='/console/channel'
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const routerMap = {
|
|||||||
redemption: '/console/redemption',
|
redemption: '/console/redemption',
|
||||||
topup: '/console/topup',
|
topup: '/console/topup',
|
||||||
user: '/console/user',
|
user: '/console/user',
|
||||||
|
subscription: '/console/subscription',
|
||||||
log: '/console/log',
|
log: '/console/log',
|
||||||
midjourney: '/console/midjourney',
|
midjourney: '/console/midjourney',
|
||||||
setting: '/console/setting',
|
setting: '/console/setting',
|
||||||
@@ -50,7 +51,7 @@ const routerMap = {
|
|||||||
personal: '/console/personal',
|
personal: '/console/personal',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SiderBar = ({ onNavigate = () => {} }) => {
|
const SiderBar = ({ onNavigate = () => { } }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
const [collapsed, toggleCollapsed] = useSidebarCollapsed();
|
||||||
const {
|
const {
|
||||||
@@ -152,6 +153,12 @@ const SiderBar = ({ onNavigate = () => {} }) => {
|
|||||||
to: '/channel',
|
to: '/channel',
|
||||||
className: isAdmin() ? '' : 'tableHiddle',
|
className: isAdmin() ? '' : 'tableHiddle',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: t('订阅管理'),
|
||||||
|
itemKey: 'subscription',
|
||||||
|
to: '/subscription',
|
||||||
|
className: isAdmin() ? '' : 'tableHiddle',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: t('模型管理'),
|
text: t('模型管理'),
|
||||||
itemKey: 'models',
|
itemKey: 'models',
|
||||||
|
|||||||
@@ -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,161 @@
|
|||||||
|
/*
|
||||||
|
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 } from '@douyinfe/semi-ui';
|
||||||
|
|
||||||
|
const quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPlanTitle = (text, record) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>{text}</div>
|
||||||
|
{record?.plan?.subtitle ? (
|
||||||
|
<div className='text-xs text-gray-500'>{record.plan.subtitle}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPrice = (text, record) => {
|
||||||
|
return `${record?.plan?.currency || 'USD'} ${Number(text || 0).toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDuration = (text, record, t) => {
|
||||||
|
return formatDuration(record?.plan, t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEnabled = (text, record) => {
|
||||||
|
return text ? (
|
||||||
|
<Tag color='green' shape='circle'>
|
||||||
|
启用
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color='grey' shape='circle'>
|
||||||
|
禁用
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderModels = (text, record, t) => {
|
||||||
|
const items = record?.items || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <div className='text-xs text-gray-500'>{t('无模型')}</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className='text-xs space-y-1'>
|
||||||
|
{items.slice(0, 3).map((it, idx) => (
|
||||||
|
<div key={idx}>
|
||||||
|
{it.model_name} ({quotaTypeLabel(it.quota_type)}: {it.amount_total})
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{items.length > 3 && (
|
||||||
|
<div className='text-gray-500'>...{t('共')} {items.length} {t('个模型')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderOperations = (text, record, { openEdit, disablePlan, t }) => {
|
||||||
|
const handleDisable = () => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: t('确认禁用'),
|
||||||
|
content: t('禁用后用户端不再展示,但历史订单不受影响。是否继续?'),
|
||||||
|
centered: true,
|
||||||
|
onOk: () => disablePlan(record?.plan?.id),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type='tertiary'
|
||||||
|
size='small'
|
||||||
|
onClick={() => {
|
||||||
|
openEdit(record);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('编辑')}
|
||||||
|
</Button>
|
||||||
|
<Button type='danger' size='small' onClick={handleDisable}>
|
||||||
|
{t('禁用')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSubscriptionsColumns = ({ t, openEdit, disablePlan }) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: ['plan', 'id'],
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('标题'),
|
||||||
|
dataIndex: ['plan', 'title'],
|
||||||
|
render: (text, record) => renderPlanTitle(text, record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('价格'),
|
||||||
|
dataIndex: ['plan', 'price_amount'],
|
||||||
|
width: 140,
|
||||||
|
render: (text, record) => renderPrice(text, record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('有效期'),
|
||||||
|
width: 140,
|
||||||
|
render: (text, record) => renderDuration(text, record, t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('状态'),
|
||||||
|
dataIndex: ['plan', 'enabled'],
|
||||||
|
width: 90,
|
||||||
|
render: (text, record) => renderEnabled(text, record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('模型权益'),
|
||||||
|
width: 200,
|
||||||
|
render: (text, record) => renderModels(text, record, t),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'operate',
|
||||||
|
fixed: 'right',
|
||||||
|
width: 180,
|
||||||
|
render: (text, record) =>
|
||||||
|
renderOperations(text, record, { openEdit, disablePlan, 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,84 @@
|
|||||||
|
/*
|
||||||
|
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,
|
||||||
|
disablePlan,
|
||||||
|
t,
|
||||||
|
} = subscriptionsData;
|
||||||
|
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return getSubscriptionsColumns({
|
||||||
|
t,
|
||||||
|
openEdit,
|
||||||
|
disablePlan,
|
||||||
|
});
|
||||||
|
}, [t, openEdit, disablePlan]);
|
||||||
|
|
||||||
|
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;
|
||||||
90
web/src/components/table/subscriptions/index.jsx
Normal file
90
web/src/components/table/subscriptions/index.jsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
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 } 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';
|
||||||
|
|
||||||
|
const SubscriptionsPage = () => {
|
||||||
|
const subscriptionsData = useSubscriptionsData();
|
||||||
|
|
||||||
|
const {
|
||||||
|
showEdit,
|
||||||
|
editingPlan,
|
||||||
|
sheetPlacement,
|
||||||
|
closeEdit,
|
||||||
|
refresh,
|
||||||
|
openCreate,
|
||||||
|
compactMode,
|
||||||
|
setCompactMode,
|
||||||
|
pricingModels,
|
||||||
|
t,
|
||||||
|
} = subscriptionsData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AddEditSubscriptionModal
|
||||||
|
visible={showEdit}
|
||||||
|
handleClose={closeEdit}
|
||||||
|
editingPlan={editingPlan}
|
||||||
|
placement={sheetPlacement}
|
||||||
|
pricingModels={pricingModels}
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
t={t}
|
||||||
|
>
|
||||||
|
<SubscriptionsTable {...subscriptionsData} />
|
||||||
|
</CardPro>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionsPage;
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
/*
|
||||||
|
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, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
SideSheet,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import {
|
||||||
|
IconCalendarClock,
|
||||||
|
IconClose,
|
||||||
|
IconCreditCard,
|
||||||
|
IconSave,
|
||||||
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { Trash2, Clock } from 'lucide-react';
|
||||||
|
import { API, showError, showSuccess } from '../../../../helpers';
|
||||||
|
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 quotaTypeLabel = (quotaType) => (quotaType === 1 ? '按次' : '按量');
|
||||||
|
|
||||||
|
const AddEditSubscriptionModal = ({
|
||||||
|
visible,
|
||||||
|
handleClose,
|
||||||
|
editingPlan,
|
||||||
|
placement = 'left',
|
||||||
|
pricingModels = [],
|
||||||
|
refresh,
|
||||||
|
t,
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = 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,
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
stripe_price_id: '',
|
||||||
|
creem_product_id: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
|
||||||
|
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: p.currency || 'USD',
|
||||||
|
duration_unit: p.duration_unit || 'month',
|
||||||
|
duration_value: Number(p.duration_value || 1),
|
||||||
|
custom_seconds: Number(p.custom_seconds || 0),
|
||||||
|
enabled: p.enabled !== false,
|
||||||
|
sort_order: Number(p.sort_order || 0),
|
||||||
|
stripe_price_id: p.stripe_price_id || '',
|
||||||
|
creem_product_id: p.creem_product_id || '',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1) always keep items in sync
|
||||||
|
if (visible && isEdit && editingPlan) {
|
||||||
|
setItems((editingPlan.items || []).map((it) => ({ ...it })));
|
||||||
|
} else if (visible && !isEdit) {
|
||||||
|
setItems([]);
|
||||||
|
}
|
||||||
|
}, [visible, editingPlan]);
|
||||||
|
|
||||||
|
const modelOptions = useMemo(() => {
|
||||||
|
return (pricingModels || []).map((m) => ({
|
||||||
|
label: `${m.model_name} (${quotaTypeLabel(m.quota_type)})`,
|
||||||
|
value: m.model_name,
|
||||||
|
quota_type: m.quota_type,
|
||||||
|
}));
|
||||||
|
}, [pricingModels]);
|
||||||
|
|
||||||
|
const addItem = (modelName) => {
|
||||||
|
const modelMeta = modelOptions.find((m) => m.value === modelName);
|
||||||
|
if (!modelMeta) return;
|
||||||
|
if (items.some((it) => it.model_name === modelName)) {
|
||||||
|
showError(t('该模型已添加'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems([
|
||||||
|
...items,
|
||||||
|
{
|
||||||
|
model_name: modelName,
|
||||||
|
quota_type: modelMeta.quota_type,
|
||||||
|
amount_total: 0,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateItem = (idx, patch) => {
|
||||||
|
const next = [...items];
|
||||||
|
next[idx] = { ...next[idx], ...patch };
|
||||||
|
setItems(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeItem = (idx) => {
|
||||||
|
const next = [...items];
|
||||||
|
next.splice(idx, 1);
|
||||||
|
setItems(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (values) => {
|
||||||
|
if (!values.title || values.title.trim() === '') {
|
||||||
|
showError(t('套餐标题不能为空'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cleanedItems = items
|
||||||
|
.filter((it) => it.model_name && Number(it.amount_total) > 0)
|
||||||
|
.map((it) => ({
|
||||||
|
model_name: it.model_name,
|
||||||
|
quota_type: Number(it.quota_type || 0),
|
||||||
|
amount_total: Number(it.amount_total),
|
||||||
|
}));
|
||||||
|
if (cleanedItems.length === 0) {
|
||||||
|
showError(t('请至少配置一个模型权益(且数量>0)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
plan: {
|
||||||
|
...values,
|
||||||
|
price_amount: Number(values.price_amount || 0),
|
||||||
|
duration_value: Number(values.duration_value || 0),
|
||||||
|
custom_seconds: Number(values.custom_seconds || 0),
|
||||||
|
sort_order: Number(values.sort_order || 0),
|
||||||
|
},
|
||||||
|
items: cleanedItems,
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemColumns = [
|
||||||
|
{
|
||||||
|
title: t('模型'),
|
||||||
|
dataIndex: 'model_name',
|
||||||
|
render: (v, row) => (
|
||||||
|
<div className='text-sm'>
|
||||||
|
<div className='font-medium'>{v}</div>
|
||||||
|
<div className='text-xs text-gray-500'>
|
||||||
|
{t('计费')}: {quotaTypeLabel(row.quota_type)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('数量'),
|
||||||
|
dataIndex: 'amount_total',
|
||||||
|
width: 220,
|
||||||
|
render: (v, row, idx) => (
|
||||||
|
<InputNumber
|
||||||
|
value={Number(v || 0)}
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
onChange={(val) => updateItem(idx, { amount_total: val })}
|
||||||
|
placeholder={row.quota_type === 1 ? t('次数') : t('额度')}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
width: 60,
|
||||||
|
render: (_, __, idx) => (
|
||||||
|
<Button
|
||||||
|
type='danger'
|
||||||
|
theme='borderless'
|
||||||
|
icon={<Trash2 size={14} />}
|
||||||
|
onClick={() => removeItem(idx)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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%' : 700}
|
||||||
|
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('例如:基础套餐')}
|
||||||
|
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('实付金额')}
|
||||||
|
min={0}
|
||||||
|
precision={2}
|
||||||
|
rules={[{ required: true, message: t('请输入金额') }]}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Select
|
||||||
|
field='currency'
|
||||||
|
label={t('币种')}
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
>
|
||||||
|
<Select.Option value='USD'>USD</Select.Option>
|
||||||
|
<Select.Option value='EUR'>EUR</Select.Option>
|
||||||
|
<Select.Option value='CNY'>CNY</Select.Option>
|
||||||
|
</Form.Select>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.InputNumber
|
||||||
|
field='sort_order'
|
||||||
|
label={t('排序')}
|
||||||
|
precision={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('有效期单位')}
|
||||||
|
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('自定义秒数')}
|
||||||
|
min={0}
|
||||||
|
precision={0}
|
||||||
|
rules={[{ required: true, message: t('请输入秒数') }]}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form.InputNumber
|
||||||
|
field='duration_value'
|
||||||
|
label={t('有效期数值')}
|
||||||
|
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='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>
|
||||||
|
|
||||||
|
{/* 模型权益 */}
|
||||||
|
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||||
|
<div className='flex items-center justify-between mb-3'>
|
||||||
|
<div>
|
||||||
|
<Text className='text-lg font-medium'>
|
||||||
|
{t('模型权益')}
|
||||||
|
</Text>
|
||||||
|
<div className='text-xs text-gray-600'>
|
||||||
|
{t('配置套餐可使用的模型及额度')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
placeholder={t('添加模型')}
|
||||||
|
style={{ width: 280 }}
|
||||||
|
filter
|
||||||
|
onChange={addItem}
|
||||||
|
>
|
||||||
|
{modelOptions.map((o) => (
|
||||||
|
<Select.Option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
columns={itemColumns}
|
||||||
|
dataSource={items}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(row) => `${row.model_name}-${row.quota_type}`}
|
||||||
|
empty={
|
||||||
|
<div className='py-6 text-center text-gray-500'>
|
||||||
|
{t('尚未添加任何模型')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</Spin>
|
||||||
|
</SideSheet>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddEditSubscriptionModal;
|
||||||
@@ -182,6 +182,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) {
|
function renderModelName(record, copyText, t) {
|
||||||
let other = getLogOther(record.other);
|
let other = getLogOther(record.other);
|
||||||
let modelMapped =
|
let modelMapped =
|
||||||
@@ -191,7 +203,7 @@ function renderModelName(record, copyText, t) {
|
|||||||
if (!modelMapped) {
|
if (!modelMapped) {
|
||||||
return renderModelTag(record.model_name, {
|
return renderModelTag(record.model_name, {
|
||||||
onClick: (event) => {
|
onClick: (event) => {
|
||||||
copyText(event, record.model_name).then((r) => {});
|
copyText(event, record.model_name).then((r) => { });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -208,7 +220,7 @@ function renderModelName(record, copyText, t) {
|
|||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
{renderModelTag(record.model_name, {
|
{renderModelTag(record.model_name, {
|
||||||
onClick: (event) => {
|
onClick: (event) => {
|
||||||
copyText(event, record.model_name).then((r) => {});
|
copyText(event, record.model_name).then((r) => { });
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +231,7 @@ function renderModelName(record, copyText, t) {
|
|||||||
{renderModelTag(other.upstream_model_name, {
|
{renderModelTag(other.upstream_model_name, {
|
||||||
onClick: (event) => {
|
onClick: (event) => {
|
||||||
copyText(event, other.upstream_model_name).then(
|
copyText(event, other.upstream_model_name).then(
|
||||||
(r) => {},
|
(r) => { },
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
@@ -230,7 +242,7 @@ function renderModelName(record, copyText, t) {
|
|||||||
>
|
>
|
||||||
{renderModelTag(record.model_name, {
|
{renderModelTag(record.model_name, {
|
||||||
onClick: (event) => {
|
onClick: (event) => {
|
||||||
copyText(event, record.model_name).then((r) => {});
|
copyText(event, record.model_name).then((r) => { });
|
||||||
},
|
},
|
||||||
suffixIcon: (
|
suffixIcon: (
|
||||||
<Route
|
<Route
|
||||||
@@ -457,11 +469,20 @@ export const getLogsColumns = ({
|
|||||||
title: t('花费'),
|
title: t('花费'),
|
||||||
dataIndex: 'quota',
|
dataIndex: 'quota',
|
||||||
render: (text, record, index) => {
|
render: (text, record, index) => {
|
||||||
return record.type === 0 || record.type === 2 || record.type === 5 ? (
|
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
|
||||||
<>{renderQuota(text, 6)}</>
|
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 +553,42 @@ export const getLogsColumns = ({
|
|||||||
return isAdminUser ? (
|
return isAdminUser ? (
|
||||||
<Space>
|
<Space>
|
||||||
<div>{content}</div>
|
<div>{content}</div>
|
||||||
{affinity ? (
|
{affinity ? (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<div style={{ lineHeight: 1.6 }}>
|
<div style={{ lineHeight: 1.6 }}>
|
||||||
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
<Typography.Text strong>{t('渠道亲和性')}</Typography.Text>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type='secondary'>
|
<Typography.Text type='secondary'>
|
||||||
{t('规则')}:{affinity.rule_name || '-'}
|
{t('规则')}:{affinity.rule_name || '-'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type='secondary'>
|
<Typography.Text type='secondary'>
|
||||||
{t('分组')}:{affinity.selected_group || '-'}
|
{t('分组')}:{affinity.selected_group || '-'}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Typography.Text type='secondary'>
|
<Typography.Text type='secondary'>
|
||||||
{t('Key')}:
|
{t('Key')}:
|
||||||
{(affinity.key_source || '-') +
|
{(affinity.key_source || '-') +
|
||||||
':' +
|
':' +
|
||||||
(affinity.key_path || affinity.key_key || '-') +
|
(affinity.key_path || affinity.key_key || '-') +
|
||||||
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
|
(affinity.key_fp ? `#${affinity.key_fp}` : '')}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
<Tag className='channel-affinity-tag' color='cyan' shape='circle'>
|
||||||
<span className='channel-affinity-tag-content'>
|
<span className='channel-affinity-tag-content'>
|
||||||
<IconStarStroked style={{ fontSize: 13 }} />
|
<IconStarStroked style={{ fontSize: 13 }} />
|
||||||
{t('优选')}
|
{t('优选')}
|
||||||
</span>
|
</span>
|
||||||
</Tag>
|
</Tag>
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
) : (
|
) : (
|
||||||
@@ -632,45 +653,49 @@ export const getLogsColumns = ({
|
|||||||
|
|
||||||
let content = other?.claude
|
let content = other?.claude
|
||||||
? renderModelPriceSimple(
|
? renderModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
other.cache_tokens || 0,
|
other.cache_tokens || 0,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
other.cache_creation_tokens || 0,
|
other.cache_creation_tokens || 0,
|
||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
other.cache_creation_tokens_5m || 0,
|
other.cache_creation_tokens_5m || 0,
|
||||||
other.cache_creation_ratio_5m ||
|
other.cache_creation_ratio_5m ||
|
||||||
other.cache_creation_ratio ||
|
other.cache_creation_ratio ||
|
||||||
1.0,
|
1.0,
|
||||||
other.cache_creation_tokens_1h || 0,
|
other.cache_creation_tokens_1h || 0,
|
||||||
other.cache_creation_ratio_1h ||
|
other.cache_creation_ratio_1h ||
|
||||||
other.cache_creation_ratio ||
|
other.cache_creation_ratio ||
|
||||||
1.0,
|
1.0,
|
||||||
false,
|
false,
|
||||||
1.0,
|
1.0,
|
||||||
other?.is_system_prompt_overwritten,
|
other?.is_system_prompt_overwritten,
|
||||||
'claude',
|
'claude',
|
||||||
)
|
)
|
||||||
: renderModelPriceSimple(
|
: renderModelPriceSimple(
|
||||||
other.model_ratio,
|
other.model_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
other.cache_tokens || 0,
|
other.cache_tokens || 0,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
0,
|
0,
|
||||||
1.0,
|
1.0,
|
||||||
0,
|
0,
|
||||||
1.0,
|
1.0,
|
||||||
0,
|
0,
|
||||||
1.0,
|
1.0,
|
||||||
false,
|
false,
|
||||||
1.0,
|
1.0,
|
||||||
other?.is_system_prompt_overwritten,
|
other?.is_system_prompt_overwritten,
|
||||||
'openai',
|
'openai',
|
||||||
);
|
);
|
||||||
|
// Do not add billing source here; keep details clean.
|
||||||
|
const summary = [content, text ? `${t('详情')}:${text}` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
return (
|
return (
|
||||||
<Typography.Paragraph
|
<Typography.Paragraph
|
||||||
ellipsis={{
|
ellipsis={{
|
||||||
@@ -678,7 +703,7 @@ export const getLogsColumns = ({
|
|||||||
}}
|
}}
|
||||||
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
|
style={{ maxWidth: 240, whiteSpace: 'pre-line' }}
|
||||||
>
|
>
|
||||||
{content}
|
{summary}
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ const renderOperations = (
|
|||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
showResetPasskeyModal,
|
showResetPasskeyModal,
|
||||||
showResetTwoFAModal,
|
showResetTwoFAModal,
|
||||||
|
showBindSubscriptionModal,
|
||||||
t,
|
t,
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -216,6 +217,14 @@ const renderOperations = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moreMenu = [
|
const moreMenu = [
|
||||||
|
{
|
||||||
|
node: 'item',
|
||||||
|
name: t('绑定订阅套餐'),
|
||||||
|
onClick: () => showBindSubscriptionModal(record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: 'divider',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
node: 'item',
|
node: 'item',
|
||||||
name: t('重置 Passkey'),
|
name: t('重置 Passkey'),
|
||||||
@@ -299,6 +308,7 @@ export const getUsersColumns = ({
|
|||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
showResetPasskeyModal,
|
showResetPasskeyModal,
|
||||||
showResetTwoFAModal,
|
showResetTwoFAModal,
|
||||||
|
showBindSubscriptionModal,
|
||||||
}) => {
|
}) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -355,6 +365,7 @@ export const getUsersColumns = ({
|
|||||||
showDeleteModal,
|
showDeleteModal,
|
||||||
showResetPasskeyModal,
|
showResetPasskeyModal,
|
||||||
showResetTwoFAModal,
|
showResetTwoFAModal,
|
||||||
|
showBindSubscriptionModal,
|
||||||
t,
|
t,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import EnableDisableUserModal from './modals/EnableDisableUserModal';
|
|||||||
import DeleteUserModal from './modals/DeleteUserModal';
|
import DeleteUserModal from './modals/DeleteUserModal';
|
||||||
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
import ResetPasskeyModal from './modals/ResetPasskeyModal';
|
||||||
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
import ResetTwoFAModal from './modals/ResetTwoFAModal';
|
||||||
|
import BindSubscriptionModal from './modals/BindSubscriptionModal';
|
||||||
|
|
||||||
const UsersTable = (usersData) => {
|
const UsersTable = (usersData) => {
|
||||||
const {
|
const {
|
||||||
@@ -61,6 +62,8 @@ const UsersTable = (usersData) => {
|
|||||||
const [enableDisableAction, setEnableDisableAction] = useState('');
|
const [enableDisableAction, setEnableDisableAction] = useState('');
|
||||||
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
const [showResetPasskeyModal, setShowResetPasskeyModal] = useState(false);
|
||||||
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
const [showResetTwoFAModal, setShowResetTwoFAModal] = useState(false);
|
||||||
|
const [showBindSubscriptionModal, setShowBindSubscriptionModal] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// Modal handlers
|
// Modal handlers
|
||||||
const showPromoteUserModal = (user) => {
|
const showPromoteUserModal = (user) => {
|
||||||
@@ -94,6 +97,11 @@ const UsersTable = (usersData) => {
|
|||||||
setShowResetTwoFAModal(true);
|
setShowResetTwoFAModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const showBindSubscriptionUserModal = (user) => {
|
||||||
|
setModalUser(user);
|
||||||
|
setShowBindSubscriptionModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
// Modal confirm handlers
|
// Modal confirm handlers
|
||||||
const handlePromoteConfirm = () => {
|
const handlePromoteConfirm = () => {
|
||||||
manageUser(modalUser.id, 'promote', modalUser);
|
manageUser(modalUser.id, 'promote', modalUser);
|
||||||
@@ -132,6 +140,7 @@ const UsersTable = (usersData) => {
|
|||||||
showDeleteModal: showDeleteUserModal,
|
showDeleteModal: showDeleteUserModal,
|
||||||
showResetPasskeyModal: showResetPasskeyUserModal,
|
showResetPasskeyModal: showResetPasskeyUserModal,
|
||||||
showResetTwoFAModal: showResetTwoFAUserModal,
|
showResetTwoFAModal: showResetTwoFAUserModal,
|
||||||
|
showBindSubscriptionModal: showBindSubscriptionUserModal,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
t,
|
t,
|
||||||
@@ -143,6 +152,7 @@ const UsersTable = (usersData) => {
|
|||||||
showDeleteUserModal,
|
showDeleteUserModal,
|
||||||
showResetPasskeyUserModal,
|
showResetPasskeyUserModal,
|
||||||
showResetTwoFAUserModal,
|
showResetTwoFAUserModal,
|
||||||
|
showBindSubscriptionUserModal,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Handle compact mode by removing fixed positioning
|
// Handle compact mode by removing fixed positioning
|
||||||
@@ -242,6 +252,14 @@ const UsersTable = (usersData) => {
|
|||||||
user={modalUser}
|
user={modalUser}
|
||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BindSubscriptionModal
|
||||||
|
visible={showBindSubscriptionModal}
|
||||||
|
onCancel={() => setShowBindSubscriptionModal(false)}
|
||||||
|
user={modalUser}
|
||||||
|
t={t}
|
||||||
|
onSuccess={() => refresh?.()}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
124
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
124
web/src/components/table/users/modals/BindSubscriptionModal.jsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
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;
|
||||||
|
|
||||||
537
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
537
web/src/components/topup/SubscriptionPlansCard.jsx
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
/*
|
||||||
|
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,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Divider,
|
||||||
|
Select,
|
||||||
|
Skeleton,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Typography,
|
||||||
|
} from '@douyinfe/semi-ui';
|
||||||
|
import { API, showError, showSuccess } from '../../helpers';
|
||||||
|
import { CalendarClock, Check, Crown, RefreshCw, Sparkles } from 'lucide-react';
|
||||||
|
import SubscriptionPurchaseModal from './modals/SubscriptionPurchaseModal';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// 格式化有效期显示
|
||||||
|
function formatDuration(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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 过滤易支付方式
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取货币符号
|
||||||
|
function getCurrencySymbol(currency) {
|
||||||
|
const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' };
|
||||||
|
return symbols[currency] || currency + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 items = sub?.items || [];
|
||||||
|
if (items.length === 0) return 0;
|
||||||
|
let totalUsed = 0;
|
||||||
|
let totalAmount = 0;
|
||||||
|
items.forEach((it) => {
|
||||||
|
totalUsed += Number(it.amount_used || 0);
|
||||||
|
totalAmount += Number(it.amount_total || 0);
|
||||||
|
});
|
||||||
|
if (totalAmount === 0) return 0;
|
||||||
|
return Math.round((totalUsed / totalAmount) * 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='green' size='small' shape='circle'>
|
||||||
|
{activeSubscriptions.length} {t('个生效中')}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color='grey' size='small' shape='circle'>{t('无生效')}</Tag>
|
||||||
|
)}
|
||||||
|
{allSubscriptions.length > activeSubscriptions.length && (
|
||||||
|
<Tag color='grey' size='small' shape='circle' type='light'>
|
||||||
|
{allSubscriptions.length - activeSubscriptions.length} {t('个已过期')}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
theme='borderless'
|
||||||
|
icon={<RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
loading={refreshing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAnySubscription ? (
|
||||||
|
<div className='space-y-3 max-h-64 overflow-y-auto'>
|
||||||
|
{allSubscriptions.map((sub, subIndex) => {
|
||||||
|
const subscription = sub.subscription;
|
||||||
|
const items = sub.items || [];
|
||||||
|
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}
|
||||||
|
className={`p-2 rounded-lg ${isActive ? 'bg-green-50' : 'bg-gray-100 opacity-70'}`}
|
||||||
|
>
|
||||||
|
{/* 订阅概要 */}
|
||||||
|
<div className='flex items-center justify-between text-xs mb-2'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='font-medium'>
|
||||||
|
{t('订阅')} #{subscription?.id}
|
||||||
|
</span>
|
||||||
|
{isActive ? (
|
||||||
|
<Tag color='green' size='small' shape='circle'>{t('生效')}</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag color='grey' size='small' shape='circle'>{t('已过期')}</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<span className='text-gray-500'>
|
||||||
|
{t('剩余')} {remainDays} {t('天')} · {t('已用')} {usagePercent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-gray-500 mb-2'>
|
||||||
|
{isActive ? t('至') : t('过期于')} {new Date((subscription?.end_time || 0) * 1000).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
{/* 权益列表 */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-1'>
|
||||||
|
{items.slice(0, 4).map((it) => {
|
||||||
|
const used = Number(it.amount_used || 0);
|
||||||
|
const total = Number(it.amount_total || 0);
|
||||||
|
const remain = total - used;
|
||||||
|
const percent = total > 0 ? Math.round((used / total) * 100) : 0;
|
||||||
|
const label = it.quota_type === 1 ? t('次') : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
key={`${it.id}-${it.model_name}`}
|
||||||
|
size='small'
|
||||||
|
color={isActive ? (percent > 80 ? 'red' : 'blue') : 'grey'}
|
||||||
|
type='light'
|
||||||
|
shape='circle'
|
||||||
|
>
|
||||||
|
{it.model_name}: {remain}{label}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{items.length > 4 && (
|
||||||
|
<Tag size='small' color='grey' type='light' shape='circle'>
|
||||||
|
+{items.length - 4}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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 planItems = p?.items || [];
|
||||||
|
const currency = getCurrencySymbol(plan?.currency || 'USD');
|
||||||
|
const price = Number(plan?.price_amount || 0);
|
||||||
|
const isPopular = index === 0 && plans.length > 1;
|
||||||
|
|
||||||
|
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'>
|
||||||
|
{currency}
|
||||||
|
</span>
|
||||||
|
<span className='text-3xl font-bold text-purple-600'>
|
||||||
|
{price.toFixed(price % 1 === 0 ? 0 : 2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='text-sm text-gray-500 mt-1'>
|
||||||
|
<CalendarClock size={12} className='inline mr-1' />
|
||||||
|
{formatDuration(plan, t)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Divider margin={12} />
|
||||||
|
|
||||||
|
{/* 权益列表 */}
|
||||||
|
<div className='space-y-2 mb-4'>
|
||||||
|
{planItems.slice(0, 5).map((it, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className='flex items-center text-sm'
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
size={14}
|
||||||
|
className='text-green-500 mr-2 flex-shrink-0'
|
||||||
|
/>
|
||||||
|
<span className='truncate flex-1'>{it.model_name}</span>
|
||||||
|
<Tag size='small' color='blue' shape='circle' type='light'>
|
||||||
|
{it.amount_total}
|
||||||
|
{it.quota_type === 1 ? t('次') : ''}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{planItems.length > 5 && (
|
||||||
|
<div className='text-xs text-gray-400 text-center'>
|
||||||
|
+{planItems.length - 5} {t('项更多权益')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{planItems.length === 0 && (
|
||||||
|
<div className='text-xs text-gray-400 text-center py-2'>
|
||||||
|
{t('暂无权益配置')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 购买按钮 */}
|
||||||
|
<Button
|
||||||
|
theme='solid'
|
||||||
|
type='primary'
|
||||||
|
block
|
||||||
|
onClick={() => openBuy(p)}
|
||||||
|
className={isPopular ? '!bg-purple-600 hover:!bg-purple-700' : ''}
|
||||||
|
>
|
||||||
|
{t('立即订阅')}
|
||||||
|
</Button>
|
||||||
|
</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}
|
||||||
|
onPayStripe={payStripe}
|
||||||
|
onPayCreem={payCreem}
|
||||||
|
onPayEpay={payEpay}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionPlansCard;
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ import { StatusContext } from '../../context/Status';
|
|||||||
|
|
||||||
import RechargeCard from './RechargeCard';
|
import RechargeCard from './RechargeCard';
|
||||||
import InvitationCard from './InvitationCard';
|
import InvitationCard from './InvitationCard';
|
||||||
|
import SubscriptionPlansCard from './SubscriptionPlansCard';
|
||||||
import TransferModal from './modals/TransferModal';
|
import TransferModal from './modals/TransferModal';
|
||||||
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
import PaymentConfirmModal from './modals/PaymentConfirmModal';
|
||||||
import TopupHistoryModal from './modals/TopupHistoryModal';
|
import TopupHistoryModal from './modals/TopupHistoryModal';
|
||||||
@@ -87,6 +88,13 @@ const TopUp = () => {
|
|||||||
// 账单Modal状态
|
// 账单Modal状态
|
||||||
const [openHistory, setOpenHistory] = useState(false);
|
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 [presetAmounts, setPresetAmounts] = useState([]);
|
||||||
const [selectedPreset, setSelectedPreset] = useState(null);
|
const [selectedPreset, setSelectedPreset] = useState(null);
|
||||||
@@ -313,6 +321,53 @@ 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) => {
|
||||||
|
setBillingPreference(pref);
|
||||||
|
try {
|
||||||
|
const res = await API.put('/api/subscription/self/preference', {
|
||||||
|
billing_preference: pref,
|
||||||
|
});
|
||||||
|
if (res.data?.success) {
|
||||||
|
showSuccess(t('更新成功'));
|
||||||
|
} else {
|
||||||
|
showError(res.data?.message || t('更新失败'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError(t('请求失败'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 获取充值配置信息
|
// 获取充值配置信息
|
||||||
const getTopupInfo = async () => {
|
const getTopupInfo = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -479,6 +534,8 @@ const TopUp = () => {
|
|||||||
// 在 statusState 可用时获取充值信息
|
// 在 statusState 可用时获取充值信息
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getTopupInfo().then();
|
getTopupInfo().then();
|
||||||
|
getSubscriptionPlans().then();
|
||||||
|
getSubscriptionSelf().then();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -661,60 +718,72 @@ const TopUp = () => {
|
|||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* 用户信息头部 */}
|
{/* 主布局区域 */}
|
||||||
<div className='space-y-6'>
|
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
||||||
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
|
{/* 左侧 - 订阅套餐 */}
|
||||||
{/* 左侧充值区域 */}
|
<div className='lg:col-span-7'>
|
||||||
<div className='lg:col-span-7 space-y-6 w-full'>
|
<SubscriptionPlansCard
|
||||||
<RechargeCard
|
t={t}
|
||||||
t={t}
|
loading={subscriptionLoading}
|
||||||
enableOnlineTopUp={enableOnlineTopUp}
|
plans={subscriptionPlans}
|
||||||
enableStripeTopUp={enableStripeTopUp}
|
payMethods={payMethods}
|
||||||
enableCreemTopUp={enableCreemTopUp}
|
enableOnlineTopUp={enableOnlineTopUp}
|
||||||
creemProducts={creemProducts}
|
enableStripeTopUp={enableStripeTopUp}
|
||||||
creemPreTopUp={creemPreTopUp}
|
enableCreemTopUp={enableCreemTopUp}
|
||||||
presetAmounts={presetAmounts}
|
billingPreference={billingPreference}
|
||||||
selectedPreset={selectedPreset}
|
onChangeBillingPreference={updateBillingPreference}
|
||||||
selectPresetAmount={selectPresetAmount}
|
activeSubscriptions={activeSubscriptions}
|
||||||
formatLargeNumber={formatLargeNumber}
|
allSubscriptions={allSubscriptions}
|
||||||
priceRatio={priceRatio}
|
reloadSubscriptionSelf={getSubscriptionSelf}
|
||||||
topUpCount={topUpCount}
|
/>
|
||||||
minTopUp={minTopUp}
|
</div>
|
||||||
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='lg:col-span-5'>
|
<div className='lg:col-span-5 flex flex-col gap-6'>
|
||||||
<InvitationCard
|
<RechargeCard
|
||||||
t={t}
|
t={t}
|
||||||
userState={userState}
|
enableOnlineTopUp={enableOnlineTopUp}
|
||||||
renderQuota={renderQuota}
|
enableStripeTopUp={enableStripeTopUp}
|
||||||
setOpenTransfer={setOpenTransfer}
|
enableCreemTopUp={enableCreemTopUp}
|
||||||
affLink={affLink}
|
creemProducts={creemProducts}
|
||||||
handleAffLinkClick={handleAffLinkClick}
|
creemPreTopUp={creemPreTopUp}
|
||||||
/>
|
presetAmounts={presetAmounts}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
236
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
236
web/src/components/topup/modals/SubscriptionPurchaseModal.jsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
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 { Modal, Typography, Card, Tag, Button, Select, Divider } from '@douyinfe/semi-ui';
|
||||||
|
import { Crown, CalendarClock, Package, Check } from 'lucide-react';
|
||||||
|
import { SiStripe } from 'react-icons/si';
|
||||||
|
import { IconCreditCard } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// 格式化有效期显示
|
||||||
|
function formatDuration(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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取货币符号
|
||||||
|
function getCurrencySymbol(currency) {
|
||||||
|
const symbols = { USD: '$', EUR: '€', CNY: '¥', GBP: '£', JPY: '¥' };
|
||||||
|
return symbols[currency] || currency + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
const SubscriptionPurchaseModal = ({
|
||||||
|
t,
|
||||||
|
visible,
|
||||||
|
onCancel,
|
||||||
|
selectedPlan,
|
||||||
|
paying,
|
||||||
|
selectedEpayMethod,
|
||||||
|
setSelectedEpayMethod,
|
||||||
|
epayMethods = [],
|
||||||
|
enableOnlineTopUp = false,
|
||||||
|
enableStripeTopUp = false,
|
||||||
|
enableCreemTopUp = false,
|
||||||
|
onPayStripe,
|
||||||
|
onPayCreem,
|
||||||
|
onPayEpay,
|
||||||
|
}) => {
|
||||||
|
const plan = selectedPlan?.plan;
|
||||||
|
const items = selectedPlan?.items || [];
|
||||||
|
const currency = plan ? getCurrencySymbol(plan.currency || 'USD') : '$';
|
||||||
|
const price = plan ? Number(plan.price_amount || 0) : 0;
|
||||||
|
// 只有当管理员开启支付网关 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;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<Crown className='mr-2' size={18} />
|
||||||
|
{t('购买订阅套餐')}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
visible={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
footer={null}
|
||||||
|
maskClosable={false}
|
||||||
|
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'>
|
||||||
|
{formatDuration(plan, t)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</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' />
|
||||||
|
<Text className='text-slate-900 dark:text-slate-100'>
|
||||||
|
{items.length} {t('项')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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'>
|
||||||
|
{currency}{price.toFixed(price % 1 === 0 ? 0 : 2)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 权益列表 */}
|
||||||
|
{items.length > 0 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Text size='small' type='tertiary'>{t('权益明细')}:</Text>
|
||||||
|
<div className='flex flex-wrap gap-1'>
|
||||||
|
{items.slice(0, 6).map((it, idx) => (
|
||||||
|
<Tag key={idx} size='small' color='blue' type='light' shape='circle'>
|
||||||
|
<Check size={10} className='mr-1' />
|
||||||
|
{it.model_name}: {it.amount_total}{it.quota_type === 1 ? t('次') : ''}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
{items.length > 6 && (
|
||||||
|
<Tag size='small' color='grey' type='light' shape='circle'>
|
||||||
|
+{items.length - 6}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 支付方式 */}
|
||||||
|
{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}
|
||||||
|
>
|
||||||
|
Stripe
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasCreem && (
|
||||||
|
<Button
|
||||||
|
theme='light'
|
||||||
|
className='flex-1'
|
||||||
|
icon={<IconCreditCard />}
|
||||||
|
onClick={onPayCreem}
|
||||||
|
loading={paying}
|
||||||
|
>
|
||||||
|
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,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
theme='solid'
|
||||||
|
type='primary'
|
||||||
|
onClick={onPayEpay}
|
||||||
|
loading={paying}
|
||||||
|
disabled={!selectedEpayMethod}
|
||||||
|
>
|
||||||
|
{t('支付')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='text-gray-500 text-sm p-3 bg-gray-50 rounded-lg border border-dashed border-gray-300 text-center'>
|
||||||
|
{t('管理员未开启在线支付,请联系管理员配置')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubscriptionPurchaseModal;
|
||||||
@@ -74,6 +74,7 @@ import {
|
|||||||
CircleUser,
|
CircleUser,
|
||||||
Package,
|
Package,
|
||||||
Server,
|
Server,
|
||||||
|
CalendarClock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
// 获取侧边栏Lucide图标组件
|
// 获取侧边栏Lucide图标组件
|
||||||
@@ -117,6 +118,8 @@ export function getLucideIcon(key, selected = false) {
|
|||||||
return <Package {...commonProps} color={iconColor} />;
|
return <Package {...commonProps} color={iconColor} />;
|
||||||
case 'deployment':
|
case 'deployment':
|
||||||
return <Server {...commonProps} color={iconColor} />;
|
return <Server {...commonProps} color={iconColor} />;
|
||||||
|
case 'subscription':
|
||||||
|
return <CalendarClock {...commonProps} color={iconColor} />;
|
||||||
case 'setting':
|
case 'setting':
|
||||||
return <Settings {...commonProps} color={iconColor} />;
|
return <Settings {...commonProps} color={iconColor} />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const DEFAULT_ADMIN_CONFIG = {
|
|||||||
deployment: true,
|
deployment: true,
|
||||||
redemption: true,
|
redemption: true,
|
||||||
user: true,
|
user: true,
|
||||||
|
subscription: true,
|
||||||
setting: true,
|
setting: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
144
web/src/hooks/subscriptions/useSubscriptionsData.jsx
Normal file
144
web/src/hooks/subscriptions/useSubscriptionsData.jsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
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 { useState, useEffect } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { API, showError, showSuccess } from '../../helpers';
|
||||||
|
import { useTableCompactMode } from '../common/useTableCompactMode';
|
||||||
|
|
||||||
|
export const useSubscriptionsData = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [compactMode, setCompactMode] = useTableCompactMode('subscriptions');
|
||||||
|
|
||||||
|
// State management
|
||||||
|
const [plans, setPlans] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [pricingModels, setPricingModels] = useState([]);
|
||||||
|
|
||||||
|
// Drawer states
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [editingPlan, setEditingPlan] = useState(null);
|
||||||
|
const [sheetPlacement, setSheetPlacement] = useState('left'); // 'left' | 'right'
|
||||||
|
|
||||||
|
// Load pricing models for dropdown
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const res = await API.get('/api/pricing');
|
||||||
|
if (res.data?.success) {
|
||||||
|
setPricingModels(res.data.data || []);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setPricingModels([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load subscription plans
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Refresh data
|
||||||
|
const refresh = async () => {
|
||||||
|
await loadPlans();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disable plan
|
||||||
|
const disablePlan = async (planId) => {
|
||||||
|
if (!planId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await API.delete(`/api/subscription/admin/plans/${planId}`);
|
||||||
|
if (res.data?.success) {
|
||||||
|
showSuccess(t('已禁用'));
|
||||||
|
await loadPlans();
|
||||||
|
} else {
|
||||||
|
showError(res.data?.message || t('操作失败'));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError(t('请求失败'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Modal control functions
|
||||||
|
const closeEdit = () => {
|
||||||
|
setShowEdit(false);
|
||||||
|
setEditingPlan(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setSheetPlacement('left');
|
||||||
|
setEditingPlan(null);
|
||||||
|
setShowEdit(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (planRecord) => {
|
||||||
|
setSheetPlacement('right');
|
||||||
|
setEditingPlan(planRecord);
|
||||||
|
setShowEdit(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize data on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadModels();
|
||||||
|
loadPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Data state
|
||||||
|
plans,
|
||||||
|
loading,
|
||||||
|
pricingModels,
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
showEdit,
|
||||||
|
editingPlan,
|
||||||
|
sheetPlacement,
|
||||||
|
setShowEdit,
|
||||||
|
setEditingPlan,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
compactMode,
|
||||||
|
setCompactMode,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadPlans,
|
||||||
|
disablePlan,
|
||||||
|
refresh,
|
||||||
|
closeEdit,
|
||||||
|
openCreate,
|
||||||
|
openEdit,
|
||||||
|
|
||||||
|
// Translation
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -364,32 +364,32 @@ export const useLogsData = () => {
|
|||||||
key: t('日志详情'),
|
key: t('日志详情'),
|
||||||
value: other?.claude
|
value: other?.claude
|
||||||
? renderClaudeLogContent(
|
? renderClaudeLogContent(
|
||||||
other?.model_ratio,
|
other?.model_ratio,
|
||||||
other.completion_ratio,
|
other.completion_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
other.cache_creation_tokens_5m || 0,
|
other.cache_creation_tokens_5m || 0,
|
||||||
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio_5m || other.cache_creation_ratio || 1.0,
|
||||||
other.cache_creation_tokens_1h || 0,
|
other.cache_creation_tokens_1h || 0,
|
||||||
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0,
|
||||||
)
|
)
|
||||||
: renderLogContent(
|
: renderLogContent(
|
||||||
other?.model_ratio,
|
other?.model_ratio,
|
||||||
other.completion_ratio,
|
other.completion_ratio,
|
||||||
other.model_price,
|
other.model_price,
|
||||||
other.group_ratio,
|
other.group_ratio,
|
||||||
other?.user_group_ratio,
|
other?.user_group_ratio,
|
||||||
other.cache_ratio || 1.0,
|
other.cache_ratio || 1.0,
|
||||||
false,
|
false,
|
||||||
1.0,
|
1.0,
|
||||||
other.web_search || false,
|
other.web_search || false,
|
||||||
other.web_search_call_count || 0,
|
other.web_search_call_count || 0,
|
||||||
other.file_search || false,
|
other.file_search || false,
|
||||||
other.file_search_call_count || 0,
|
other.file_search_call_count || 0,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (logs[i]?.content) {
|
if (logs[i]?.content) {
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
@@ -458,12 +458,12 @@ export const useLogsData = () => {
|
|||||||
other.cache_creation_ratio || 1.0,
|
other.cache_creation_ratio || 1.0,
|
||||||
other.cache_creation_tokens_5m || 0,
|
other.cache_creation_tokens_5m || 0,
|
||||||
other.cache_creation_ratio_5m ||
|
other.cache_creation_ratio_5m ||
|
||||||
other.cache_creation_ratio ||
|
other.cache_creation_ratio ||
|
||||||
1.0,
|
1.0,
|
||||||
other.cache_creation_tokens_1h || 0,
|
other.cache_creation_tokens_1h || 0,
|
||||||
other.cache_creation_ratio_1h ||
|
other.cache_creation_ratio_1h ||
|
||||||
other.cache_creation_ratio ||
|
other.cache_creation_ratio ||
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
content = renderModelPrice(
|
content = renderModelPrice(
|
||||||
@@ -510,6 +510,60 @@ export const useLogsData = () => {
|
|||||||
value: other.request_path,
|
value: other.request_path,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (other?.billing_source === 'subscription') {
|
||||||
|
const planId = other?.subscription_plan_id;
|
||||||
|
const planTitle = other?.subscription_plan_title || '';
|
||||||
|
const itemId = other?.subscription_item_id;
|
||||||
|
const quotaType = other?.subscription_quota_type;
|
||||||
|
const unit = quotaType === 1 ? t('次') : t('额度');
|
||||||
|
const pre = other?.subscription_pre_consumed ?? 0;
|
||||||
|
const postDelta = other?.subscription_post_delta ?? 0;
|
||||||
|
const finalConsumed =
|
||||||
|
other?.subscription_consumed ?? (quotaType === 1 ? 1 : pre + postDelta);
|
||||||
|
const remain = other?.subscription_remain;
|
||||||
|
const total = other?.subscription_total;
|
||||||
|
// Use multiple Description items to avoid an overlong single line.
|
||||||
|
if (planId) {
|
||||||
|
expandDataLocal.push({
|
||||||
|
key: t('订阅套餐'),
|
||||||
|
value: `#${planId} ${planTitle}`.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (itemId) {
|
||||||
|
expandDataLocal.push({
|
||||||
|
key: t('订阅权益'),
|
||||||
|
value:
|
||||||
|
quotaType === 1
|
||||||
|
? `${t('权益ID')} ${itemId} · ${t('按次')}(1 ${t('次')}/${t('请求')})`
|
||||||
|
: `${t('权益ID')} ${itemId} · ${t('按量')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const settlementLines = [
|
||||||
|
`${t('预扣')}:${pre} ${unit}`,
|
||||||
|
quotaType === 0
|
||||||
|
? `${t('结算差额')}:${postDelta > 0 ? '+' : ''}${postDelta} ${unit}`
|
||||||
|
: null,
|
||||||
|
`${t('最终抵扣')}:${finalConsumed} ${unit}`,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
expandDataLocal.push({
|
||||||
|
key: t('订阅结算'),
|
||||||
|
value: <div style={{ whiteSpace: 'pre-line' }}>{settlementLines}</div>,
|
||||||
|
});
|
||||||
|
if (remain !== undefined && total !== undefined) {
|
||||||
|
expandDataLocal.push({
|
||||||
|
key: t('订阅剩余'),
|
||||||
|
value: `${remain}/${total} ${unit}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expandDataLocal.push({
|
||||||
|
key: t('订阅说明'),
|
||||||
|
value: t(
|
||||||
|
'token 会按倍率换算成“额度/次数”,请求结束后再做差额结算(补扣/返还)。',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
if (isAdminUser) {
|
if (isAdminUser) {
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
key: t('请求转换'),
|
key: t('请求转换'),
|
||||||
@@ -524,8 +578,8 @@ export const useLogsData = () => {
|
|||||||
localCountMode = t('上游返回');
|
localCountMode = t('上游返回');
|
||||||
}
|
}
|
||||||
expandDataLocal.push({
|
expandDataLocal.push({
|
||||||
key: t('计费模式'),
|
key: t('计费模式'),
|
||||||
value: localCountMode,
|
value: localCountMode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
expandDatesLocal[logs[i].key] = expandDataLocal;
|
expandDatesLocal[logs[i].key] = expandDataLocal;
|
||||||
@@ -584,7 +638,7 @@ export const useLogsData = () => {
|
|||||||
// Page handlers
|
// Page handlers
|
||||||
const handlePageChange = (page) => {
|
const handlePageChange = (page) => {
|
||||||
setActivePage(page);
|
setActivePage(page);
|
||||||
loadLogs(page, pageSize).then((r) => {});
|
loadLogs(page, pageSize).then((r) => { });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageSizeChange = async (size) => {
|
const handlePageSizeChange = async (size) => {
|
||||||
|
|||||||
32
web/src/pages/Subscription/index.jsx
Normal file
32
web/src/pages/Subscription/index.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
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 SubscriptionsTable from '../../components/table/subscriptions';
|
||||||
|
|
||||||
|
const Subscription = () => {
|
||||||
|
return (
|
||||||
|
<div className='mt-[60px] px-2'>
|
||||||
|
<SubscriptionsTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Subscription;
|
||||||
|
|
||||||
Reference in New Issue
Block a user