Просмотр исходного кода

✨ feat: Add topup billing history with admin manual completion

Implement comprehensive topup billing system with user history viewing and admin management capabilities.

## Features Added

### Frontend
- Add topup history modal with paginated billing records
- Display order details: trade number, payment method, amount, money, status, create time
- Implement empty state with proper illustrations
- Add payment method column with localized display (Stripe, Alipay, WeChat)
- Add admin manual completion feature for pending orders
- Add Coins icon for recharge amount display
- Integrate "Bills" button in RechargeCard header
- Optimize code quality by using shared utility functions (isAdmin)
- Extract constants for status and payment method mappings
- Use React.useMemo for performance optimization

### Backend
- Create GET `/api/user/topup/self` endpoint for user topup history with pagination
- Create POST `/api/user/topup/complete` endpoint for admin manual order completion
- Add `payment_method` field to TopUp model for tracking payment types
- Implement `GetUserTopUps` method with proper pagination and ordering
- Implement `ManualCompleteTopUp` with transaction safety and row-level locking
- Add application-level mutex locks to prevent concurrent order processing
- Record payment method in Epay and Stripe payment flows
- Ensure idempotency and data consistency with proper error handling

### Internationalization
- Add i18n keys for Chinese (zh), English (en), and French (fr)
- Support for billing-related UI text and status messages

## Technical Improvements
- Use database transactions with FOR UPDATE row-level locking
- Implement sync.Map-based mutex for order-level concurrency control
- Proper error handling and user-friendly toast notifications
- Follow existing codebase patterns for empty states and modals
- Maintain code quality with extracted render functions and constants

## Files Changed
- Backend: controller/topup.go, controller/topup_stripe.go, model/topup.go, router/api-router.go
- Frontend: web/src/components/topup/modals/TopupHistoryModal.jsx (new), web/src/components/topup/RechargeCard.jsx, web/src/components/topup/index.jsx
- i18n: web/src/i18n/locales/{zh,en,fr}.json
Apple\Apple 2 месяцев назад
Родитель
Сommit
6ef95c97cc
34 измененных файлов с 1743 добавлено и 943 удалено
  1. 45 6
      controller/topup.go
  2. 7 6
      controller/topup_stripe.go
  3. 116 8
      model/topup.go
  4. 2 0
      router/api-router.go
  5. 16 4
      web/src/components/auth/LoginForm.jsx
  6. 3 7
      web/src/components/common/examples/ChannelKeyViewExample.jsx
  7. 90 53
      web/src/components/common/modals/SecureVerificationModal.jsx
  8. 8 5
      web/src/components/settings/PersonalSetting.jsx
  9. 39 11
      web/src/components/settings/SystemSetting.jsx
  10. 8 2
      web/src/components/settings/personal/cards/AccountManagement.jsx
  11. 4 4
      web/src/components/settings/personal/cards/NotificationSettings.jsx
  12. 3 2
      web/src/components/setup/components/steps/DatabaseStep.jsx
  13. 597 523
      web/src/components/table/channels/modals/EditChannelModal.jsx
  14. 13 2
      web/src/components/table/channels/modals/EditTagModal.jsx
  15. 15 3
      web/src/components/table/channels/modals/ModelTestModal.jsx
  16. 2 10
      web/src/components/table/users/UsersColumnDefs.jsx
  17. 3 2
      web/src/components/table/users/modals/ResetPasskeyModal.jsx
  18. 6 3
      web/src/components/table/users/modals/ResetTwoFAModal.jsx
  19. 40 16
      web/src/components/topup/RechargeCard.jsx
  20. 20 0
      web/src/components/topup/index.jsx
  21. 253 0
      web/src/components/topup/modals/TopupHistoryModal.jsx
  22. 1 1
      web/src/constants/channel.constants.js
  23. 49 9
      web/src/helpers/passkey.js
  24. 112 94
      web/src/helpers/render.jsx
  25. 3 3
      web/src/helpers/secureApiCall.js
  26. 1 1
      web/src/hooks/channels/useChannelsData.jsx
  27. 125 97
      web/src/hooks/common/useSecureVerification.jsx
  28. 1 1
      web/src/hooks/users/useUsersData.jsx
  29. 14 3
      web/src/i18n/locales/en.json
  30. 14 1
      web/src/i18n/locales/fr.json
  31. 18 1
      web/src/i18n/locales/zh.json
  32. 1 1
      web/src/pages/Setting/Chat/SettingsChats.jsx
  33. 54 19
      web/src/pages/Setting/Operation/SettingsLog.jsx
  34. 60 45
      web/src/services/secureVerification.js

+ 45 - 6
controller/topup.go

@@ -183,12 +183,13 @@ func RequestEpay(c *gin.Context) {
 		amount = dAmount.Div(dQuotaPerUnit).IntPart()
 	}
 	topUp := &model.TopUp{
-		UserId:     id,
-		Amount:     amount,
-		Money:      payMoney,
-		TradeNo:    tradeNo,
-		CreateTime: time.Now().Unix(),
-		Status:     "pending",
+		UserId:        id,
+		Amount:        amount,
+		Money:         payMoney,
+		TradeNo:       tradeNo,
+		PaymentMethod: req.PaymentMethod,
+		CreateTime:    time.Now().Unix(),
+		Status:        "pending",
 	}
 	err = topUp.Insert()
 	if err != nil {
@@ -313,3 +314,41 @@ func RequestAmount(c *gin.Context) {
 	}
 	c.JSON(200, gin.H{"message": "success", "data": strconv.FormatFloat(payMoney, 'f', 2, 64)})
 }
+
+func GetUserTopUps(c *gin.Context) {
+	userId := c.GetInt("id")
+	pageInfo := common.GetPageQuery(c)
+
+	topups, total, err := model.GetUserTopUps(userId, pageInfo)
+	if err != nil {
+		common.ApiError(c, err)
+		return
+	}
+
+	pageInfo.SetTotal(int(total))
+	pageInfo.SetItems(topups)
+	common.ApiSuccess(c, pageInfo)
+}
+
+type AdminCompleteTopupRequest struct {
+	TradeNo string `json:"trade_no"`
+}
+
+// AdminCompleteTopUp 管理员补单接口
+func AdminCompleteTopUp(c *gin.Context) {
+	var req AdminCompleteTopupRequest
+	if err := c.ShouldBindJSON(&req); err != nil || req.TradeNo == "" {
+		common.ApiErrorMsg(c, "参数错误")
+		return
+	}
+
+	// 订单级互斥,防止并发补单
+	LockOrder(req.TradeNo)
+	defer UnlockOrder(req.TradeNo)
+
+	if err := model.ManualCompleteTopUp(req.TradeNo); err != nil {
+		common.ApiError(c, err)
+		return
+	}
+	common.ApiSuccess(c, nil)
+}

+ 7 - 6
controller/topup_stripe.go

