feat: add payment (#132) (#250)

This commit is contained in:
Buer 2024-06-05 13:30:12 +08:00 committed by GitHub
parent e4665a16c8
commit 03d847fe68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 2890 additions and 20 deletions

View File

@ -43,6 +43,10 @@ _本项目是基于[one-api](https://github.com/songquanpeng/one-api)二次开
</div>
> [!WARNING]
> 本项目为个人学习使用,不保证稳定性,且不提供任何技术支持,使用者必须在遵循 OpenAI 的使用条款以及法律法规的情况下使用,不得用于非法用途。
> 根据[《生成式人工智能服务管理暂行办法》](http://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
## 功能变化
- 全新的 UI 界面

4
common/config/payment.go Normal file
View File

@ -0,0 +1,4 @@
package config
var PaymentUSDRate = 7.3
var PaymentMinAmount = 1

View File

@ -14,10 +14,21 @@ import (
"strings"
"time"
"github.com/bwmarrin/snowflake"
"github.com/google/uuid"
"github.com/spf13/viper"
)
var node *snowflake.Node
func init() {
var err error
node, err = snowflake.NewNode(1)
if err != nil {
log.Fatalf("snowflake.NewNode failed: %v", err)
}
}
func OpenBrowser(url string) {
var err error
@ -203,6 +214,14 @@ func String2Int(str string) int {
return num
}
func String2Int64(str string) int64 {
num, err := strconv.ParseInt(str, 10, 64)
if err != nil {
return 0
}
return num
}
func IsFileExist(path string) bool {
_, err := os.Stat(path)
return err == nil || os.IsExist(err)
@ -256,3 +275,19 @@ func Marshal[T interface{}](data T) string {
}
return string(res)
}
func GenerateTradeNo() string {
id := node.Generate()
return id.String()
}
func Decimal(value float64, decimalPlace int) float64 {
format := fmt.Sprintf("%%.%df", decimalPlace)
value, _ = strconv.ParseFloat(fmt.Sprintf(format, value), 64)
return value
}
func GetUnixTime() int64 {
return time.Now().Unix()
}

View File

@ -47,6 +47,8 @@ func GetStatus(c *gin.Context) {
"mj_notify_enabled": config.MjNotifyEnabled,
"chat_cache_enabled": config.ChatCacheEnabled,
"chat_links": config.ChatLinks,
"PaymentUSDRate": config.PaymentUSDRate,
"PaymentMinAmount": config.PaymentMinAmount,
},
})
}

223
controller/order.go Normal file
View File

@ -0,0 +1,223 @@
package controller
import (
"errors"
"fmt"
"net/http"
"sync"
"one-api/common"
"one-api/common/config"
"one-api/common/logger"
"one-api/common/utils"
"one-api/model"
"one-api/payment"
"one-api/payment/types"
"github.com/gin-gonic/gin"
)
type OrderRequest struct {
UUID string `json:"uuid" binding:"required"`
Amount int `json:"amount" binding:"required"`
}
type OrderResponse struct {
TradeNo string `json:"trade_no"`
*types.PayRequest
}
// CreateOrder
func CreateOrder(c *gin.Context) {
var orderReq OrderRequest
if err := c.ShouldBindJSON(&orderReq); err != nil {
common.APIRespondWithError(c, http.StatusOK, errors.New("invalid request"))
return
}
if orderReq.Amount <= 0 || orderReq.Amount < config.PaymentMinAmount {
common.APIRespondWithError(c, http.StatusOK, fmt.Errorf("金额必须大于等于 %d", config.PaymentMinAmount))
return
}
userId := c.GetInt("id")
// 关闭用户未完成的订单
go model.CloseUnfinishedOrder()
paymentService, err := payment.NewPaymentService(orderReq.UUID)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
// 获取手续费和支付金额
fee, payMoney := calculateOrderAmount(paymentService.Payment, orderReq.Amount)
// 开始支付
tradeNo := utils.GenerateTradeNo()
payRequest, err := paymentService.Pay(tradeNo, payMoney)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, errors.New("创建支付失败,请稍后再试"))
return
}
// 创建订单
order := &model.Order{
UserId: userId,
TradeNo: tradeNo,
Amount: orderReq.Amount,
OrderAmount: payMoney,
OrderCurrency: paymentService.Payment.Currency,
Fee: fee,
Status: model.OrderStatusPending,
Quota: orderReq.Amount * int(config.QuotaPerUnit),
}
err = order.Insert()
if err != nil {
common.APIRespondWithError(c, http.StatusOK, errors.New("创建订单失败,请稍后再试"))
return
}
orderResp := &OrderResponse{
TradeNo: tradeNo,
PayRequest: payRequest,
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": orderResp,
})
}
// tradeNo lock
var orderLocks sync.Map
var createLock sync.Mutex
// LockOrder 尝试对给定订单号加锁
func LockOrder(tradeNo string) {
lock, ok := orderLocks.Load(tradeNo)
if !ok {
createLock.Lock()
defer createLock.Unlock()
lock, ok = orderLocks.Load(tradeNo)
if !ok {
lock = new(sync.Mutex)
orderLocks.Store(tradeNo, lock)
}
}
lock.(*sync.Mutex).Lock()
}
// UnlockOrder 释放给定订单号的锁
func UnlockOrder(tradeNo string) {
lock, ok := orderLocks.Load(tradeNo)
if ok {
lock.(*sync.Mutex).Unlock()
}
}
func PaymentCallback(c *gin.Context) {
uuid := c.Param("uuid")
paymentService, err := payment.NewPaymentService(uuid)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, errors.New("payment not found"))
return
}
payNotify, err := paymentService.HandleCallback(c, paymentService.Payment.Config)
if err != nil {
return
}
LockOrder(payNotify.GatewayNo)
defer UnlockOrder(payNotify.GatewayNo)
order, err := model.GetOrderByTradeNo(payNotify.TradeNo)
if err != nil {
logger.SysError(fmt.Sprintf("gateway callback failed to find order, trade_no: %s,", payNotify.TradeNo))
return
}
fmt.Println(order.Status, order.Status != model.OrderStatusPending)
if order.Status != model.OrderStatusPending {
return
}
order.GatewayNo = payNotify.GatewayNo
order.Status = model.OrderStatusSuccess
err = order.Update()
if err != nil {
logger.SysError(fmt.Sprintf("gateway callback failed to update order, trade_no: %s,", payNotify.TradeNo))
return
}
err = model.IncreaseUserQuota(order.UserId, order.Quota)
if err != nil {
logger.SysError(fmt.Sprintf("gateway callback failed to increase user quota, trade_no: %s,", payNotify.TradeNo))
return
}
model.RecordLog(order.UserId, model.LogTypeTopup, fmt.Sprintf("在线充值成功充值quota: %d支付金额%.2f %s", order.Quota, order.OrderAmount, order.OrderCurrency))
}
func CheckOrderStatus(c *gin.Context) {
tradeNo := c.Query("trade_no")
userId := c.GetInt("id")
success := false
if tradeNo != "" {
order, err := model.GetUserOrder(userId, tradeNo)
if err == nil {
if order.Status == model.OrderStatusSuccess {
success = true
}
}
}
c.JSON(http.StatusOK, gin.H{
"success": success,
"message": "",
})
}
func calculateOrderAmount(payment *model.Payment, amount int) (fee, payMoney float64) {
if payment.PercentFee > 0 {
fee = utils.Decimal(float64(amount)*payment.PercentFee, 2)
} else if payment.FixedFee > 0 {
fee = payment.FixedFee
}
total := utils.Decimal(float64(amount)+fee, 2)
if payment.Currency == model.CurrencyTypeUSD {
payMoney = total
} else {
payMoney = utils.Decimal(total*config.PaymentUSDRate, 2)
}
return
}
func GetOrderList(c *gin.Context) {
var params model.SearchOrderParams
if err := c.ShouldBindQuery(&params); err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
payments, err := model.GetOrderList(&params)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": payments,
})
}

140
controller/payment.go Normal file
View File

@ -0,0 +1,140 @@
package controller
import (
"net/http"
"one-api/common"
"one-api/model"
"strconv"
"github.com/gin-gonic/gin"
)
func GetPaymentList(c *gin.Context) {
var params model.SearchPaymentParams
if err := c.ShouldBindQuery(&params); err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
payments, err := model.GetPanymentList(&params)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": payments,
})
}
func GetPayment(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
payment, err := model.GetPaymentByID(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": payment,
})
}
func AddPayment(c *gin.Context) {
payment := model.Payment{}
err := c.ShouldBindJSON(&payment)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
err = payment.Insert()
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": payment,
})
}
func UpdatePayment(c *gin.Context) {
payment := model.Payment{}
err := c.ShouldBindJSON(&payment)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
overwrite := true
if payment.UUID == "" {
overwrite = false
}
err = payment.Update(overwrite)
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": payment,
})
}
func DeletePayment(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
payment := model.Payment{ID: id}
err = payment.Delete()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
})
}
func GetUserPaymentList(c *gin.Context) {
payments, err := model.GetUserPaymentList()
if err != nil {
common.APIRespondWithError(c, http.StatusOK, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "",
"data": payments,
})
}

1
go.mod
View File

@ -35,6 +35,7 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect

2
go.sum
View File

@ -14,6 +14,8 @@ github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC
github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw=
github.com/bradfitz/gomemcache v0.0.0-20190913173617-a41fca850d0b/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA=

View File

