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
This commit is contained in:
Apple\Apple
2025-10-07 00:22:45 +08:00
parent 2397ec8075
commit 6ef95c97cc
34 changed files with 2354 additions and 1571 deletions

View File

@@ -183,12 +183,13 @@ func RequestEpay(c *gin.Context) {
amount = dAmount.Div(dQuotaPerUnit).IntPart() amount = dAmount.Div(dQuotaPerUnit).IntPart()
} }
topUp := &model.TopUp{ topUp := &model.TopUp{
UserId: id, UserId: id,
Amount: amount, Amount: amount,
Money: payMoney, Money: payMoney,
TradeNo: tradeNo, TradeNo: tradeNo,
CreateTime: time.Now().Unix(), PaymentMethod: req.PaymentMethod,
Status: "pending", CreateTime: time.Now().Unix(),
Status: "pending",
} }
err = topUp.Insert() err = topUp.Insert()
if err != nil { 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)}) 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)
}

View File

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

View File

@@ -6,18 +6,20 @@ import (
"one-api/common" "one-api/common"
"one-api/logger" "one-api/logger"
"github.com/shopspring/decimal"
"gorm.io/gorm" "gorm.io/gorm"
) )
type TopUp struct { type TopUp struct {
Id int `json:"id"` Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"` UserId int `json:"user_id" gorm:"index"`
Amount int64 `json:"amount"` Amount int64 `json:"amount"`
Money float64 `json:"money"` Money float64 `json:"money"`
TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"` TradeNo string `json:"trade_no" gorm:"unique;type:varchar(255);index"`
CreateTime int64 `json:"create_time"` PaymentMethod string `json:"payment_method" gorm:"type:varchar(50)"`
CompleteTime int64 `json:"complete_time"` CreateTime int64 `json:"create_time"`
Status string `json:"status"` CompleteTime int64 `json:"complete_time"`
Status string `json:"status"`
} }
func (topUp *TopUp) Insert() error { func (topUp *TopUp) Insert() error {
@@ -99,3 +101,109 @@ func Recharge(referenceId string, customerId string) (err error) {
return nil 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
}

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,16 @@ For commercial licensing, please contact support@quantumnous.com
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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('安全验证')} title={title || t('安全验证')}
visible={visible} visible={visible}
onCancel={onCancel} onCancel={onCancel}
footer={ footer={<Button onClick={onCancel}>{t('确定')}</Button>}
<Button onClick={onCancel}>{t('确定')}</Button>
}
width={500} width={500}
style={{ maxWidth: '90vw' }} style={{ maxWidth: '90vw' }}
> >
@@ -123,21 +130,21 @@ const SecureVerificationModal = ({
width={460} width={460}
centered centered
style={{ style={{
maxWidth: 'calc(100vw - 32px)' maxWidth: 'calc(100vw - 32px)',
}} }}
bodyStyle={{ bodyStyle={{
padding: '20px 24px' padding: '20px 24px',
}} }}
> >
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
{/* 描述信息 */} {/* 描述信息 */}
{description && ( {description && (
<Typography.Paragraph <Typography.Paragraph
type="tertiary" type='tertiary'
style={{ style={{
margin: '0 0 20px 0', margin: '0 0 20px 0',
fontSize: '14px', fontSize: '14px',
lineHeight: '1.6' lineHeight: '1.6',
}} }}
> >
{description} {description}
@@ -153,10 +160,7 @@ const SecureVerificationModal = ({
style={{ margin: 0 }} style={{ margin: 0 }}
> >
{has2FA && ( {has2FA && (
<TabPane <TabPane tab={t('两步验证')} itemKey='2fa'>
tab={t('两步验证')}
itemKey='2fa'
>
<div style={{ paddingTop: '20px' }}> <div style={{ paddingTop: '20px' }}>
<div style={{ marginBottom: '12px' }}> <div style={{ marginBottom: '12px' }}>
<Input <Input
@@ -169,8 +173,21 @@ const SecureVerificationModal = ({
autoFocus={method === '2fa'} autoFocus={method === '2fa'}
disabled={loading} disabled={loading}
prefix={ prefix={
<svg style={{ width: 16, height: 16, marginRight: 8, flexShrink: 0 }} fill='currentColor' viewBox='0 0 20 20'> <svg
<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' /> 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> </svg>
} }
style={{ width: '100%' }} style={{ width: '100%' }}
@@ -178,24 +195,26 @@ const SecureVerificationModal = ({
</div> </div>
<Typography.Text <Typography.Text
type="tertiary" type='tertiary'
size="small" size='small'
style={{ style={{
display: 'block', display: 'block',
marginBottom: '20px', marginBottom: '20px',
fontSize: '13px', fontSize: '13px',
lineHeight: '1.5' lineHeight: '1.5',
}} }}
> >
{t('从认证器应用中获取验证码,或使用备用码')} {t('从认证器应用中获取验证码,或使用备用码')}
</Typography.Text> </Typography.Text>
<div style={{ <div
display: 'flex', style={{
justifyContent: 'flex-end', display: 'flex',
gap: '8px', justifyContent: 'flex-end',
flexWrap: 'wrap' gap: '8px',
}}> flexWrap: 'wrap',
}}
>
<Button onClick={onCancel} disabled={loading}> <Button onClick={onCancel} disabled={loading}>
{t('取消')} {t('取消')}
</Button> </Button>
@@ -214,31 +233,47 @@ const SecureVerificationModal = ({
)} )}
{hasPasskey && passkeySupported && ( {hasPasskey && passkeySupported && (
<TabPane <TabPane tab={t('Passkey')} itemKey='passkey'>
tab={t('Passkey')}
itemKey='passkey'
>
<div style={{ paddingTop: '20px' }}> <div style={{ paddingTop: '20px' }}>
<div style={{ <div
textAlign: 'center', style={{
padding: '24px 16px', textAlign: 'center',
marginBottom: '20px' padding: '24px 16px',
}}> marginBottom: '20px',
<div style={{ }}
width: 56, >
height: 56, <div
margin: '0 auto 16px', style={{
display: 'flex', width: 56,
alignItems: 'center', height: 56,
justifyContent: 'center', margin: '0 auto 16px',
borderRadius: '50%', display: 'flex',
background: 'var(--semi-color-primary-light-default)', alignItems: 'center',
}}> justifyContent: 'center',
<svg style={{ width: 28, height: 28, color: 'var(--semi-color-primary)' }} fill='currentColor' viewBox='0 0 20 20'> borderRadius: '50%',
<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' /> 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> </svg>
</div> </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 验证')} {t('使用 Passkey 验证')}
</Typography.Title> </Typography.Title>
<Typography.Text <Typography.Text
@@ -247,19 +282,21 @@ const SecureVerificationModal = ({
display: 'block', display: 'block',
margin: 0, margin: 0,
fontSize: '13px', fontSize: '13px',
lineHeight: '1.5' lineHeight: '1.5',
}} }}
> >
{t('点击验证按钮,使用您的生物特征或安全密钥')} {t('点击验证按钮,使用您的生物特征或安全密钥')}
</Typography.Text> </Typography.Text>
</div> </div>
<div style={{ <div
display: 'flex', style={{
justifyContent: 'flex-end', display: 'flex',
gap: '8px', justifyContent: 'flex-end',
flexWrap: 'wrap' gap: '8px',
}}> flexWrap: 'wrap',
}}
>
<Button onClick={onCancel} disabled={loading}> <Button onClick={onCancel} disabled={loading}>
{t('取消')} {t('取消')}
</Button> </Button>
@@ -282,4 +319,4 @@ const SecureVerificationModal = ({
); );
}; };
export default SecureVerificationModal; export default SecureVerificationModal;

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -119,8 +119,19 @@ const EditTagModal = (props) => {
localModels = ['suno_music', 'suno_lyrics']; localModels = ['suno_music', 'suno_lyrics'];
break; break;
case 53: 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']; localModels = [
break; '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: default:
localModels = getChannelModels(value); localModels = getChannelModels(value);
break; break;

View File

@@ -67,9 +67,15 @@ const ModelTestModal = ({
{ value: 'openai', label: 'OpenAI (/v1/chat/completions)' }, { value: 'openai', label: 'OpenAI (/v1/chat/completions)' },
{ value: 'openai-response', label: 'OpenAI Response (/v1/responses)' }, { value: 'openai-response', label: 'OpenAI Response (/v1/responses)' },
{ value: 'anthropic', label: 'Anthropic (/v1/messages)' }, { 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: '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)' }, { value: 'embeddings', label: 'Embeddings (/v1/embeddings)' },
]; ];
@@ -166,7 +172,13 @@ const ModelTestModal = ({
return ( return (
<Button <Button
type='tertiary' type='tertiary'
onClick={() => testChannel(currentTestChannel, record.model, selectedEndpointType)} onClick={() =>
testChannel(
currentTestChannel,
record.model,
selectedEndpointType,
)
}
loading={isTesting} loading={isTesting}
size='small' size='small'
> >

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 support@quantumnous.com
*/
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1285,7 +1285,6 @@
"可视化倍率设置": "Visual model ratio settings", "可视化倍率设置": "Visual model ratio settings",
"确定重置模型倍率吗?": "Confirm to reset model ratio?", "确定重置模型倍率吗?": "Confirm to reset model ratio?",
"模型固定价格": "Model price per call", "模型固定价格": "Model price per call",
"模型补全倍率(仅对自定义模型有效)": "Model completion ratio (only effective for custom models)",
"保存模型倍率设置": "Save model ratio settings", "保存模型倍率设置": "Save model ratio settings",
"重置模型倍率": "Reset model ratio", "重置模型倍率": "Reset model ratio",
"一次调用消耗多少刀,优先级大于模型倍率": "How much USD one call costs, priority over model ratio", "一次调用消耗多少刀,优先级大于模型倍率": "How much USD one call costs, priority over model ratio",
@@ -2177,7 +2176,6 @@
"最后使用时间": "Last used time", "最后使用时间": "Last used time",
"备份支持": "Backup support", "备份支持": "Backup support",
"支持备份": "Supported", "支持备份": "Supported",
"不支持": "Not supported",
"备份状态": "Backup state", "备份状态": "Backup state",
"已备份": "Backed up", "已备份": "Backed up",
"未备份": "Not 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", "轮询模式必须搭配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": { "common": {
"changeLanguage": "Change Language" "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?"
} }

View File

@@ -2238,5 +2238,18 @@
"配置 Passkey": "Configurer Passkey", "配置 Passkey": "Configurer Passkey",
"重置 2FA": "Réinitialiser 2FA", "重置 2FA": "Réinitialiser 2FA",
"重置 Passkey": "Réinitialiser le Passkey", "重置 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 ?"
} }

View File

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

View File

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

View File

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

View File

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