Merge pull request #1823 from littlewrite/feat_subscribe_sp1

新增 creem 支付
This commit is contained in:
Seefs
2025-10-28 18:37:32 +09:00
committed by GitHub
12 changed files with 1109 additions and 5 deletions

View File

@@ -22,6 +22,7 @@ import { Card, Spin } from '@douyinfe/semi-ui';
import SettingsGeneralPayment from '../../pages/Setting/Payment/SettingsGeneralPayment';
import SettingsPaymentGateway from '../../pages/Setting/Payment/SettingsPaymentGateway';
import SettingsPaymentGatewayStripe from '../../pages/Setting/Payment/SettingsPaymentGatewayStripe';
import SettingsPaymentGatewayCreem from '../../pages/Setting/Payment/SettingsPaymentGatewayCreem';
import { API, showError, toBoolean } from '../../helpers';
import { useTranslation } from 'react-i18next';
@@ -142,6 +143,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

@@ -52,6 +52,9 @@ const RechargeCard = ({
t,
enableOnlineTopUp,
enableStripeTopUp,
enableCreemTopUp,
creemProducts,
creemPreTopUp,
presetAmounts,
selectedPreset,
selectPresetAmount,
@@ -84,6 +87,7 @@ const RechargeCard = ({
const onlineFormApiRef = useRef(null);
const redeemFormApiRef = useRef(null);
const showAmountSkeleton = useMinimumLoadingTime(amountLoading);
console.log(' enabled screem ?', enableCreemTopUp, ' products ?', creemProducts);
return (
<Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */}
@@ -216,7 +220,7 @@ const RechargeCard = ({
<div className='py-8 flex justify-center'>
<Spin size='large' />
</div>
) : enableOnlineTopUp || enableStripeTopUp ? (
) : enableOnlineTopUp || enableStripeTopUp || enableCreemTopUp ? (
<Form
getFormApi={(api) => (onlineFormApiRef.current = api)}
initValues={{ topUpCount: topUpCount }}
@@ -480,6 +484,32 @@ const RechargeCard = ({
</div>
</Form.Slot>
)}
{/* Creem 充值区域 */}
{enableCreemTopUp && creemProducts.length > 0 && (
<Form.Slot label={t('Creem 充值')}>
<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>
</Form.Slot>
)}
</div>
</Form>
) : (

View File

@@ -63,6 +63,12 @@ const TopUp = () => {
);
const [statusLoading, setStatusLoading] = useState(true);
// Creem 相关状态
const [creemProducts, setCreemProducts] = useState([]);
const [enableCreemTopUp, setEnableCreemTopUp] = useState(false);
const [creemOpen, setCreemOpen] = useState(false);
const [selectedCreemProduct, setSelectedCreemProduct] = useState(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [open, setOpen] = useState(false);
const [payWay, setPayWay] = useState('');
@@ -248,6 +254,55 @@ const TopUp = () => {
}
};
const creemPreTopUp = async (product) => {
if (!enableCreemTopUp) {
showError(t('管理员未开启 Creem 充值!'));
return;
}
setSelectedCreemProduct(product);
setCreemOpen(true);
};
const onlineCreemTopUp = async () => {
if (!selectedCreemProduct) {
showError(t('请选择产品'));
return;
}
// Validate product has required fields
if (!selectedCreemProduct.productId) {
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 () => {
let res = await API.get(`/api/user/self`);
const { success, message, data } = res.data;
@@ -322,6 +377,7 @@ const TopUp = () => {
setPayMethods(payMethods);
const enableStripeTopUp = data.enable_stripe_topup || false;
const enableOnlineTopUp = data.enable_online_topup || false;
const enableCreemTopUp = data.enable_creem_topup || false;
const minTopUpValue = enableOnlineTopUp
? data.min_topup
: enableStripeTopUp
@@ -329,9 +385,20 @@ const TopUp = () => {
: 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setEnableCreemTopUp(enableCreemTopUp);
setMinTopUp(minTopUpValue);
setTopUpCount(minTopUpValue);
// 设置 Creem 产品
try {
console.log(' data is ?', data);
console.log(' creem products is ?', data.creem_products);
const products = JSON.parse(data.creem_products || '[]');
setCreemProducts(products);
} catch (e) {
setCreemProducts([]);
}
// 如果没有自定义充值数量选项,根据最小充值金额生成预设充值额度选项
if (topupInfo.amount_options.length === 0) {
setPresetAmounts(generatePresetAmounts(minTopUpValue));
@@ -500,6 +567,11 @@ const TopUp = () => {
setOpenHistory(false);
};
const handleCreemCancel = () => {
setCreemOpen(false);
setSelectedCreemProduct(null);
};
// 选择预设充值额度
const selectPresetAmount = (preset) => {
setTopUpCount(preset.value);
@@ -563,6 +635,33 @@ const TopUp = () => {
t={t}
/>
{/* Creem 充值确认模态框 */}
<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='space-y-6'>
<div className='grid grid-cols-1 lg:grid-cols-12 gap-6'>
@@ -572,6 +671,9 @@ const TopUp = () => {
t={t}
enableOnlineTopUp={enableOnlineTopUp}
enableStripeTopUp={enableStripeTopUp}
enableCreemTopUp={enableCreemTopUp}
creemProducts={creemProducts}
creemPreTopUp={creemPreTopUp}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
selectPresetAmount={selectPresetAmount}

View File

@@ -2071,6 +2071,35 @@
"默认区域,如: us-central1": "Default region, e.g.: us-central1",
"默认折叠侧边栏": "Default collapse sidebar",
"默认测试模型": "Default Test Model",
"默认补全倍率": "Default completion ratio"
"默认补全倍率": "Default completion ratio",
"选择充值套餐": "Choose a top-up package",
"Creem 设置": "Creem Setting",
"Creem 充值": "Creem Recharge",
"Creem 介绍": "Creem is the payment partner you always deserved, we strive for simplicity and straightforwardness on our APIs.",
"Creem Setting Tips": "Creem only supports preset fixed-amount products. These products and their prices need to be created and configured in advance on the Creem website, so custom dynamic amount top-ups are not supported. Configure the product name and price on Creem, obtain the Product Id, and then fill it in for the product below. Set the top-up amount and display price for this product in the new API.",
"Webhook 密钥": "Webhook Secret",
"测试模式": "Test Mode",
"Creem API 密钥,敏感信息不显示": "Creem API key, sensitive information not displayed",
"用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示": "The key used to validate webhook requests for the callback new-api, sensitive information is not displayed.",
"启用后将使用 Creem Test Mode": "",
"展示价格": "Display Pricing",
"Recharge Quota": "Recharge Quota",
"产品配置": "Product Configuration",
"产品名称": "Product Name",
"产品ID": "Product ID",
"暂无产品配置": "No product configuration",
"更新 Creem 设置": "Update Creem Settings",
"编辑产品": "Edit Product",
"添加产品": "Add Product",
"例如:基础套餐": "e.g.: Basic Package",
"例如prod_6I8rBerHpPxyoiU9WK4kot": "e.g.: prod_6I8rBerHpPxyoiU9WK4kot",
"货币": "Currency",
"欧元": "EUR",
"USD (美元)": "USD (US Dollar)",
"EUR (欧元)": "EUR (Euro)",
"例如4.99": "e.g.: 4.99",
"例如100000": "e.g.: 100000",
"请填写完整的产品信息": "Please fill in complete product information",
"产品ID已存在": "Product ID already exists"
}
}
}

View File

@@ -2062,6 +2062,8 @@
"默认区域,如: us-central1": "默认区域,如: us-central1",
"默认折叠侧边栏": "默认折叠侧边栏",
"默认测试模型": "默认测试模型",
"默认补全倍率": "默认补全倍率"
"默认补全倍率": "默认补全倍率",
"Creem 介绍": "Creem 是一个简单的支付处理平台,支持固定金额产品销售,以及订阅销售。",
"Creem Setting Tips": "Creem 只支持预设的固定金额产品这产品以及价格需要提前在Creem网站内创建配置所以不支持自定义动态金额充值。在Creem端配置产品的名字以及价格获取Product Id 后填到下面的产品在new-api为该产品设置充值额度以及展示价格。"
}
}
}

View File

@@ -0,0 +1,385 @@
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: '',
CreemWebhookSecret: '',
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 || '',
CreemWebhookSecret: props.options.CreemWebhookSecret || '',
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 });
}
if (inputs.CreemWebhookSecret && inputs.CreemWebhookSecret !== '') {
options.push({ key: 'CreemWebhookSecret', value: inputs.CreemWebhookSecret });
}
// 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>
{t('Creem 介绍')}
<a
href='https://creem.io'
target='_blank'
rel='noreferrer'
>Creem Official Site</a>
<br />
</Text>
<Banner
type='info'
description={t('Creem Setting Tips')}
/>
<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='CreemApiKey'
label={t('API 密钥')}
placeholder={t('Creem API 密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Input
field='CreemWebhookSecret'
label={t('Webhook 密钥')}
placeholder={t('用于验证回调 new-api 的 webhook 请求的密钥,敏感信息不显示')}
type='password'
/>
</Col>
<Col xs={24} sm={24} md={8} lg={8} xl={8}>
<Form.Switch
field='CreemTestMode'
label={t('测试模式')}
extraText={t('启用后将使用 Creem Test Mode')}
/>
</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'>{t('USD (美元)')}</Select.Option>
<Select.Option value='EUR'>{t('EUR (欧元)')}</Select.Option>
</Select>
</div>
<div>
<Text strong className='block mb-2'>
{t('价格')} ({productForm.currency === 'EUR' ? t('欧元') : t('美元')})
</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'
defaultValue={4.49}
/>
</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>
);
}