Merge pull request #1976 from QuantumNous/feature/invoicing

 feat: Add topup billing history with admin manual completion
This commit is contained in:
Seefs
2025-10-07 12:08:33 +08:00
committed by GitHub
34 changed files with 2502 additions and 1571 deletions

View File

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

View File

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

View File

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

View File

@@ -73,6 +73,7 @@ func SetApiRouter(router *gin.Engine) {
selfRoute.DELETE("/passkey", controller.PasskeyDelete)
selfRoute.GET("/aff", controller.GetAffCode)
selfRoute.GET("/topup/info", controller.GetTopUpInfo)
selfRoute.GET("/topup/self", controller.GetUserTopUps)
selfRoute.POST("/topup", middleware.CriticalRateLimit(), controller.TopUp)
selfRoute.POST("/pay", middleware.CriticalRateLimit(), controller.RequestEpay)
selfRoute.POST("/amount", controller.RequestAmount)
@@ -93,6 +94,8 @@ func SetApiRouter(router *gin.Engine) {
adminRoute.Use(middleware.AdminAuth())
{
adminRoute.GET("/", controller.GetAllUsers)
adminRoute.GET("/topup", controller.GetAllTopUps)
adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp)
adminRoute.GET("/search", controller.SearchUsers)
adminRoute.GET("/:id", controller.GetUser)
adminRoute.POST("/", controller.CreateUser)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,268 @@
/*
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,
Input,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { Coins } from 'lucide-react';
import { IconSearch } from '@douyinfe/semi-icons';
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 [keyword, setKeyword] = useState('');
const isMobile = useIsMobile();
const loadTopups = async (currentPage, currentPageSize) => {
setLoading(true);
try {
const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self';
const qs =
`p=${currentPage}&page_size=${currentPageSize}` +
(keyword ? `&keyword=${encodeURIComponent(keyword)}` : '');
const endpoint = `${base}?${qs}`;
const res = await API.get(endpoint);
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, keyword]);
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'}
>
<div className='mb-3'>
<Input
prefix={<IconSearch />}
placeholder={t('订单号')}
value={keyword}
onChange={setKeyword}
showClear
/>
</div>
<Table
columns={columns}
dataSource={topups}
loading={loading}
rowKey='id'
pagination={{
currentPage: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
onPageSizeChange: handlePageSizeChange,
}}
size='small'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无充值记录')}
style={{ padding: 30 }}
/>
}
/>
</Modal>
);
};
export default TopupHistoryModal;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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