Merge pull request #1352 from wzxjohn/feature/simple_stripe
Add stripe support and fix wrong top up loading state
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
195
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js
Normal file
195
web/src/pages/Setting/Payment/SettingsPaymentGatewayStripe.js
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user