mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-04-19 07:27:28 +00:00
✨ 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:
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
124
model/topup.go
124
model/topup.go
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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('允许不安全的 Origin(HTTP)')}
|
{t('允许不安全的 Origin(HTTP)')}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('更多信息请参考')}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
253
web/src/components/topup/modals/TopupHistoryModal.jsx
Normal file
253
web/src/components/topup/modals/TopupHistoryModal.jsx
Normal 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;
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,5 +94,22 @@
|
|||||||
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
|
"允许通过 Passkey 登录 & 认证": "允许通过 Passkey 登录 & 认证",
|
||||||
"确认解绑 Passkey": "确认解绑 Passkey",
|
"确认解绑 Passkey": "确认解绑 Passkey",
|
||||||
"解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
|
"解绑后将无法使用 Passkey 登录,确定要继续吗?": "解绑后将无法使用 Passkey 登录,确定要继续吗?",
|
||||||
"确认解绑": "确认解绑"
|
"确认解绑": "确认解绑",
|
||||||
|
"充值账单": "充值账单",
|
||||||
|
"订单号": "订单号",
|
||||||
|
"支付金额": "支付金额",
|
||||||
|
"待支付": "待支付",
|
||||||
|
"加载失败": "加载失败",
|
||||||
|
"加载账单失败": "加载账单失败",
|
||||||
|
"暂无充值记录": "暂无充值记录",
|
||||||
|
"账单": "账单",
|
||||||
|
"支付方式": "支付方式",
|
||||||
|
"支付宝": "支付宝",
|
||||||
|
"微信": "微信",
|
||||||
|
"补单": "补单",
|
||||||
|
"补单成功": "补单成功",
|
||||||
|
"补单失败": "补单失败",
|
||||||
|
"确认补单": "确认补单",
|
||||||
|
"是否将该订单标记为成功并为用户入账?": "是否将该订单标记为成功并为用户入账?",
|
||||||
|
"操作": "操作"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 设置 session(Passkey 验证已完成)
|
// 调用通用验证 API 设置 session(Passkey 验证已完成)
|
||||||
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;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user