@ -154,6 +154,14 @@ func InitDB() (err error) {
if err != nil {
return err
}
err = db.AutoMigrate(&Payment{})
if err != nil {
return err
}
err = db.AutoMigrate(&Order{})
if err != nil {
return err
}
logger.SysLog("database migrated")
err = createRootAccountIfNeed()
return err

View File

@ -84,6 +84,9 @@ func InitOptionMap() {
config.OptionMap["ChatImageRequestProxy"] = ""
config.OptionMap["PaymentUSDRate"] = strconv.FormatFloat(config.PaymentUSDRate, 'f', -1, 64)
config.OptionMap["PaymentMinAmount"] = strconv.Itoa(config.PaymentMinAmount)
config.OptionMapRWMutex.Unlock()
loadOptionsFromDatabase()
}
@ -132,6 +135,7 @@ var optionIntMap = map[string]*int{
"RetryTimes": &config.RetryTimes,
"RetryCooldownSeconds": &config.RetryCooldownSeconds,
"ChatCacheExpireMinute": &config.ChatCacheExpireMinute,
"PaymentMinAmount": &config.PaymentMinAmount,
}
var optionBoolMap = map[string]*bool{
@ -206,6 +210,8 @@ func updateOptionMap(key string, value string) (err error) {
config.ChannelDisableThreshold, _ = strconv.ParseFloat(value, 64)
case "QuotaPerUnit":
config.QuotaPerUnit, _ = strconv.ParseFloat(value, 64)
case "PaymentUSDRate":
config.PaymentUSDRate, _ = strconv.ParseFloat(value, 64)
}
return err
}

107
model/order.go Normal file
View File

@ -0,0 +1,107 @@
package model
import (
"time"
"gorm.io/gorm"
)
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusSuccess OrderStatus = "success"
OrderStatusFailed OrderStatus = "failed"
OrderStatusClosed OrderStatus = "closed"
)
type Order struct {
ID int `json:"id"`
UserId int `json:"user_id"`
TradeNo string `json:"trade_no" gorm:"type:varchar(50);uniqueIndex"`
GatewayNo string `json:"gateway_no" gorm:"type:varchar(100)"`
Amount int `json:"amount" gorm:"default:0"`
OrderAmount float64 `json:"order_amount" gorm:"type:decimal(10,2);default:0"`
OrderCurrency CurrencyType `json:"order_currency" gorm:"type:varchar(16)"`
Quota int `json:"quota" gorm:"type:int;default:0"`
Fee float64 `json:"fee" gorm:"type:decimal(10,2);default:0"`
Status OrderStatus `json:"status" gorm:"type:varchar(32)"`
CreatedAt int `json:"created_at"`
UpdatedAt int `json:"-"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
// 查询并关闭未完成的订单
func CloseUnfinishedOrder() error {
// 关闭超过 3 小时未支付的订单
unixTime := time.Now().Unix() - 3*3600
return DB.Model(&Order{}).Where("status = ? AND created_at < ?", OrderStatusPending, unixTime).Update("status", OrderStatusClosed).Error
}
func GetOrderByTradeNo(tradeNo string) (*Order, error) {
var order Order
err := DB.Where("trade_no = ?", tradeNo).First(&order).Error
return &order, err
}
func GetUserOrder(userId int, tradeNo string) (*Order, error) {
var order Order
err := DB.Where("user_id = ? AND trade_no = ?", userId, tradeNo).First(&order).Error
return &order, err
}
func (o *Order) Insert() error {
return DB.Create(o).Error
}
func (o *Order) Update() error {
return DB.Save(o).Error
}
var allowedOrderFields = map[string]bool{
"id": true,
"user_id": true,
"status": true,
"created_at": true,
}
type SearchOrderParams struct {
UserId int `form:"user_id"`
TradeNo string `form:"trade_no"`
GatewayNo string `form:"gateway_no"`
Status string `form:"status"`
StartTimestamp int64 `form:"start_timestamp"`
EndTimestamp int64 `form:"end_timestamp"`
PaginationParams
}
func GetOrderList(params *SearchOrderParams) (*DataResult[Order], error) {
var orders []*Order
db := DB.Omit("key")
if params.UserId != 0 {
db = db.Where("user_id = ?", params.UserId)
}
if params.TradeNo != "" {
db = db.Where("trade_no = ?", params.TradeNo)
}
if params.GatewayNo != "" {
db = db.Where("gateway_no = ?", params.GatewayNo)
}
if params.Status != "" {
db = db.Where("status = ?", params.Status)
}
if params.StartTimestamp != 0 {
db = db.Where("created_at >= ?", params.StartTimestamp)
}
if params.EndTimestamp != 0 {
db = db.Where("created_at <= ?", params.EndTimestamp)
}
return PaginateAndOrder(db, &params.PaginationParams, &orders, allowedOrderFields)
}

110
model/payment.go Normal file
View File

@ -0,0 +1,110 @@
package model
import (
"one-api/common/utils"
"gorm.io/gorm"
)
type CurrencyType string
const (
CurrencyTypeUSD CurrencyType = "USD"
CurrencyTypeCNY CurrencyType = "CNY"
)
type Payment struct {
ID int `json:"id"`
Type string `json:"type" form:"type" gorm:"type:varchar(16)"`
UUID string `json:"uuid" form:"uuid" gorm:"type:char(32);uniqueIndex"`
Name string `json:"name" form:"name" gorm:"type:varchar(255); not null"`
Icon string `json:"icon" form:"icon" gorm:"type:varchar(300)"`
NotifyDomain string `json:"notify_domain" form:"notify_domain" gorm:"type:varchar(300)"`
FixedFee float64 `json:"fixed_fee" form:"fixed_fee" gorm:"type:decimal(10,2); default:0.00"`
PercentFee float64 `json:"percent_fee" form:"percent_fee" gorm:"type:decimal(10,2); default:0.00"`
Currency CurrencyType `json:"currency" form:"currency" gorm:"type:varchar(5)"`
Config string `json:"config" form:"config" gorm:"type:text"`
Sort int `json:"sort" form:"sort" gorm:"default:1"`
Enable *bool `json:"enable" form:"enable" gorm:"default:true"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"-" gorm:"bigint"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index"`
}
func GetPaymentByID(id int) (*Payment, error) {
var payment Payment
err := DB.First(&payment, id).Error
return &payment, err
}
func GetPaymentByUUID(uuid string) (*Payment, error) {
var payment Payment
err := DB.Where("uuid = ? AND enable = ?", uuid, true).First(&payment).Error
return &payment, err
}
var allowedPaymentOrderFields = map[string]bool{
"id": true,
"uuid": true,
"name": true,
"type": true,
"sort": true,
"enable": true,
"created_at": true,
}
type SearchPaymentParams struct {
Payment
PaginationParams
}
func GetPanymentList(params *SearchPaymentParams) (*DataResult[Payment], error) {
var payments []*Payment
db := DB.Omit("key")
if params.Type != "" {
db = db.Where("type = ?", params.Type)
}
if params.Name != "" {
db = db.Where("name LIKE ?", params.Name+"%")
}
if params.UUID != "" {
db = db.Where("uuid = ?", params.UUID)
}
if params.Currency != "" {
db = db.Where("currency = ?", params.Currency)
}
return PaginateAndOrder(db, &params.PaginationParams, &payments, allowedPaymentOrderFields)
}
func GetUserPaymentList() ([]*Payment, error) {
var payments []*Payment
err := DB.Model(payments).Select("uuid, name, icon, fixed_fee, percent_fee, currency, sort").Where("enable = ?", true).Find(&payments).Error
return payments, err
}
func (p *Payment) Insert() error {
p.UUID = utils.GetUUID()
return DB.Create(p).Error
}
func (p *Payment) Update(overwrite bool) error {
var err error
if overwrite {
err = DB.Model(p).Select("*").Updates(p).Error
} else {
err = DB.Model(p).Updates(p).Error
}
return err
}
func (p *Payment) Delete() error {
return DB.Delete(p).Error
}

View File

@ -0,0 +1,80 @@
package epay
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
"github.com/mitchellh/mapstructure"
)
type Client struct {
PayDomain string `json:"pay_domain"`
PartnerID string `json:"partner_id"`
Key string `json:"key"`
}
// FormPay 表单支付
func (c *Client) FormPay(args *PayArgs) (string, map[string]string, error) {
formPayArgs := map[string]string{
"type": string(args.Type),
"pid": c.PartnerID,
"out_trade_no": args.OutTradeNo,
"notify_url": args.NotifyUrl,
"return_url": args.ReturnUrl,
"name": args.Name,
"money": args.Money,
// "device": string(args.Device),
}
formPayArgs["sign"] = c.Sign(formPayArgs)
formPayArgs["sign_type"] = FormArgsSignType
domain := strings.TrimSuffix(c.PayDomain, "/")
return domain + FormSubmitUrl, formPayArgs, nil
}
func (c *Client) Verify(params map[string]string) (*PaymentResult, bool) {
sign := params["sign"]
tradeStatus := params["trade_status"]
if sign == "" || tradeStatus != TradeStatusSuccess {
return nil, false
}
if sign != c.Sign(params) {
return nil, false
}
var paymentResult PaymentResult
mapstructure.Decode(params, &paymentResult)
return &paymentResult, true
}
// Sign 签名
func (c *Client) Sign(args map[string]string) string {
keys := make([]string, 0, len(args))
for k := range args {
if k != "sign" && k != "sign_type" && args[k] != "" {
keys = append(keys, k)
}
}
sort.Strings(keys)
signStrs := make([]string, len(keys))
for i, k := range keys {
signStrs[i] = k + "=" + args[k]
}
signStr := strings.Join(signStrs, "&") + c.Key
h := md5.New()
h.Write([]byte(signStr))
return hex.EncodeToString(h.Sum(nil))
}

View File

@ -0,0 +1,91 @@
package epay
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"one-api/payment/types"
"strconv"
"github.com/gin-gonic/gin"
)
type Epay struct{}
type EpayConfig struct {
PayType PayType `json:"pay_type"`
Client
}
func (e *Epay) Name() string {
return "易支付"
}
func (e *Epay) Pay(config *types.PayConfig, gatewayConfig string) (*types.PayRequest, error) {
epayConfig, err := getEpayConfig(gatewayConfig)
if err != nil {
return nil, err
}
payArgs := &PayArgs{
Type: epayConfig.PayType,
OutTradeNo: config.TradeNo,
NotifyUrl: config.NotifyURL,
ReturnUrl: config.ReturnURL,
Name: config.TradeNo,
Money: strconv.FormatFloat(config.Money, 'f', 2, 64),
}
formPayURL, formPayArgs, err := epayConfig.FormPay(payArgs)
if err != nil {
return nil, err
}
payRequest := &types.PayRequest{
Type: 1,
Data: types.PayRequestData{
URL: formPayURL,
Params: &formPayArgs,
Method: http.MethodPost,
},
}
return payRequest, nil
}
func (e *Epay) HandleCallback(c *gin.Context, gatewayConfig string) (*types.PayNotify, error) {
queryMap := make(map[string]string)
if err := c.ShouldBindQuery(&queryMap); err != nil {
c.Writer.Write([]byte("fail"))
return nil, err
}
epayConfig, err := getEpayConfig(gatewayConfig)
if err != nil {
c.Writer.Write([]byte("fail"))
return nil, fmt.Errorf("tradeNo: %s, PaymentNo: %s, err: %v", queryMap["out_trade_no"], queryMap["trade_no"], err)
}
paymentResult, success := epayConfig.Verify(queryMap)
if paymentResult != nil && success {
c.Writer.Write([]byte("success"))
payNotify := &types.PayNotify{
TradeNo: paymentResult.OutTradeNo,
GatewayNo: paymentResult.TradeNo,
}
return payNotify, nil
}
c.Writer.Write([]byte("fail"))
return nil, fmt.Errorf("tradeNo: %s, PaymentNo: %s, Verify Sign failed", queryMap["out_trade_no"], queryMap["trade_no"])
}
func getEpayConfig(gatewayConfig string) (*EpayConfig, error) {
var epayConfig EpayConfig
if err := json.Unmarshal([]byte(gatewayConfig), &epayConfig); err != nil {
return nil, errors.New("config error")
}
return &epayConfig, nil
}

View File

@ -0,0 +1,45 @@
package epay
type PayType string
var (
EpayPay PayType = "" // 网关
Alipay PayType = "alipay" // 支付宝
Wechat PayType = "wxpay" // 微信
QQ PayType = "qqpay" // QQ
Bank PayType = "bank" // 银行
JD PayType = "jdpay" // 京东
PayPal PayType = "paypal" // PayPal
USDT PayType = "usdt" // USDT
)
// type DeviceType string
// var (
// PC DeviceType = "pc" // PC
// Mobile DeviceType = "mobile" // 移动端
// )
const (
FormArgsSignType = "MD5"
FormSubmitUrl = "/submit.php"
TradeStatusSuccess = "TRADE_SUCCESS"
)
type PayArgs struct {
Type PayType `json:"type,omitempty"`
OutTradeNo string `json:"out_trade_no"`
NotifyUrl string `json:"notify_url"`
ReturnUrl string `json:"return_url"`
Name string `json:"name"`
Money string `json:"money"`
}
type PaymentResult struct {
Type PayType `mapstructure:"type"`
TradeNo string `mapstructure:"trade_no"`
OutTradeNo string `mapstructure:"out_trade_no"`
Name string `mapstructure:"name"`
Money string `mapstructure:"money"`
TradeStatus string `mapstructure:"trade_status"`
}

