Files
new-api/web/src/components/topup/index.jsx
t0ng7u 81272da9ac ♻️ refactor(oauth2): restructure OAuth2 client settings UI and extract modal components
- **UI Restructuring:**
  - Separate client info into individual table columns (name, ID, description)
  - Replace icon-only action buttons with text labels for better UX
  - Adjust table scroll width from 1000px to 1200px for new column layout
  - Remove unnecessary Tooltip wrappers and Lucide icons (Edit, Key, Trash2)

- **Component Architecture:**
  - Extract all modal dialogs into separate reusable components:
    * SecretDisplayModal.jsx - for displaying regenerated client secrets
    * ServerInfoModal.jsx - for OAuth2 server configuration info
    * JWKSInfoModal.jsx - for JWKS key set information
  - Simplify main component by removing ~60 lines of inline modal code
  - Implement proper state management for each modal component

- **Code Quality:**
  - Remove unused imports and clean up component dependencies
  - Consolidate modal logic into dedicated components with error handling
  - Improve code maintainability and reusability across the application

- **Internationalization:**
  - Add English translation for '客户端名称': 'Client Name'
  - Remove duplicate translation keys to fix linter warnings
  - Ensure all new components support full i18n functionality

- **User Experience:**
  - Enhance table readability with dedicated columns for each data type
  - Maintain copyable client ID functionality in separate column
  - Improve action button accessibility with clear text labels
  - Add loading states and proper error handling in modal components

This refactoring improves code organization, enhances user experience, and follows React best practices for component composition and separation of concerns.
2025-09-20 22:52:50 +08:00

