前端部分,调试 完善

This commit is contained in:
Little Write
2025-09-08 23:25:30 +08:00
parent 51a7aa440b
commit edf46c701f
3 changed files with 630 additions and 3 deletions

View File

@@ -3,9 +3,11 @@ 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 SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem.js';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
const PaymentSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
@@ -24,6 +26,9 @@ const PaymentSetting = () => {
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
CreemApiKey: '',
CreemProducts: '[]',
});
let [loading, setLoading] = useState(false);
@@ -43,6 +48,14 @@ const PaymentSetting = () => {
newInputs[item.key] = item.value;
}
break;
case 'CreemProducts':
try {
newInputs[item.key] = item.value;
} catch (error) {
console.error('解析CreemProducts出错:', error);
newInputs[item.key] = '[]';
}
break;
case 'Price':
case 'MinTopUp':
case 'StripeUnitPrice':
@@ -92,6 +105,9 @@ const PaymentSetting = () => {
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayStripe options={inputs} refresh={onRefresh} />
</Card>
<Card style={{ marginTop: '10px' }}>
<SettingsPaymentGatewayCreem options={inputs} refresh={onRefresh} />
</Card>
</Spin>
</>
);

View File

@@ -0,0 +1,373 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Banner,
Button,
Form,
Row,
Col,
Typography,
Spin,
Table,
Modal,
Input,
InputNumber,
Select,
} from '@douyinfe/semi-ui';
const { Text } = Typography;
import {
API,
showError,
showSuccess,
} from '../../../helpers';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2 } from 'lucide-react';
export default function SettingsPaymentGatewayCreem(props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [inputs, setInputs] = useState({
CreemApiKey: '',
CreemProducts: '[]',
CreemTestMode: false,
});
const [originInputs, setOriginInputs] = useState({});
const [products, setProducts] = useState([]);
const [showProductModal, setShowProductModal] = useState(false);
const [editingProduct, setEditingProduct] = useState(null);
const [productForm, setProductForm] = useState({
name: '',
productId: '',
price: 0,
quota: 0,
currency: 'USD',
});
const formApiRef = useRef(null);
useEffect(() => {
if (props.options && formApiRef.current) {
const currentInputs = {
CreemApiKey: props.options.CreemApiKey || '',
CreemProducts: props.options.CreemProducts || '[]',
CreemTestMode: props.options.CreemTestMode === 'true',
};
setInputs(currentInputs);
setOriginInputs({ ...currentInputs });
formApiRef.current.setValues(currentInputs);
// Parse products
try {
const parsedProducts = JSON.parse(currentInputs.CreemProducts);
setProducts(parsedProducts);
} catch (e) {
setProducts([]);
}
}
}, [props.options]);
const handleFormChange = (values) => {
setInputs(values);
};
const submitCreemSetting = async () => {
setLoading(true);
try {
const options = [];
if (inputs.CreemApiKey && inputs.CreemApiKey !== '') {
options.push({ key: 'CreemApiKey', value: inputs.CreemApiKey });
}
// Save test mode setting
options.push({ key: 'CreemTestMode', value: inputs.CreemTestMode ? 'true' : 'false' });
// Save products as JSON string
options.push({ key: 'CreemProducts', value: JSON.stringify(products) });
// 发送请求
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);
};
const openProductModal = (product = null) => {
if (product) {
setEditingProduct(product);
setProductForm({ ...product });
} else {
setEditingProduct(null);
setProductForm({
name: '',
productId: '',
price: 0,
quota: 0,
currency: 'USD',
});
}
setShowProductModal(true);
};
const closeProductModal = () => {
setShowProductModal(false);
setEditingProduct(null);
setProductForm({
name: '',
productId: '',
price: 0,
quota: 0,
currency: 'USD',
});
};
const saveProduct = () => {
if (!productForm.name || !productForm.productId || productForm.price <= 0 || productForm.quota <= 0 || !productForm.currency) {
showError(t('请填写完整的产品信息'));
return;
}
let newProducts = [...products];
if (editingProduct) {
// 编辑现有产品
const index = newProducts.findIndex(p => p.productId === editingProduct.productId);
if (index !== -1) {
newProducts[index] = { ...productForm };
}
} else {
// 添加新产品
if (newProducts.find(p => p.productId === productForm.productId)) {
showError(t('产品ID已存在'));
return;
}
newProducts.push({ ...productForm });
}
setProducts(newProducts);
closeProductModal();
};
const deleteProduct = (productId) => {
const newProducts = products.filter(p => p.productId !== productId);
setProducts(newProducts);
};
const columns = [
{
title: t('产品名称'),
dataIndex: 'name',
key: 'name',
},
{
title: t('产品ID'),
dataIndex: 'productId',
key: 'productId',
},
{
title: t('价格'),
dataIndex: 'price',
key: 'price',
render: (price, record) => `${record.currency === 'EUR' ? '€' : '$'}${price}`,
},
{
title: t('充值额度'),
dataIndex: 'quota',
key: 'quota',
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<div className='flex gap-2'>
<Button
type='tertiary'
size='small'
onClick={() => openProductModal(record)}
>
{t('编辑')}
</Button>
<Button
type='danger'
theme='borderless'
size='small'
icon={<Trash2 size={14} />}
onClick={() => deleteProduct(record.productId)}
/>
</div>
),
},
];
return (
<Spin spinning={loading}>
<Form
initValues={inputs}
onValueChange={handleFormChange}
getFormApi={(api) => (formApiRef.current = api)}
>
<Form.Section text={t('Creem 设置')}>
<Text>
Creem 是一个简单的支付处理平台支持固定金额的产品销售请在
<a
href='https://creem.io'
target='_blank'
rel='noreferrer'
>
Creem 官网
</a>
创建账户并获取 API 密钥
<br />
</Text>
<Banner
type='info'
description={t('Creem 只支持预设的固定金额产品,不支持自定义金额充值')}
/>
<Row gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Input
field='CreemApiKey'
label={t('API 密钥')}
placeholder={t('creem_xxx 的 Creem API 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={12} lg={12} xl={12}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
extraText={t('启用后将使用 Creem 测试环境,可使用测试卡号 4242 4242 4242 4242 进行测试')}
/>
</Col>
</Row>
<div style={{ marginTop: 24 }}>
<div className='flex justify-between items-center mb-4'>
<Text strong>{t('产品配置')}</Text>
<Button
type='primary'
icon={<Plus size={16} />}
onClick={() => openProductModal()}
>
{t('添加产品')}
</Button>
</div>
<Table
columns={columns}
dataSource={products}
pagination={false}
empty={
<div className='text-center py-8'>
<Text type='tertiary'>{t('暂无产品配置')}</Text>
</div>
}
/>
</div>
<Button onClick={submitCreemSetting} style={{ marginTop: 16 }}>
{t('更新 Creem 设置')}
</Button>
</Form.Section>
</Form>
{/* 产品配置模态框 */}
<Modal
title={editingProduct ? t('编辑产品') : t('添加产品')}
visible={showProductModal}
onOk={saveProduct}
onCancel={closeProductModal}
maskClosable={false}
size='small'
centered
>
<div className='space-y-4'>
<div>
<Text strong className='block mb-2'>
{t('产品名称')}
</Text>
<Input
value={productForm.name}
onChange={(value) => setProductForm({ ...productForm, name: value })}
placeholder={t('例如:基础套餐')}
size='large'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('产品ID')}
</Text>
<Input
value={productForm.productId}
onChange={(value) => setProductForm({ ...productForm, productId: value })}
placeholder={t('例如prod_6I8rBerHpPxyoiU9WK4kot')}
size='large'
disabled={!!editingProduct}
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('货币')}
</Text>
<Select
value={productForm.currency}
onChange={(value) => setProductForm({ ...productForm, currency: value })}
size='large'
className='w-full'
>
<Select.Option value='USD'>USD (美元)</Select.Option>
<Select.Option value='EUR'>EUR (欧元)</Select.Option>
</Select>
</div>
<div>
<Text strong className='block mb-2'>
{t('价格')} ({productForm.currency === 'EUR' ? '欧元' : '美元'})
</Text>
<InputNumber
value={productForm.price}
onChange={(value) => setProductForm({ ...productForm, price: value })}
placeholder={t('例如4.99')}
min={0.01}
precision={2}
size='large'
className='w-full'
/>
</div>
<div>
<Text strong className='block mb-2'>
{t('充值额度')}
</Text>
<InputNumber
value={productForm.quota}
onChange={(value) => setProductForm({ ...productForm, quota: value })}
placeholder={t('例如100000')}
min={1}
precision={0}
size='large'
className='w-full'
/>
</div>
</div>
</Modal>
</Spin>
);
}