20
payment/payment.go Normal file
View File

@ -0,0 +1,20 @@
package payment
import (
"one-api/payment/gateway/epay"
"one-api/payment/types"
"github.com/gin-gonic/gin"
)
type PaymentProcessor interface {
Name() string
Pay(config *types.PayConfig, gatewayConfig string) (*types.PayRequest, error)
HandleCallback(c *gin.Context, gatewayConfig string) (*types.PayNotify, error)
}
var Gateways = make(map[string]PaymentProcessor)
func init() {
Gateways["epay"] = &epay.Epay{}
}

81
payment/service.go Normal file
View File

@ -0,0 +1,81 @@
package payment
import (
"errors"
"fmt"
"one-api/common/config"
"one-api/common/logger"
"one-api/model"
"one-api/payment/types"
"strings"
"github.com/gin-gonic/gin"
)
type PaymentService struct {
Payment *model.Payment
gateway PaymentProcessor
}
type PayMoney struct {
Amount float64
Currency model.CurrencyType
}
func NewPaymentService(uuid string) (*PaymentService, error) {
payment, err := model.GetPaymentByUUID(uuid)
if err != nil {
return nil, errors.New("payment not found")
}
gateway, ok := Gateways[payment.Type]
if !ok {
return nil, errors.New("payment gateway not found")
}
return &PaymentService{
Payment: payment,
gateway: gateway,
}, nil
}
func (s *PaymentService) Pay(tradeNo string, amount float64) (*types.PayRequest, error) {
config := &types.PayConfig{
Money: amount,
TradeNo: tradeNo,
NotifyURL: s.getNotifyURL(),
ReturnURL: s.getReturnURL(),
}
payRequest, err := s.gateway.Pay(config, s.Payment.Config)
if err != nil {
return nil, err
}
return payRequest, nil
}
func (s *PaymentService) HandleCallback(c *gin.Context, gatewayConfig string) (*types.PayNotify, error) {
payNotify, err := s.gateway.HandleCallback(c, gatewayConfig)
if err != nil {
logger.SysError(fmt.Sprintf("%s payment callback error: %v", s.gateway.Name(), err))
}
return payNotify, err
}
func (s *PaymentService) getNotifyURL() string {
notifyDomain := s.Payment.NotifyDomain
if notifyDomain == "" {
notifyDomain = config.ServerAddress
}
notifyDomain = strings.TrimSuffix(notifyDomain, "/")
return fmt.Sprintf("%s/api/payment/notify/%s", notifyDomain, s.Payment.UUID)
}
func (s *PaymentService) getReturnURL() string {
serverAdd := strings.TrimSuffix(config.ServerAddress, "/")
return fmt.Sprintf("%s/panel/log", serverAdd)
}

27
payment/types/types.go Normal file
View File

@ -0,0 +1,27 @@
package types
// 支付网关的通用配置
type PayConfig struct {
NotifyURL string `json:"notify_url"`
ReturnURL string `json:"return_url"`
TradeNo string `json:"trade_no"`
Money float64 `json:"money"`
}
// 请求支付时的数据结构
type PayRequest struct {
Type int `json:"type"` // 支付类型 1 url 2 qrcode
Data PayRequestData `json:"data"`
}
type PayRequestData struct {
URL string `json:"url"`
Method string `json:"method,omitempty"`
Params any `json:"params,omitempty"`
}
// 支付回调时的数据结构
type PayNotify struct {
TradeNo string `json:"trade_no"`
GatewayNo string `json:"gateway_no"`
}

View File

@ -31,6 +31,8 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
apiRouter.Any("/payment/notify/:uuid", controller.PaymentCallback)
userRoute := apiRouter.Group("/user")
{
userRoute.POST("/register", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.Register)
@ -48,6 +50,9 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.POST("/topup", controller.TopUp)
selfRoute.GET("/models", relay.ListModels)
selfRoute.GET("/payment", controller.GetUserPaymentList)
selfRoute.POST("/order", controller.CreateOrder)
selfRoute.GET("/order/status", controller.CheckOrderStatus)
}
adminRoute := userRoute.Group("/")
@ -148,6 +153,17 @@ func SetApiRouter(router *gin.Engine) {
}
paymentRoute := apiRouter.Group("/payment")
paymentRoute.Use(middleware.AdminAuth())
{
paymentRoute.GET("/order", controller.GetOrderList)
paymentRoute.GET("/", controller.GetPaymentList)
paymentRoute.GET("/:id", controller.GetPayment)
paymentRoute.POST("/", controller.AddPayment)
paymentRoute.PUT("/", controller.UpdatePayment)
paymentRoute.DELETE("/:id", controller.DeletePayment)
}
mjRoute := apiRouter.Group("/mj")
mjRoute.GET("/self", middleware.UserAuth(), controller.GetUserMidjourney)
mjRoute.GET("/", middleware.AdminAuth(), controller.GetAllMidjourney)

View File

@ -31,6 +31,7 @@
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-perfect-scrollbar": "^1.5.8",
"react-qrcode-logo": "^3.0.0",
"react-redux": "^9.1.0",
"react-router": "6.21.3",
"react-router-dom": "6.21.3",
@ -82,5 +83,6 @@
"immutable": "^4.3.5",
"prettier": "^3.2.4",
"sass": "^1.70.0"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

