Merge pull request #1352 from wzxjohn/feature/simple_stripe

Add stripe support and fix wrong top up loading state
This commit is contained in:
IcedTangerine
2025-07-18 22:00:52 +08:00
committed by GitHub
14 changed files with 839 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment.js';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway.js';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe.js';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -17,6 +18,12 @@ const PaymentSetting = () => {
TopupGroupRatio: '',
CustomCallbackAddress: '',
PayMethods: '',
StripeApiSecret: '',
StripeWebhookSecret: '',
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
});
let [loading, setLoading] = useState(false);
@@ -38,6 +45,8 @@ const PaymentSetting = () => {
break;
case 'Price':
case 'MinTopUp':
case 'StripeUnitPrice':
case 'StripeMinTopUp':
newInputs[item.key] = parseFloat(item.value);
break;
default:
@@ -80,6 +89,9 @@ const PaymentSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGateway options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);

View File

@@ -0,0 +1,195 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Banner,
Button,
Form,
Row,
Col,
Typography,
Spin,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
API,
removeTrailingSlash,
showError,
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
export default function SettingsPaymentGateway(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
StripeApiSecret: '',
StripeWebhookSecret: '',
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
});
const [originInputs, setOriginInputs] = useState({});
const formApiRef = useRef(null);
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
StripeApiSecret: props.options.StripeApiSecret || '',
StripeWebhookSecret: props.options.StripeWebhookSecret || '',
StripePriceId: props.options.StripePriceId || '',
StripeUnitPrice: props.options.StripeUnitPrice !== undefined ? parseFloat(props.options.StripeUnitPrice) : 8.0,
StripeMinTopUp: props.options.StripeMinTopUp !== undefined ? parseFloat(props.options.StripeMinTopUp) : 1,
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
}
}, [props.options]);
const handleFormChange = (values) => {
setInputs(values);
};
const submitStripeSetting = async () => {
if (props.options.ServerAddress === '') {
showError(t('请先填写服务器地址'));
return;
}
setLoading(true);
try {
const options = []
if (inputs.StripeApiSecret && inputs.StripeApiSecret !== '') {
options.push({ key: 'StripeApiSecret', value: inputs.StripeApiSecret });
}
if (inputs.StripeWebhookSecret && inputs.StripeWebhookSecret !== '') {
options.push({ key: 'StripeWebhookSecret', value: inputs.StripeWebhookSecret });
}
if (inputs.StripePriceId !== '') {
options.push({key: 'StripePriceId', value: inputs.StripePriceId,});
}
if (inputs.StripeUnitPrice !== undefined && inputs.StripeUnitPrice !== null) {
options.push({ key: 'StripeUnitPrice', value: inputs.StripeUnitPrice.toString() });
}
if (inputs.StripeMinTopUp !== undefined && inputs.StripeMinTopUp !== null) {
options.push({ key: 'StripeMinTopUp', value: inputs.StripeMinTopUp.toString() });
}
// 发送请求
const requestQueue = options.map(opt =>
API.put('/api/option/', {
key: opt.key,
value: opt.value,
})
);
const results = await Promise.all(requestQueue);
// 检查所有请求是否成功
const errorResults = results.filter(res => !res.data.success);
if (errorResults.length > 0) {
errorResults.forEach(res => {
showError(res.data.message);
});
} else {
showSuccess(t('更新成功'));
// 更新本地存储的原始值
setOriginInputs({ ...inputs });
props.refresh?.();
}
} catch (error) {
showError(t('更新失败'));
}
setLoading(false);
};
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Stripe 设置')}>
<Text>
Stripe 密钥Webhook 等设置请
<a
href='https://dashboard.stripe.com/developers'
target='_blank'
rel='noreferrer'
>
点击此处
</a>
进行设置最好先在
<a
href='https://dashboard.stripe.com/test/developers'
target='_blank'
rel='noreferrer'
>
测试环境
</a>
进行测试
<br />
</Text>
<Banner
type='info'
description={`Webhook 填:${props.options.ServerAddress ? removeTrailingSlash(props.options.ServerAddress) : t('网站地址')}/api/stripe/webhook`}
/>
<Banner
type='warning'
description={`需要包含事件checkout.session.completed 和 checkout.session.expired`}
/>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='StripeApiSecret'
label={t('API 密钥')}
placeholder={t('sk_xxx 或 rk_xxx 的 Stripe 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='StripeWebhookSecret'
label={t('Webhook 签名密钥')}
placeholder={t('whsec_xxx 的 Webhook 签名密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='StripePriceId'
label={t('商品价格 ID')}
placeholder={t('price_xxx 的商品价格 ID新建产品后可获得')}
/>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='StripeUnitPrice'
precision={2}
label={t('充值价格x元/美金)')}
placeholder={t('例如7就是7元/美金')}
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.InputNumber
field='StripeMinTopUp'
label={t('最低充值美元数量')}
placeholder={t('例如2就是最低充值2$')}
/>
</Col>
</Row>
<Button onClick={submitStripeSetting}>{t('更新 Stripe 设置')}</Button>
</Form.Section>
</Form>
</Spin>
);
}

View File

@@ -59,6 +59,13 @@ const TopUp = () => {
statusState?.status?.enable_online_topup || false,
);
const [priceRatio, setPriceRatio] = useState(statusState?.status?.price || 1);
const [stripeAmount, setStripeAmount] = useState(0.0);
const [stripeMinTopUp, setStripeMinTopUp] = useState(statusState?.status?.stripe_min_topup || 1);
const [stripeTopUpCount, setStripeTopUpCount] = useState(statusState?.status?.stripe_min_topup || 1);
const [enableStripeTopUp, setEnableStripeTopUp] = useState(statusState?.status?.enable_stripe_topup || false);
const [stripeOpen, setStripeOpen] = useState(false);
const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
@@ -161,6 +168,7 @@ const TopUp = () => {
showError(t('管理员未开启在线充值!'));
return;
}
setPayWay(payment);
setPaymentLoading(true);
try {
await getAmount();
@@ -168,7 +176,6 @@ const TopUp = () => {
showError(t('充值数量不能小于') + minTopUp);
return;
}
setPayWay(payment);
setOpen(true);
} catch (error) {
showError(t('获取金额失败'));
@@ -186,7 +193,6 @@ const TopUp = () => {
return;
}
setConfirmLoading(true);
setOpen(false);
try {
const res = await API.post('/api/user/pay', {
amount: parseInt(topUpCount),
@@ -227,10 +233,69 @@ const TopUp = () => {
console.log(err);
showError(t('支付请求失败'));
} finally {
setOpen(false);
setConfirmLoading(false);
}
};
const stripePreTopUp = async () => {
if (!enableStripeTopUp) {
showError(t('管理员未开启在线充值!'));
return;
}
setPayWay('stripe');
setPaymentLoading(true);
try {
await getStripeAmount();
if (stripeTopUpCount < stripeMinTopUp) {
showError(t('充值数量不能小于') + stripeMinTopUp);
return;
}
setStripeOpen(true);
} catch (error) {
showError(t('获取金额失败'));
} finally {
setPaymentLoading(false);
}
};
const onlineStripeTopUp = async () => {
if (stripeAmount === 0) {
await getStripeAmount();
}
if (stripeTopUpCount < stripeMinTopUp) {
showError(t('充值数量不能小于') + stripeMinTopUp);
return;
}
setConfirmLoading(true);
try {
const res = await API.post('/api/user/stripe/pay', {
amount: parseInt(stripeTopUpCount),
payment_method: 'stripe',
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
processStripeCallback(data);
} else {
showError(data);
}
} else {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setStripeOpen(false);
setConfirmLoading(false);
}
}
const processStripeCallback = (data) => {
window.open(data.pay_link, '_blank');
};
const getUserQuota = async () => {
setUserDataLoading(true);
let res = await API.get(`/api/user/self`);
@@ -327,6 +392,10 @@ const TopUp = () => {
setTopUpLink(statusState.status.top_up_link || '');
setEnableOnlineTopUp(statusState.status.enable_online_topup || false);
setPriceRatio(statusState.status.price || 1);
setStripeMinTopUp(statusState.status.stripe_min_topup || 1);
setStripeTopUpCount(statusState.status.stripe_min_topup || 1);
setEnableStripeTopUp(statusState.status.enable_stripe_topup || false);
}
}, [statusState?.status]);
@@ -334,6 +403,10 @@ const TopUp = () => {
return amount + ' ' + t('元');
};
const renderStripeAmount = () => {
return stripeAmount + ' ' + t('元');
};
const getAmount = async (value) => {
if (value === undefined) {
value = topUpCount;
@@ -361,10 +434,42 @@ const TopUp = () => {
setAmountLoading(false);
};
const getStripeAmount = async (value) => {
if (value === undefined) {
value = stripeTopUpCount
}
setAmountLoading(true);
try {
const res = await API.post('/api/user/stripe/amount', {
amount: parseFloat(value),
});
if (res !== undefined) {
const { message, data } = res.data;
// showInfo(message);
if (message === 'success') {
setStripeAmount(parseFloat(data));
} else {
setStripeAmount(0);
Toast.error({ content: '错误:' + data, id: 'getAmount' });
}
} else {
showError(res);
}
} catch (err) {
console.log(err);
} finally {
setAmountLoading(false);
}
}
const handleCancel = () => {
setOpen(false);
};
const handleStripeCancel = () => {
setStripeOpen(false);
};
const handleTransferCancel = () => {
setOpenTransfer(false);
};
@@ -374,6 +479,9 @@ const TopUp = () => {
setTopUpCount(preset.value);
setSelectedPreset(preset.value);
setAmount(preset.value * priceRatio);
setStripeTopUpCount(preset.value);
setStripeAmount(preset.value);
};
// 格式化大数字显示
@@ -496,6 +604,25 @@ const TopUp = () => {
</div>
</Modal>
<Modal
title={t('确定要充值吗')}
visible={stripeOpen}
onOk={onlineStripeTopUp}
onCancel={handleStripeCancel}
maskClosable={false}
size='small'
centered
confirmLoading={confirmLoading}
>
<p>
{t('充值数量')}{stripeTopUpCount}
</p>
<p>
{t('实付金额')}{renderStripeAmount()}
</p>
<p>{t('是否确认充值?')}</p>
</Modal>
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
{/* 左侧充值区域 */}
<div className='lg:col-span-7 space-y-6 w-full'>
@@ -798,7 +925,7 @@ const TopUp = () => {
</>
)}
{!enableOnlineTopUp && (
{!enableOnlineTopUp && !enableStripeTopUp && (
<Banner
type='warning'
description={t(
@@ -809,6 +936,89 @@ const TopUp = () => {
/>
)}
{enableStripeTopUp && (
<>
{/* 桌面端显示的自定义金额和支付按钮 */}
<div className='hidden md:block space-y-4'>
<Divider style={{ margin: '24px 0' }}>
<Text className='text-sm font-medium'>
{t(!enableOnlineTopUp ? '或输入自定义金额' : 'Stripe')}
</Text>
</Divider>
<div>
<div className='flex justify-between mb-2'>
<Text strong>{t('充值数量')}</Text>
{amountLoading ? (
<Skeleton.Title
style={{ width: '80px', height: '16px' }}
/>
) : (
<Text type='tertiary'>
{t('实付金额:') + renderStripeAmount()}
</Text>
)}
</div>
<InputNumber
disabled={!enableStripeTopUp}
placeholder={
t('充值数量,最低 ') + renderQuotaWithAmount(stripeMinTopUp)
}
value={stripeTopUpCount}
min={stripeMinTopUp}
max={999999999}
step={1}
precision={0}
onChange={async (value) => {
if (value && value >= 1) {
setStripeTopUpCount(value);
setSelectedPreset(null);
await getStripeAmount(value);
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (!value || value < 1) {
setStripeTopUpCount(1);
getStripeAmount(1);
}
}}
size='large'
className='w-full'
formatter={(value) => (value ? `${value}` : '')}
parser={(value) =>
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
}
/>
</div>
<div>
<Text strong className='block mb-3'>
{t('选择支付方式')}
</Text>
<div className='grid grid-cols-1 gap-3'>
<Button
key='stripe'
type='primary'
onClick={() => stripePreTopUp()}
size='large'
disabled={!enableStripeTopUp}
loading={paymentLoading && payWay === 'stripe'}
icon={<CreditCard size={16} />}
style={{
height: '40px',
color: '#b161fe',
}}
className='transition-all hover:shadow-md w-full'
>
<span className='ml-1'>Stripe</span>
</Button>
</div>
</div>
</div>
</>
)}
<Divider style={{ margin: '24px 0' }}>
<Text className='text-sm font-medium'>{t('兑换码充值')}</Text>
</Divider>