parent
e4665a16c8
commit
03d847fe68
@ -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
4
common/config/payment.go
Normal file
@ -0,0 +1,4 @@
|
||||
package config
|
||||
|
||||
var PaymentUSDRate = 7.3
|
||||
var PaymentMinAmount = 1
|
@ -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()
|
||||
}
|
||||
|
@ -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
223
controller/order.go
Normal 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(¶ms); err != nil {
|
||||
common.APIRespondWithError(c, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
|
||||
payments, err := model.GetOrderList(¶ms)
|
||||
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
140
controller/payment.go
Normal 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(¶ms); err != nil {
|
||||
common.APIRespondWithError(c, http.StatusOK, err)
|
||||
return
|
||||
}
|
||||
|
||||
payments, err := model.GetPanymentList(¶ms)
|
||||
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
1
go.mod
@ -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
2
go.sum
@ -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=
|
||||
|
@ -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
|
||||
|
@ -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
107
model/order.go
Normal 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, ¶ms.PaginationParams, &orders, allowedOrderFields)
|
||||
}
|
110
model/payment.go
Normal file
110
model/payment.go
Normal 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, ¶ms.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
|
||||
}
|
80
payment/gateway/epay/client.go
Normal file
80
payment/gateway/epay/client.go
Normal 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))
|
||||
}
|
91
payment/gateway/epay/payment.go
Normal file
91
payment/gateway/epay/payment.go
Normal 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
|
||||
}
|
45
payment/gateway/epay/type.go
Normal file
45
payment/gateway/epay/type.go
Normal 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
20
payment/payment.go
Normal 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
81
payment/service.go
Normal 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
27
payment/types/types.go
Normal 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"`
|
||||
}
|
@ -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)
|
||||
|
@ -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
BIN
web/public/ali_pay.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.7 KiB |
5
web/public/logo-loading-white.svg
Normal file
5
web/public/logo-loading-white.svg
Normal 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 |
5
web/public/logo-loading.svg
Normal file
5
web/public/logo-loading.svg
Normal 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
BIN
web/public/wechat_pay.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.0 KiB |
1
web/src/assets/images/success.svg
Normal file
1
web/src/assets/images/success.svg
Normal 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 |
@ -26,7 +26,7 @@ const config = {
|
||||
lark_login: false,
|
||||
lark_client_id: '',
|
||||
telegram_bot: '',
|
||||
isLoading: true, // 添加加载状态
|
||||
isLoading: true // 添加加载状态
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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: '设置',
|
||||
|
@ -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 />
|
||||
}
|
||||
]
|
||||
};
|
||||
|
@ -9,7 +9,7 @@ const siteInfoReducer = (state = initialState, action) => {
|
||||
return {
|
||||
...state,
|
||||
...action.payload,
|
||||
isLoading: false, // 添加加载状态
|
||||
isLoading: false // 添加加载状态
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
294
web/src/views/Payment/Gateway.js
Normal file
294
web/src/views/Payment/Gateway.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
220
web/src/views/Payment/Order.js
Normal file
220
web/src/views/Payment/Order.js
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
359
web/src/views/Payment/component/EditModal.js
Normal file
359
web/src/views/Payment/component/EditModal.js
Normal 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
|
||||
};
|
45
web/src/views/Payment/component/OrderTableRow.js
Normal file
45
web/src/views/Payment/component/OrderTableRow.js
Normal 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
|
||||
};
|
138
web/src/views/Payment/component/OrderTableToolBar.js
Normal file
138
web/src/views/Payment/component/OrderTableToolBar.js
Normal 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
|
||||
};
|
153
web/src/views/Payment/component/TableRow.js
Normal file
153
web/src/views/Payment/component/TableRow.js
Normal 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
|
||||
};
|
73
web/src/views/Payment/component/TableToolBar.js
Normal file
73
web/src/views/Payment/component/TableToolBar.js
Normal 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
|
||||
};
|
86
web/src/views/Payment/index.js
Normal file
86
web/src/views/Payment/index.js
Normal 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;
|
73
web/src/views/Payment/type/Config.js
Normal file
73
web/src/views/Payment/type/Config.js
Normal 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 };
|
@ -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>
|
||||
|
129
web/src/views/Topup/component/PayDialog.js
Normal file
129
web/src/views/Topup/component/PayDialog.js
Normal 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
|
||||
};
|
@ -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>
|
||||
|
@ -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==
|
||||
|
Loading…
Reference in New Issue
Block a user