BIN
web/public/ali_pay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,5 @@
<svg id="eKwXi4fbWI71" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 590 360" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
<style><![CDATA[
#eKwXi4fbWI73_to {animation: eKwXi4fbWI73_to__to 3000ms linear infinite normal forwards}@keyframes eKwXi4fbWI73_to__to { 0% {transform: translate(386.852639px,-200.193104px)} 16.666667% {transform: translate(386.848452px,-187.188246px)} 33.333333% {transform: translate(386.848452px,-200.188246px)} 50% {transform: translate(386.848452px,-187.188246px)} 66.666667% {transform: translate(386.848452px,-200.188246px)} 83.333333% {transform: translate(386.848452px,-187.188246px)} 100% {transform: translate(386.848452px,-200.188246px)}}
]]></style>
<g><g id="eKwXi4fbWI73_to" transform="translate(386.852639,-200.193104)"><ellipse rx="28.47" ry="28.14" transform="rotate(103.599994) translate(287.6,33.87)" fill="#fff"/></g><path d="M232.92,128.89c3.78,27.29-1.81,55.44-17.71,78.09-.610963.870887-.634672,2.024751-.06,2.92c1.24,1.92,2.96,5.05,5.56,4.94q5.25-.22,10.79.11c.672283.037376,1.196382.596709,1.19,1.27l-.4,42.53c-.005493.719585-.590394,1.300021-1.31,1.3q-16.77-.09-36.53.01-2.25.02-3.71-1.56-16.02-17.28-31.98-35.32c-5.13-5.8-10.18-11.16-14.86-17.59-.234342-.319666-.313973-.727248-.217488-1.113186s.359185-.710636.717488-.886814q12.88-6.32,22.13-17.12q18.18-21.23,15.08-48.84-2.66-23.7-22.4-40.46-23.43-19.9-54.88-13.86c-4.1.79-7.83,2.5-11.72,4.12q-11.86,4.94-20.59,14.64c-14.25,15.81-20.07,36.4-15.05,57.16q4.99,20.63,22.86,35.71c10.45,8.81,23.7,13.12,37.26,14.18q1.47.11,3.6,2.65c11.68,13.89,24.48,27.72,35.94,41.96.089095.111267.117261.259455.0752.39565s-.148879.242697-.2852.28435q-22.51,7.27-47.37,5.37-19.4-1.47-39.74-11.22-18.27-8.75-30.59-21.28Q20.06,208.3,10.7,183.71q-10.8-28.4-4.93-58.67c1.59-8.17,4.03-17,7.42-24.61Q18.27,89.05,24.8,79.79Q50.21,43.76,93.25,33.66q32.42-7.61,64.23,3.92q25.31,9.17,43.2,27.31c16.85,17.09,28.91,40.01,32.24,64Z" transform="translate(-.326548 38.749742)" fill="#fff"/><path d="M499.47,180.61c6.45,13.53,16.44,21.75,31.96,22q11.94.19,22.17-5.36q2.21-1.2,3.93.69q12.56,13.78,24.89,28.47q1.21,1.44,1.44,3.13c.049759.339937-.087897.680254-.36.89-1.62,1.23-3.33,2.71-5.03,3.69Q549.1,251.13,516,245.43c-20.61-3.55-39.05-15.24-51.47-32.51q-6.4-8.89-9.91-17.08c-2.62-6.12-4.73-13.3-5.41-20.08q-3.96-39.88,22.94-67.74c9.48-9.81,21.15-16.67,34.39-19.49c16.54-3.53,34.64-1.83,48.77,7.1q13.92,8.79,21.13,20.4q11.07,17.84,10.48,38.92c-.02.94-.21,1.81-.85,2.54q-7.73,8.77-18.71,20.16c-1.28,1.32-2.61,2.26-4.51,2.23q-24.45-.37-51.64-.41-5.03,0-10.84-.22c-.334463-.009876-.650686.153565-.834487.431309s-.208627.629665-.065513.928691Zm1.12-37.17q-.55,1.19-.63,2.34-.08,1.01.94,1.03q19.01.25,36.98.01.5,0,.94-.22.57-.28.44-.9-2.34-11.6-14.11-15.25-3.59-1.11-6.44-.57-13.07,2.5-18.12,13.56Z" transform="translate(-.326548 38.749742)" fill="#fff"/><path d="M312.3,100.22c-.001444.196624.116164.373518.298977.44969s.395635.036957.541023-.09969q2.76-2.64,5.82-4.31q8.45-4.62,16.71-6.57c15.81-3.72,33.58-3.2,48.2,3.95q24.49,11.98,35.05,35.76c4.66,10.5,5.44,22.96,5.5,35.35q.21,49.99-.12,88-.03,3.06-.08,6.16c-.010966.725105-.604834,1.305577-1.33,1.3q-20.22-.18-40.18-.23-3.64-.01-8.13-.44c-.537084-.051394-.94781-.505354-.95-1.05q.02-45.49-.22-92.99c-.03-6.25-1.21-13.88-5.05-18.95q-5.33-7.03-12.32-10.18c-10.99-4.93-24.52-1.84-33.13,6.37q-10.01,9.53-10.07,23.76-.11,25.46-.1,48.98c0,3.52-.06,8.31-1.1,11.68-4.37,14.04-17.31,19.5-31.04,16.77-8.22-1.64-15.07-7.75-17.62-15.62q-1.45-4.49-1.42-10.2.3-64.69.1-129.86c0-.124652.049518-.244198.13766-.33234s.207688-.13766.33234-.13766l48.46-.35c.852152-.005489,1.548978.682403,1.56,1.54l.15,11.25Z" transform="translate(-.326548 38.749742)" fill="#fff"/></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,5 @@
<svg id="eKwXi4fbWI71" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 590 360" shape-rendering="geometricPrecision" text-rendering="geometricPrecision">
<style><![CDATA[
#eKwXi4fbWI73_to {animation: eKwXi4fbWI73_to__to 3000ms linear infinite normal forwards}@keyframes eKwXi4fbWI73_to__to { 0% {transform: translate(386.852639px,-200.193104px)} 16.666667% {transform: translate(386.848452px,-187.188246px)} 33.333333% {transform: translate(386.848452px,-200.188246px)} 50% {transform: translate(386.848452px,-187.188246px)} 66.666667% {transform: translate(386.848452px,-200.188246px)} 83.333333% {transform: translate(386.848452px,-187.188246px)} 100% {transform: translate(386.848452px,-200.188246px)}}
]]></style>
<g><g id="eKwXi4fbWI73_to" transform="translate(386.852639,-200.193104)"><ellipse rx="28.47" ry="28.14" transform="rotate(103.599994) translate(287.6,33.87)" fill="#0d161f"/></g><path d="M232.92,128.89c3.78,27.29-1.81,55.44-17.71,78.09-.610963.870887-.634672,2.024751-.06,2.92c1.24,1.92,2.96,5.05,5.56,4.94q5.25-.22,10.79.11c.672283.037376,1.196382.596709,1.19,1.27l-.4,42.53c-.005493.719585-.590394,1.300021-1.31,1.3q-16.77-.09-36.53.01-2.25.02-3.71-1.56-16.02-17.28-31.98-35.32c-5.13-5.8-10.18-11.16-14.86-17.59-.234342-.319666-.313973-.727248-.217488-1.113186s.359185-.710636.717488-.886814q12.88-6.32,22.13-17.12q18.18-21.23,15.08-48.84-2.66-23.7-22.4-40.46-23.43-19.9-54.88-13.86c-4.1.79-7.83,2.5-11.72,4.12q-11.86,4.94-20.59,14.64c-14.25,15.81-20.07,36.4-15.05,57.16q4.99,20.63,22.86,35.71c10.45,8.81,23.7,13.12,37.26,14.18q1.47.11,3.6,2.65c11.68,13.89,24.48,27.72,35.94,41.96.089095.111267.117261.259455.0752.39565s-.148879.242697-.2852.28435q-22.51,7.27-47.37,5.37-19.4-1.47-39.74-11.22-18.27-8.75-30.59-21.28Q20.06,208.3,10.7,183.71q-10.8-28.4-4.93-58.67c1.59-8.17,4.03-17,7.42-24.61Q18.27,89.05,24.8,79.79Q50.21,43.76,93.25,33.66q32.42-7.61,64.23,3.92q25.31,9.17,43.2,27.31c16.85,17.09,28.91,40.01,32.24,64Z" transform="translate(-.326548 38.749742)" fill="#0d161f"/><path d="M499.47,180.61c6.45,13.53,16.44,21.75,31.96,22q11.94.19,22.17-5.36q2.21-1.2,3.93.69q12.56,13.78,24.89,28.47q1.21,1.44,1.44,3.13c.049759.339937-.087897.680254-.36.89-1.62,1.23-3.33,2.71-5.03,3.69Q549.1,251.13,516,245.43c-20.61-3.55-39.05-15.24-51.47-32.51q-6.4-8.89-9.91-17.08c-2.62-6.12-4.73-13.3-5.41-20.08q-3.96-39.88,22.94-67.74c9.48-9.81,21.15-16.67,34.39-19.49c16.54-3.53,34.64-1.83,48.77,7.1q13.92,8.79,21.13,20.4q11.07,17.84,10.48,38.92c-.02.94-.21,1.81-.85,2.54q-7.73,8.77-18.71,20.16c-1.28,1.32-2.61,2.26-4.51,2.23q-24.45-.37-51.64-.41-5.03,0-10.84-.22c-.334463-.009876-.650686.153565-.834487.431309s-.208627.629665-.065513.928691Zm1.12-37.17q-.55,1.19-.63,2.34-.08,1.01.94,1.03q19.01.25,36.98.01.5,0,.94-.22.57-.28.44-.9-2.34-11.6-14.11-15.25-3.59-1.11-6.44-.57-13.07,2.5-18.12,13.56Z" transform="translate(-.326548 38.749742)" fill="#0d161f"/><path d="M312.3,100.22c-.001444.196624.116164.373518.298977.44969s.395635.036957.541023-.09969q2.76-2.64,5.82-4.31q8.45-4.62,16.71-6.57c15.81-3.72,33.58-3.2,48.2,3.95q24.49,11.98,35.05,35.76c4.66,10.5,5.44,22.96,5.5,35.35q.21,49.99-.12,88-.03,3.06-.08,6.16c-.010966.725105-.604834,1.305577-1.33,1.3q-20.22-.18-40.18-.23-3.64-.01-8.13-.44c-.537084-.051394-.94781-.505354-.95-1.05q.02-45.49-.22-92.99c-.03-6.25-1.21-13.88-5.05-18.95q-5.33-7.03-12.32-10.18c-10.99-4.93-24.52-1.84-33.13,6.37q-10.01,9.53-10.07,23.76-.11,25.46-.1,48.98c0,3.52-.06,8.31-1.1,11.68-4.37,14.04-17.31,19.5-31.04,16.77-8.22-1.64-15.07-7.75-17.62-15.62q-1.45-4.49-1.42-10.2.3-64.69.1-129.86c0-.124652.049518-.244198.13766-.33234s.207688-.13766.33234-.13766l48.46-.35c.852152-.005489,1.548978.682403,1.56,1.54l.15,11.25Z" transform="translate(-.326548 38.749742)" fill="#0d161f"/></g></svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
web/public/wechat_pay.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1717527029040" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6841" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M874.119618 149.859922A510.816461 510.816461 0 0 0 511.997 0.00208a509.910462 509.910462 0 0 0-362.119618 149.857842c-199.817789 199.679789-199.817789 524.581447 0 724.260236a509.969462 509.969462 0 0 0 362.119618 149.857842A508.872463 508.872463 0 0 0 874.119618 874.120158c199.836789-199.679789 199.836789-524.581447 0-724.260236zM814.94268 378.210681L470.999043 744.132295a15.359984 15.359984 0 0 1-5.887994 4.095996c-1.751998 1.180999-2.913997 2.362998-5.276994 2.913997a34.499964 34.499964 0 0 1-13.469986 2.914997 45.547952 45.547952 0 0 1-12.897986-2.303998l-4.095996-2.363997a45.291952 45.291952 0 0 1-7.009992-4.095996l-196.902793-193.789796a34.126964 34.126964 0 0 1-10.555989-25.186973c0-9.37399 3.583996-18.74698 9.98399-25.186974a36.429962 36.429962 0 0 1 50.372947 0l169.98382 167.423824L763.389735 330.220732a37.059961 37.059961 0 0 1 50.371947-1.732998 33.647965 33.647965 0 0 1 11.165988 25.186973 35.544963 35.544963 0 0 1-9.98399 24.575974v-0.04z m0 0" fill="#52C41A" p-id="6842"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -26,7 +26,7 @@ const config = {
lark_login: false,
lark_client_id: '',
telegram_bot: '',
isLoading: true, // 添加加载状态
isLoading: true // 添加加载状态
}
};

View File

@ -14,7 +14,8 @@ import {
IconReceipt2,
IconBrush,
IconBrandGithubCopilot,
IconBallFootball
IconBallFootball,
IconBrandPaypal
} from '@tabler/icons-react';
// constant
@ -33,7 +34,8 @@ const icons = {
IconReceipt2,
IconBrush,
IconBrandGithubCopilot,
IconBallFootball
IconBallFootball,
IconBrandPaypal
};
// ==============================|| DASHBOARD MENU ITEMS ||============================== //
@ -154,6 +156,15 @@ const panel = {
breadcrumbs: false,
isAdmin: false
},
{
id: 'payment',
title: '支付',
type: 'item',
url: '/panel/payment',
icon: icons.IconBrandPaypal,
breadcrumbs: false,
isAdmin: true
},
{
id: 'setting',
title: '设置',

View File

@ -19,6 +19,7 @@ const Pricing = Loadable(lazy(() => import('views/Pricing')));
const Midjourney = Loadable(lazy(() => import('views/Midjourney')));
const ModelPrice = Loadable(lazy(() => import('views/ModelPrice')));
const Playground = Loadable(lazy(() => import('views/Playground')));
const Payment = Loadable(lazy(() => import('views/Payment')));
// dashboard routing
const Dashboard = Loadable(lazy(() => import('views/Dashboard')));
@ -96,6 +97,10 @@ const MainRoutes = {
{
path: 'playground',
element: <Playground />
},
{
path: 'payment',
element: <Payment />
}
]
};

View File