@@ -83,12 +83,13 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
 	}
 
 	topUp := &model.TopUp{
-		UserId:     id,
-		Amount:     req.Amount,
-		Money:      chargedMoney,
-		TradeNo:    referenceId,
-		CreateTime: time.Now().Unix(),
-		Status:     common.TopUpStatusPending,
+		UserId:        id,
+		Amount:        req.Amount,
+		Money:         chargedMoney,
+		TradeNo:       referenceId,
+		PaymentMethod: PaymentMethodStripe,
+		CreateTime:    time.Now().Unix(),
+		Status:        common.TopUpStatusPending,
 	}
 	err = topUp.Insert()
 	if err != nil {

+ 116 - 8
model/topup.go

@@ -6,18 +6,20 @@ import (
 	"one-api/common"
 	"one-api/logger"
 
+	"github.com/shopspring/decimal"
 	"gorm.io/gorm"
 )
 
 type TopUp struct {
-	Id           int     `json:"id"`
-	UserId       int     `json:"user_id" gorm:"index"`
-	Amount       int64   `json:"amount"`
-	Money        float64 `json:"money"`
-	TradeNo      string  `json:"trade_no" gorm:"unique;type:varchar(255);index"`
-	CreateTime   int64   `json:"create_time"`
-	CompleteTime int64   `json:"complete_time"`
-	Status       string  `json:"status"`
+	Id            int     `json:"id"`
+	UserId        int     `json:"user_id" gorm:"index"`
+	Amount        int64   `json:"amount"`
+	Money         float64 `json:"money"`
+	TradeNo       string  `json:"trade_no" gorm:"unique;type:varchar(255);index"`
+	PaymentMethod string  `json:"payment_method" gorm:"type:varchar(50)"`
+	CreateTime    int64   `json:"create_time"`
+	CompleteTime  int64   `json:"complete_time"`
+	Status        string  `json:"status"`
 }
 
 func (topUp *TopUp) Insert() error {
@@ -99,3 +101,109 @@ func Recharge(referenceId string, customerId string) (err error) {
 
 	return nil
 }
+
+func GetUserTopUps(userId int, pageInfo *common.PageInfo) (topups []*TopUp, total int64, err error) {
+	// Start transaction
+	tx := DB.Begin()
+	if tx.Error != nil {
+		return nil, 0, tx.Error
+	}
+	defer func() {
+		if r := recover(); r != nil {
+			tx.Rollback()
+		}
+	}()
+
+	// Get total count within transaction
+	err = tx.Model(&TopUp{}).Where("user_id = ?", userId).Count(&total).Error
+	if err != nil {
+		tx.Rollback()
+		return nil, 0, err
+	}
+
+	// Get paginated topups within same transaction
+	err = tx.Where("user_id = ?", userId).Order("id desc").Limit(pageInfo.GetPageSize()).Offset(pageInfo.GetStartIdx()).Find(&topups).Error
+	if err != nil {
+		tx.Rollback()
+		return nil, 0, err
+	}
+
+	// Commit transaction
+	if err = tx.Commit().Error; err != nil {
+		return nil, 0, err
+	}
+
+	return topups, total, nil
+}
+
+// ManualCompleteTopUp 管理员手动完成订单并给用户充值
+func ManualCompleteTopUp(tradeNo string) error {
+	if tradeNo == "" {
+		return errors.New("未提供订单号")
+	}
+
+	refCol := "`trade_no`"
+	if common.UsingPostgreSQL {
+		refCol = `"trade_no"`
+	}
+
+	var userId int
+	var quotaToAdd int
+	var payMoney float64
+
+	err := DB.Transaction(func(tx *gorm.DB) error {
+		topUp := &TopUp{}
+		// 行级锁,避免并发补单
+		if err := tx.Set("gorm:query_option", "FOR UPDATE").Where(refCol+" = ?", tradeNo).First(topUp).Error; err != nil {
+			return errors.New("充值订单不存在")
+		}
+
+		// 幂等处理:已成功直接返回
+		if topUp.Status == common.TopUpStatusSuccess {
+			return nil
+		}
+
+		if topUp.Status != common.TopUpStatusPending {
+			return errors.New("订单状态不是待支付,无法补单")
+		}
+
+		// 计算应充值额度:
+		// - Stripe 订单:Money 代表经分组倍率换算后的美元数量,直接 * QuotaPerUnit
+		// - 其他订单(如易支付):Amount 为美元数量,* QuotaPerUnit
+		if topUp.PaymentMethod == "stripe" {
+			dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
+			quotaToAdd = int(decimal.NewFromFloat(topUp.Money).Mul(dQuotaPerUnit).IntPart())
+		} else {
+			dAmount := decimal.NewFromInt(topUp.Amount)
+			dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
+			quotaToAdd = int(dAmount.Mul(dQuotaPerUnit).IntPart())
+		}
+		if quotaToAdd <= 0 {
+			return errors.New("无效的充值额度")
+		}
+
+		// 标记完成
+		topUp.CompleteTime = common.GetTimestamp()
+		topUp.Status = common.TopUpStatusSuccess
+		if err := tx.Save(topUp).Error; err != nil {
+			return err
+		}
+
+		// 增加用户额度(立即写库,保持一致性)
+		if err := tx.Model(&User{}).Where("id = ?", topUp.UserId).Update("quota", gorm.Expr("quota + ?", quotaToAdd)).Error; err != nil {
+			return err
+		}
+
+		userId = topUp.UserId
+		payMoney = topUp.Money
+		return nil
+	})
+
+	if err != nil {
+		return err
+	}
+
+	// 事务外记录日志,避免阻塞
+	RecordLog(userId, LogTypeTopup, fmt.Sprintf("管理员补单成功,充值金额: %v,支付金额:%f", logger.FormatQuota(quotaToAdd), payMoney))
+	return nil
+}

+ 2 - 0
router/api-router.go

@@ -73,12 +73,14 @@ func SetApiRouter(router *gin.Engine) {
 				selfRoute.DELETE("/passkey", controller.PasskeyDelete)
 				selfRoute.GET("/aff", controller.GetAffCode)
 				selfRoute.GET("/topup/info", controller.GetTopUpInfo)
+				selfRoute.GET("/topup/self", controller.GetUserTopUps)
 				selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
 				selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
 				selfRoute.POST("/amount", controller.RequestAmount)
 				selfRoute.POST("/stripe/pay", middleware.CriticalRateLimit(), controller.RequestStripePay)
 				selfRoute.POST("/stripe/amount", controller.RequestStripeAmount)
 				selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
+				selfRoute.POST("/topup/complete", middleware.AdminAuth(), controller.AdminCompleteTopUp)
 				selfRoute.PUT("/setting", controller.UpdateUserSetting)
 
 				// 2FA routes

+ 16 - 4
web/src/components/auth/LoginForm.jsx

@@ -42,7 +42,12 @@ import Title from '@douyinfe/semi-ui/lib/es/typography/title';
 import Text from '@douyinfe/semi-ui/lib/es/typography/text';
 import TelegramLoginButton from 'react-telegram-login';
 
-import { IconGithubLogo, IconMail, IconLock, IconKey } from '@douyinfe/semi-icons';
+import {
+  IconGithubLogo,
+  IconMail,
+  IconLock,
+  IconKey,
+} from '@douyinfe/semi-icons';
 import OIDCIcon from '../common/logo/OIDCIcon';
 import WeChatIcon from '../common/logo/WeChatIcon';
 import LinuxDoIcon from '../common/logo/LinuxDoIcon';
@@ -296,15 +301,22 @@ const LoginForm = () => {
         return;
       }
 
-      const publicKeyOptions = prepareCredentialRequestOptions(data?.options || data?.publicKey || data);
-      const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
+      const publicKeyOptions = prepareCredentialRequestOptions(
+        data?.options || data?.publicKey || data,
+      );
+      const assertion = await navigator.credentials.get({
+        publicKey: publicKeyOptions,
+      });
       const payload = buildAssertionResult(assertion);
       if (!payload) {
         showError('Passkey 验证失败,请重试');
         return;
       }
 
-      const finishRes = await API.post('/api/user/passkey/login/finish', payload);
+      const finishRes = await API.post(
+        '/api/user/passkey/login/finish',
+        payload,
+      );
       const finish = finishRes.data;
       if (finish.success) {
         userDispatch({ type: 'login', payload: finish.data });

+ 3 - 7
web/src/components/common/examples/ChannelKeyViewExample.jsx

@@ -58,7 +58,7 @@ const ChannelKeyViewExample = ({ channelId }) => {
   // 开始查看密钥流程
   const handleViewKey = async () => {
     const apiCall = createApiCalls.viewChannelKey(channelId);
-    
+
     await startVerification(apiCall, {
       title: t('查看渠道密钥'),
       description: t('为了保护账户安全,请验证您的身份。'),
@@ -69,11 +69,7 @@ const ChannelKeyViewExample = ({ channelId }) => {
   return (
     <>
       {/* 查看密钥按钮 */}
-      <Button
-        type='primary'
-        theme='outline'
-        onClick={handleViewKey}
-      >
+      <Button type='primary' theme='outline' onClick={handleViewKey}>
         {t('查看密钥')}
       </Button>
 
@@ -114,4 +110,4 @@ const ChannelKeyViewExample = ({ channelId }) => {
   );
 };
 
-export default ChannelKeyViewExample;
+export default ChannelKeyViewExample;

+ 90 - 53
web/src/components/common/modals/SecureVerificationModal.jsx

@@ -19,7 +19,16 @@ For commercial licensing, please contact [email protected]
 
 import React, { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Modal, Button, Input, Typography, Tabs, TabPane, Space, Spin } from '@douyinfe/semi-ui';
+import {
+  Modal,
+  Button,
+  Input,
+  Typography,
+  Tabs,
+  TabPane,
+  Space,
+  Spin,
+} from '@douyinfe/semi-ui';
 
 /**
  * 通用安全验证模态框组件
@@ -78,9 +87,7 @@ const SecureVerificationModal = ({
         title={title || t('安全验证')}
         visible={visible}
         onCancel={onCancel}
-        footer={
-          <Button onClick={onCancel}>{t('确定')}</Button>
-        }
+        footer={<Button onClick={onCancel}>{t('确定')}</Button>}
         width={500}
         style={{ maxWidth: '90vw' }}
       >
@@ -123,21 +130,21 @@ const SecureVerificationModal = ({
       width={460}
       centered
       style={{
-        maxWidth: 'calc(100vw - 32px)'
+        maxWidth: 'calc(100vw - 32px)',
       }}
       bodyStyle={{
-        padding: '20px 24px'
+        padding: '20px 24px',
       }}
     >
       <div style={{ width: '100%' }}>
         {/* 描述信息 */}
         {description && (
           <Typography.Paragraph
-            type="tertiary"
+            type='tertiary'
             style={{
               margin: '0 0 20px 0',
               fontSize: '14px',
-              lineHeight: '1.6'
+              lineHeight: '1.6',
             }}
           >
             {description}
@@ -153,10 +160,7 @@ const SecureVerificationModal = ({
           style={{ margin: 0 }}
         >
           {has2FA && (
-            <TabPane
-              tab={t('两步验证')}
-              itemKey='2fa'
-            >
+            <TabPane tab={t('两步验证')} itemKey='2fa'>
               <div style={{ paddingTop: '20px' }}>
                 <div style={{ marginBottom: '12px' }}>
                   <Input
@@ -169,8 +173,21 @@ const SecureVerificationModal = ({
                     autoFocus={method === '2fa'}
                     disabled={loading}
                     prefix={
-                      <svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'>
-                        <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
+                      <svg
+                        style={{
+                          width: 16,
+                          height: 16,
+                          marginRight: 8,
+                          flexShrink: 0,
+                        }}
+                        fill='currentColor'
+                        viewBox='0 0 20 20'
+                      >
+                        <path
+                          fillRule='evenodd'
+                          d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
+                          clipRule='evenodd'
+                        />
                       </svg>
                     }
                     style={{ width: '100%' }}
@@ -178,24 +195,26 @@ const SecureVerificationModal = ({
                 </div>
 
                 <Typography.Text
-                  type="tertiary"
-                  size="small"
+                  type='tertiary'
+                  size='small'
                   style={{
                     display: 'block',
                     marginBottom: '20px',
                     fontSize: '13px',
-                    lineHeight: '1.5'
+                    lineHeight: '1.5',
                   }}
                 >
                   {t('从认证器应用中获取验证码,或使用备用码')}
                 </Typography.Text>
 
-                <div style={{
-                  display: 'flex',
-                  justifyContent: 'flex-end',
-                  gap: '8px',
-                  flexWrap: 'wrap'
-                }}>
+                <div
+                  style={{
+                    display: 'flex',
+                    justifyContent: 'flex-end',
+                    gap: '8px',
+                    flexWrap: 'wrap',
+                  }}
+                >
                   <Button onClick={onCancel} disabled={loading}>
                     {t('取消')}
                   </Button>
@@ -214,31 +233,47 @@ const SecureVerificationModal = ({
           )}
 
           {hasPasskey && passkeySupported && (
-            <TabPane
-              tab={t('Passkey')}
-              itemKey='passkey'
-            >
+            <TabPane tab={t('Passkey')} itemKey='passkey'>
               <div style={{ paddingTop: '20px' }}>
-                <div style={{
-                  textAlign: 'center',
-                  padding: '24px 16px',
-                  marginBottom: '20px'
-                }}>
-                  <div style={{
-                    width: 56,
-                    height: 56,
-                    margin: '0 auto 16px',
-                    display: 'flex',
-                    alignItems: 'center',
-                    justifyContent: 'center',
-                    borderRadius: '50%',
-                    background: 'var(--semi-color-primary-light-default)',
-                  }}>
-                    <svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'>
-                      <path fillRule='evenodd' d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z' clipRule='evenodd' />
+                <div
+                  style={{
+                    textAlign: 'center',
+                    padding: '24px 16px',
+                    marginBottom: '20px',
+                  }}
+                >
+                  <div
+                    style={{
+                      width: 56,
+                      height: 56,
+                      margin: '0 auto 16px',
+                      display: 'flex',
+                      alignItems: 'center',
+                      justifyContent: 'center',
+                      borderRadius: '50%',
+                      background: 'var(--semi-color-primary-light-default)',
+                    }}
+                  >
+                    <svg
+                      style={{
+                        width: 28,
+                        height: 28,
+                        color: 'var(--semi-color-primary)',
+                      }}
+                      fill='currentColor'
+                      viewBox='0 0 20 20'
+                    >
+                      <path
+                        fillRule='evenodd'
+                        d='M18 8a6 6 0 01-7.743 5.743L10 14l-1 1-1 1H6v2H2v-4l4.257-4.257A6 6 0 1118 8zm-6-4a1 1 0 100 2 2 2 0 012 2 1 1 0 102 0 4 4 0 00-4-4z'
+                        clipRule='evenodd'
+                      />
                     </svg>
                   </div>
-                  <Typography.Title heading={5} style={{ margin: '0 0 8px', fontSize: '16px' }}>
+                  <Typography.Title
+                    heading={5}
+                    style={{ margin: '0 0 8px', fontSize: '16px' }}
+                  >
                     {t('使用 Passkey 验证')}
                   </Typography.Title>
                   <Typography.Text
@@ -247,19 +282,21 @@ const SecureVerificationModal = ({
                       display: 'block',
                       margin: 0,
                       fontSize: '13px',
-                      lineHeight: '1.5'
+                      lineHeight: '1.5',
                     }}
                   >
                     {t('点击验证按钮,使用您的生物特征或安全密钥')}
                   </Typography.Text>
                 </div>
 
-                <div style={{
-                  display: 'flex',
-                  justifyContent: 'flex-end',
-                  gap: '8px',
-                  flexWrap: 'wrap'
-                }}>
+                <div
+                  style={{
+                    display: 'flex',
+                    justifyContent: 'flex-end',
+                    gap: '8px',
+                    flexWrap: 'wrap',
+                  }}
+                >
                   <Button onClick={onCancel} disabled={loading}>
                     {t('取消')}
                   </Button>
@@ -282,4 +319,4 @@ const SecureVerificationModal = ({
   );
 };
 
-export default SecureVerificationModal;
+export default SecureVerificationModal;

+ 8 - 5
web/src/components/settings/PersonalSetting.jsx

@@ -155,9 +155,7 @@ const PersonalSetting = () => {
         gotifyUrl: settings.gotify_url || '',
         gotifyToken: settings.gotify_token || '',
         gotifyPriority:
-          settings.gotify_priority !== undefined
-            ? settings.gotify_priority
-            : 5,
+          settings.gotify_priority !== undefined ? settings.gotify_priority : 5,
         acceptUnsetModelRatioModel:
           settings.accept_unset_model_ratio_model || false,
         recordIpLog: settings.record_ip_log || false,
@@ -214,7 +212,9 @@ const PersonalSetting = () => {
         return;
       }
 
-      const publicKey = prepareCredentialCreationOptions(data?.options || data?.publicKey || data);
+      const publicKey = prepareCredentialCreationOptions(
+        data?.options || data?.publicKey || data,
+      );
       const credential = await navigator.credentials.create({ publicKey });
       const payload = buildRegistrationResult(credential);
       if (!payload) {
@@ -222,7 +222,10 @@ const PersonalSetting = () => {
         return;
       }
 
-      const finishRes = await API.post('/api/user/passkey/register/finish', payload);
+      const finishRes = await API.post(
+        '/api/user/passkey/register/finish',
+        payload,
+      );
       if (finishRes.data.success) {
         showSuccess(t('Passkey 注册成功'));
         await loadPasskeyStatus();

+ 39 - 11
web/src/components/settings/SystemSetting.jsx

@@ -615,7 +615,10 @@ const SystemSetting = () => {
 
     options.push({
       key: 'passkey.rp_display_name',
-      value: formValues['passkey.rp_display_name'] || inputs['passkey.rp_display_name'] || '',
+      value:
+        formValues['passkey.rp_display_name'] ||
+        inputs['passkey.rp_display_name'] ||
+        '',
     });
     options.push({
       key: 'passkey.rp_id',
@@ -623,11 +626,17 @@ const SystemSetting = () => {
     });
     options.push({
       key: 'passkey.user_verification',
-      value: formValues['passkey.user_verification'] || inputs['passkey.user_verification'] || 'preferred',
+      value:
+        formValues['passkey.user_verification'] ||
+        inputs['passkey.user_verification'] ||
+        'preferred',
     });
     options.push({
       key: 'passkey.attachment_preference',
-      value: formValues['passkey.attachment_preference'] || inputs['passkey.attachment_preference'] || '',
+      value:
+        formValues['passkey.attachment_preference'] ||
+        inputs['passkey.attachment_preference'] ||
+        '',
     });
     options.push({
       key: 'passkey.origins',
@@ -1044,7 +1053,9 @@ const SystemSetting = () => {
                   <Text>{t('用以支持基于 WebAuthn 的无密码登录注册')}</Text>
                   <Banner
                     type='info'
-                    description={t('Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式')}
+                    description={t(
+                      'Passkey 是基于 WebAuthn 标准的无密码身份验证方法,支持指纹、面容、硬件密钥等认证方式',
+                    )}
                     style={{ marginBottom: 20, marginTop: 16 }}
                   />
                   <Row
@@ -1070,7 +1081,9 @@ const SystemSetting = () => {
                         field="['passkey.rp_display_name']"
                         label={t('服务显示名称')}
                         placeholder={t('默认使用系统名称')}
-                        extraText={t('用户注册时看到的网站名称,比如\'我的网站\'')}
+                        extraText={t(
+                          "用户注册时看到的网站名称,比如'我的网站'",
+                        )}
                       />
                     </Col>
                     <Col xs={24} sm={24} md={12} lg={12} xl={12}>
@@ -1078,7 +1091,9 @@ const SystemSetting = () => {
                         field="['passkey.rp_id']"
                         label={t('网站域名标识')}
                         placeholder={t('例如:example.com')}
-                        extraText={t('留空则默认使用服务器地址,注意不能携带http://或者https://')}
+                        extraText={t(
+                          '留空则默认使用服务器地址,注意不能携带http://或者https://',
+                        )}
                       />
                     </Col>
                   </Row>
@@ -1092,7 +1107,10 @@ const SystemSetting = () => {
                         label={t('安全验证级别')}
                         placeholder={t('是否要求指纹/面容等生物识别')}
                         optionList={[
-                          { label: t('推荐使用(用户可选)'), value: 'preferred' },
+                          {
+                            label: t('推荐使用(用户可选)'),
+                            value: 'preferred',
+                          },
                           { label: t('强制要求'), value: 'required' },
                           { label: t('不建议使用'), value: 'discouraged' },
                         ]}
@@ -1109,7 +1127,9 @@ const SystemSetting = () => {
                           { label: t('本设备内置'), value: 'platform' },
                           { label: t('外接设备'), value: 'cross-platform' },
                         ]}
-                        extraText={t('本设备:手机指纹/面容,外接:USB安全密钥')}
+                        extraText={t(
+                          '本设备:手机指纹/面容,外接:USB安全密钥',
+                        )}
                       />
                     </Col>
                   </Row>
@@ -1123,7 +1143,10 @@ const SystemSetting = () => {
                         noLabel
                         extraText={t('仅用于开发环境,生产环境应使用 HTTPS')}
                         onChange={(e) =>
-                          handleCheckboxChange('passkey.allow_insecure_origin', e)
+                          handleCheckboxChange(
+                            'passkey.allow_insecure_origin',
+                            e,
+                          )
                         }
                       >
                         {t('允许不安全的 Origin(HTTP)')}
@@ -1139,11 +1162,16 @@ const SystemSetting = () => {
                         field="['passkey.origins']"
                         label={t('允许的 Origins')}
                         placeholder={t('填写带https的域名,逗号分隔')}
-                        extraText={t('为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https')}
+                        extraText={t(
+                          '为空则默认使用服务器地址,多个 Origin 用逗号分隔,例如 https://newapi.pro,https://newapi.com ,注意不能携带[],需使用https',
+                        )}
                       />
                     </Col>
                   </Row>
-                  <Button onClick={submitPasskeySettings} style={{ marginTop: 16 }}>
+                  <Button
+                    onClick={submitPasskeySettings}
+                    style={{ marginTop: 16 }}
+                  >
                     {t('保存 Passkey 设置')}
                   </Button>
                 </Form.Section>

+ 8 - 2
web/src/components/settings/personal/cards/AccountManagement.jsx

@@ -535,7 +535,9 @@ const AccountManagement = ({
                           ? () => {
                               Modal.confirm({
                                 title: t('确认解绑 Passkey'),
-                                content: t('解绑后将无法使用 Passkey 登录,确定要继续吗?'),
+                                content: t(
+                                  '解绑后将无法使用 Passkey 登录,确定要继续吗?',
+                                ),
                                 okText: t('确认解绑'),
                                 cancelText: t('取消'),
                                 okType: 'danger',
@@ -547,7 +549,11 @@ const AccountManagement = ({
                       className={`w-full sm:w-auto ${passkeyEnabled ? '!bg-slate-500 hover:!bg-slate-600' : ''}`}
                       icon={<IconKey />}
                       disabled={!passkeySupported && !passkeyEnabled}
-                      loading={passkeyEnabled ? passkeyDeleteLoading : passkeyRegisterLoading}
+                      loading={
+                        passkeyEnabled
+                          ? passkeyDeleteLoading
+                          : passkeyRegisterLoading
+                      }
                     >
                       {passkeyEnabled ? t('解绑 Passkey') : t('注册 Passkey')}
                     </Button>

+ 4 - 4
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -621,7 +621,9 @@ const NotificationSettings = ({
                         },
                         {
                           pattern: /^https?:\/\/.+/,
-                          message: t('Gotify服务器地址必须以http://或https://开头'),
+                          message: t(
+                            'Gotify服务器地址必须以http://或https://开头',
+                          ),
                         },
                       ]}
                     />
@@ -678,9 +680,7 @@ const NotificationSettings = ({
                             '复制应用的令牌(Token)并填写到上方的应用令牌字段',
                           )}
                         </div>
-                        <div>
-                          3. {t('填写Gotify服务器的完整URL地址')}
-                        </div>
+                        <div>3. {t('填写Gotify服务器的完整URL地址')}</div>
                         <div className='mt-3 pt-3 border-t border-gray-200'>
                           <span className='text-gray-400'>
                             {t('更多信息请参考')}

+ 3 - 2
web/src/components/setup/components/steps/DatabaseStep.jsx

@@ -26,8 +26,9 @@ import { Banner } from '@douyinfe/semi-ui';
  */
 const DatabaseStep = ({ setupStatus, renderNavigationButtons, t }) => {
   // 检测是否在 Electron 环境中运行
-  const isElectron = typeof window !== 'undefined' && window.electron?.isElectron;
-  
+  const isElectron =
+    typeof window !== 'undefined' && window.electron?.isElectron;
+
   return (
     <>
       {/* 数据库警告 */}

Разница между файлами не показана из-за своего большого размера
+ 597 - 523
web/src/components/table/channels/modals/EditChannelModal.jsx


+ 13 - 2
web/src/components/table/channels/modals/EditTagModal.jsx

@@ -119,8 +119,19 @@ const EditTagModal = (props) => {
           localModels = ['suno_music', 'suno_lyrics'];
           break;
         case 53:
-          localModels = ['NousResearch/Hermes-4-405B-FP8', 'Qwen/Qwen3-235B-A22B-Thinking-2507', 'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8','Qwen/Qwen3-235B-A22B-Instruct-2507', 'zai-org/GLM-4.5-FP8', 'openai/gpt-oss-120b', 'deepseek-ai/DeepSeek-R1-0528', 'deepseek-ai/DeepSeek-R1', 'deepseek-ai/DeepSeek-V3-0324', 'deepseek-ai/DeepSeek-V3.1'];
-          break; 
+          localModels = [
+            'NousResearch/Hermes-4-405B-FP8',
+            'Qwen/Qwen3-235B-A22B-Thinking-2507',
+            'Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8',
+            'Qwen/Qwen3-235B-A22B-Instruct-2507',
+            'zai-org/GLM-4.5-FP8',
+            'openai/gpt-oss-120b',
+            'deepseek-ai/DeepSeek-R1-0528',
+            'deepseek-ai/DeepSeek-R1',
+            'deepseek-ai/DeepSeek-V3-0324',
+            'deepseek-ai/DeepSeek-V3.1',
+          ];
+          break;
         default:
           localModels = getChannelModels(value);
           break;

+ 15 - 3
web/src/components/table/channels/modals/ModelTestModal.jsx

@@ -67,9 +67,15 @@ const ModelTestModal = ({
     { value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
     { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
     { value: 'anthropic', label: 'Anthropic (/v1/messages)' },
-    { value: 'gemini', label: 'Gemini (/v1beta/models/{model}:generateContent)' },
+    {
+      value: 'gemini',
+      label: 'Gemini (/v1beta/models/{model}:generateContent)',
+    },
     { value: 'jina-rerank', label: 'Jina Rerank (/rerank)' },
-    { value: 'image-generation', label: t('图像生成') + ' (/v1/images/generations)' },
+    {
+      value: 'image-generation',
+      label: t('图像生成') + ' (/v1/images/generations)',
+    },
     { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
   ];
 
@@ -166,7 +172,13 @@ const ModelTestModal = ({
         return (
           <Button
             type='tertiary'
-            onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)}
+            onClick={() =>
+              testChannel(
+                currentTestChannel,
+                record.model,
+                selectedEndpointType,
+              )
+            }
             loading={isTesting}
             size='small'
           >

+ 2 - 10
web/src/components/table/users/UsersColumnDefs.jsx

@@ -279,16 +279,8 @@ const renderOperations = (
       >
         {t('降级')}
       </Button>
-      <Dropdown
-        menu={moreMenu}
-        trigger='click'
-        position='bottomRight'
-      >
-        <Button
-          type='tertiary'
-          size='small'
-          icon={<IconMore />}
-        />
+      <Dropdown menu={moreMenu} trigger='click' position='bottomRight'>
+        <Button type='tertiary' size='small' icon={<IconMore />} />
       </Dropdown>
     </Space>
   );

+ 3 - 2
web/src/components/table/users/modals/ResetPasskeyModal.jsx

@@ -30,10 +30,11 @@ const ResetPasskeyModal = ({ visible, onCancel, onConfirm, user, t }) => {
       type='warning'
     >
       {t('此操作将解绑用户当前的 Passkey,下次登录需要重新注册。')}{' '}
-      {user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
+      {user?.username
+        ? t('目标用户:{{username}}', { username: user.username })
+        : ''}
     </Modal>
   );
 };
 
 export default ResetPasskeyModal;
-

+ 6 - 3
web/src/components/table/users/modals/ResetTwoFAModal.jsx

@@ -29,11 +29,14 @@ const ResetTwoFAModal = ({ visible, onCancel, onConfirm, user, t }) => {
       onOk={onConfirm}
       type='warning'
     >
-      {t('此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。')}{' '}
-      {user?.username ? t('目标用户:{{username}}', { username: user.username }) : ''}
+      {t(
+        '此操作将禁用该用户当前的两步验证配置,下次登录将不再强制输入验证码,直到用户重新启用。',
+      )}{' '}
+      {user?.username
+        ? t('目标用户:{{username}}', { username: user.username })
+        : ''}
     </Modal>
   );
 };
 
 export default ResetTwoFAModal;
-

+ 40 - 16
web/src/components/topup/RechargeCard.jsx

@@ -34,7 +34,14 @@ import {
   Tooltip,
 } from '@douyinfe/semi-ui';
 import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
-import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
+import {
+  CreditCard,
+  Coins,
+  Wallet,
+  BarChart2,
+  TrendingUp,
+  Receipt,
+} from 'lucide-react';
 import { IconGift } from '@douyinfe/semi-icons';
 import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
 import { getCurrencyConfig } from '../../helpers/render';
@@ -72,6 +79,7 @@ const RechargeCard = ({
   renderQuota,
   statusLoading,
   topupInfo,
+  onOpenHistory,
 }) => {
   const onlineFormApiRef = useRef(null);
   const redeemFormApiRef = useRef(null);
@@ -79,16 +87,25 @@ const RechargeCard = ({
   return (
     <Card className='!rounded-2xl shadow-sm border-0'>
       {/* 卡片头部 */}
-      <div className='flex items-center mb-4'>
-        <Avatar size='small' color='blue' className='mr-3 shadow-md'>
-          <CreditCard size={16} />
-        </Avatar>
-        <div>
-          <Typography.Text className='text-lg font-medium'>
-            {t('账户充值')}
-          </Typography.Text>
-          <div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
+      <div className='flex items-center justify-between mb-4'>
+        <div className='flex items-center'>
+          <Avatar size='small' color='blue' className='mr-3 shadow-md'>
+            <CreditCard size={16} />
+          </Avatar>
+          <div>
+            <Typography.Text className='text-lg font-medium'>
+              {t('账户充值')}
+            </Typography.Text>
+            <div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
+          </div>
         </div>
+        <Button
+          icon={<Receipt size={16} />}
+          theme='solid'
+          onClick={onOpenHistory}
+        >
+          {t('账单')}
+        </Button>
       </div>
 
       <Space vertical style={{ width: '100%' }}>
@@ -339,16 +356,22 @@ const RechargeCard = ({
                 )}
 
                 {(enableOnlineTopUp || enableStripeTopUp) && (
-                  <Form.Slot 
+                  <Form.Slot
                     label={
                       <div className='flex items-center gap-2'>
                         <span>{t('选择充值额度')}</span>
                         {(() => {
                           const { symbol, rate, type } = getCurrencyConfig();
                           if (type === 'USD') return null;
-                          
+
                           return (
-                            <span style={{ color: 'var(--semi-color-text-2)', fontSize: '12px', fontWeight: 'normal' }}>
+                            <span
+                              style={{
+                                color: 'var(--semi-color-text-2)',
+                                fontSize: '12px',
+                                fontWeight: 'normal',
+                              }}
+                            >
                               (1 $ = {rate.toFixed(2)} {symbol})
                             </span>
                           );
@@ -378,11 +401,11 @@ const RechargeCard = ({
                             usdRate = s?.usd_exchange_rate || 7;
                           }
                         } catch (e) {}
-                        
+
                         let displayValue = preset.value; // 显示的数量
                         let displayActualPay = actualPay;
                         let displaySave = save;
-                        
+
                         if (type === 'USD') {
                           // 数量保持USD,价格从CNY转USD
                           displayActualPay = actualPay / usdRate;
@@ -444,7 +467,8 @@ const RechargeCard = ({
                                   margin: '4px 0',
                                 }}
                               >
-                                {t('实付')} {symbol}{displayActualPay.toFixed(2)},
+                                {t('实付')} {symbol}
+                                {displayActualPay.toFixed(2)},
                                 {hasDiscount
                                   ? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
                                   : `${t('节省')} ${symbol}0.00`}

+ 20 - 0
web/src/components/topup/index.jsx

@@ -37,6 +37,7 @@ import RechargeCard from './RechargeCard';
 import InvitationCard from './InvitationCard';
 import TransferModal from './modals/TransferModal';
 import PaymentConfirmModal from './modals/PaymentConfirmModal';
+import TopupHistoryModal from './modals/TopupHistoryModal';
 
 const TopUp = () => {
   const { t } = useTranslation();
@@ -77,6 +78,9 @@ const TopUp = () => {
   const [openTransfer, setOpenTransfer] = useState(false);
   const [transferAmount, setTransferAmount] = useState(0);
 
+  // 账单Modal状态
+  const [openHistory, setOpenHistory] = useState(false);
+
   // 预设充值额度选项
   const [presetAmounts, setPresetAmounts] = useState([]);
   const [selectedPreset, setSelectedPreset] = useState(null);
@@ -488,6 +492,14 @@ const TopUp = () => {
     setOpenTransfer(false);
   };
 
+  const handleOpenHistory = () => {
+    setOpenHistory(true);
+  };
+
+  const handleHistoryCancel = () => {
+    setOpenHistory(false);
+  };
+
   // 选择预设充值额度
   const selectPresetAmount = (preset) => {
     setTopUpCount(preset.value);
@@ -544,6 +556,13 @@ const TopUp = () => {
         discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
       />
 
+      {/* 充值账单模态框 */}
+      <TopupHistoryModal
+        visible={openHistory}
+        onCancel={handleHistoryCancel}
+        t={t}
+      />
+
       {/* 用户信息头部 */}
       <div className='space-y-6'>
         <div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
@@ -580,6 +599,7 @@ const TopUp = () => {
               renderQuota={renderQuota}
               statusLoading={statusLoading}
               topupInfo={topupInfo}
+              onOpenHistory={handleOpenHistory}
             />
           </div>
 

+ 253 - 0
web/src/components/topup/modals/TopupHistoryModal.jsx

@@ -0,0 +1,253 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
+import React, { useState, useEffect, useMemo } from 'react';
+import {
+  Modal,
+  Table,
+  Badge,
+  Typography,
+  Toast,
+  Empty,
+  Button,
+} from '@douyinfe/semi-ui';
+import {
+  IllustrationNoResult,
+  IllustrationNoResultDark,
+} from '@douyinfe/semi-illustrations';
+import { Coins } from 'lucide-react';
+import { API, timestamp2string } from '../../../helpers';
+import { isAdmin } from '../../../helpers/utils';
+import { useIsMobile } from '../../../hooks/common/useIsMobile';
+
+const { Text } = Typography;
+
+// 状态映射配置
+const STATUS_CONFIG = {
+  success: { type: 'success', key: '成功' },
+  pending: { type: 'warning', key: '待支付' },
+  expired: { type: 'danger', key: '已过期' },
+};
+
+// 支付方式映射
+const PAYMENT_METHOD_MAP = {
+  stripe: 'Stripe',
+  alipay: '支付宝',
+  wxpay: '微信',
+};
+
+const TopupHistoryModal = ({ visible, onCancel, t }) => {
+  const [loading, setLoading] = useState(false);
+  const [topups, setTopups] = useState([]);
+  const [total, setTotal] = useState(0);
+  const [page, setPage] = useState(1);
+  const [pageSize, setPageSize] = useState(10);
+
+  const isMobile = useIsMobile();
+
+  const loadTopups = async (currentPage, currentPageSize) => {
+    setLoading(true);
+    try {
+      const res = await API.get(
+        `/api/user/topup/self?p=${currentPage}&page_size=${currentPageSize}`,
+      );
+      const { success, message, data } = res.data;
+      if (success) {
+        setTopups(data.items || []);
+        setTotal(data.total || 0);
+      } else {
+        Toast.error({ content: message || t('加载失败') });
+      }
+    } catch (error) {
+      console.error('Load topups error:', error);
+      Toast.error({ content: t('加载账单失败') });
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (visible) {
+      loadTopups(page, pageSize);
+    }
+  }, [visible, page, pageSize]);
+
+  const handlePageChange = (currentPage) => {
+    setPage(currentPage);
+  };
+
+  const handlePageSizeChange = (currentPageSize) => {
+    setPageSize(currentPageSize);
+    setPage(1);
+  };
+
+  // 管理员补单
+  const handleAdminComplete = async (tradeNo) => {
+    try {
+      const res = await API.post('/api/user/topup/complete', {
+        trade_no: tradeNo,
+      });
+      const { success, message } = res.data;
+      if (success) {
+        Toast.success({ content: t('补单成功') });
+        await loadTopups(page, pageSize);
+      } else {
+        Toast.error({ content: message || t('补单失败') });
+      }
+    } catch (e) {
+      Toast.error({ content: t('补单失败') });
+    }
+  };
+
+  const confirmAdminComplete = (tradeNo) => {
+    Modal.confirm({
+      title: t('确认补单'),
+      content: t('是否将该订单标记为成功并为用户入账?'),
+      onOk: () => handleAdminComplete(tradeNo),
+    });
+  };
+
+  // 渲染状态徽章
+  const renderStatusBadge = (status) => {
+    const config = STATUS_CONFIG[status] || { type: 'primary', key: status };
+    return (
+      <span className='flex items-center gap-2'>
+        <Badge dot type={config.type} />
+        <span>{t(config.key)}</span>
+      </span>
+    );
+  };
+
+  // 渲染支付方式
+  const renderPaymentMethod = (pm) => {
+    const displayName = PAYMENT_METHOD_MAP[pm];
+    return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
+  };
+
+  // 检查是否为管理员
+  const userIsAdmin = useMemo(() => isAdmin(), []);
+
+  const columns = useMemo(() => {
+    const baseColumns = [
+      {
+        title: t('订单号'),
+        dataIndex: 'trade_no',
+        key: 'trade_no',
+        render: (text) => <Text copyable>{text}</Text>,
+      },
+      {
+        title: t('支付方式'),
+        dataIndex: 'payment_method',
+        key: 'payment_method',
+        render: renderPaymentMethod,
+      },
+      {
+        title: t('充值额度'),
+        dataIndex: 'amount',
+        key: 'amount',
+        render: (amount) => (
+          <span className='flex items-center gap-1'>
+            <Coins size={16} />
+            <Text>{amount}</Text>
+          </span>
+        ),
+      },
+      {
+        title: t('支付金额'),
+        dataIndex: 'money',
+        key: 'money',
+        render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
+      },
+      {
+        title: t('状态'),
+        dataIndex: 'status',
+        key: 'status',
+        render: renderStatusBadge,
+      },
+    ];
+
+    // 管理员才显示操作列
+    if (userIsAdmin) {
+      baseColumns.push({
+        title: t('操作'),
+        key: 'action',
+        render: (_, record) => {
+          if (record.status !== 'pending') return null;
+          return (
+            <Button
+              size='small'
+              type='primary'
+              theme='outline'
+              onClick={() => confirmAdminComplete(record.trade_no)}
+            >
+              {t('补单')}
+            </Button>
+          );
+        },
+      });
+    }
+
+    baseColumns.push({
+      title: t('创建时间'),
+      dataIndex: 'create_time',
+      key: 'create_time',
+      render: (time) => timestamp2string(time),
+    });
+
+    return baseColumns;
+  }, [t, userIsAdmin]);
+
+  return (
+    <Modal
+      title={t('充值账单')}
+      visible={visible}
+      onCancel={onCancel}
+      footer={null}
+      size={isMobile ? 'full-width' : 'large'}
+    >
+      <Table
+        columns={columns}
+        dataSource={topups}
+        loading={loading}
+        rowKey='id'
+        pagination={{
+          currentPage: page,
+          pageSize: pageSize,
+          total: total,
+          showSizeChanger: true,
+          pageSizeOpts: [10, 20, 50, 100],
+          onPageChange: handlePageChange,
+          onPageSizeChange: handlePageSizeChange,
+        }}
+        size='small'
+        empty={
+          <Empty
+            image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
+            darkModeImage={
+              <IllustrationNoResultDark style={{ width: 150, height: 150 }} />
+            }
+            description={t('暂无充值记录')}
+            style={{ padding: 30 }}
+          />
+        }
+      />
+    </Modal>
+  );
+};
+
+export default TopupHistoryModal;

+ 1 - 1
web/src/constants/channel.constants.js

@@ -159,7 +159,7 @@ export const CHANNEL_OPTIONS = [
     color: 'purple',
     label: 'Vidu',
   },
-   {
+  {
     value: 53,
     color: 'blue',
     label: 'SubModel',

+ 49 - 9
web/src/helpers/passkey.js

@@ -1,3 +1,21 @@
+/*
+Copyright (C) 2025 QuantumNous
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+For commercial licensing, please contact [email protected]
+*/
 export function base64UrlToBuffer(base64url) {
   if (!base64url) return new ArrayBuffer(0);
   let padding = '='.repeat((4 - (base64url.length % 4)) % 4);
@@ -26,7 +44,11 @@ export function bufferToBase64Url(buffer) {
 }
 
 export function prepareCredentialCreationOptions(payload) {
-  const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
+  const options =
+    payload?.publicKey ||
+    payload?.PublicKey ||
+    payload?.response ||
+    payload?.Response;
   if (!options) {
     throw new Error('无法从服务端响应中解析 Passkey 注册参数');
   }
@@ -46,7 +68,10 @@ export function prepareCredentialCreationOptions(payload) {
     }));
   }
 
-  if (Array.isArray(options.attestationFormats) && options.attestationFormats.length === 0) {
+  if (
+    Array.isArray(options.attestationFormats) &&
+    options.attestationFormats.length === 0
+  ) {
     delete publicKey.attestationFormats;
   }
 
@@ -54,7 +79,11 @@ export function prepareCredentialCreationOptions(payload) {
 }
 
 export function prepareCredentialRequestOptions(payload) {
-  const options = payload?.publicKey || payload?.PublicKey || payload?.response || payload?.Response;
+  const options =
+    payload?.publicKey ||
+    payload?.PublicKey ||
+    payload?.response ||
+    payload?.Response;
   if (!options) {
     throw new Error('无法从服务端响应中解析 Passkey 登录参数');
   }
@@ -77,7 +106,10 @@ export function buildRegistrationResult(credential) {
   if (!credential) return null;
 
   const { response } = credential;
-  const transports = typeof response.getTransports === 'function' ? response.getTransports() : undefined;
+  const transports =
+    typeof response.getTransports === 'function'
+      ? response.getTransports()
+      : undefined;
 
   return {
     id: credential.id,
@@ -107,7 +139,9 @@ export function buildAssertionResult(assertion) {
       authenticatorData: bufferToBase64Url(response.authenticatorData),
       clientDataJSON: bufferToBase64Url(response.clientDataJSON),
       signature: bufferToBase64Url(response.signature),
-      userHandle: response.userHandle ? bufferToBase64Url(response.userHandle) : null,
+      userHandle: response.userHandle
+        ? bufferToBase64Url(response.userHandle)
+        : null,
     },
     clientExtensionResults: assertion.getClientExtensionResults?.() ?? {},
   };
@@ -117,15 +151,22 @@ export async function isPasskeySupported() {
   if (typeof window === 'undefined' || !window.PublicKeyCredential) {
     return false;
   }
-  if (typeof window.PublicKeyCredential.isConditionalMediationAvailable === 'function') {
+  if (
+    typeof window.PublicKeyCredential.isConditionalMediationAvailable ===
+    'function'
+  ) {
     try {
-      const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
+      const available =
+        await window.PublicKeyCredential.isConditionalMediationAvailable();
       if (available) return true;
     } catch (error) {
       // ignore
     }
   }
-  if (typeof window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === 'function') {
+  if (
+    typeof window.PublicKeyCredential
+      .isUserVerifyingPlatformAuthenticatorAvailable === 'function'
+  ) {
     try {
       return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
     } catch (error) {
@@ -134,4 +175,3 @@ export async function isPasskeySupported() {
   }
   return true;
 }
-

+ 112 - 94
web/src/helpers/render.jsx

@@ -929,10 +929,10 @@ export function renderQuotaWithAmount(amount) {
 export function getCurrencyConfig() {
   const quotaDisplayType = localStorage.getItem('quota_display_type') || 'USD';
   const statusStr = localStorage.getItem('status');
-  
+
   let symbol = '$';
   let rate = 1;
-  
+
   if (quotaDisplayType === 'CNY') {
     symbol = '¥';
     try {
@@ -950,7 +950,7 @@ export function getCurrencyConfig() {
       }
     } catch (e) {}
   }
-  
+
   return { symbol, rate, type: quotaDisplayType };
 }
 
@@ -1128,7 +1128,7 @@ export function renderModelPrice(
     user_group_ratio,
   );
   groupRatio = effectiveGroupRatio;
-  
+
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
 
@@ -1177,13 +1177,16 @@ export function renderModelPrice(
       <>
         <article>
           <p>
-            {i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
-              symbol: symbol,
-              price: (inputRatioPrice * rate).toFixed(6),
-              audioPrice: audioInputSeperatePrice
-                ? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
-                : '',
-            })}
+            {i18next.t(
+              '输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}',
+              {
+                symbol: symbol,
+                price: (inputRatioPrice * rate).toFixed(6),
+                audioPrice: audioInputSeperatePrice
+                  ? `,音频 ${symbol}${(audioInputPrice * rate).toFixed(6)} / 1M tokens`
+                  : '',
+              },
+            )}
           </p>
           <p>
             {i18next.t(
@@ -1311,27 +1314,27 @@ export function renderModelPrice(
               const extraServices = [
                 webSearch && webSearchCallCount > 0
                   ? i18next.t(
-                    ' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
-                    {
-                      count: webSearchCallCount,
-                      symbol: symbol,
-                      price: (webSearchPrice * rate).toFixed(6),
-                      ratio: groupRatio,
-                      ratioType: ratioLabel,
-                    },
-                  )
+                      ' + Web搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
+                      {
+                        count: webSearchCallCount,
+                        symbol: symbol,
+                        price: (webSearchPrice * rate).toFixed(6),
+                        ratio: groupRatio,
+                        ratioType: ratioLabel,
+                      },
+                    )
                   : '',
                 fileSearch && fileSearchCallCount > 0
                   ? i18next.t(
-                    ' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
-                    {
-                      count: fileSearchCallCount,
-                      symbol: symbol,
-                      price: (fileSearchPrice * rate).toFixed(6),
-                      ratio: groupRatio,
-                      ratioType: ratioLabel,
-                    },
-                  )
+                      ' + 文件搜索 {{count}}次 / 1K 次 * {{symbol}}{{price}} * {{ratioType}} {{ratio}}',
+                      {
+                        count: fileSearchCallCount,
+                        symbol: symbol,
+                        price: (fileSearchPrice * rate).toFixed(6),
+                        ratio: groupRatio,
+                        ratioType: ratioLabel,
+                      },
+                    )
                   : '',
                 imageGenerationCall && imageGenerationCallPrice > 0
                   ? i18next.t(
@@ -1384,7 +1387,7 @@ export function renderLogContent(
     label: ratioLabel,
     useUserGroupRatio: useUserGroupRatio,
   } = getEffectiveRatio(groupRatio, user_group_ratio);
-  
+
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
 
@@ -1484,10 +1487,10 @@ export function renderAudioModelPrice(
     user_group_ratio,
   );
   groupRatio = effectiveGroupRatio;
-  
+
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
-  
+
   // 1 ratio = $0.002 / 1K tokens
   if (modelPrice !== -1) {
     return i18next.t(
@@ -1522,10 +1525,10 @@ export function renderAudioModelPrice(
     let audioPrice =
       (audioInputTokens / 1000000) * inputRatioPrice * audioRatio * groupRatio +
       (audioCompletionTokens / 1000000) *
-      inputRatioPrice *
-      audioRatio *
-      audioCompletionRatio *
-      groupRatio;
+        inputRatioPrice *
+        audioRatio *
+        audioCompletionRatio *
+        groupRatio;
     let price = textPrice + audioPrice;
     return (
       <>
@@ -1577,7 +1580,12 @@ export function renderAudioModelPrice(
               {
                 symbol: symbol,
                 price: (inputRatioPrice * rate).toFixed(6),
-                total: (inputRatioPrice * audioRatio * audioCompletionRatio * rate).toFixed(6),
+                total: (
+                  inputRatioPrice *
+                  audioRatio *
+                  audioCompletionRatio *
+                  rate
+                ).toFixed(6),
                 audioRatio: audioRatio,
                 audioCompRatio: audioCompletionRatio,
               },
@@ -1586,29 +1594,31 @@ export function renderAudioModelPrice(
           <p>
             {cacheTokens > 0
               ? i18next.t(
-                '文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
-                {
-                  nonCacheInput: inputTokens - cacheTokens,
-                  cacheInput: cacheTokens,
-                  symbol: symbol,
-                  cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
-                  price: (inputRatioPrice * rate).toFixed(6),
-                  completion: completionTokens,
-                  compPrice: (completionRatioPrice * rate).toFixed(6),
-                  total: (textPrice * rate).toFixed(6),
-                },
-              )
+                  '文字提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
+                  {
+                    nonCacheInput: inputTokens - cacheTokens,
+                    cacheInput: cacheTokens,
+                    symbol: symbol,
+                    cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(
+                      6,
+                    ),
+                    price: (inputRatioPrice * rate).toFixed(6),
+                    completion: completionTokens,
+                    compPrice: (completionRatioPrice * rate).toFixed(6),
+                    total: (textPrice * rate).toFixed(6),
+                  },
+                )
               : i18next.t(
-                '文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
-                {
-                  input: inputTokens,
-                  symbol: symbol,
-                  price: (inputRatioPrice * rate).toFixed(6),
-                  completion: completionTokens,
-                  compPrice: (completionRatioPrice * rate).toFixed(6),
-                  total: (textPrice * rate).toFixed(6),
-                },
-              )}
+                  '文字提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 文字补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} = {{symbol}}{{total}}',
+                  {
+                    input: inputTokens,
+                    symbol: symbol,
+                    price: (inputRatioPrice * rate).toFixed(6),
+                    completion: completionTokens,
+                    compPrice: (completionRatioPrice * rate).toFixed(6),
+                    total: (textPrice * rate).toFixed(6),
+                  },
+                )}
           </p>
           <p>
             {i18next.t(
@@ -1617,9 +1627,15 @@ export function renderAudioModelPrice(
                 input: audioInputTokens,
                 completion: audioCompletionTokens,
                 symbol: symbol,
-                audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(6),
-                audioCompPrice:
-                  (audioRatio * audioCompletionRatio * inputRatioPrice * rate).toFixed(6),
+                audioInputPrice: (audioRatio * inputRatioPrice * rate).toFixed(
+                  6,
+                ),
+                audioCompPrice: (
+                  audioRatio *
+                  audioCompletionRatio *
+                  inputRatioPrice *
+                  rate
+                ).toFixed(6),
                 total: (audioPrice * rate).toFixed(6),
               },
             )}
@@ -1668,7 +1684,7 @@ export function renderClaudeModelPrice(
     user_group_ratio,
   );
   groupRatio = effectiveGroupRatio;
-  
+
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
 
@@ -1757,37 +1773,39 @@ export function renderClaudeModelPrice(
           <p>
             {cacheTokens > 0 || cacheCreationTokens > 0
               ? i18next.t(
-                '提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
-                {
-                  nonCacheInput: nonCachedTokens,
-                  cacheInput: cacheTokens,
-                  cacheRatio: cacheRatio,
-                  cacheCreationInput: cacheCreationTokens,
-                  cacheCreationRatio: cacheCreationRatio,
-                  symbol: symbol,
-                  cachePrice: (cacheRatioPrice * rate).toFixed(2),
-                  cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
-                  price: (inputRatioPrice * rate).toFixed(6),
-                  completion: completionTokens,
-                  compPrice: (completionRatioPrice * rate).toFixed(6),
-                  ratio: groupRatio,
-                  ratioType: ratioLabel,
-                  total: (price * rate).toFixed(6),
-                },
-              )
+                  '提示 {{nonCacheInput}} tokens / 1M tokens * {{symbol}}{{price}} + 缓存 {{cacheInput}} tokens / 1M tokens * {{symbol}}{{cachePrice}} + 缓存创建 {{cacheCreationInput}} tokens / 1M tokens * {{symbol}}{{cacheCreationPrice}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
+                  {
+                    nonCacheInput: nonCachedTokens,
+                    cacheInput: cacheTokens,
+                    cacheRatio: cacheRatio,
+                    cacheCreationInput: cacheCreationTokens,
+                    cacheCreationRatio: cacheCreationRatio,
+                    symbol: symbol,
+                    cachePrice: (cacheRatioPrice * rate).toFixed(2),
+                    cacheCreationPrice: (
+                      cacheCreationRatioPrice * rate
+                    ).toFixed(6),
+                    price: (inputRatioPrice * rate).toFixed(6),
+                    completion: completionTokens,
+                    compPrice: (completionRatioPrice * rate).toFixed(6),
+                    ratio: groupRatio,
+                    ratioType: ratioLabel,
+                    total: (price * rate).toFixed(6),
+                  },
+                )
               : i18next.t(
-                '提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
-                {
-                  input: inputTokens,
-                  symbol: symbol,
-                  price: (inputRatioPrice * rate).toFixed(6),
-                  completion: completionTokens,
-                  compPrice: (completionRatioPrice * rate).toFixed(6),
-                  ratio: groupRatio,
-                  ratioType: ratioLabel,
-                  total: (price * rate).toFixed(6),
-                },
-              )}
+                  '提示 {{input}} tokens / 1M tokens * {{symbol}}{{price}} + 补全 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}} * {{ratioType}} {{ratio}} = {{symbol}}{{total}}',
+                  {
+                    input: inputTokens,
+                    symbol: symbol,
+                    price: (inputRatioPrice * rate).toFixed(6),
+                    completion: completionTokens,
+                    compPrice: (completionRatioPrice * rate).toFixed(6),
+                    ratio: groupRatio,
+                    ratioType: ratioLabel,
+                    total: (price * rate).toFixed(6),
+                  },
+                )}
           </p>
           <p>{i18next.t('仅供参考,以实际扣费为准')}</p>
         </article>
@@ -1810,7 +1828,7 @@ export function renderClaudeLogContent(
     user_group_ratio,
   );
   groupRatio = effectiveGroupRatio;
-  
+
   // 获取货币配置
   const { symbol, rate } = getCurrencyConfig();
 

+ 3 - 3
web/src/helpers/secureApiCall.js

@@ -37,7 +37,7 @@ export function isVerificationRequiredError(error) {
     const verificationCodes = [
       'VERIFICATION_REQUIRED',
       'VERIFICATION_EXPIRED',
-      'VERIFICATION_INVALID'
+      'VERIFICATION_INVALID',
     ];
 
     return verificationCodes.includes(data.code);
@@ -57,6 +57,6 @@ export function extractVerificationInfo(error) {
   return {
     code: data.code,
     message: data.message || '需要安全验证',
-    required: true
+    required: true,
   };
-}
+}

+ 1 - 1
web/src/hooks/channels/useChannelsData.jsx

@@ -84,7 +84,7 @@ export const useChannelsData = () => {
   const [selectedModelKeys, setSelectedModelKeys] = useState([]);
   const [isBatchTesting, setIsBatchTesting] = useState(false);
   const [modelTablePage, setModelTablePage] = useState(1);
-const [selectedEndpointType, setSelectedEndpointType] = useState('');
+  const [selectedEndpointType, setSelectedEndpointType] = useState('');
 
   // 使用 ref 来避免闭包问题,类似旧版实现
   const shouldStopBatchTestingRef = useRef(false);

+ 125 - 97
web/src/hooks/common/useSecureVerification.jsx

@@ -31,11 +31,11 @@ import { isVerificationRequiredError } from '../../helpers/secureApiCall';
  * @param {string} options.successMessage - 成功提示消息
  * @param {boolean} options.autoReset - 验证完成后是否自动重置状态,默认为 true
  */
-export const useSecureVerification = ({ 
-  onSuccess, 
-  onError, 
+export const useSecureVerification = ({
+  onSuccess,
+  onError,
   successMessage,
-  autoReset = true 
+  autoReset = true,
 } = {}) => {
   const { t } = useTranslation();
 
@@ -43,7 +43,7 @@ export const useSecureVerification = ({
   const [verificationMethods, setVerificationMethods] = useState({
     has2FA: false,
     hasPasskey: false,
-    passkeySupported: false
+    passkeySupported: false,
   });
 
   // 模态框状态
@@ -54,12 +54,13 @@ export const useSecureVerification = ({
     method: null, // '2fa' | 'passkey'
     loading: false,
     code: '',
-    apiCall: null
+    apiCall: null,
   });
 
   // 检查可用的验证方式
   const checkVerificationMethods = useCallback(async () => {
-    const methods = await SecureVerificationService.checkAvailableVerificationMethods();
+    const methods =
+      await SecureVerificationService.checkAvailableVerificationMethods();
     setVerificationMethods(methods);
     return methods;
   }, []);
@@ -75,94 +76,108 @@ export const useSecureVerification = ({
       method: null,
       loading: false,
       code: '',
-      apiCall: null
+      apiCall: null,
     });
     setIsModalVisible(false);
   }, []);
 
   // 开始验证流程
-  const startVerification = useCallback(async (apiCall, options = {}) => {
-    const { preferredMethod, title, description } = options;
+  const startVerification = useCallback(
+    async (apiCall, options = {}) => {
+      const { preferredMethod, title, description } = options;
 
-    // 检查验证方式
-    const methods = await checkVerificationMethods();
+      // 检查验证方式
+      const methods = await checkVerificationMethods();
 
-    if (!methods.has2FA && !methods.hasPasskey) {
-      const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
-      showError(errorMessage);
-      onError?.(new Error(errorMessage));
-      return false;
-    }
+      if (!methods.has2FA && !methods.hasPasskey) {
+        const errorMessage = t('您需要先启用两步验证或 Passkey 才能执行此操作');
+        showError(errorMessage);
+        onError?.(new Error(errorMessage));
+        return false;
+      }
 
-    // 设置默认验证方式
-    let defaultMethod = preferredMethod;
-    if (!defaultMethod) {
-      if (methods.hasPasskey && methods.passkeySupported) {
-        defaultMethod = 'passkey';
-      } else if (methods.has2FA) {
-        defaultMethod = '2fa';
+      // 设置默认验证方式
+      let defaultMethod = preferredMethod;
+      if (!defaultMethod) {
+        if (methods.hasPasskey && methods.passkeySupported) {
+          defaultMethod = 'passkey';
+        } else if (methods.has2FA) {
+          defaultMethod = '2fa';
+        }
       }
-    }
 
-    setVerificationState(prev => ({
-      ...prev,
-      method: defaultMethod,
-      apiCall,
-      title,
-      description
-    }));
-    setIsModalVisible(true);
+      setVerificationState((prev) => ({
+        ...prev,
+        method: defaultMethod,
+        apiCall,
+        title,
+        description,
+      }));
+      setIsModalVisible(true);
 
-    return true;
-  }, [checkVerificationMethods, onError, t]);
+      return true;
+    },
+    [checkVerificationMethods, onError, t],
+  );
 
   // 执行验证
-  const executeVerification = useCallback(async (method, code = '') => {
-    if (!verificationState.apiCall) {
-      showError(t('验证配置错误'));
-      return;
-    }
+  const executeVerification = useCallback(
+    async (method, code = '') => {
+      if (!verificationState.apiCall) {
+        showError(t('验证配置错误'));
+        return;
+      }
 
-    setVerificationState(prev => ({ ...prev, loading: true }));
+      setVerificationState((prev) => ({ ...prev, loading: true }));
 
-    try {
-      // 先调用验证 API,成功后后端会设置 session
-      await SecureVerificationService.verify(method, code);
+      try {
+        // 先调用验证 API,成功后后端会设置 session
+        await SecureVerificationService.verify(method, code);
 
-      // 验证成功,调用业务 API(此时中间件会通过)
-      const result = await verificationState.apiCall();
+        // 验证成功,调用业务 API(此时中间件会通过)
+        const result = await verificationState.apiCall();
 
-      // 显示成功消息
-      if (successMessage) {
-        showSuccess(successMessage);
-      }
+        // 显示成功消息
+        if (successMessage) {
+          showSuccess(successMessage);
+        }
 
-      // 调用成功回调
-      onSuccess?.(result, method);
+        // 调用成功回调
+        onSuccess?.(result, method);
 
-      // 自动重置状态
-      if (autoReset) {
-        resetState();
-      }
+        // 自动重置状态
+        if (autoReset) {
+          resetState();
+        }
 
-      return result;
-    } catch (error) {
-      showError(error.message || t('验证失败,请重试'));
-      onError?.(error);
-      throw error;
-    } finally {
-      setVerificationState(prev => ({ ...prev, loading: false }));
-    }
-  }, [verificationState.apiCall, successMessage, onSuccess, onError, autoReset, resetState, t]);
+        return result;
+      } catch (error) {
+        showError(error.message || t('验证失败,请重试'));
+        onError?.(error);
+        throw error;
+      } finally {
+        setVerificationState((prev) => ({ ...prev, loading: false }));
+      }
+    },
+    [
+      verificationState.apiCall,
+      successMessage,
+      onSuccess,
+      onError,
+      autoReset,
+      resetState,
+      t,
+    ],
+  );
 
   // 设置验证码
   const setVerificationCode = useCallback((code) => {
-    setVerificationState(prev => ({ ...prev, code }));
+    setVerificationState((prev) => ({ ...prev, code }));
   }, []);
 
   // 切换验证方式
   const switchVerificationMethod = useCallback((method) => {
-    setVerificationState(prev => ({ ...prev, method, code: '' }));
+    setVerificationState((prev) => ({ ...prev, method, code: '' }));
   }, []);
 
   // 取消验证
@@ -171,20 +186,29 @@ export const useSecureVerification = ({
   }, [resetState]);
 
   // 检查是否可以使用某种验证方式
-  const canUseMethod = useCallback((method) => {
-    switch (method) {
-      case '2fa':
-        return verificationMethods.has2FA;
-      case 'passkey':
-        return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
-      default:
-        return false;
-    }
-  }, [verificationMethods]);
+  const canUseMethod = useCallback(
+    (method) => {
+      switch (method) {
+        case '2fa':
+          return verificationMethods.has2FA;
+        case 'passkey':
+          return (
+            verificationMethods.hasPasskey &&
+            verificationMethods.passkeySupported
+          );
+        default:
+          return false;
+      }
+    },
+    [verificationMethods],
+  );
 
   // 获取推荐的验证方式
   const getRecommendedMethod = useCallback(() => {
-    if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
+    if (
+      verificationMethods.hasPasskey &&
+      verificationMethods.passkeySupported
+    ) {
       return 'passkey';
     }
     if (verificationMethods.has2FA) {
@@ -200,22 +224,25 @@ export const useSecureVerification = ({
    * @param {Object} options - 验证选项(同 startVerification)
    * @returns {Promise<any>}
    */
-  const withVerification = useCallback(async (apiCall, options = {}) => {
-    try {
-      // 直接尝试调用 API
-      return await apiCall();
-    } catch (error) {
-      // 检查是否是需要验证的错误
-      if (isVerificationRequiredError(error)) {
-        // 自动触发验证流程
-        await startVerification(apiCall, options);
-        // 不抛出错误,让验证模态框处理
-        return null;
+  const withVerification = useCallback(
+    async (apiCall, options = {}) => {
+      try {
+        // 直接尝试调用 API
+        return await apiCall();
+      } catch (error) {
+        // 检查是否是需要验证的错误
+        if (isVerificationRequiredError(error)) {
+          // 自动触发验证流程
+          await startVerification(apiCall, options);
+          // 不抛出错误,让验证模态框处理
+          return null;
+        }
+        // 其他错误继续抛出
+        throw error;
       }
-      // 其他错误继续抛出
-      throw error;
-    }
-  }, [startVerification]);
+    },
+    [startVerification],
+  );
 
   return {
     // 状态
@@ -238,9 +265,10 @@ export const useSecureVerification = ({
     withVerification, // 新增:自动处理验证的包装函数
 
     // 便捷属性
-    hasAnyVerificationMethod: verificationMethods.has2FA || verificationMethods.hasPasskey,
+    hasAnyVerificationMethod:
+      verificationMethods.has2FA || verificationMethods.hasPasskey,
     isLoading: verificationState.loading,
     currentMethod: verificationState.method,
-    code: verificationState.code
+    code: verificationState.code,
   };
-};
+};

+ 1 - 1
web/src/hooks/users/useUsersData.jsx

@@ -86,7 +86,7 @@ export const useUsersData = () => {
   };
 
   // Search users with keyword and group
-const searchUsers = async (
+  const searchUsers = async (
     startIdx,
     pageSize,
     searchKeyword = null,

+ 14 - 3
web/src/i18n/locales/en.json

@@ -1285,7 +1285,6 @@
   "可视化倍率设置": "Visual model ratio settings",
   "确定重置模型倍率吗?": "Confirm to reset model ratio?",
   "模型固定价格": "Model price per call",
-  "模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
   "保存模型倍率设置": "Save model ratio settings",
   "重置模型倍率": "Reset model ratio",
   "一次调用消耗多少刀,优先级大于模型倍率": "How much USD one call costs, priority over model ratio",
@@ -2177,7 +2176,6 @@
   "最后使用时间": "Last used time",
   "备份支持": "Backup support",
   "支持备份": "Supported",
-  "不支持": "Not supported",
   "备份状态": "Backup state",
   "已备份": "Backed up",
   "未备份": "Not backed up",
@@ -2248,5 +2246,18 @@
   "轮询模式必须搭配Redis和内存缓存功能使用,否则性能将大幅降低,并且无法实现轮询功能": "Polling mode must be used with Redis and memory cache functions, otherwise the performance will be significantly reduced and the polling function will not be implemented",
   "common": {
     "changeLanguage": "Change Language"
-  }
+  },
+  "充值账单": "Recharge Bills",
+  "订单号": "Order No.",
+  "支付金额": "Payment Amount",
+  "待支付": "Pending",
+  "加载失败": "Load failed",
+  "加载账单失败": "Failed to load bills",
+  "暂无充值记录": "No recharge records",
+  "账单": "Bills",
+  "补单": "Complete Order",
+  "补单成功": "Order completed successfully",
+  "补单失败": "Failed to complete order",
+  "确认补单": "Confirm Order Completion",
+  "是否将该订单标记为成功并为用户入账?": "Mark this order as successful and credit the user?"
 }

+ 14 - 1
web/src/i18n/locales/fr.json

@@ -2238,5 +2238,18 @@
   "配置 Passkey": "Configurer Passkey",
   "重置 2FA": "Réinitialiser 2FA",
   "重置 Passkey": "Réinitialiser le Passkey",
-  "默认使用系统名称": "Le nom du système est utilisé par défaut"
+  "默认使用系统名称": "Le nom du système est utilisé par défaut",
+  "充值账单": "Factures de recharge",
+  "订单号": "N° de commande",
+  "支付金额": "Montant payé",
+  "待支付": "En attente",
+  "加载失败": "Échec du chargement",
+  "加载账单失败": "Échec du chargement des factures",
+  "暂无充值记录": "Aucune recharge",
+  "账单": "Factures",
+  "补单": "Compléter la commande",
+  "补单成功": "Commande complétée avec succès",
+  "补单失败": "Échec de la complétion de la commande",
+  "确认补单": "Confirmer la complétion",
+  "是否将该订单标记为成功并为用户入账?": "Marquer cette commande comme réussie et créditer l'utilisateur ?"
 }

+ 18 - 1
web/src/i18n/locales/zh.json

@@ -94,5 +94,22 @@
   "允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
   "确认解绑 Passkey": "确认解绑 Passkey",
   "解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
-  "确认解绑": "确认解绑"
+  "确认解绑": "确认解绑",
+  "充值账单": "充值账单",
+  "订单号": "订单号",
+  "支付金额": "支付金额",
+  "待支付": "待支付",
+  "加载失败": "加载失败",
+  "加载账单失败": "加载账单失败",
+  "暂无充值记录": "暂无充值记录",
+  "账单": "账单",
+  "支付方式": "支付方式",
+  "支付宝": "支付宝",
+  "微信": "微信",
+  "补单": "补单",
+  "补单成功": "补单成功",
+  "补单失败": "补单失败",
+  "确认补单": "确认补单",
+  "是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?",
+  "操作": "操作"
 }

+ 1 - 1
web/src/pages/Setting/Chat/SettingsChats.jsx

@@ -227,7 +227,7 @@ export default function SettingsChats(props) {
           const isDuplicate = chatConfigs.some(
             (config) =>
               config.name === values.name &&
-              (!isEdit || config.id !== editingConfig.id)
+              (!isEdit || config.id !== editingConfig.id),
           );
 
           if (isDuplicate) {

+ 54 - 19
web/src/pages/Setting/Operation/SettingsLog.jsx

@@ -18,7 +18,16 @@ For commercial licensing, please contact [email protected]
 */
 
 import React, { useEffect, useState, useRef } from 'react';
-import { Button, Col, Form, Row, Spin, DatePicker, Typography, Modal } from '@douyinfe/semi-ui';
+import {
+  Button,
+  Col,
+  Form,
+  Row,
+  Spin,
+  DatePicker,
+  Typography,
+  Modal,
+} from '@douyinfe/semi-ui';
 import dayjs from 'dayjs';
 import { useTranslation } from 'react-i18next';
 import {
@@ -90,40 +99,58 @@ export default function SettingsLog(props) {
     const targetTime = targetDate.format('YYYY-MM-DD HH:mm:ss');
     const currentTime = now.format('YYYY-MM-DD HH:mm:ss');
     const daysDiff = now.diff(targetDate, 'day');
-    
+
     Modal.confirm({
       title: t('确认清除历史日志'),
       content: (
         <div style={{ lineHeight: '1.8' }}>
           <p>
             <Text>{t('当前时间')}:</Text>
-            <Text strong style={{ color: '#52c41a' }}>{currentTime}</Text>
+            <Text strong style={{ color: '#52c41a' }}>
+              {currentTime}
+            </Text>
           </p>
           <p>
             <Text>{t('选择时间')}:</Text>
-            <Text strong type="danger">{targetTime}</Text>
+            <Text strong type='danger'>
+              {targetTime}
+            </Text>
             {daysDiff > 0 && (
-              <Text type="tertiary"> ({t('约')} {daysDiff} {t('天前')})</Text>
+              <Text type='tertiary'>
+                {' '}
+                ({t('约')} {daysDiff} {t('天前')})
+              </Text>
             )}
           </p>
-          <div style={{
-            background: '#fff7e6',
-            border: '1px solid #ffd591',
-            padding: '12px',
-            borderRadius: '4px',
-            marginTop: '12px',
-            color: '#333'
-          }}>
-            <Text strong style={{ color: '#d46b08' }}>⚠️ {t('注意')}:</Text>
+          <div
+            style={{
+              background: '#fff7e6',
+              border: '1px solid #ffd591',
+              padding: '12px',
+              borderRadius: '4px',
+              marginTop: '12px',
+              color: '#333',
+            }}
+          >
+            <Text strong style={{ color: '#d46b08' }}>
+              ⚠️ {t('注意')}:
+            </Text>
             <Text style={{ color: '#333' }}>{t('将删除')} </Text>
-            <Text strong style={{ color: '#cf1322' }}>{targetTime}</Text>
+            <Text strong style={{ color: '#cf1322' }}>
+              {targetTime}
+            </Text>
             {daysDiff > 0 && (
-              <Text style={{ color: '#8c8c8c' }}> ({t('约')} {daysDiff} {t('天前')})</Text>
+              <Text style={{ color: '#8c8c8c' }}>
+                {' '}
+                ({t('约')} {daysDiff} {t('天前')})
+              </Text>
             )}
             <Text style={{ color: '#333' }}> {t('之前的所有日志')}</Text>
           </div>
           <p style={{ marginTop: '12px' }}>
-            <Text type="danger">{t('此操作不可恢复,请仔细确认时间后再操作!')}</Text>
+            <Text type='danger'>
+              {t('此操作不可恢复,请仔细确认时间后再操作!')}
+            </Text>
           </p>
         </div>
       ),
@@ -203,10 +230,18 @@ export default function SettingsLog(props) {
                       });
                     }}
                   />
-                  <Text type="tertiary" size="small" style={{ display: 'block', marginTop: 4, marginBottom: 8 }}>
+                  <Text
+                    type='tertiary'
+                    size='small'
+                    style={{ display: 'block', marginTop: 4, marginBottom: 8 }}
+                  >
                     {t('将清除选定时间之前的所有日志')}
                   </Text>
-                  <Button size='default' type='danger' onClick={onCleanHistoryLog}>
+                  <Button
+                    size='default'
+                    type='danger'
+                    onClick={onCleanHistoryLog}
+                  >
                     {t('清除历史日志')}
                   </Button>
                 </Spin>

+ 60 - 45
web/src/services/secureVerification.js

@@ -21,7 +21,7 @@ import { API, showError } from '../helpers';
 import {
   prepareCredentialRequestOptions,
   buildAssertionResult,
-  isPasskeySupported
+  isPasskeySupported,
 } from '../helpers/passkey';
 
 /**
@@ -35,46 +35,54 @@ export class SecureVerificationService {
    */
   static async checkAvailableVerificationMethods() {
     try {
-      const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
-        API.get('/api/user/2fa/status'),
-        API.get('/api/user/passkey'),
-        isPasskeySupported()
-      ]);
+      const [twoFAResponse, passkeyResponse, passkeySupported] =
+        await Promise.all([
+          API.get('/api/user/2fa/status'),
+          API.get('/api/user/passkey'),
+          isPasskeySupported(),
+        ]);
 
       console.log('=== DEBUGGING VERIFICATION METHODS ===');
       console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2));
-      console.log('Passkey Response:', JSON.stringify(passkeyResponse, null, 2));
-      
-      const has2FA = twoFAResponse.data?.success && twoFAResponse.data?.data?.enabled === true;
-      const hasPasskey = passkeyResponse.data?.success && passkeyResponse.data?.data?.enabled === true;
-      
+      console.log(
+        'Passkey Response:',
+        JSON.stringify(passkeyResponse, null, 2),
+      );
+
+      const has2FA =
+        twoFAResponse.data?.success &&
+        twoFAResponse.data?.data?.enabled === true;
+      const hasPasskey =
+        passkeyResponse.data?.success &&
+        passkeyResponse.data?.data?.enabled === true;
+
       console.log('has2FA calculation:', {
         success: twoFAResponse.data?.success,
         dataExists: !!twoFAResponse.data?.data,
         enabled: twoFAResponse.data?.data?.enabled,
-        result: has2FA
+        result: has2FA,
       });
-      
+
       console.log('hasPasskey calculation:', {
         success: passkeyResponse.data?.success,
         dataExists: !!passkeyResponse.data?.data,
         enabled: passkeyResponse.data?.data?.enabled,
-        result: hasPasskey
+        result: hasPasskey,
       });
 
       const result = {
         has2FA,
         hasPasskey,
-        passkeySupported
+        passkeySupported,
       };
-      
+
       return result;
     } catch (error) {
       console.error('Failed to check verification methods:', error);
       return {
         has2FA: false,
         hasPasskey: false,
-        passkeySupported: false
+        passkeySupported: false,
       };
     }
   }
@@ -92,7 +100,7 @@ export class SecureVerificationService {
     // 调用通用验证 API,验证成功后后端会设置 session
     const verifyResponse = await API.post('/api/verify', {
       method: '2fa',
-      code: code.trim()
+      code: code.trim(),
     });
 
     if (!verifyResponse.data?.success) {
@@ -115,7 +123,9 @@ export class SecureVerificationService {
       }
 
       // 准备WebAuthn选项
-      const publicKey = prepareCredentialRequestOptions(beginResponse.data.data.options);
+      const publicKey = prepareCredentialRequestOptions(
+        beginResponse.data.data.options,
+      );
 
       // 执行WebAuthn验证
       const credential = await navigator.credentials.get({ publicKey });
@@ -127,14 +137,17 @@ export class SecureVerificationService {
       const assertionResult = buildAssertionResult(credential);
 
       // 完成验证
-      const finishResponse = await API.post('/api/user/passkey/verify/finish', assertionResult);
+      const finishResponse = await API.post(
+        '/api/user/passkey/verify/finish',
+        assertionResult,
+      );
       if (!finishResponse.data?.success) {
         throw new Error(finishResponse.data?.message || '验证失败');
       }
 
       // 调用通用验证 API 设置 session(Passkey 验证已完成)
       const verifyResponse = await API.post('/api/verify', {
-        method: 'passkey'
+        method: 'passkey',
       });
 
       if (!verifyResponse.data?.success) {
@@ -191,27 +204,29 @@ export const createApiCalls = {
    * @param {string} method - HTTP方法,默认为 'POST'
    * @param {Object} extraData - 额外的请求数据
    */
-  custom: (url, method = 'POST', extraData = {}) => async () => {
-    // 新系统中,验证已通过中间件处理
-    const data = extraData;
-
-    let response;
-    switch (method.toUpperCase()) {
-      case 'GET':
-        response = await API.get(url, { params: data });
-        break;
-      case 'POST':
-        response = await API.post(url, data);
-        break;
-      case 'PUT':
-        response = await API.put(url, data);
-        break;
-      case 'DELETE':
-        response = await API.delete(url, { data });
-        break;
-      default:
-        throw new Error(`不支持的HTTP方法: ${method}`);
-    }
-    return response.data;
-  }
-};
+  custom:
+    (url, method = 'POST', extraData = {}) =>
+    async () => {
+      // 新系统中,验证已通过中间件处理
+      const data = extraData;
+
+      let response;
+      switch (method.toUpperCase()) {
+        case 'GET':
+          response = await API.get(url, { params: data });
+          break;
+        case 'POST':
+          response = await API.post(url, data);
+          break;
+        case 'PUT':
+          response = await API.put(url, data);
+          break;
+        case 'DELETE':
+          response = await API.delete(url, { data });
+          break;
+        default:
+          throw new Error(`不支持的HTTP方法: ${method}`);
+      }
+      return response.data;
+    },
+};

Некоторые файлы не были показаны из-за большого количества измененных файлов