Merge branch 'main' into feat_subscribe_sp1

This commit is contained in:
Little Write
2025-10-20 22:36:33 +08:00
396 changed files with 28798 additions and 5948 deletions

View File

@@ -30,12 +30,21 @@ import {
Space,
Row,
Col,
Spin, Tooltip
Spin,
Tooltip,
} from '@douyinfe/semi-ui';
import { SiAlipay, SiWechat, SiStripe } from 'react-icons/si';
import { CreditCard, Coins, Wallet, BarChart2, TrendingUp } from 'lucide-react';
import {
CreditCard,
Coins,
Wallet,
BarChart2,
TrendingUp,
Receipt,
} from 'lucide-react';
import { IconGift } from '@douyinfe/semi-icons';
import { useMinimumLoadingTime } from '../../hooks/common/useMinimumLoadingTime';
import { getCurrencyConfig } from '../../helpers/render';
const { Text } = Typography;
@@ -73,6 +82,7 @@ const RechargeCard = ({
renderQuota,
statusLoading,
topupInfo,
onOpenHistory,
}) => {
const onlineFormApiRef = useRef(null);
const redeemFormApiRef = useRef(null);
@@ -81,16 +91,25 @@ const RechargeCard = ({
return (
<Card className='!rounded-2xl shadow-sm border-0'>
{/* 卡片头部 */}
<div className='flex items-center mb-4'>
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
<CreditCard size={16} />
</Avatar>
<div>
<Typography.Text className='text-lg font-medium'>
{t('账户充值')}
</Typography.Text>
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
<div className='flex items-center justify-between mb-4'>
<div className='flex items-center'>
<Avatar size='small' color='blue' className='mr-3 shadow-md'>
<CreditCard size={16} />
</Avatar>
<div>
<Typography.Text className='text-lg font-medium'>
{t('账户充值')}
</Typography.Text>
<div className='text-xs'>{t('多种充值方式,安全便捷')}</div>
</div>
</div>
<Button
icon={<Receipt size={16} />}
theme='solid'
onClick={onOpenHistory}
>
{t('账单')}
</Button>
</div>
<Space vertical style={{ width: '100%' }}>
@@ -270,7 +289,8 @@ const RechargeCard = ({
{payMethods && payMethods.length > 0 ? (
<Space wrap>
{payMethods.map((payMethod) => {
const minTopupVal = Number(payMethod.min_topup) || 0;
const minTopupVal =
Number(payMethod.min_topup) || 0;
const isStripe = payMethod.type === 'stripe';
const disabled =
(!enableOnlineTopUp && !isStripe) ||
@@ -284,7 +304,9 @@ const RechargeCard = ({
type='tertiary'
onClick={() => preTopUp(payMethod.type)}
disabled={disabled}
loading={paymentLoading && payWay === payMethod.type}
loading={
paymentLoading && payWay === payMethod.type
}
icon={
payMethod.type === 'alipay' ? (
<SiAlipay size={18} color='#1677FF' />
@@ -295,7 +317,10 @@ const RechargeCard = ({
) : (
<CreditCard
size={18}
color={payMethod.color || 'var(--semi-color-text-2)'}
color={
payMethod.color ||
'var(--semi-color-text-2)'
}
/>
)
}
@@ -305,12 +330,22 @@ const RechargeCard = ({
</Button>
);
return disabled && minTopupVal > Number(topUpCount || 0) ? (
<Tooltip content={t('此支付方式最低充值金额为') + ' ' + minTopupVal} key={payMethod.type}>
return disabled &&
minTopupVal > Number(topUpCount || 0) ? (
<Tooltip
content={
t('此支付方式最低充值金额为') +
' ' +
minTopupVal
}
key={payMethod.type}
>
{buttonEl}
</Tooltip>
) : (
<React.Fragment key={payMethod.type}>{buttonEl}</React.Fragment>
<React.Fragment key={payMethod.type}>
{buttonEl}
</React.Fragment>
);
})}
</Space>
@@ -325,26 +360,81 @@ const RechargeCard = ({
)}
{(enableOnlineTopUp || enableStripeTopUp) && (
<Form.Slot label={t('选择充值额度')}>
<Form.Slot
label={
<div className='flex items-center gap-2'>
<span>{t('选择充值额度')}</span>
{(() => {
const { symbol, rate, type } = getCurrencyConfig();
if (type === 'USD') return null;
return (
<span
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
fontWeight: 'normal',
}}
>
(1 $ = {rate.toFixed(2)} {symbol})
</span>
);
})()}
</div>
}
>
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
{presetAmounts.map((preset, index) => {
const discount = preset.discount || topupInfo?.discount?.[preset.value] || 1.0;
const discount =
preset.discount ||
topupInfo?.discount?.[preset.value] ||
1.0;
const originalPrice = preset.value * priceRatio;
const discountedPrice = originalPrice * discount;
const hasDiscount = discount < 1.0;
const actualPay = discountedPrice;
const save = originalPrice - discountedPrice;
// 根据当前货币类型换算显示金额和数量
const { symbol, rate, type } = getCurrencyConfig();
const statusStr = localStorage.getItem('status');
let usdRate = 7; // 默认CNY汇率
try {
if (statusStr) {
const s = JSON.parse(statusStr);
usdRate = s?.usd_exchange_rate || 7;
}
} catch (e) {}
let displayValue = preset.value; // 显示的数量
let displayActualPay = actualPay;
let displaySave = save;
if (type === 'USD') {
// 数量保持USD价格从CNY转USD
displayActualPay = actualPay / usdRate;
displaySave = save / usdRate;
} else if (type === 'CNY') {
// 数量转CNY价格已是CNY
displayValue = preset.value * usdRate;
} else if (type === 'CUSTOM') {
// 数量和价格都转自定义货币
displayValue = preset.value * rate;
displayActualPay = (actualPay / usdRate) * rate;
displaySave = (save / usdRate) * rate;
}
return (
<Card
key={index}
style={{
cursor: 'pointer',
border: selectedPreset === preset.value
? '2px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
border:
selectedPreset === preset.value
? '2px solid var(--semi-color-primary)'
: '1px solid var(--semi-color-border)',
height: '100%',
width: '100%'
width: '100%',
}}
bodyStyle={{ padding: '12px' }}
onClick={() => {
@@ -356,24 +446,36 @@ const RechargeCard = ({
}}
>
<div style={{ textAlign: 'center' }}>
<Typography.Title heading={6} style={{ margin: '0 0 8px 0' }}>
<Typography.Title
heading={6}
style={{ margin: '0 0 8px 0' }}
>
<Coins size={18} />
{formatLargeNumber(preset.value)}
{formatLargeNumber(preset.value)} $
{hasDiscount && (
<Tag style={{ marginLeft: 4 }} color="green">
{t('折').includes('off') ?
((1 - parseFloat(discount)) * 100).toFixed(1) :
(discount * 10).toFixed(1)}{t('折')}
</Tag>
<Tag style={{ marginLeft: 4 }} color='green'>
{t('折').includes('off')
? (
(1 - parseFloat(discount)) *
100
).toFixed(1)
: (discount * 10).toFixed(1)}
{t('折')}
</Tag>
)}
</Typography.Title>
<div style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
margin: '4px 0'
}}>
{t('实付')} {actualPay.toFixed(2)}
{hasDiscount ? `${t('节省')} ${save.toFixed(2)}` : `${t('节省')} 0.00`}
<div
style={{
color: 'var(--semi-color-text-2)',
fontSize: '12px',
margin: '4px 0',
}}
>
{t('实付')} {symbol}
{displayActualPay.toFixed(2)}
{hasDiscount
? `${t('节省')} ${symbol}${displaySave.toFixed(2)}`
: `${t('节省')} ${symbol}0.00`}
</div>
</div>
</Card>

View File

@@ -37,6 +37,7 @@ import RechargeCard from './RechargeCard';
import InvitationCard from './InvitationCard';
import TransferModal from './modals/TransferModal';
import PaymentConfirmModal from './modals/PaymentConfirmModal';
import TopupHistoryModal from './modals/TopupHistoryModal';
const TopUp = () => {
const { t } = useTranslation();
@@ -83,14 +84,17 @@ const TopUp = () => {
const [openTransfer, setOpenTransfer] = useState(false);
const [transferAmount, setTransferAmount] = useState(0);
// 账单Modal状态
const [openHistory, setOpenHistory] = useState(false);
// 预设充值额度选项
const [presetAmounts, setPresetAmounts] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null);
// 充值配置信息
const [topupInfo, setTopupInfo] = useState({
amount_options: [],
discount: {}
discount: {},
});
const topUp = async () => {
@@ -317,9 +321,9 @@ const TopUp = () => {
if (success) {
setTopupInfo({
amount_options: data.amount_options || [],
discount: data.discount || {}
discount: data.discount || {},
});
// 处理支付方式
let payMethods = data.pay_methods || [];
try {
@@ -335,10 +339,15 @@ const TopUp = () => {
payMethods = payMethods.map((method) => {
// 规范化最小充值数
const normalizedMinTopup = Number(method.min_topup);
method.min_topup = Number.isFinite(normalizedMinTopup) ? normalizedMinTopup : 0;
method.min_topup = Number.isFinite(normalizedMinTopup)
? normalizedMinTopup
: 0;
// Stripe 的最小充值从后端字段回填
if (method.type === 'stripe' && (!method.min_topup || method.min_topup <= 0)) {
if (
method.type === 'stripe' &&
(!method.min_topup || method.min_topup <= 0)
) {
const stripeMin = Number(data.stripe_min_topup);
if (Number.isFinite(stripeMin)) {
method.min_topup = stripeMin;
@@ -369,7 +378,11 @@ const TopUp = () => {
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? data.stripe_min_topup : 1;
const minTopUpValue = enableOnlineTopUp
? data.min_topup
: enableStripeTopUp
? data.stripe_min_topup
: 1;
setEnableOnlineTopUp(enableOnlineTopUp);
setEnableStripeTopUp(enableStripeTopUp);
setEnableCreemTopUp(enableCreemTopUp);
@@ -397,12 +410,12 @@ const TopUp = () => {
console.log('解析支付方式失败:', e);
setPayMethods([]);
}
// 如果有自定义充值数量选项,使用它们替换默认的预设选项
if (data.amount_options && data.amount_options.length > 0) {
const customPresets = data.amount_options.map(amount => ({
const customPresets = data.amount_options.map((amount) => ({
value: amount,
discount: data.discount[amount] || 1.0
discount: data.discount[amount] || 1.0,
}));
setPresetAmounts(customPresets);
}
@@ -546,6 +559,14 @@ const TopUp = () => {
setOpenTransfer(false);
};
const handleOpenHistory = () => {
setOpenHistory(true);
};
const handleHistoryCancel = () => {
setOpenHistory(false);
};
const handleCreemCancel = () => {
setCreemOpen(false);
setSelectedCreemProduct(null);
@@ -555,7 +576,7 @@ const TopUp = () => {
const selectPresetAmount = (preset) => {
setTopUpCount(preset.value);
setSelectedPreset(preset.value);
// 计算实际支付金额,考虑折扣
const discount = preset.discount || topupInfo.discount[preset.value] || 1.0;
const discountedAmount = preset.value * priceRatio * discount;
@@ -607,6 +628,13 @@ const TopUp = () => {
discountRate={topupInfo?.discount?.[topUpCount] || 1.0}
/>
{/* 充值账单模态框 */}
<TopupHistoryModal
visible={openHistory}
onCancel={handleHistoryCancel}
t={t}
/>
{/* Creem 充值确认模态框 */}
<Modal
title={t('确定要充值 $')}
@@ -673,6 +701,7 @@ const TopUp = () => {
renderQuota={renderQuota}
statusLoading={statusLoading}
topupInfo={topupInfo}
onOpenHistory={handleOpenHistory}
/>
</div>

View File

@@ -40,9 +40,10 @@ const PaymentConfirmModal = ({
amountNumber,
discountRate,
}) => {
const hasDiscount = discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
const originalAmount = hasDiscount ? (amountNumber / discountRate) : 0;
const discountAmount = hasDiscount ? (originalAmount - amountNumber) : 0;
const hasDiscount =
discountRate && discountRate > 0 && discountRate < 1 && amountNumber > 0;
const originalAmount = hasDiscount ? amountNumber / discountRate : 0;
const discountAmount = hasDiscount ? originalAmount - amountNumber : 0;
return (
<Modal
title={

View File

@@ -0,0 +1,273 @@
/*
Copyright (C) 2025 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import React, { useState, useEffect, useMemo } from 'react';
import {
Modal,
Table,
Badge,
Typography,
Toast,
Empty,
Button,
Input,
} from '@douyinfe/semi-ui';
import {
IllustrationNoResult,
IllustrationNoResultDark,
} from '@douyinfe/semi-illustrations';
import { Coins } from 'lucide-react';
import { IconSearch } from '@douyinfe/semi-icons';
import { API, timestamp2string } from '../../../helpers';
import { isAdmin } from '../../../helpers/utils';
import { useIsMobile } from '../../../hooks/common/useIsMobile';
const { Text } = Typography;
// 状态映射配置
const STATUS_CONFIG = {
success: { type: 'success', key: '成功' },
pending: { type: 'warning', key: '待支付' },
expired: { type: 'danger', key: '已过期' },
};
// 支付方式映射
const PAYMENT_METHOD_MAP = {
stripe: 'Stripe',
alipay: '支付宝',
wxpay: '微信',
};
const TopupHistoryModal = ({ visible, onCancel, t }) => {
const [loading, setLoading] = useState(false);
const [topups, setTopups] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState('');
const isMobile = useIsMobile();
const loadTopups = async (currentPage, currentPageSize) => {
setLoading(true);
try {
const base = isAdmin() ? '/api/user/topup' : '/api/user/topup/self';
const qs =
`p=${currentPage}&page_size=${currentPageSize}` +
(keyword ? `&keyword=${encodeURIComponent(keyword)}` : '');
const endpoint = `${base}?${qs}`;
const res = await API.get(endpoint);
const { success, message, data } = res.data;
if (success) {
setTopups(data.items || []);
setTotal(data.total || 0);
} else {
Toast.error({ content: message || t('加载失败') });
}
} catch (error) {
console.error('Load topups error:', error);
Toast.error({ content: t('加载账单失败') });
} finally {
setLoading(false);
}
};
useEffect(() => {
if (visible) {
loadTopups(page, pageSize);
}
}, [visible, page, pageSize, keyword]);
const handlePageChange = (currentPage) => {
setPage(currentPage);
};
const handlePageSizeChange = (currentPageSize) => {
setPageSize(currentPageSize);
setPage(1);
};
const handleKeywordChange = (value) => {
setKeyword(value);
setPage(1);
};
// 管理员补单
const handleAdminComplete = async (tradeNo) => {
try {
const res = await API.post('/api/user/topup/complete', {
trade_no: tradeNo,
});
const { success, message } = res.data;
if (success) {
Toast.success({ content: t('补单成功') });
await loadTopups(page, pageSize);
} else {
Toast.error({ content: message || t('补单失败') });
}
} catch (e) {
Toast.error({ content: t('补单失败') });
}
};
const confirmAdminComplete = (tradeNo) => {
Modal.confirm({
title: t('确认补单'),
content: t('是否将该订单标记为成功并为用户入账?'),
onOk: () => handleAdminComplete(tradeNo),
});
};
// 渲染状态徽章
const renderStatusBadge = (status) => {
const config = STATUS_CONFIG[status] || { type: 'primary', key: status };
return (
<span className='flex items-center gap-2'>
<Badge dot type={config.type} />
<span>{t(config.key)}</span>
</span>
);
};
// 渲染支付方式
const renderPaymentMethod = (pm) => {
const displayName = PAYMENT_METHOD_MAP[pm];
return <Text>{displayName ? t(displayName) : pm || '-'}</Text>;
};
// 检查是否为管理员
const userIsAdmin = useMemo(() => isAdmin(), []);
const columns = useMemo(() => {
const baseColumns = [
{
title: t('订单号'),
dataIndex: 'trade_no',
key: 'trade_no',
render: (text) => <Text copyable>{text}</Text>,
},
{
title: t('支付方式'),
dataIndex: 'payment_method',
key: 'payment_method',
render: renderPaymentMethod,
},
{
title: t('充值额度'),
dataIndex: 'amount',
key: 'amount',
render: (amount) => (
<span className='flex items-center gap-1'>
<Coins size={16} />
<Text>{amount}</Text>
</span>
),
},
{
title: t('支付金额'),
dataIndex: 'money',
key: 'money',
render: (money) => <Text type='danger'>¥{money.toFixed(2)}</Text>,
},
{
title: t('状态'),
dataIndex: 'status',
key: 'status',
render: renderStatusBadge,
},
];
// 管理员才显示操作列
if (userIsAdmin) {
baseColumns.push({
title: t('操作'),
key: 'action',
render: (_, record) => {
if (record.status !== 'pending') return null;
return (
<Button
size='small'
type='primary'
theme='outline'
onClick={() => confirmAdminComplete(record.trade_no)}
>
{t('补单')}
</Button>
);
},
});
}
baseColumns.push({
title: t('创建时间'),
dataIndex: 'create_time',
key: 'create_time',
render: (time) => timestamp2string(time),
});
return baseColumns;
}, [t, userIsAdmin]);
return (
<Modal
title={t('充值账单')}
visible={visible}
onCancel={onCancel}
footer={null}
size={isMobile ? 'full-width' : 'large'}
>
<div className='mb-3'>
<Input
prefix={<IconSearch />}
placeholder={t('订单号')}
value={keyword}
onChange={handleKeywordChange}
showClear
/>
</div>
<Table
columns={columns}
dataSource={topups}
loading={loading}
rowKey='id'
pagination={{
currentPage: page,
pageSize: pageSize,
total: total,
showSizeChanger: true,
pageSizeOpts: [10, 20, 50, 100],
onPageChange: handlePageChange,
onPageSizeChange: handlePageSizeChange,
}}
size='small'
empty={
<Empty
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
darkModeImage={
<IllustrationNoResultDark style={{ width: 150, height: 150 }} />
}
description={t('暂无充值记录')}
style={{ padding: 30 }}
/>
}
/>
</Modal>
);
};
export default TopupHistoryModal;