Little Write 3 месяцев назад
Родитель
Сommit
dc6fbffa96

+ 16 - 0
web/src/components/settings/PaymentSetting.js

@@ -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>
     </>
   );

+ 373 - 0
web/src/pages/Setting/Payment/SettingsPaymentGatewayCreem.js

@@ -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>
+  );
+}

+ 241 - 3
web/src/pages/TopUp/index.js

@@ -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'