@ -9,7 +9,7 @@ const siteInfoReducer = (state = initialState, action) => {
return {
...state,
...action.payload,
isLoading: false, // 添加加载状态
isLoading: false // 添加加载状态
};
default:
return state;

View File

@ -0,0 +1,294 @@
import { useState, useEffect, useCallback } from 'react';
import { showError, trims, showSuccess } from 'utils/common';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import PerfectScrollbar from 'react-perfect-scrollbar';
import TablePagination from '@mui/material/TablePagination';
import LinearProgress from '@mui/material/LinearProgress';
import ButtonGroup from '@mui/material/ButtonGroup';
import Toolbar from '@mui/material/Toolbar';
import { Button, Card, Stack, Container, Typography, Box } from '@mui/material';
import PaymentTableRow from './component/TableRow';
import KeywordTableHead from 'ui-component/TableHead';
import TableToolBar from './component/TableToolBar';
import EditeModal from './component/EditModal';
import { API } from 'utils/api';
import { ITEMS_PER_PAGE } from 'constants';
import { IconRefresh, IconSearch, IconPlus } from '@tabler/icons-react';
export default function Gateway() {
const originalKeyword = {
p: 0,
type: '',
name: '',
uuid: '',
currency: ''
};
const [page, setPage] = useState(0);
const [order, setOrder] = useState('desc');
const [orderBy, setOrderBy] = useState('created_at');
const [rowsPerPage, setRowsPerPage] = useState(ITEMS_PER_PAGE);
const [listCount, setListCount] = useState(0);
const [searching, setSearching] = useState(false);
const [toolBarValue, setToolBarValue] = useState(originalKeyword);
const [searchKeyword, setSearchKeyword] = useState(originalKeyword);
const [refreshFlag, setRefreshFlag] = useState(false);
const [openModal, setOpenModal] = useState(false);
const [editPaymentId, setEditPaymentId] = useState(0);
const [payment, setPayment] = useState([]);
const handleSort = (event, id) => {
const isAsc = orderBy === id && order === 'asc';
if (id !== '') {
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(id);
}
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleOpenModal = (channelId) => {
setEditPaymentId(channelId);
setOpenModal(true);
};
const handleCloseModal = () => {
setOpenModal(false);
setEditPaymentId(0);
};
const handleOkModal = (status) => {
if (status === true) {
handleCloseModal();
handleRefresh();
}
};
const handleChangeRowsPerPage = (event) => {
setPage(0);
setRowsPerPage(parseInt(event.target.value, 10));
};
const search = async () => {
setPage(0);
setSearchKeyword(toolBarValue);
};
const handleToolBarValue = (event) => {
setToolBarValue({ ...toolBarValue, [event.target.name]: event.target.value });
};
const managePayment = async (id, action, value) => {
const url = '/api/payment/';
let data = { id };
let res;
try {
switch (action) {
case 'delete':
res = await API.delete(url + id);
break;
case 'status':
res = await API.put(url, {
...data,
enable: value
});
break;
case 'sort':
res = await API.put(url, {
...data,
sort: value
});
break;
}
const { success, message } = res.data;
if (success) {
showSuccess('操作成功完成!');
await handleRefresh();
} else {
showError(message);
}
return res.data;
} catch (error) {
return;
}
};
const fetchData = useCallback(async (page, rowsPerPage, keyword, order, orderBy) => {
setSearching(true);
keyword = trims(keyword);
try {
if (orderBy) {
orderBy = order === 'desc' ? '-' + orderBy : orderBy;
}
const res = await API.get('/api/payment/', {
params: {
page: page + 1,
size: rowsPerPage,
order: orderBy,
...keyword
}
});
const { success, message, data } = res.data;
if (success) {
setListCount(data.total_count);
setPayment(data.data);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
setSearching(false);
}, []);
// 处理刷新
const handleRefresh = async () => {
setOrderBy('created_at');
setOrder('desc');
setToolBarValue(originalKeyword);
setSearchKeyword(originalKeyword);
setRefreshFlag(!refreshFlag);
};
useEffect(() => {
fetchData(page, rowsPerPage, searchKeyword, order, orderBy);
}, [page, rowsPerPage, searchKeyword, order, orderBy, fetchData, refreshFlag]);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
<Typography variant="h4">支付网关</Typography>
<Button variant="contained" color="primary" startIcon={<IconPlus />} onClick={() => handleOpenModal(0)}>
新建支付
</Button>
</Stack>
<Card>
<Box component="form" noValidate>
<TableToolBar filterName={toolBarValue} handleFilterName={handleToolBarValue} />
</Box>
<Toolbar
sx={{
textAlign: 'right',
height: 50,
display: 'flex',
justifyContent: 'space-between',
p: (theme) => theme.spacing(0, 1, 0, 3)
}}
>
<Container>
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
刷新/清除搜索条件
</Button>
<Button onClick={search} startIcon={<IconSearch width={'18px'} />}>
搜索
</Button>
</ButtonGroup>
</Container>
</Toolbar>
{searching && <LinearProgress />}
<PerfectScrollbar component="div">
<TableContainer sx={{ overflow: 'unset' }}>
<Table sx={{ minWidth: 800 }}>
<KeywordTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleSort}
headLabel={[
{
id: 'id',
label: 'ID',
disableSort: false
},
{
id: 'uuid',
label: 'UUID',
disableSort: false
},
{
id: 'name',
label: '名称',
disableSort: true
},
{
id: 'type',
label: '类型',
disableSort: false
},
{
id: 'icon',
label: '图标',
disableSort: true
},
{
id: 'fixed_fee',
label: '固定手续费',
disableSort: true
},
{
id: 'percent_fee',
label: '百分比手续费',
disableSort: true
},
{
id: 'sort',
label: '排序',
disableSort: false
},
{
id: 'enable',
label: '启用',
disableSort: false
},
{
id: 'created_at',
label: '创建时间',
disableSort: false
},
{
id: 'action',
label: '操作',
disableSort: true
}
]}
/>
<TableBody>
{payment.map((row, index) => (
<PaymentTableRow
item={row}
key={`${row.id}_${index}`}
managePayment={managePayment}
handleOpenModal={handleOpenModal}
setModalPaymentId={setEditPaymentId}
/>
))}
</TableBody>
</Table>
</TableContainer>
</PerfectScrollbar>
<TablePagination
page={page}
component="div"
count={listCount}
rowsPerPage={rowsPerPage}
onPageChange={handleChangePage}
rowsPerPageOptions={[10, 25, 30]}
onRowsPerPageChange={handleChangeRowsPerPage}
showFirstButton
showLastButton
/>
<EditeModal open={openModal} onCancel={handleCloseModal} onOk={handleOkModal} paymentId={editPaymentId} />
</Card>
</>
);
}

View File

@ -0,0 +1,220 @@
import { useState, useEffect, useCallback } from 'react';
import { showError, trims } from 'utils/common';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import PerfectScrollbar from 'react-perfect-scrollbar';
import TablePagination from '@mui/material/TablePagination';
import LinearProgress from '@mui/material/LinearProgress';
import ButtonGroup from '@mui/material/ButtonGroup';
import Toolbar from '@mui/material/Toolbar';
import { Button, Card, Stack, Container, Typography, Box } from '@mui/material';
import LogTableRow from './component/OrderTableRow';
import KeywordTableHead from 'ui-component/TableHead';
import TableToolBar from './component/OrderTableToolBar';
import { API } from 'utils/api';
import { ITEMS_PER_PAGE } from 'constants';
import { IconRefresh, IconSearch } from '@tabler/icons-react';
import dayjs from 'dayjs';
export default function Order() {
const originalKeyword = {
p: 0,
user_id: '',
trade_no: '',
status: '',
gateway_no: '',
start_timestamp: 0,
end_timestamp: dayjs().unix() + 3600
};
const [page, setPage] = useState(0);
const [order, setOrder] = useState('desc');
const [orderBy, setOrderBy] = useState('created_at');
const [rowsPerPage, setRowsPerPage] = useState(ITEMS_PER_PAGE);
const [listCount, setListCount] = useState(0);
const [searching, setSearching] = useState(false);
const [toolBarValue, setToolBarValue] = useState(originalKeyword);
const [searchKeyword, setSearchKeyword] = useState(originalKeyword);
const [refreshFlag, setRefreshFlag] = useState(false);
const [orderList, setOrderList] = useState([]);
const handleSort = (event, id) => {
const isAsc = orderBy === id && order === 'asc';
if (id !== '') {
setOrder(isAsc ? 'desc' : 'asc');
setOrderBy(id);
}
};
const handleChangePage = (event, newPage) => {
setPage(newPage);
};
const handleChangeRowsPerPage = (event) => {
setPage(0);
setRowsPerPage(parseInt(event.target.value, 10));
};
const searchLogs = async () => {
setPage(0);
setSearchKeyword(toolBarValue);
};
const handleToolBarValue = (event) => {
setToolBarValue({ ...toolBarValue, [event.target.name]: event.target.value });
};
const fetchData = useCallback(async (page, rowsPerPage, keyword, order, orderBy) => {
setSearching(true);
keyword = trims(keyword);
try {
if (orderBy) {
orderBy = order === 'desc' ? '-' + orderBy : orderBy;
}
const res = await API.get('/api/payment/order', {
params: {
page: page + 1,
size: rowsPerPage,
order: orderBy,
...keyword
}
});
const { success, message, data } = res.data;
if (success) {
setListCount(data.total_count);
setOrderList(data.data);
} else {
showError(message);
}
} catch (error) {
console.error(error);
}
setSearching(false);
}, []);
// 处理刷新
const handleRefresh = async () => {
setOrderBy('created_at');
setOrder('desc');
setToolBarValue(originalKeyword);
setSearchKeyword(originalKeyword);
setRefreshFlag(!refreshFlag);
};
useEffect(() => {
fetchData(page, rowsPerPage, searchKeyword, order, orderBy);
}, [page, rowsPerPage, searchKeyword, order, orderBy, fetchData, refreshFlag]);
return (
<>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={5}>
<Typography variant="h4">日志</Typography>
</Stack>
<Card>
<Box component="form" noValidate>
<TableToolBar filterName={toolBarValue} handleFilterName={handleToolBarValue} />
</Box>
<Toolbar
sx={{
textAlign: 'right',
height: 50,
display: 'flex',
justifyContent: 'space-between',
p: (theme) => theme.spacing(0, 1, 0, 3)
}}
>
<Container>
<ButtonGroup variant="outlined" aria-label="outlined small primary button group">
<Button onClick={handleRefresh} startIcon={<IconRefresh width={'18px'} />}>
刷新/清除搜索条件
</Button>
<Button onClick={searchLogs} startIcon={<IconSearch width={'18px'} />}>
搜索
</Button>
</ButtonGroup>
</Container>
</Toolbar>
{searching && <LinearProgress />}
<PerfectScrollbar component="div">
<TableContainer sx={{ overflow: 'unset' }}>
<Table sx={{ minWidth: 800 }}>
<KeywordTableHead
order={order}
orderBy={orderBy}
onRequestSort={handleSort}
headLabel={[
{
id: 'created_at',
label: '时间',
disableSort: false
},
{
id: 'user_id',
label: '用户',
disableSort: false
},
{
id: 'trade_no',
label: '订单号',
disableSort: true
},
{
id: 'gateway_no',
label: '网关订单号',
disableSort: true
},
{
id: 'amount',
label: '充值金额',
disableSort: true
},
{
id: 'fee',
label: '手续费',
disableSort: true
},
{
id: 'order_amount',
label: '实际支付金额',
disableSort: true
},
{
id: 'quota',
label: '到帐点数',
disableSort: true
},
{
id: 'status',
label: '状态',
disableSort: true
}
]}
/>
<TableBody>
{orderList.map((row, index) => (
<LogTableRow item={row} key={`${row.id}_${index}`} />
))}
</TableBody>
</Table>
</TableContainer>
</PerfectScrollbar>
<TablePagination
page={page}
component="div"
count={listCount}
rowsPerPage={rowsPerPage}
onPageChange={handleChangePage}
rowsPerPageOptions={[10, 25, 30]}
onRowsPerPageChange={handleChangeRowsPerPage}
showFirstButton
showLastButton
/>
</Card>
</>
);
}

View File

@ -0,0 +1,359 @@
import PropTypes from 'prop-types';
import * as Yup from 'yup';
import { Formik } from 'formik';
import { useTheme } from '@mui/material/styles';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
Divider,
FormControl,
InputLabel,
OutlinedInput,
TextField,
Select,
MenuItem,
FormHelperText
} from '@mui/material';
import { showSuccess, showError, trims } from 'utils/common';
import { API } from 'utils/api';
import { PaymentType, CurrencyType, PaymentConfig } from '../type/Config';
const validationSchema = Yup.object().shape({
is_edit: Yup.boolean(),
name: Yup.string().required('名称 不能为空'),
icon: Yup.string().required('图标 不能为空'),
fixed_fee: Yup.number().min(0, '固定手续费 不能小于 0'),
percent_fee: Yup.number().min(0, '百分比手续费 不能小于 0'),
currency: Yup.string().required('货币 不能为空')
});
const originInputs = {
is_edit: false,
type: 'epay',
uuid: '',
name: '',
icon: '',
notify_domain: '',
fixed_fee: 0,
percent_fee: 0,
currency: 'CNY',
config: {},
sort: 0,
enable: true
};
const EditModal = ({ open, paymentId, onCancel, onOk }) => {
const theme = useTheme();
const [inputs, setInputs] = useState(originInputs);
const submit = async (values, { setErrors, setStatus, setSubmitting }) => {
setSubmitting(true);
let config = JSON.stringify(values.config);
let res;
values = trims(values);
try {
if (values.is_edit) {
res = await API.put(`/api/payment/`, { ...values, id: parseInt(paymentId), config });
} else {
res = await API.post(`/api/payment/`, { ...values, config });
}
const { success, message } = res.data;
if (success) {
if (values.is_edit) {
showSuccess('更新成功!');
} else {
showSuccess('创建成功!');
}
setSubmitting(false);
setStatus({ success: true });
onOk(true);
} else {
showError(message);
setErrors({ submit: message });
}
} catch (error) {
return;
}
};
const loadPayment = async () => {
try {
let res = await API.get(`/api/payment/${paymentId}`);
const { success, message, data } = res.data;
if (success) {
data.is_edit = true;
data.config = JSON.parse(data.config);
setInputs(data);
} else {
showError(message);
}
} catch (error) {
return;
}
};
useEffect(() => {
if (paymentId) {
loadPayment().then();
} else {
setInputs(originInputs);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paymentId]);
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth={'md'}>
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>
{paymentId ? '编辑支付' : '新建支付'}
</DialogTitle>
<Divider />
<DialogContent>
<Formik initialValues={inputs} enableReinitialize validationSchema={validationSchema} onSubmit={submit}>
{({ errors, handleBlur, handleChange, handleSubmit, touched, values, isSubmitting }) => (
<form noValidate onSubmit={handleSubmit}>
<FormControl fullWidth error={Boolean(touched.type && errors.type)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-type-label">类型</InputLabel>
<Select
id="channel-type-label"
label="类型"
value={values.type}
name="type"
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.entries(PaymentType).map(([value, text]) => (
<MenuItem key={value} value={value}>
{text}
</MenuItem>
))}
</Select>
{touched.type && errors.type ? (
<FormHelperText error id="helper-tex-channel-type-label">
{errors.type}
</FormHelperText>
) : (
<FormHelperText id="helper-tex-channel-type-label"> 支付类型 </FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.name && errors.name)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-name-label">名称</InputLabel>
<OutlinedInput
id="channel-name-label"
label="名称"
type="text"
value={values.name}
name="name"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'name' }}
aria-describedby="helper-text-channel-name-label"
/>
{touched.name && errors.name && (
<FormHelperText error id="helper-tex-channel-name-label">
{errors.name}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.icon && errors.icon)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-icon-label">图标</InputLabel>
<OutlinedInput
id="channel-icon-label"
label="图标"
type="text"
value={values.icon}
name="icon"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'icon' }}
aria-describedby="helper-text-channel-icon-label"
/>
{touched.icon && errors.icon && (
<FormHelperText error id="helper-tex-channel-icon-label">
{errors.icon}
</FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.notify_domain && errors.notify_domain)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-notify_domain-label">回调域名</InputLabel>
<OutlinedInput
id="channel-notify_domain-label"
label="回调域名"
type="text"
value={values.notify_domain}
name="notify_domain"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'notify_domain' }}
aria-describedby="helper-text-channel-notify_domain-label"
/>
{touched.notify_domain && errors.notify_domain ? (
<FormHelperText error id="helper-tex-notify_domain-label">
{errors.notify_domain}
</FormHelperText>
) : (
<FormHelperText id="helper-tex-notify_domain-label"> 支付回调的域名除非你自行配置过否则保持为空 </FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.fixed_fee && errors.fixed_fee)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-fixed_fee-label">固定手续费</InputLabel>
<OutlinedInput
id="channel-fixed_fee-label"
label="固定手续费"
type="number"
value={values.fixed_fee}
name="fixed_fee"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'fixed_fee' }}
aria-describedby="helper-text-channel-fixed_fee-label"
/>
{touched.fixed_fee && errors.fixed_fee ? (
<FormHelperText error id="helper-tex-fixed_fee-label">
{errors.fixed_fee}
</FormHelperText>
) : (
<FormHelperText id="helper-tex-fixed_fee-label"> 每次支付收取固定的手续费单位 美元 </FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.percent_fee && errors.percent_fee)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-percent_fee-label">百分比手续费</InputLabel>
<OutlinedInput
id="channel-percent_fee-label"
label="百分比手续费"
type="number"
value={values.percent_fee}
name="percent_fee"
onBlur={handleBlur}
onChange={handleChange}
inputProps={{ autoComplete: 'percent_fee' }}
aria-describedby="helper-text-channel-percent_fee-label"
/>
{touched.percent_fee && errors.percent_fee ? (
<FormHelperText error id="helper-tex-percent_fee-label">
{errors.percent_fee}
</FormHelperText>
) : (
<FormHelperText id="helper-tex-percent_fee-label"> 每次支付按百分比收取手续费如果为5%请填写 0.05 </FormHelperText>
)}
</FormControl>
<FormControl fullWidth error={Boolean(touched.currency && errors.currency)} sx={{ ...theme.typography.otherInput }}>
<InputLabel htmlFor="channel-currency-label">网关货币类型</InputLabel>
<Select
id="channel-currency-label"
label="网关货币类型"
value={values.currency}
name="currency"
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.entries(CurrencyType).map(([value, text]) => (
<MenuItem key={value} value={value}>
{text}
</MenuItem>
))}
</Select>
{touched.currency && errors.currency ? (
<FormHelperText error id="helper-tex-channel-currency-label">
{errors.currency}
</FormHelperText>
) : (
<FormHelperText id="helper-tex-channel-currency-label"> 该网关是收取什么货币的请查询对应网关文档 </FormHelperText>
)}
</FormControl>
{PaymentConfig[values.type] &&
Object.keys(PaymentConfig[values.type]).map((configKey) => {
const param = PaymentConfig[values.type][configKey];
const name = `config.${configKey}`;
return param.type === 'select' ? (
<FormControl key={name} fullWidth>
<InputLabel htmlFor="channel-currency-label">{param.name}</InputLabel>
<Select
label={param.name}
value={values.config?.[configKey] || ''}
key={name}
name={name}
onBlur={handleBlur}
onChange={handleChange}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.values(param.options).map((option) => {
return (
<MenuItem key={option.value} value={option.value}>
{option.name}
</MenuItem>
);
})}
</Select>
<FormHelperText id="helper-tex-channel-currency-label"> {param.description} </FormHelperText>
</FormControl>
) : (
<FormControl key={name} fullWidth sx={{ ...theme.typography.otherInput }}>
<TextField
multiline
key={name}
name={name}
value={values.config?.[configKey] || ''}
label={param.name}
placeholder={param.description}
onChange={handleChange}
/>
<FormHelperText id="helper-tex-channel-key-label"> {param.description} </FormHelperText>
</FormControl>
);
})}
<DialogActions>
<Button onClick={onCancel}>取消</Button>
<Button disableElevation disabled={isSubmitting} type="submit" variant="contained" color="primary">
提交
</Button>
</DialogActions>
</form>
)}
</Formik>
</DialogContent>
</Dialog>
);
};
export default EditModal;
EditModal.propTypes = {
open: PropTypes.bool,
paymentId: PropTypes.number,
onCancel: PropTypes.func,
onOk: PropTypes.func
};