View File

@@ -66,6 +66,11 @@ const TopUp = () => {
const [enableStripeTopUp, setEnableStripeTopUp] = useState(statusState?.status?.enable_stripe_topup || false);
const [stripeOpen, setStripeOpen] = useState(false);
const [creemProducts, setCreemProducts] = useState([]);
const [enableCreemTopUp, setEnableCreemTopUp] = useState(false);
const [creemOpen, setCreemOpen] = useState(false);
const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
const [userQuota, setUserQuota] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
@@ -296,6 +301,50 @@ const TopUp = () => {
window.open(data.pay_link, '_blank');
};
const creemPreTopUp = async (product) => {
if (!enableCreemTopUp) {
showError(t('管理员未开启 Creem 充值!'));
return;
}
setSelectedCreemProduct(product);
setCreemOpen(true);
};
const onlineCreemTopUp = async () => {
if (!selectedCreemProduct) {
showError(t('请选择产品'));
return;
}
setConfirmLoading(true);
try {
const res = await API.post('/api/user/creem/pay', {
product_id: selectedCreemProduct.productId,
payment_method: 'creem',
});
if (res !== undefined) {
const { message, data } = res.data;
if (message === 'success') {
processCreemCallback(data);
} else {
showError(data);
}
} else {
showError(res);
}
} catch (err) {
console.log(err);
showError(t('支付请求失败'));
} finally {
setCreemOpen(false);
setConfirmLoading(false);
}
};
const processCreemCallback = (data) => {
// 与 Stripe 保持一致的实现方式
window.open(data.checkout_url, '_blank');
};
const getUserQuota = async () => {
setUserDataLoading(true);
let res = await API.get(`/api/user/self`);
@@ -396,6 +445,15 @@ const TopUp = () => {
setStripeMinTopUp(statusState.status.stripe_min_topup || 1);
setStripeTopUpCount(statusState.status.stripe_min_topup || 1);
setEnableStripeTopUp(statusState.status.enable_stripe_topup || false);
// Creem settings
setEnableCreemTopUp(statusState.status.enable_creem_topup || false);
try {
const products = JSON.parse(statusState.status.creem_products || '[]');
setCreemProducts(products);
} catch (e) {
setCreemProducts([]);
}
}
}, [statusState?.status]);
@@ -470,6 +528,11 @@ const TopUp = () => {
setStripeOpen(false);
};
const handleCreemCancel = () => {
setCreemOpen(false);
setSelectedCreemProduct(null);
};
const handleTransferCancel = () => {
setOpenTransfer(false);
};
@@ -623,6 +686,32 @@ const TopUp = () => {
<p>{t('是否确认充值?')}</p>
</Modal>
<Modal
title={t('确定要充值吗')}
visible={creemOpen}
onOk={onlineCreemTopUp}
onCancel={handleCreemCancel}
maskClosable={false}
size='small'
centered
confirmLoading={confirmLoading}
>
{selectedCreemProduct && (
<>
<p>
{t('产品名称')}{selectedCreemProduct.name}
</p>
<p>
{t('价格')}{selectedCreemProduct.currency === 'EUR' ? '€' : '$'}{selectedCreemProduct.price}
</p>
<p>
{t('充值额度')}{selectedCreemProduct.quota}
</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'>
@@ -925,7 +1014,7 @@ const TopUp = () => {
</>
)}
{!enableOnlineTopUp && !enableStripeTopUp && (
{!enableOnlineTopUp && !enableStripeTopUp && !enableCreemTopUp && (
<Banner
type='warning'
description={t(
@@ -1016,7 +1105,151 @@ const TopUp = () => {
</div>
</div>
</div>
</>
{/* 移动端 Stripe 充值区域 */}
<div className='md:hidden space-y-4'>
<Divider style={{ margin: '24px 0' }}>
<Text className='text-sm font-medium'>
{t('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);
}
}}
className='w-full'
formatter={(value) => (value ? `${value}` : '')}
parser={(value) =>
value ? parseInt(value.replace(/[^\d]/g, '')) : 0
}
/>
</div>
<div>
<Button
type='primary'
onClick={() => stripePreTopUp()}
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>
</>
)}
{enableCreemTopUp && creemProducts.length > 0 && (
<>
<div className='hidden md:block space-y-4'>
<Divider style={{ margin: '24px 0' }}>
<Text className='text-sm font-medium'>
{t('Creem 充值')}
</Text>
</Divider>
<div>
<Text strong className='block mb-3'>
{t('选择充值套餐')}
</Text>
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3'>
{creemProducts.map((product, index) => (
<Card
key={index}
onClick={() => creemPreTopUp(product)}
className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300'
bodyStyle={{ textAlign: 'center', padding: '16px' }}
>
<div className='font-medium text-lg mb-2'>
{product.name}
</div>
<div className='text-sm text-gray-600 mb-2'>
{t('充值额度')}: {product.quota}
</div>
<div className='text-lg font-semibold text-blue-600'>
{product.currency === 'EUR' ? '€' : '$'}{product.price}
</div>
</Card>
))}
</div>
</div>
</div>
{/* 移动端 Creem 充值区域 */}
<div className='md:hidden space-y-4'>
<Divider style={{ margin: '24px 0' }}>
<Text className='text-sm font-medium'>
{t('Creem 充值')}
</Text>
</Divider>
<div>
<Text strong className='block mb-3'>
{t('选择充值套餐')}
</Text>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{creemProducts.map((product, index) => (
<Card
key={index}
onClick={() => creemPreTopUp(product)}
className='cursor-pointer !rounded-2xl transition-all hover:shadow-md border-gray-200 hover:border-gray-300'
bodyStyle={{ textAlign: 'center', padding: '16px' }}
>
<div className='font-medium text-lg mb-2'>
{product.name}
</div>
<div className='text-sm text-gray-600 mb-2'>
{t('充值额度')}: {product.quota}
</div>
<div className='text-lg font-semibold text-blue-600'>
{product.currency === 'EUR' ? '€' : '$'}{product.price}
</div>
</Card>
))}
</div>
</div>
</div>
</>
)}
<Divider style={{ margin: '24px 0' }}>
@@ -1185,7 +1418,12 @@ const TopUp = () => {
</div>
</div>
{/* 移动端底部固定的自定义金额和支付区域 */}
{/* 移动端底部间距,避免内容被固定区域遮挡 */}
{enableOnlineTopUp && (
<div className='md:hidden h-32'></div>
)}
{/* 移动端底部固定的自定义金额和支付区域 - 仅限在线充值 */}
{enableOnlineTopUp && (
<div
className='md:hidden fixed bottom-0 left-0 right-0 p-4 shadow-lg z-50'