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

@@ -187,6 +187,7 @@ func RequestEpay(c *gin.Context) {
Amount: amount,
Money: payMoney,
TradeNo: tradeNo,
PaymentMethod: req.PaymentMethod,
CreateTime: time.Now().Unix(),
Status: "pending",
}
@@ -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

@@ -87,6 +87,7 @@ func (*StripeAdaptor) RequestPay(c *gin.Context, req *StripePayRequest) {
Amount: req.Amount,
Money: chargedMoney,
TradeNo: referenceId,
PaymentMethod: PaymentMethodStripe,
CreateTime: time.Now().Unix(),
Status: common.TopUpStatusPending,
}

View File

@@ -6,6 +6,7 @@ import (
"one-api/common"
"one-api/logger"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
@@ -15,6 +16,7 @@ type TopUp struct {
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"`
@@ -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

@@ -69,11 +69,7 @@ const ChannelKeyViewExample = ({ channelId }) => {
return (
<>
{/* 查看密钥按钮 */}
<Button
type='primary'
theme='outline'
onClick={handleViewKey}
>
<Button type='primary' theme='outline' onClick={handleViewKey}>
{t('查看密钥')}
</Button>

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={{
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
flexWrap: 'wrap',
}}
>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>
@@ -214,17 +233,17 @@ const SecureVerificationModal = ({
)}
{hasPasskey && passkeySupported && (
<TabPane
tab={t('Passkey')}
itemKey='passkey'
>
<TabPane tab={t('Passkey')} itemKey='passkey'>
<div style={{ paddingTop: '20px' }}>
<div style={{
<div
style={{
textAlign: 'center',
padding: '24px 16px',
marginBottom: '20px'
}}>
<div style={{
marginBottom: '20px',
}}
>
<div
style={{
width: 56,
height: 56,
margin: '0 auto 16px',
@@ -233,12 +252,28 @@ const SecureVerificationModal = ({
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
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={{
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '8px',
flexWrap: 'wrap'
}}>
flexWrap: 'wrap',
}}
>
<Button onClick={onCancel} disabled={loading}>
{t('取消')}
</Button>

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,7 +26,8 @@ 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 (
<>

View File

@@ -206,7 +206,13 @@ const EditChannelModal = (props) => {
channelExtraSettings: null,
});
const [currentSectionIndex, setCurrentSectionIndex] = useState(0);
const formSections = ['basicInfo', 'apiConfig', 'modelConfig', 'advancedSettings', 'channelExtraSettings'];
const formSections = [
'basicInfo',
'apiConfig',
'modelConfig',
'advancedSettings',
'channelExtraSettings',
];
const formContainerRef = useRef(null);
// 2FA状态更新辅助函数
@@ -266,13 +272,13 @@ const EditChannelModal = (props) => {
sectionElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
inline: 'nearest',
});
}
};
const navigateToSection = (direction) => {
const availableSections = formSections.filter(section => {
const availableSections = formSections.filter((section) => {
if (section === 'apiConfig') {
return showApiConfigCard;
}
@@ -281,9 +287,15 @@ const EditChannelModal = (props) => {
let newIndex;
if (direction === 'up') {
newIndex = currentSectionIndex > 0 ? currentSectionIndex - 1 : availableSections.length - 1;
newIndex =
currentSectionIndex > 0
? currentSectionIndex - 1
: availableSections.length - 1;
} else {
newIndex = currentSectionIndex < availableSections.length - 1 ? currentSectionIndex + 1 : 0;
newIndex =
currentSectionIndex < availableSections.length - 1
? currentSectionIndex + 1
: 0;
}
setCurrentSectionIndex(newIndex);
@@ -509,7 +521,8 @@ const EditChannelModal = (props) => {
// 读取字段透传控制设置
data.allow_service_tier = parsedSettings.allow_service_tier || false;
data.disable_store = parsedSettings.disable_store || false;
data.allow_safety_identifier = parsedSettings.allow_safety_identifier || false;
data.allow_safety_identifier =
parsedSettings.allow_safety_identifier || false;
} catch (error) {
console.error('解析其他设置失败:', error);
data.azure_responses_version = '';
@@ -686,7 +699,7 @@ const EditChannelModal = (props) => {
title: t('查看渠道密钥'),
description: t('为了保护账户安全,请验证您的身份。'),
preferredMethod: 'passkey', // 优先使用 Passkey
}
},
);
// 如果直接返回了结果(已验证),显示密钥
@@ -990,7 +1003,8 @@ const EditChannelModal = (props) => {
// 仅 OpenAI 渠道需要 store 和 safety_identifier
if (localInputs.type === 1) {
settings.disable_store = localInputs.disable_store === true;
settings.allow_safety_identifier = localInputs.allow_safety_identifier === true;
settings.allow_safety_identifier =
localInputs.allow_safety_identifier === true;
}
}
@@ -1339,7 +1353,7 @@ const EditChannelModal = (props) => {
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
}}
title={t('上一个表单块')}
/>
@@ -1355,7 +1369,7 @@ const EditChannelModal = (props) => {
padding: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
}}
title={t('下一个表单块')}
/>
@@ -1390,11 +1404,8 @@ const EditChannelModal = (props) => {
>
{() => (
<Spin spinning={loading}>
<div
className='p-2'
ref={formContainerRef}
>
<div ref={el => formSectionRefs.current.basicInfo = el}>
<div className='p-2' ref={formContainerRef}>
<div ref={(el) => (formSectionRefs.current.basicInfo = el)}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Basic Info */}
<div className='flex items-center mb-2'>
@@ -1512,7 +1523,12 @@ const EditChannelModal = (props) => {
rules={
isEdit
? []
: [{ required: true, message: t('请上传密钥文件') }]
: [
{
required: true,
message: t('请上传密钥文件'),
},
]
}
extraText={batchExtra}
/>
@@ -1585,7 +1601,9 @@ const EditChannelModal = (props) => {
</Button>
<Button
size='small'
type={useManualInput ? 'primary' : 'tertiary'}
type={
useManualInput ? 'primary' : 'tertiary'
}
onClick={() => {
setUseManualInput(true);
// 切换到手动输入模式时清空文件上传相关状态
@@ -1624,7 +1642,9 @@ const EditChannelModal = (props) => {
field='key'
label={
isEdit
? t('密钥(编辑模式下,保存的密钥不会显示)')
? t(
'密钥(编辑模式下,保存的密钥不会显示)',
)
: t('密钥')
}
placeholder={t(
@@ -1717,7 +1737,9 @@ const EditChannelModal = (props) => {
: [{ required: true, message: t('请输入密钥') }]
}
autoComplete='new-password'
onChange={(value) => handleInputChange('key', value)}
onChange={(value) =>
handleInputChange('key', value)
}
extraText={
<div className='flex items-center gap-2'>
{isEdit &&
@@ -1820,7 +1842,9 @@ const EditChannelModal = (props) => {
)}
value={inputs.other || ''}
onChange={(value) => handleInputChange('other', value)}
rules={[{ required: true, message: t('请填写部署地区') }]}
rules={[
{ required: true, message: t('请填写部署地区') },
]}
template={REGION_EXAMPLE}
templateLabel={t('填入模板')}
editorType='region'
@@ -1878,7 +1902,7 @@ const EditChannelModal = (props) => {
{/* API Configuration Card */}
{showApiConfigCard && (
<div ref={el => formSectionRefs.current.apiConfig = el}>
<div ref={(el) => (formSectionRefs.current.apiConfig = el)}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: API Config */}
<div className='flex items-center mb-2'>
@@ -2084,8 +2108,10 @@ const EditChannelModal = (props) => {
label: 'https://ark.cn-beijing.volces.com',
},
{
value: 'https://ark.ap-southeast.bytepluses.com',
label: 'https://ark.ap-southeast.bytepluses.com',
value:
'https://ark.ap-southeast.bytepluses.com',
label:
'https://ark.ap-southeast.bytepluses.com',
},
]}
defaultValue='https://ark.cn-beijing.volces.com'
@@ -2097,7 +2123,7 @@ const EditChannelModal = (props) => {
)}
{/* Model Configuration Card */}
<div ref={el => formSectionRefs.current.modelConfig = el}>
<div ref={(el) => (formSectionRefs.current.modelConfig = el)}>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Model Config */}
<div className='flex items-center mb-2'>
@@ -2223,7 +2249,9 @@ const EditChannelModal = (props) => {
try {
if (Array.isArray(group.items)) {
items = group.items;
} else if (typeof group.items === 'string') {
} else if (
typeof group.items === 'string'
) {
const parsed = JSON.parse(
group.items || '[]',
);
@@ -2272,7 +2300,9 @@ const EditChannelModal = (props) => {
field='test_model'
label={t('默认测试模型')}
placeholder={t('不填则为模型列表第一个')}
onChange={(value) => handleInputChange('test_model', value)}
onChange={(value) =>
handleInputChange('test_model', value)
}
showClear
/>
@@ -2283,7 +2313,8 @@ const EditChannelModal = (props) => {
placeholder={
t(
'此项可选,用于修改请求体中的模型名称,为一个 JSON 字符串,键为请求中模型名称,值为要替换的模型名称,例如:',
) + `\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`
) +
`\n${JSON.stringify(MODEL_MAPPING_EXAMPLE, null, 2)}`
}
value={inputs.model_mapping || ''}
onChange={(value) =>
@@ -2293,13 +2324,17 @@ const EditChannelModal = (props) => {
templateLabel={t('填入模板')}
editorType='keyValue'
formApi={formApiRef.current}
extraText={t('键为请求中的模型名称,值为要替换的模型名称')}
extraText={t(
'键为请求中的模型名称,值为要替换的模型名称',
)}
/>
</Card>
</div>
{/* Advanced Settings Card */}
<div ref={el => formSectionRefs.current.advancedSettings = el}>
<div
ref={(el) => (formSectionRefs.current.advancedSettings = el)}
>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Advanced Settings */}
<div className='flex items-center mb-2'>
@@ -2393,7 +2428,9 @@ const EditChannelModal = (props) => {
field='param_override'
label={t('参数覆盖')}
placeholder={
t('此项可选,用于覆盖请求参数。不支持覆盖 stream 参数') +
t(
'此项可选,用于覆盖请求参数。不支持覆盖 stream 参数',
) +
'\n' +
t('旧格式(直接覆盖):') +
'\n{\n "temperature": 0,\n "max_tokens": 1000\n}' +
@@ -2468,7 +2505,6 @@ const EditChannelModal = (props) => {
handleInputChange('header_override', value)
}
extraText={
<div className='flex flex-col gap-1'>
<div className='flex gap-2 flex-wrap items-center'>
<Text
@@ -2480,7 +2516,7 @@ const EditChannelModal = (props) => {
{
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 Edg/139.0.0.0',
'Authorization': 'Bearer{api_key}',
Authorization: 'Bearer{api_key}',
},
null,
2,
@@ -2496,7 +2532,9 @@ const EditChannelModal = (props) => {
{t('支持变量:')}
</Text>
<div className='text-xs text-tertiary ml-2'>
<div>{t('渠道密钥')}: {'{api_key}'}</div>
<div>
{t('渠道密钥')}: {'{api_key}'}
</div>
</div>
</div>
</div>
@@ -2541,7 +2579,10 @@ const EditChannelModal = (props) => {
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
handleChannelOtherSettingsChange(
'allow_service_tier',
value,
)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
@@ -2554,7 +2595,10 @@ const EditChannelModal = (props) => {
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('disable_store', value)
handleChannelOtherSettingsChange(
'disable_store',
value,
)
}
extraText={t(
'store 字段用于授权 OpenAI 存储请求数据以评估和优化产品。默认关闭,开启后可能导致 Codex 无法正常使用',
@@ -2567,7 +2611,10 @@ const EditChannelModal = (props) => {
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_safety_identifier', value)
handleChannelOtherSettingsChange(
'allow_safety_identifier',
value,
)
}
extraText={t(
'safety_identifier 字段用于帮助 OpenAI 识别可能违反使用政策的应用程序用户。默认关闭以保护用户隐私',
@@ -2577,7 +2624,7 @@ const EditChannelModal = (props) => {
)}
{/* 字段透传控制 - Claude 渠道 */}
{(inputs.type === 14) && (
{inputs.type === 14 && (
<>
<div className='mt-4 mb-2 text-sm font-medium text-gray-700'>
{t('字段透传控制')}
@@ -2589,7 +2636,10 @@ const EditChannelModal = (props) => {
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelOtherSettingsChange('allow_service_tier', value)
handleChannelOtherSettingsChange(
'allow_service_tier',
value,
)
}
extraText={t(
'service_tier 字段用于指定服务层级,允许透传可能导致实际计费高于预期。默认关闭以避免额外费用',
@@ -2601,7 +2651,11 @@ const EditChannelModal = (props) => {
</div>
{/* Channel Extra Settings Card */}
<div ref={el => formSectionRefs.current.channelExtraSettings = el}>
<div
ref={(el) =>
(formSectionRefs.current.channelExtraSettings = el)
}
>
<Card className='!rounded-2xl shadow-sm border-0 mb-6'>
{/* Header: Channel Extra Settings */}
<div className='flex items-center mb-2'>
@@ -2640,7 +2694,10 @@ const EditChannelModal = (props) => {
checkedText={t('开')}
uncheckedText={t('关')}
onChange={(value) =>
handleChannelSettingsChange('thinking_to_content', value)
handleChannelSettingsChange(
'thinking_to_content',
value,
)
}
extraText={t(
'将 reasoning_content 转换为 <think> 标签拼接到内容中',

View File

@@ -119,7 +119,18 @@ 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'];
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);

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,7 +87,8 @@ const RechargeCard = ({
return (
<Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */}
<div className='flex items-center mb-4'>
<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>
@@ -90,6 +99,14 @@ const RechargeCard = ({
<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%' }}>
{/* 统计数据 */}
@@ -348,7 +365,13 @@ const RechargeCard = ({
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>
);
@@ -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

@@ -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

@@ -1177,13 +1177,16 @@ export function renderModelPrice(
<>
<article>
<p>
{i18next.t('输入价格:{{symbol}}{{price}} / 1M tokens{{audioPrice}}', {
{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(
@@ -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,
},
@@ -1591,7 +1599,9 @@ export function renderAudioModelPrice(
nonCacheInput: inputTokens - cacheTokens,
cacheInput: cacheTokens,
symbol: symbol,
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(6),
cachePrice: (inputRatioPrice * cacheRatio * rate).toFixed(
6,
),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),
@@ -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),
},
)}
@@ -1766,7 +1782,9 @@ export function renderClaudeModelPrice(
cacheCreationRatio: cacheCreationRatio,
symbol: symbol,
cachePrice: (cacheRatioPrice * rate).toFixed(2),
cacheCreationPrice: (cacheCreationRatioPrice * rate).toFixed(6),
cacheCreationPrice: (
cacheCreationRatioPrice * rate
).toFixed(6),
price: (inputRatioPrice * rate).toFixed(6),
completion: completionTokens,
compPrice: (completionRatioPrice * rate).toFixed(6),

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

@@ -35,7 +35,7 @@ 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,13 +76,14 @@ export const useSecureVerification = ({
method: null,
loading: false,
code: '',
apiCall: null
apiCall: null,
});
setIsModalVisible(false);
}, []);
// 开始验证流程
const startVerification = useCallback(async (apiCall, options = {}) => {
const startVerification = useCallback(
async (apiCall, options = {}) => {
const { preferredMethod, title, description } = options;
// 检查验证方式
@@ -104,26 +106,29 @@ export const useSecureVerification = ({
}
}
setVerificationState(prev => ({
setVerificationState((prev) => ({
...prev,
method: defaultMethod,
apiCall,
title,
description
description,
}));
setIsModalVisible(true);
return true;
}, [checkVerificationMethods, onError, t]);
},
[checkVerificationMethods, onError, t],
);
// 执行验证
const executeVerification = useCallback(async (method, code = '') => {
const executeVerification = useCallback(
async (method, code = '') => {
if (!verificationState.apiCall) {
showError(t('验证配置错误'));
return;
}
setVerificationState(prev => ({ ...prev, loading: true }));
setVerificationState((prev) => ({ ...prev, loading: true }));
try {
// 先调用验证 API成功后后端会设置 session
@@ -151,18 +156,28 @@ export const useSecureVerification = ({
onError?.(error);
throw error;
} finally {
setVerificationState(prev => ({ ...prev, loading: false }));
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) => {
const canUseMethod = useCallback(
(method) => {
switch (method) {
case '2fa':
return verificationMethods.has2FA;
case 'passkey':
return verificationMethods.hasPasskey && verificationMethods.passkeySupported;
return (
verificationMethods.hasPasskey &&
verificationMethods.passkeySupported
);
default:
return false;
}
}, [verificationMethods]);
},
[verificationMethods],
);
// 获取推荐的验证方式
const getRecommendedMethod = useCallback(() => {
if (verificationMethods.hasPasskey && verificationMethods.passkeySupported) {
if (
verificationMethods.hasPasskey &&
verificationMethods.passkeySupported
) {
return 'passkey';
}
if (verificationMethods.has2FA) {
@@ -200,7 +224,8 @@ export const useSecureVerification = ({
* @param {Object} options - 验证选项(同 startVerification
* @returns {Promise<any>}
*/
const withVerification = useCallback(async (apiCall, options = {}) => {
const withVerification = useCallback(
async (apiCall, options = {}) => {
try {
// 直接尝试调用 API
return await apiCall();
@@ -215,7 +240,9 @@ export const useSecureVerification = ({
// 其他错误继续抛出
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

@@ -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 {
@@ -97,33 +106,51 @@ export default function SettingsLog(props) {
<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={{
<div
style={{
background: '#fff7e6',
border: '1px solid #ffd591',
padding: '12px',
borderRadius: '4px',
marginTop: '12px',
color: '#333'
}}>
<Text strong style={{ color: '#d46b08' }}> {t('注意')}</Text>
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,37 +35,45 @@ export class SecureVerificationService {
*/
static async checkAvailableVerificationMethods() {
try {
const [twoFAResponse, passkeyResponse, passkeySupported] = await Promise.all([
const [twoFAResponse, passkeyResponse, passkeySupported] =
await Promise.all([
API.get('/api/user/2fa/status'),
API.get('/api/user/passkey'),
isPasskeySupported()
isPasskeySupported(),
]);
console.log('=== DEBUGGING VERIFICATION METHODS ===');
console.log('2FA Response:', JSON.stringify(twoFAResponse, null, 2));
console.log('Passkey Response:', JSON.stringify(passkeyResponse, 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;
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;
@@ -74,7 +82,7 @@ export class SecureVerificationService {
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,7 +204,9 @@ export const createApiCalls = {
* @param {string} method - HTTP方法默认为 'POST'
* @param {Object} extraData - 额外的请求数据
*/
custom: (url, method = 'POST', extraData = {}) => async () => {
custom:
(url, method = 'POST', extraData = {}) =>
async () => {
// 新系统中,验证已通过中间件处理
const data = extraData;
@@ -213,5 +228,5 @@ export const createApiCalls = {
throw new Error(`不支持的HTTP方法: ${method}`);
}
return response.data;
}
},
};