View File

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import { TableRow, TableCell } from '@mui/material';
import { timestamp2string } from 'utils/common';
import Label from 'ui-component/Label';
const StatusType = {
pending: { name: '待支付', value: 'pending', color: 'primary' },
success: { name: '支付成功', value: 'success', color: 'success' },
failed: { name: '支付失败', value: 'failed', color: 'error' },
closed: { name: '已关闭', value: 'closed', color: 'default' }
};
function statusLabel(status) {
let statusOption = StatusType[status];
return <Label color={statusOption?.color || 'secondary'}> {statusOption?.name || '未知'} </Label>;
}
export { StatusType };
export default function OrderTableRow({ item }) {
return (
<>
<TableRow tabIndex={item.id}>
<TableCell>{timestamp2string(item.created_at)}</TableCell>
<TableCell>{item.user_id}</TableCell>
<TableCell>{item.trade_no}</TableCell>
<TableCell>{item.gateway_no}</TableCell>
<TableCell>${item.amount}</TableCell>
<TableCell>${item.fee}</TableCell>
<TableCell>
{item.order_amount} {item.order_currency}
</TableCell>
<TableCell>{item.quota}</TableCell>
<TableCell>{statusLabel(item.status)}</TableCell>
</TableRow>
</>
);
}
OrderTableRow.propTypes = {
item: PropTypes.object
};

View File

@ -0,0 +1,138 @@
import PropTypes from 'prop-types';
import { OutlinedInput, Stack, FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { LocalizationProvider, DateTimePicker } from '@mui/x-date-pickers';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs from 'dayjs';
import { StatusType } from './OrderTableRow';
require('dayjs/locale/zh-cn');
// ----------------------------------------------------------------------
export default function OrderTableToolBar({ filterName, handleFilterName }) {
return (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 3, sm: 2, md: 4 }} padding={'24px'} paddingBottom={'0px'}>
<FormControl>
<InputLabel htmlFor="channel-user_id-label">用户ID</InputLabel>
<OutlinedInput
id="user_id"
name="user_id"
sx={{
minWidth: '100%'
}}
label="用户ID"
value={filterName.user_id}
onChange={handleFilterName}
placeholder="用户ID"
/>
</FormControl>
<FormControl>
<InputLabel htmlFor="channel-trade_no-label">订单号</InputLabel>
<OutlinedInput
id="trade_no"
name="trade_no"
sx={{
minWidth: '100%'
}}
label="订单号"
value={filterName.trade_no}
onChange={handleFilterName}
placeholder="订单号"
/>
</FormControl>
<FormControl>
<InputLabel htmlFor="channel-gateway_no-label">网关订单号</InputLabel>
<OutlinedInput
id="gateway_no"
name="gateway_no"
sx={{
minWidth: '100%'
}}
label="网关订单号"
value={filterName.gateway_no}
onChange={handleFilterName}
placeholder="网关订单号"
/>
</FormControl>
<FormControl>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={'zh-cn'}>
<DateTimePicker
label="起始时间"
ampm={false}
name="start_timestamp"
value={filterName.start_timestamp === 0 ? null : dayjs.unix(filterName.start_timestamp)}
onChange={(value) => {
if (value === null) {
handleFilterName({ target: { name: 'start_timestamp', value: 0 } });
return;
}
handleFilterName({ target: { name: 'start_timestamp', value: value.unix() } });
}}
slotProps={{
actionBar: {
actions: ['clear', 'today', 'accept']
}
}}
/>
</LocalizationProvider>
</FormControl>
<FormControl>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={'zh-cn'}>
<DateTimePicker
label="结束时间"
name="end_timestamp"
ampm={false}
value={filterName.end_timestamp === 0 ? null : dayjs.unix(filterName.end_timestamp)}
onChange={(value) => {
if (value === null) {
handleFilterName({ target: { name: 'end_timestamp', value: 0 } });
return;
}
handleFilterName({ target: { name: 'end_timestamp', value: value.unix() } });
}}
slotProps={{
actionBar: {
actions: ['clear', 'today', 'accept']
}
}}
/>
</LocalizationProvider>
</FormControl>
<FormControl sx={{ minWidth: '22%' }}>
<InputLabel htmlFor="channel-status-label">状态</InputLabel>
<Select
id="channel-type-label"
label="状态"
value={filterName.status}
name="status"
onChange={handleFilterName}
sx={{
minWidth: '100%'
}}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.values(StatusType).map((option) => {
return (
<MenuItem key={option.value} value={option.value}>
{option.name}
</MenuItem>
);
})}
</Select>
</FormControl>
</Stack>
</>
);
}
OrderTableToolBar.propTypes = {
filterName: PropTypes.object,
handleFilterName: PropTypes.func
};

View File