604 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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, { useEffect, useState, useContext, useRef } from 'react';
import {
API,
showError,
showInfo,
showSuccess,
renderQuota,
renderQuotaWithAmount,
copy,
getQuotaPerUnit,
} from '../../helpers';
import { Modal, Toast } from '@douyinfe/semi-ui';
import { useTranslation } from 'react-i18next';
import { UserContext } from '../../context/User';
import { StatusContext } from '../../context/Status';
import RechargeCard from './RechargeCard';
import InvitationCard from './InvitationCard';
import TransferModal from './modals/TransferModal';
import PaymentConfirmModal from './modals/PaymentConfirmModal';
const TopUp = () => {
const { t } = useTranslation();
const [userState, userDispatch] = useContext(UserContext);
const [statusState] = useContext(StatusContext);
const [redemptionCode, setRedemptionCode] = useState('');
const [amount, setAmount] = useState(0.0);
const [minTopUp, setMinTopUp] = useState(statusState?.status?.min_topup || 1);
const [topUpCount, setTopUpCount] = useState(
statusState?.status?.min_topup || 1,
);
const [topUpLink, setTopUpLink] = useState(
statusState?.status?.top_up_link || '',
);
const [enableOnlineTopUp, setEnableOnlineTopUp] = useState(
statusState?.status?.enable_online_topup || false,
);
const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
const [enableStripeTopUp, setEnableStripeTopUp] = useState(
statusState?.status?.enable_stripe_topup || false,
);
const [statusLoading, setStatusLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [payWay, setPayWay] = useState('');
const [amountLoading, setAmountLoading] = useState(false);
const [paymentLoading, setPaymentLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [payMethods, setPayMethods] = useState([]);
const affFetchedRef = useRef(false);
// 邀请相关状态
const [affLink, setAffLink] = useState('');
const [openTransfer, setOpenTransfer] = useState(false);
const [transferAmount, setTransferAmount] = useState(0);
// 预设充值额度选项
const [presetAmounts, setPresetAmounts] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null);
// 充值配置信息
const [topupInfo, setTopupInfo] = useState({
amount_options: [],
discount: {},
});
const topUp = async () => {
if (redemptionCode === '') {
showInfo(t('请输入兑换码!'));
return;
}
setIsSubmitting(true);
try {
const res = await API.post('/api/user/topup', {
key: redemptionCode,
});
const { success, message, data } = res.data;
if (success) {
showSuccess(t('兑换成功!'));
Modal.success({
title: t('兑换成功!'),
content: t('成功兑换额度:') + renderQuota(data),
centered: true,
});
if (userState.user) {
const updatedUser = {
...userState.user,
quota: userState.user.quota + data,
};
userDispatch({ type: 'login', payload: updatedUser });
}
setRedemptionCode('');
} else {
showError(message);
}
} catch (err) {
showError(t('请求失败'));
} finally {
setIsSubmitting(false);
}
};
const openTopUpLink = () => {
if (!topUpLink) {
showError(t('超级管理员未设置充值链接!'));
return;
}
window.open(topUpLink, '_blank');
};
const preTopUp = async (payment) => {
if (payment === 'stripe') {
if (!enableStripeTopUp) {
showError(t('管理员未开启Stripe充值'));
return;
}
} else {
if (!enableOnlineTopUp) {
showError(t('管理员未开启在线充值!'));
return;
}
}
setPayWay(payment);
setPaymentLoading(true);
try {
if (payment === 'stripe') {
await getStripeAmount();
} else {
await getAmount();
}
if (topUpCount < minTopUp) {
showError(t('充值数量不能小于') + minTopUp);
return;
}
setOpen(true);
} catch (error) {
showError(t('获取金额失败'));
} finally {
setPaymentLoading(false);
}
};
const onlineTopUp = async () => {
if (payWay === 'stripe') {
// Stripe 支付处理
if (amount === 0) {
await getStripeAmount();
}
} else {
// 普通支付处理
if (amount === 0) {
await getAmount();
}
}
if (topUpCount < minTopUp) {
showError('充值数量不能小于' + minTopUp);
return;
}
setConfirmLoading(true);
try {
let res;
if (payWay === 'stripe') {
// Stripe 支付请求
res = await API.post('/api/user/stripe/pay', {
amount: parseInt(topUpCount),
payment_method: 'stripe',
});
} else {
// 普通支付请求
res = await API.post('/api/user/pay', {
amount: parseInt(topUpCount),
payment_method: payWay,
});
}
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
if (payWay === 'stripe') {
// Stripe 支付回调处理
window.open(data.pay_link, '_blank');
} else {
// 普通支付表单提交
let params = data;
let url = res.data.url;
let form = document.createElement('form');
form.action = url;
form.method = 'POST';
let isSafari =
navigator.userAgent.indexOf('Safari') > -1 &&
navigator.userAgent.indexOf('Chrome') < 1;
if (!isSafari) {
form.target = '_blank';
}
for (let key in params) {
let input = document.createElement('input');
input.type = 'hidden';
input.name = key;
input.value = params[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
} else {
showError(data);
}
} else {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setOpen(false);
setConfirmLoading(false);
}
};
const getUserQuota = async () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
if (success) {
userDispatch({ type: 'login', payload: data });
} else {
showError(message);
}
};
// 获取充值配置信息
const getTopupInfo = async () => {
try {
const res = await API.get('/api/user/topup/info');
const { message, data, success } = res.data;
if (success) {
setTopupInfo({
amount_options: data.amount_options || [],
discount: data.discount || {},
});
// 处理支付方式
let payMethods = data.pay_methods || [];
try {
if (typeof payMethods === 'string') {
payMethods = JSON.parse(payMethods);
}
if (payMethods && payMethods.length > 0) {
// 检查name和type是否为空
payMethods = payMethods.filter((method) => {
return method.name && method.type;
});
// 如果没有color则设置默认颜色
payMethods = payMethods.map((method) => {
// 规范化最小充值数
const normalizedMinTopup = Number(method.min_topup);
method.min_topup = Number.isFinite(normalizedMinTopup)
? normalizedMinTopup
: 0;
// Stripe 的最小充值从后端字段回填
if (
method.type === 'stripe' &&
(!method.min_topup || method.min_topup <= 0)
) {
const stripeMin = Number(data.stripe_min_topup);
if (Number.isFinite(stripeMin)) {
method.min_topup = stripeMin;
}
}
if (!method.color) {
if (method.type === 'alipay') {
method.color = 'rgba(var(--semi-blue-5), 1)';
} else if (method.type === 'wxpay') {
method.color = 'rgba(var(--semi-green-5), 1)';
} else if (method.type === 'stripe') {
method.color = 'rgba(var(--semi-purple-5), 1)';
} else {
method.color = 'rgba(var(--semi-primary-5), 1)';
}
}
return method;
});
} else {
payMethods = [];
}
// 如果启用了 Stripe 支付,添加到支付方法列表
// 这个逻辑现在由后端处理,如果 Stripe 启用,后端会在 pay_methods 中包含它
setPayMethods(payMethods);
const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_topup || false;
const minTopUpValue = enableOnlineTopUp
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue);
// 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
if (topupInfo.amount_options.length === 0) {
setPresetAmounts(generatePresetAmounts(minTopUpValue));
}
// 初始化显示实付金额
getAmount(minTopUpValue);
} catch (e) {
console.log('解析支付方式失败:', e);
setPayMethods([]);
}
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
if (data.amount_options && data.amount_options.length > 0) {
const customPresets = data.amount_options.map((amount) => ({
value: amount,
discount: data.discount[amount] || 1.0,
}));
setPresetAmounts(customPresets);
}
} else {
console.error('获取充值配置失败:', data);
}
} catch (error) {
console.error('获取充值配置异常:', error);
}
};
// 获取邀请链接
const getAffLink = async () => {
const res = await API.get('/api/user/aff');
const { success, message, data } = res.data;
if (success) {
let link = `${window.location.origin}/register?aff=${data}`;
setAffLink(link);
} else {
showError(message);
}
};
// 划转邀请额度
const transfer = async () => {
if (transferAmount < getQuotaPerUnit()) {
showError(t('划转金额最低为') + ' ' + renderQuota(getQuotaPerUnit()));
return;
}
const res = await API.post(`/api/user/aff_transfer`, {
quota: transferAmount,
});
const { success, message } = res.data;
if (success) {
showSuccess(message);
setOpenTransfer(false);
getUserQuota().then();
} else {
showError(message);
}
};
// 复制邀请链接
const handleAffLinkClick = async () => {
await copy(affLink);
showSuccess(t('邀请链接已复制到剪切板'));
};
useEffect(() => {
if (!userState?.user?.id) {
getUserQuota().then();
}
setTransferAmount(getQuotaPerUnit());
}, []);
useEffect(() => {
if (affFetchedRef.current) return;
affFetchedRef.current = true;
getAffLink().then();
}, []);
// 在 statusState 可用时获取充值信息
useEffect(() => {
getTopupInfo().then();
}, []);
useEffect(() => {
if (statusState?.status) {
// const minTopUpValue = statusState.status.min_topup || 1;
// setMinTopUp(minTopUpValue);
// setTopUpCount(minTopUpValue);
setTopUpLink(statusState.status.top_up_link || '');
setPriceRatio(statusState.status.price || 1);
setStatusLoading(false);
}
}, [statusState?.status]);
const renderAmount = () => {
return amount + ' ' + t('元');
};
const getAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/amount', {
amount: parseFloat(value),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
setAmount(parseFloat(data));
} else {
setAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
console.log(err);
}
setAmountLoading(false);
};
const getStripeAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/stripe/amount', {
amount: parseFloat(value),
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
setAmount(parseFloat(data));
} else {
setAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
console.log(err);
} finally {
setAmountLoading(false);
}
};
const handleCancel = () => {
setOpen(false);
};
const handleTransferCancel = () => {
setOpenTransfer(false);
};
// 选择预设充值额度
const selectPresetAmount = (preset) => {
setTopUpCount(preset.value);
setSelectedPreset(preset.value);
// 计算实际支付金额,考虑折扣
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
const discountedAmount = preset.value * priceRatio * discount;
setAmount(discountedAmount);
};
// 格式化大数字显示
const formatLargeNumber = (num) => {
return num.toString();
};
// 根据最小充值金额生成预设充值额度选项
const generatePresetAmounts = (minAmount) => {
const multipliers = [1, 5, 10, 30, 50, 100, 300, 500];
return multipliers.map((multiplier) => ({
value: minAmount * multiplier,
}));
};
return (
<div className='w-full max-w-7xl mx-auto relative min-h-screen lg:min-h-0 mt-[60px] px-2'>
{/* 划转模态框 */}
<TransferModal
t={t}
openTransfer={openTransfer}
transfer={transfer}
handleTransferCancel={handleTransferCancel}
userState={userState}
renderQuota={renderQuota}
getQuotaPerUnit={getQuotaPerUnit}
transferAmount={transferAmount}
setTransferAmount={setTransferAmount}
/>
{/* 充值确认模态框 */}
<PaymentConfirmModal
t={t}
open={open}
onlineTopUp={onlineTopUp}
handleCancel={handleCancel}
confirmLoading={confirmLoading}
topUpCount={topUpCount}
renderQuotaWithAmount={renderQuotaWithAmount}
amountLoading={amountLoading}
renderAmount={renderAmount}
payWay={payWay}
payMethods={payMethods}
amountNumber={amount}
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
/>
{/* 用户信息头部 */}
<div className='space-y-6'>
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
{/* 左侧充值区域 */}
<div className='lg:col-span-7 space-y-6 w-full'>
<RechargeCard
t={t}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}
formatLargeNumber={formatLargeNumber}
priceRatio={priceRatio}
topUpCount={topUpCount}
minTopUp={minTopUp}
renderQuotaWithAmount={renderQuotaWithAmount}
getAmount={getAmount}
setTopUpCount={setTopUpCount}
setSelectedPreset={setSelectedPreset}
renderAmount={renderAmount}
amountLoading={amountLoading}
payMethods={payMethods}
preTopUp={preTopUp}
paymentLoading={paymentLoading}
payWay={payWay}
redemptionCode={redemptionCode}
setRedemptionCode={setRedemptionCode}
topUp={topUp}
isSubmitting={isSubmitting}
topUpLink={topUpLink}
openTopUpLink={openTopUpLink}
userState={userState}
renderQuota={renderQuota}
statusLoading={statusLoading}
topupInfo={topupInfo}
/>
</div>
{/* 右侧信息区域 */}
<div className='lg:col-span-5'>
<InvitationCard
t={t}
userState={userState}
renderQuota={renderQuota}
setOpenTransfer={setOpenTransfer}
affLink={affLink}
handleAffLinkClick={handleAffLinkClick}
/>
</div>
</div>
</div>
</div>
);
};
export default TopUp;