@ -0,0 +1,153 @@
import PropTypes from 'prop-types';
import { useState } from 'react';
import {
TableRow,
TableCell,
Popover,
MenuItem,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Button,
TextField
} from '@mui/material';
import { IconDotsVertical, IconEdit, IconTrash } from '@tabler/icons-react';
import { timestamp2string, showError } from 'utils/common';
import { PaymentType } from '../type/Config';
import TableSwitch from 'ui-component/Switch';
export default function PaymentTableRow({ item, managePayment, handleOpenModal, setModalPaymentId }) {
const [open, setOpen] = useState(null);
const [openDelete, setOpenDelete] = useState(false);
const [sortValve, setSort] = useState(item.sort);
const handleCloseMenu = () => {
setOpen(null);
};
const handleOpenMenu = (event) => {
setOpen(event.currentTarget);
};
const handleDeleteOpen = () => {
handleCloseMenu();
setOpenDelete(true);
};
const handleDeleteClose = () => {
setOpenDelete(false);
};
const handleDelete = async () => {
handleCloseMenu();
await managePayment(item.id, 'delete', '');
};
const handleSort = async (event) => {
const currentValue = parseInt(event.target.value);
if (isNaN(currentValue) || currentValue === sortValve) {
return;
}
if (currentValue < 0) {
showError('排序不能小于 0');
return;
}
await managePayment(item.id, 'sort', currentValue);
setSort(currentValue);
};
return (
<>
<TableRow tabIndex={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>{item.uuid}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{PaymentType?.[item.type] || '未知'}</TableCell>
<TableCell>
<img src={item.icon} alt="icon" style={{ width: '24px', height: '24px' }} />
</TableCell>
<TableCell>{item.fixed_fee}</TableCell>
<TableCell>{item.percent_fee}</TableCell>
<TableCell>
<TextField
id={`sort-${item.id}`}
onBlur={handleSort}
type="number"
label="排序"
variant="standard"
defaultValue={item.sort}
inputProps={{ min: '0' }}
/>
</TableCell>
<TableCell>
<TableSwitch
id={`switch-${item.id}`}
checked={item.enable}
onChange={() => {
managePayment(item.id, 'status', !item.enable);
}}
/>
</TableCell>
<TableCell>{timestamp2string(item.created_at)}</TableCell>
<TableCell>
<IconButton onClick={handleOpenMenu} sx={{ color: 'rgb(99, 115, 129)' }}>
<IconDotsVertical />
</IconButton>
</TableCell>
</TableRow>
<Popover
open={!!open}
anchorEl={open}
onClose={handleCloseMenu}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
PaperProps={{
sx: { minWidth: 140 }
}}
>
<MenuItem
onClick={() => {
handleCloseMenu();
handleOpenModal();
setModalPaymentId(item.id);
}}
>
<IconEdit style={{ marginRight: '16px' }} />
编辑
</MenuItem>
<MenuItem onClick={handleDeleteOpen} sx={{ color: 'error.main' }}>
<IconTrash style={{ marginRight: '16px' }} />
删除
</MenuItem>
</Popover>
<Dialog open={openDelete} onClose={handleDeleteClose}>
<DialogTitle>删除通道</DialogTitle>
<DialogContent>
<DialogContentText>是否删除通道 {item.name}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteClose}>关闭</Button>
<Button onClick={handleDelete} sx={{ color: 'error.main' }} autoFocus>
删除
</Button>
</DialogActions>
</Dialog>
</>
);
}
PaymentTableRow.propTypes = {
item: PropTypes.object,
managePayment: PropTypes.func,
handleOpenModal: PropTypes.func,
setModalPaymentId: PropTypes.func
};

View File

@ -0,0 +1,73 @@
import PropTypes from 'prop-types';
import { OutlinedInput, Stack, FormControl, InputLabel, Select, MenuItem } from '@mui/material';
import { PaymentType } from '../type/Config';
require('dayjs/locale/zh-cn');
// ----------------------------------------------------------------------
export default function TableToolBar({ filterName, handleFilterName }) {
return (
<>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={{ xs: 3, sm: 2, md: 4 }} padding={'24px'} paddingBottom={'0px'}>
<FormControl>
<InputLabel htmlFor="channel-name-label">名称</InputLabel>
<OutlinedInput
id="name"
name="name"
sx={{
minWidth: '100%'
}}
label="名称"
value={filterName.name}
onChange={handleFilterName}
placeholder="名称"
/>
</FormControl>
<FormControl>
<InputLabel htmlFor="channel-uuid-label">UUID</InputLabel>
<OutlinedInput
id="uuid"
name="uuid"
sx={{
minWidth: '100%'
}}
label="模型名称"
value={filterName.uuid}
onChange={handleFilterName}
placeholder="UUID"
/>
</FormControl>
<FormControl sx={{ minWidth: '22%' }}>
<InputLabel htmlFor="channel-type-label">类型</InputLabel>
<Select
id="channel-type-label"
label="类型"
value={filterName.type}
name="type"
onChange={handleFilterName}
sx={{
minWidth: '100%'
}}
MenuProps={{
PaperProps: {
style: {
maxHeight: 200
}
}
}}
>
{Object.entries(PaymentType).map(([value, text]) => (
<MenuItem key={value} value={value}>
{text}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</>
);
}
TableToolBar.propTypes = {
filterName: PropTypes.object,
handleFilterName: PropTypes.func
};

View File

@ -0,0 +1,86 @@
import { useState, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { Tabs, Tab, Box, Card } from '@mui/material';
import Gateway from './Gateway';
import Order from './Order';
import AdminContainer from 'ui-component/AdminContainer';
import { useLocation, useNavigate } from 'react-router-dom';
function CustomTabPanel(props) {
const { children, value, index, ...other } = props;
return (
<div role="tabpanel" hidden={value !== index} id={`setting-tabpanel-${index}`} aria-labelledby={`setting-tab-${index}`} {...other}>
{value === index && <Box sx={{ p: 3 }}>{children}</Box>}
</div>
);
}
CustomTabPanel.propTypes = {
children: PropTypes.node,
index: PropTypes.number.isRequired,
value: PropTypes.number.isRequired
};
function a11yProps(index) {
return {
id: `setting-tab-${index}`,
'aria-controls': `setting-tabpanel-${index}`
};
}
const Payment = () => {
const location = useLocation();
const navigate = useNavigate();
const hash = location.hash.replace('#', '');
const tabMap = useMemo(
() => ({
order: 0,
gateway: 1
}),
[]
);
const [value, setValue] = useState(tabMap[hash] || 0);
const handleChange = (event, newValue) => {
setValue(newValue);
const hashArray = Object.keys(tabMap);
navigate(`#${hashArray[newValue]}`);
};
useEffect(() => {
const handleHashChange = () => {
const hash = location.hash.replace('#', '');
setValue(tabMap[hash] || 0);
};
window.addEventListener('hashchange', handleHashChange);
return () => {
window.removeEventListener('hashchange', handleHashChange);
};
}, [location, tabMap]);
return (
<>
<Card>
<AdminContainer>
<Box sx={{ width: '100%' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={value} onChange={handleChange} variant="scrollable" scrollButtons="auto">
<Tab label="订单列表" {...a11yProps(0)} />
<Tab label="网关设置" {...a11yProps(1)} />
</Tabs>
</Box>
<CustomTabPanel value={value} index={0}>
<Order />
</CustomTabPanel>
<CustomTabPanel value={value} index={1}>
<Gateway />
</CustomTabPanel>
</Box>
</AdminContainer>
</Card>
</>
);
};
export default Payment;

View File

@ -0,0 +1,73 @@
const PaymentType = {
epay: '易支付'
};
const CurrencyType = {
CNY: '人民币',
USD: '美元'
};
const PaymentConfig = {
epay: {
pay_domain: {
name: '支付域名',
description: '支付域名',
type: 'text',
value: ''
},
partner_id: {
name: '商户号',
description: '商户号',
type: 'text',
value: ''
},
key: {
name: '密钥',
description: '密钥',
type: 'text',
value: ''
},
pay_type: {
name: '支付类型',
description: '支付类型,如果需要跳转到易支付收银台,请选择收银台',
type: 'select',
value: '',
options: [
{
name: '收银台',
value: ''
},
{
name: '支付宝',
value: 'alipay'
},
{
name: '微信',
value: 'wxpay'
},
{
name: 'QQ',
value: 'qqpay'
},
{
name: '京东',
value: 'jdpay'
},
{
name: '银联',
value: 'bank'
},
{
name: 'Paypal',
value: 'paypal'
},
{
name: 'USDT',
value: 'usdt'
}
]
}
}
};
export { PaymentConfig, PaymentType, CurrencyType };

View File

@ -36,7 +36,9 @@ const OperationSetting = () => {
MjNotifyEnabled: '',
ChatCacheEnabled: '',
ChatCacheExpireMinute: 5,
ChatImageRequestProxy: ''
ChatImageRequestProxy: '',
PaymentUSDRate: 0,
PaymentMinAmount: 1
});
const [originInputs, setOriginInputs] = useState({});
let [loading, setLoading] = useState(false);
@ -178,6 +180,14 @@ const OperationSetting = () => {
await updateOption('ChatImageRequestProxy', inputs.ChatImageRequestProxy);
}
break;
case 'payment':
if (originInputs['PaymentUSDRate'] !== inputs.PaymentUSDRate) {
await updateOption('PaymentUSDRate', inputs.PaymentUSDRate);
}
if (originInputs['PaymentMinAmount'] !== inputs.PaymentMinAmount) {
await updateOption('PaymentMinAmount', inputs.PaymentMinAmount);
}
break;
}
showSuccess('保存成功!');
@ -532,6 +542,56 @@ const OperationSetting = () => {
</Button>
</Stack>
</SubCard>
<SubCard title="支付设置">
<Stack justifyContent="flex-start" alignItems="flex-start" spacing={2}>
<FormControl fullWidth>
<Alert severity="info">
支付设置 <br />
1. 美元汇率用于计算充值金额的美元金额 <br />
2. 最低充值金额美元最低充值金额单位为美元填写整数 <br />
3. 页面都以美元为单位计算实际用户支付的货币按照支付网关设置的货币进行转换 <br />
例如 A 网关设置货币为 CNY用户支付 100 美元那么实际支付金额为 100 * 美元汇率 <br />B 网关设置货币为 USD用户支付 100
美元那么实际支付金额为 100 美元
</Alert>
</FormControl>
<Stack direction={{ sm: 'column', md: 'row' }} spacing={{ xs: 3, sm: 2, md: 4 }}>
<FormControl fullWidth>
<InputLabel htmlFor="PaymentUSDRate">美元汇率</InputLabel>
<OutlinedInput
id="PaymentUSDRate"
name="PaymentUSDRate"
type="number"
value={inputs.PaymentUSDRate}
onChange={handleInputChange}
label="美元汇率"
placeholder="例如7.3"
disabled={loading}
/>
</FormControl>
<FormControl fullWidth>
<InputLabel htmlFor="PaymentMinAmount">最低充值金额美元</InputLabel>
<OutlinedInput
id="PaymentMinAmount"
name="PaymentMinAmount"
type="number"
value={inputs.PaymentMinAmount}
onChange={handleInputChange}
label="最低充值金额(美元)"
placeholder="例如1那么最低充值金额为1美元请填写整数"
disabled={loading}
/>
</FormControl>
</Stack>
<Button
variant="contained"
onClick={() => {
submitConfig('payment').then();
}}
>
保存支付设置
</Button>
</Stack>
</SubCard>
<SubCard title="倍率设置">
<Stack justifyContent="flex-start" alignItems="flex-start" spacing={2}>
<FormControl fullWidth>

View File

@ -0,0 +1,129 @@
import PropTypes from 'prop-types';
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogTitle, IconButton, Stack, Typography } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { useTheme } from '@mui/material/styles';
import { QRCode } from 'react-qrcode-logo';
import successSvg from 'assets/images/success.svg';
import { API } from 'utils/api';
import { showError } from 'utils/common';
const PayDialog = ({ open, onClose, amount, uuid }) => {
const theme = useTheme();
const defaultLogo = theme.palette.mode === 'light' ? '/logo-loading.svg' : '/logo-loading-white.svg';
const [message, setMessage] = useState('正在拉起支付中...');
const [loading, setLoading] = useState(false);
const [qrCodeUrl, setQrCodeUrl] = useState(null);
const [success, setSuccess] = useState(false);
const [intervalId, setIntervalId] = useState(null);
useEffect(() => {
if (!open) {
return;
}
setMessage('正在拉起支付中...');
setLoading(true);
API.post('/api/user/order', {
uuid: uuid,
amount: Number(amount)
}).then((response) => {
if (!response.data.success) {
showError(response.data.message);
setLoading(false);
onClose();
return;
}
const { type, data } = response.data.data;
if (type === 1) {
setMessage('等待支付中...');
const form = document.createElement('form');
form.method = data.method;
form.action = data.url;
form.target = '_blank';
for (const key in data.params) {
const input = document.createElement('input');
input.name = key;
input.value = data.params[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
} else if (type === 2) {
setQrCodeUrl(data.url);
setLoading(false);
setMessage('请扫码支付');
}
pollOrderStatus(response.data.data.trade_no);
});
}, [open, onClose, amount, uuid]);
const pollOrderStatus = (tradeNo) => {
const id = setInterval(() => {
API.get(`/api/user/order/status?trade_no=${tradeNo}`).then((response) => {
if (response.data.success) {
setMessage('支付成功');
setLoading(false);
setSuccess(true);
clearInterval(id);
setIntervalId(null);
}
});
}, 3000);
setIntervalId(id);
};
return (
<Dialog open={open} fullWidth maxWidth={'sm'} disableEscapeKeyDown>
<DialogTitle sx={{ margin: '0px', fontWeight: 700, lineHeight: '1.55556', padding: '24px', fontSize: '1.125rem' }}>支付</DialogTitle>
<IconButton
aria-label="close"
onClick={() => {
if (intervalId) {
clearInterval(intervalId);
setIntervalId(null);
}
onClose();
}}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500]
}}
>
<CloseIcon />
</IconButton>
<DialogContent>
<DialogContent>
<Stack direction="column" justifyContent="center" alignItems="center" spacing={2}>
{loading && <img src={defaultLogo} alt="loading" height="100" />}
{qrCodeUrl && (
<QRCode
value={qrCodeUrl}
size={256}
qrStyle="dots"
eyeRadius={20}
fgColor={theme.palette.primary.main}
bgColor={theme.palette.background.paper}
/>
)}
{success && <img src={successSvg} alt="success" height="100" />}
<Typography variant="h3">{message}</Typography>
</Stack>
</DialogContent>
</DialogContent>
</Dialog>
);
};
export default PayDialog;
PayDialog.propTypes = {
open: PropTypes.bool,
onClose: PropTypes.func,
amount: PropTypes.number,
uuid: PropTypes.string
};

View File

@ -1,8 +1,24 @@
import { Typography, Stack, OutlinedInput, InputAdornment, Button, InputLabel, FormControl } from '@mui/material';
import {
Typography,
Stack,
OutlinedInput,
InputAdornment,
Button,
InputLabel,
FormControl,
useMediaQuery,
TextField,
Box,
Grid,
Divider
} from '@mui/material';
import { IconBuildingBank } from '@tabler/icons-react';
import { useTheme } from '@mui/material/styles';
import SubCard from 'ui-component/cards/SubCard';
import UserCard from 'ui-component/cards/UserCard';
import AnimateButton from 'ui-component/extended/AnimateButton';
import { useSelector } from 'react-redux';
import PayDialog from './PayDialog';
import { API } from 'utils/api';
import React, { useEffect, useState } from 'react';
@ -11,10 +27,17 @@ import { showError, showInfo, showSuccess, renderQuota, trims } from 'utils/comm
const TopupCard = () => {
const theme = useTheme();
const [redemptionCode, setRedemptionCode] = useState('');
const [topUpLink, setTopUpLink] = useState('');
const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [payment, setPayment] = useState([]);
const [selectedPayment, setSelectedPayment] = useState(null);
const [amount, setAmount] = useState(0);
const [open, setOpen] = useState(false);
const matchDownSM = useMediaQuery(theme.breakpoints.down('md'));
const siteInfo = useSelector((state) => state.siteInfo);
const topUp = async () => {
if (redemptionCode === '') {
showInfo('请输入充值码!');
@ -42,12 +65,53 @@ const TopupCard = () => {
}
};
const handlePay = () => {
if (!selectedPayment) {
showError('请选择支付方式');
return;
}
if (amount <= 0 || amount < siteInfo.PaymentMinAmount) {
showError('金额不能小于' + siteInfo.PaymentMinAmount);
return;
}
if (amount > 1000000) {
showError('金额不能大于1000000');
return;
}
// 判读金额是否是正整数
if (!/^[1-9]\d*$/.test(amount)) {
showError('请输入正整数金额');
return;
}
setOpen(true);
};
const getPayment = async () => {
try {
let res = await API.get(`/api/user/payment`);
const { success, data } = res.data;
if (success) {
if (data.length > 0) {
data.sort((a, b) => b.sort - a.sort);
setPayment(data);
setSelectedPayment(data[0]);
}
}
} catch (error) {
return;
}
};
const openTopUpLink = () => {
if (!topUpLink) {
if (!siteInfo.top_up_link) {
showError('超级管理员未设置充值链接!');
return;
}
window.open(topUpLink, '_blank');
window.open(siteInfo.top_up_link, '_blank');
};
const getUserQuota = async () => {
@ -64,14 +128,36 @@ const TopupCard = () => {
}
};
useEffect(() => {
let status = localStorage.getItem('siteInfo');
if (status) {
status = JSON.parse(status);
if (status.top_up_link) {
setTopUpLink(status.top_up_link);
}
const handlePaymentSelect = (payment) => {
setSelectedPayment(payment);
};
const handleAmountChange = (event) => {
const value = event.target.value;
setAmount(value);
};
const calculateFee = () => {
if (!selectedPayment) return 0;
if (selectedPayment.fixed_fee > 0) {
return Number(selectedPayment.fixed_fee);
}
return parseFloat(selectedPayment.percent_fee * Number(amount)).toFixed(2);
};
const calculateTotal = () => {
if (amount === 0) return 0;
let total = Number(amount) + Number(calculateFee());
if (selectedPayment && selectedPayment.currency === 'CNY') {
total = parseFloat((total * 7.3).toFixed(2));
}
return total;
};
useEffect(() => {
getPayment().then();
getUserQuota().then();
}, []);
@ -82,10 +168,90 @@ const TopupCard = () => {
<Typography variant="h4">当前额度:</Typography>
<Typography variant="h4">{renderQuota(userQuota)}</Typography>
</Stack>
{payment.length > 0 && (
<SubCard
sx={{
marginTop: '40px'
}}
title="在线充值"
>
<Stack spacing={2}>
{payment.map((item, index) => (
<AnimateButton key={index}>
<Button
disableElevation
fullWidth
size="large"
variant="outlined"
onClick={() => handlePaymentSelect(item)}
sx={{
...theme.typography.LoginButton,
border: selectedPayment === item ? `1px solid ${theme.palette.primary.main}` : '1px solid transparent'
}}
>
<Box sx={{ mr: { xs: 1, sm: 2, width: 20 }, display: 'flex', alignItems: 'center' }}>
<img src={item.icon} alt="github" width={25} height={25} style={{ marginRight: matchDownSM ? 8 : 16 }} />
</Box>
{item.name}
</Button>
</AnimateButton>
))}
<TextField label="金额" type="number" onChange={handleAmountChange} value={amount} />
<Divider />
<Grid container direction="row" justifyContent="flex-end" spacing={2}>
<Grid item xs={9}>
<Typography variant="h6" style={{ textAlign: 'right', fontSize: '0.875rem' }}>
充值金额:{' '}
</Typography>
</Grid>
<Grid item xs={3}>
${Number(amount)}
</Grid>
{selectedPayment && (selectedPayment.percent_fee > 0 || selectedPayment.fixed_fee > 0) && (
<>
<Grid item xs={9}>
<Typography variant="h6" style={{ textAlign: 'right', fontSize: '0.875rem' }}>
手续费:
{selectedPayment &&
(selectedPayment.fixed_fee > 0
? '(固定)'
: selectedPayment.percent_fee > 0
? `(${selectedPayment.percent_fee * 100}%)`
: '')}{' '}
</Typography>
</Grid>
<Grid item xs={3}>
${calculateFee()}
</Grid>
</>
)}
<Grid item xs={9}>
<Typography variant="h6" style={{ textAlign: 'right', fontSize: '0.875rem' }}>
实际支付金额:{' '}
</Typography>
</Grid>
<Grid item xs={3}>
{calculateTotal()}{' '}
{selectedPayment &&
(selectedPayment.currency === 'CNY' ? `CNY (汇率:${siteInfo.PaymentUSDRate})` : selectedPayment.currency)}
</Grid>
</Grid>
<Divider />
<Button variant="contained" onClick={handlePay}>
充值
</Button>
</Stack>
<PayDialog open={open} onClose={() => setOpen(false)} amount={amount} uuid={selectedPayment.uuid} />
</SubCard>
)}
<SubCard
sx={{
marginTop: '40px'
}}
title="兑换码充值"
>
<FormControl fullWidth variant="outlined">
<InputLabel htmlFor="key">兑换码</InputLabel>

View File

@ -6709,6 +6709,11 @@ lodash.debounce@^4.0.8:
resolved "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==
lodash.isequal@^4.5.0:
version "4.5.0"
resolved "https://registry.npmmirror.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
integrity sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==
lodash.memoize@^4.1.2:
version "4.1.2"
resolved "https://registry.npmmirror.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@ -8077,6 +8082,11 @@ q@^1.1.2:
resolved "https://registry.npmmirror.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==
qrcode-generator@^1.4.4:
version "1.4.4"
resolved "https://registry.npmmirror.com/qrcode-generator/-/qrcode-generator-1.4.4.tgz#63f771224854759329a99048806a53ed278740e7"
integrity sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==
qs@6.11.0:
version "6.11.0"
resolved "https://registry.npmmirror.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
@ -8220,6 +8230,14 @@ react-perfect-scrollbar@^1.5.8:
perfect-scrollbar "^1.5.0"
prop-types "^15.6.1"
react-qrcode-logo@^3.0.0:
version "3.0.0"
resolved "https://registry.npmmirror.com/react-qrcode-logo/-/react-qrcode-logo-3.0.0.tgz#71cb43ddef9b338cc151800968276ca210086d11"
integrity sha512-2+vZ3GNBdUpYxIKyt6SFZsDGXa0xniyUQ0wPI4O0hJTzRjttPIx1pPnH9IWQmp/4nDMoN47IBhi3Breu1KudYw==
dependencies:
lodash.isequal "^4.5.0"
qrcode-generator "^1.4.4"
react-redux@^9.1.0:
version "9.1.0"
resolved "https://registry.npmmirror.com/react-redux/-/react-redux-9.1.0.tgz#46a46d4cfed4e534ce5452bb39ba18e1d98a8197"
@ -9000,7 +9018,16 @@ string-natural-compare@^3.0.1:
resolved "https://registry.npmmirror.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0:
"string-width-cjs@npm:string-width@^4.2.0":
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0:
version "4.2.3"
resolved "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -9087,7 +9114,14 @@ stringify-object@^3.3.0:
is-obj "^1.0.1"
is-regexp "^1.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -10198,7 +10232,16 @@ workbox-window@6.6.1:
"@types/trusted-types" "^2.0.2"
workbox-core "6.6.1"
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==