feat: require compliance confirmation for paid features
Gate payment, redemption, subscription, and invitation reward flows behind an audited compliance acknowledgement.
This commit is contained in:
@@ -62,6 +62,7 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
checklist = [],
|
||||
inputPrompt = '',
|
||||
requiredText = '',
|
||||
requiredTextParts = [],
|
||||
inputPlaceholder = '',
|
||||
mismatchText = '',
|
||||
cancelText = '',
|
||||
@@ -72,24 +73,68 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
const isMobile = useIsMobile();
|
||||
const [checkedItems, setCheckedItems] = useState([]);
|
||||
const [typedText, setTypedText] = useState('');
|
||||
const [typedTextParts, setTypedTextParts] = useState([]);
|
||||
|
||||
const normalizedRequiredTextParts = useMemo(() => {
|
||||
let inputIndex = 0;
|
||||
return requiredTextParts.map((part) => {
|
||||
if (part.type === 'input') {
|
||||
const normalizedPart = { ...part, inputIndex };
|
||||
inputIndex += 1;
|
||||
return normalizedPart;
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}, [requiredTextParts]);
|
||||
|
||||
const requiredTextInputCount = useMemo(
|
||||
() =>
|
||||
normalizedRequiredTextParts.filter((part) => part.type === 'input')
|
||||
.length,
|
||||
[normalizedRequiredTextParts],
|
||||
);
|
||||
const hasSegmentedRequiredText = requiredTextInputCount > 0;
|
||||
const requiredTextToDisplay = hasSegmentedRequiredText
|
||||
? normalizedRequiredTextParts.map((part) => part.text).join('')
|
||||
: requiredText;
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
setCheckedItems(Array(checklist.length).fill(false));
|
||||
setTypedText('');
|
||||
}, [visible, checklist.length]);
|
||||
setTypedTextParts(Array(requiredTextInputCount).fill(''));
|
||||
}, [visible, checklist.length, requiredTextInputCount]);
|
||||
|
||||
const allChecked = useMemo(() => {
|
||||
if (checklist.length === 0) return true;
|
||||
return checkedItems.length === checklist.length && checkedItems.every(Boolean);
|
||||
return (
|
||||
checkedItems.length === checklist.length && checkedItems.every(Boolean)
|
||||
);
|
||||
}, [checkedItems, checklist.length]);
|
||||
|
||||
const typedMatched = useMemo(() => {
|
||||
if (hasSegmentedRequiredText) {
|
||||
return normalizedRequiredTextParts.every((part) => {
|
||||
if (part.type === 'static') return true;
|
||||
return (
|
||||
typedTextParts[part.inputIndex ?? 0]?.trim() === part.text.trim()
|
||||
);
|
||||
});
|
||||
}
|
||||
if (!requiredText) return true;
|
||||
return typedText.trim() === requiredText.trim();
|
||||
}, [typedText, requiredText]);
|
||||
}, [
|
||||
hasSegmentedRequiredText,
|
||||
normalizedRequiredTextParts,
|
||||
requiredText,
|
||||
typedText,
|
||||
typedTextParts,
|
||||
]);
|
||||
|
||||
const detailText = useMemo(() => detailItems.join(', '), [detailItems]);
|
||||
const hasTypedRequiredText = hasSegmentedRequiredText
|
||||
? typedTextParts.some((part) => part.trim() !== '')
|
||||
: typedText.length > 0;
|
||||
const canConfirm = allChecked && typedMatched;
|
||||
|
||||
const handleChecklistChange = useCallback((index, checked) => {
|
||||
@@ -100,6 +145,14 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleTextPartChange = useCallback((index, value) => {
|
||||
setTypedTextParts((previous) => {
|
||||
const next = [...previous];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
@@ -134,7 +187,6 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
}
|
||||
>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
<RiskMarkdownBlock markdownContent={markdownContent} />
|
||||
|
||||
{detailItems.length > 0 ? (
|
||||
@@ -176,7 +228,7 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{requiredText ? (
|
||||
{requiredTextToDisplay ? (
|
||||
<div
|
||||
className='flex flex-col gap-2 rounded-lg'
|
||||
style={{
|
||||
@@ -187,19 +239,51 @@ const RiskAcknowledgementModal = React.memo(function RiskAcknowledgementModal({
|
||||
>
|
||||
{inputPrompt ? <Text strong>{inputPrompt}</Text> : null}
|
||||
<div className='font-mono text-xs break-all rounded-md p-2 bg-gray-50 border border-gray-200'>
|
||||
{requiredText}
|
||||
{requiredTextToDisplay}
|
||||
</div>
|
||||
<Input
|
||||
value={typedText}
|
||||
onChange={setTypedText}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus={visible}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
{!typedMatched && typedText ? (
|
||||
{hasSegmentedRequiredText ? (
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
{normalizedRequiredTextParts.map((part, index) =>
|
||||
part.type === 'static' ? (
|
||||
<span
|
||||
key={`static-${index}`}
|
||||
className='select-none rounded-md border border-gray-200 bg-white px-2 py-1 font-mono text-sm text-gray-500'
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
) : (
|
||||
<Input
|
||||
key={`input-${index}`}
|
||||
value={typedTextParts[part.inputIndex ?? 0] ?? ''}
|
||||
onChange={(value) =>
|
||||
handleTextPartChange(part.inputIndex ?? 0, value)
|
||||
}
|
||||
placeholder={
|
||||
part.placeholder || part.text || inputPlaceholder
|
||||
}
|
||||
autoFocus={visible && part.inputIndex === 0}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
style={{ width: isMobile ? '100%' : 260 }}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={typedText}
|
||||
onChange={setTypedText}
|
||||
placeholder={inputPlaceholder}
|
||||
autoFocus={visible}
|
||||
onCopy={(event) => event.preventDefault()}
|
||||
onCut={(event) => event.preventDefault()}
|
||||
onPaste={(event) => event.preventDefault()}
|
||||
onDrop={(event) => event.preventDefault()}
|
||||
/>
|
||||
)}
|
||||
{!typedMatched && hasTypedRequiredText ? (
|
||||
<Text type='danger' size='small'>
|
||||
{mismatchText}
|
||||
</Text>
|
||||
|
||||
+196
-49
@@ -18,15 +18,18 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Spin, Tabs } from '@douyinfe/semi-ui';
|
||||
import { Banner, Button, Card, Spin, Tabs } 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 SettingsPaymentGatewayWaffo from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffo';
|
||||
import SettingsPaymentGatewayWaffoPancake from '../../pages/Setting/Payment/SettingsPaymentGatewayWaffoPancake';
|
||||
import { API, showError, toBoolean } from '../../helpers';
|
||||
import { API, showError, showSuccess, toBoolean } from '../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import RiskAcknowledgementModal from '../common/modals/RiskAcknowledgementModal';
|
||||
|
||||
const CURRENT_COMPLIANCE_TERMS_VERSION = 'v1';
|
||||
|
||||
const PaymentSetting = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -60,9 +63,59 @@ const PaymentSetting = () => {
|
||||
WaffoPancakeCurrency: 'USD',
|
||||
WaffoPancakeUnitPrice: 1.0,
|
||||
WaffoPancakeMinTopUp: 1,
|
||||
'payment_setting.compliance_confirmed': false,
|
||||
'payment_setting.compliance_terms_version': '',
|
||||
'payment_setting.compliance_confirmed_at': 0,
|
||||
'payment_setting.compliance_confirmed_by': 0,
|
||||
});
|
||||
|
||||
let [loading, setLoading] = useState(false);
|
||||
const [complianceVisible, setComplianceVisible] = useState(false);
|
||||
|
||||
const complianceStatements = [
|
||||
t('你已合法取得所接入模型 API、账号、密钥和额度的授权;'),
|
||||
t(
|
||||
'你承诺仅在已取得上游服务商、模型服务提供方或相关权利方合法授权的范围内使用其 API、账号、密钥、额度及服务能力,不进行未经授权的转售、倒卖、分销或其他违规商业化使用。',
|
||||
),
|
||||
t(
|
||||
'如向中华人民共和国境内公众提供生成式人工智能服务,你将依法履行备案登记、安全评估、内容安全、投诉举报、生成合成内容标识、日志留存、个人信息保护等合规义务;',
|
||||
),
|
||||
t(
|
||||
'你承诺不会利用本系统实施、协助实施或变相实施违反适用法律法规、监管要求、平台规则、社会公共利益或第三方合法权益的行为。',
|
||||
),
|
||||
t('你理解并自行承担部署、运营和收费行为产生的法律责任。'),
|
||||
t(
|
||||
'你理解本合规提醒仅用于风险提示,不构成法律意见、合规审查结论或对你使用本系统行为合法性的保证;你应根据实际业务场景自行咨询专业法律或合规顾问。',
|
||||
),
|
||||
];
|
||||
const requiredComplianceText = t(
|
||||
'我已阅读并理解上述合规提醒,知悉相关法律风险,并确认自行承担部署、运营和收费行为产生的法律责任',
|
||||
);
|
||||
const requiredComplianceTextParts = [
|
||||
{
|
||||
type: 'input',
|
||||
text: t('我已阅读并理解上述合规提醒'),
|
||||
},
|
||||
{ type: 'static', text: t(',') },
|
||||
{
|
||||
type: 'input',
|
||||
text: t('知悉相关法律风险'),
|
||||
},
|
||||
{ type: 'static', text: t(',') },
|
||||
{
|
||||
type: 'input',
|
||||
text: t('并确认自行承担部署'),
|
||||
},
|
||||
{ type: 'static', text: t('、') },
|
||||
{
|
||||
type: 'input',
|
||||
text: t('运营和收费行为产生的法律责任'),
|
||||
},
|
||||
];
|
||||
const complianceConfirmed =
|
||||
inputs['payment_setting.compliance_confirmed'] &&
|
||||
inputs['payment_setting.compliance_terms_version'] ===
|
||||
CURRENT_COMPLIANCE_TERMS_VERSION;
|
||||
|
||||
const getOptions = async () => {
|
||||
const res = await API.get('/api/option/');
|
||||
@@ -104,6 +157,16 @@ const PaymentSetting = () => {
|
||||
newInputs['AmountDiscount'] = item.value;
|
||||
}
|
||||
break;
|
||||
case 'payment_setting.compliance_confirmed':
|
||||
newInputs[item.key] = toBoolean(item.value);
|
||||
break;
|
||||
case 'payment_setting.compliance_confirmed_at':
|
||||
case 'payment_setting.compliance_confirmed_by':
|
||||
newInputs[item.key] = parseInt(item.value) || 0;
|
||||
break;
|
||||
case 'payment_setting.compliance_terms_version':
|
||||
newInputs[item.key] = item.value;
|
||||
break;
|
||||
case 'Price':
|
||||
case 'MinTopUp':
|
||||
case 'StripeUnitPrice':
|
||||
@@ -154,59 +217,143 @@ const PaymentSetting = () => {
|
||||
onRefresh();
|
||||
}, []);
|
||||
|
||||
const confirmCompliance = async () => {
|
||||
try {
|
||||
const res = await API.post('/api/option/payment_compliance', {
|
||||
confirmed: true,
|
||||
});
|
||||
if (res.data.success) {
|
||||
showSuccess(t('合规声明确认成功'));
|
||||
setComplianceVisible(false);
|
||||
await onRefresh();
|
||||
} else {
|
||||
showError(res.data.message || t('确认失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('确认失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Spin spinning={loading} size='large'>
|
||||
<Card style={{ marginTop: '10px' }}>
|
||||
<Tabs
|
||||
type='card'
|
||||
defaultActiveKey='general'
|
||||
contentStyle={{ paddingTop: 24 }}
|
||||
{!complianceConfirmed ? (
|
||||
<Banner
|
||||
type='warning'
|
||||
title={t('需要确认合规声明')}
|
||||
description={
|
||||
<div className='flex flex-col gap-2'>
|
||||
<span>
|
||||
{t(
|
||||
'确认前,支付、兑换码、订阅计划和邀请返利功能将保持锁定。',
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type='warning'
|
||||
theme='solid'
|
||||
onClick={() => setComplianceVisible(true)}
|
||||
>
|
||||
{t('确认合规声明')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 16 }}
|
||||
fullMode={false}
|
||||
/>
|
||||
) : (
|
||||
<Banner
|
||||
type='success'
|
||||
title={t('合规声明已确认')}
|
||||
description={t('确认时间:{{time}},确认用户:#{{userId}}', {
|
||||
time: inputs['payment_setting.compliance_confirmed_at']
|
||||
? new Date(
|
||||
inputs['payment_setting.compliance_confirmed_at'] * 1000,
|
||||
).toLocaleString()
|
||||
: '-',
|
||||
userId:
|
||||
inputs['payment_setting.compliance_confirmed_by'] || '-',
|
||||
})}
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: 16 }}
|
||||
fullMode={false}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={
|
||||
complianceConfirmed
|
||||
? undefined
|
||||
: { opacity: 0.4, pointerEvents: 'none' }
|
||||
}
|
||||
>
|
||||
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
|
||||
<SettingsGeneralPayment
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
|
||||
<SettingsPaymentGateway
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
|
||||
<SettingsPaymentGatewayStripe
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
|
||||
<SettingsPaymentGatewayCreem
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
|
||||
<SettingsPaymentGatewayWaffo
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
|
||||
{/* <SettingsPaymentGatewayWaffoPancake*/}
|
||||
{/* options={inputs}*/}
|
||||
{/* refresh={onRefresh}*/}
|
||||
{/* hideSectionTitle*/}
|
||||
{/* />*/}
|
||||
{/*</Tabs.TabPane>*/}
|
||||
</Tabs>
|
||||
<Tabs
|
||||
type='card'
|
||||
defaultActiveKey='general'
|
||||
contentStyle={{ paddingTop: 24 }}
|
||||
>
|
||||
<Tabs.TabPane tab={t('通用设置')} itemKey='general'>
|
||||
<SettingsGeneralPayment
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('易支付设置')} itemKey='epay'>
|
||||
<SettingsPaymentGateway
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Stripe 设置')} itemKey='stripe'>
|
||||
<SettingsPaymentGatewayStripe
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Creem 设置')} itemKey='creem'>
|
||||
<SettingsPaymentGatewayCreem
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane tab={t('Waffo 设置')} itemKey='waffo'>
|
||||
<SettingsPaymentGatewayWaffo
|
||||
options={inputs}
|
||||
refresh={onRefresh}
|
||||
hideSectionTitle
|
||||
/>
|
||||
</Tabs.TabPane>
|
||||
{/*<Tabs.TabPane tab={t('Waffo Pancake 设置')} itemKey='waffo-pancake'>*/}
|
||||
{/* <SettingsPaymentGatewayWaffoPancake*/}
|
||||
{/* options={inputs}*/}
|
||||
{/* refresh={onRefresh}*/}
|
||||
{/* hideSectionTitle*/}
|
||||
{/* />*/}
|
||||
{/*</Tabs.TabPane>*/}
|
||||
</Tabs>
|
||||
</div>
|
||||
</Card>
|
||||
<RiskAcknowledgementModal
|
||||
visible={complianceVisible}
|
||||
title={t('确认合规声明')}
|
||||
markdownContent={t(
|
||||
'该操作将启用支付、兑换码、订阅计划和邀请返利相关功能。请仔细阅读并确认以下声明。',
|
||||
)}
|
||||
checklist={complianceStatements}
|
||||
inputPrompt={t('请输入以下文字以确认:')}
|
||||
requiredText={requiredComplianceText}
|
||||
requiredTextParts={requiredComplianceTextParts}
|
||||
inputPlaceholder={t('请输入确认文案')}
|
||||
mismatchText={t('输入内容与要求文案不一致')}
|
||||
cancelText={t('取消')}
|
||||
confirmText={t('确认并启用')}
|
||||
onCancel={() => setComplianceVisible(false)}
|
||||
onConfirm={confirmCompliance}
|
||||
/>
|
||||
</Spin>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ For commercial licensing, please contact support@quantumnous.com
|
||||
import React from 'react';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
|
||||
const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
const SubscriptionsActions = ({ openCreate, t, disabled = false }) => {
|
||||
return (
|
||||
<div className='flex gap-2 w-full md:w-auto'>
|
||||
<Button
|
||||
@@ -28,6 +28,7 @@ const SubscriptionsActions = ({ openCreate, t }) => {
|
||||
className='w-full md:w-auto'
|
||||
onClick={openCreate}
|
||||
size='small'
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('新建套餐')}
|
||||
</Button>
|
||||
|
||||
+21
-3
@@ -228,7 +228,11 @@ const renderPaymentConfig = (text, record, t, enableEpay) => {
|
||||
);
|
||||
};
|
||||
|
||||
const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
const renderOperations = (
|
||||
text,
|
||||
record,
|
||||
{ openEdit, setPlanEnabled, t, complianceConfirmed },
|
||||
) => {
|
||||
const isEnabled = record?.plan?.enabled;
|
||||
|
||||
const handleToggle = () => {
|
||||
@@ -256,11 +260,18 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
type='tertiary'
|
||||
size='small'
|
||||
onClick={() => openEdit(record)}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
{isEnabled ? (
|
||||
<Button theme='light' type='danger' size='small' onClick={handleToggle}>
|
||||
<Button
|
||||
theme='light'
|
||||
type='danger'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -269,6 +280,7 @@ const renderOperations = (text, record, { openEdit, setPlanEnabled, t }) => {
|
||||
type='primary'
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
disabled={!complianceConfirmed}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
@@ -282,6 +294,7 @@ export const getSubscriptionsColumns = ({
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
complianceConfirmed = true,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
@@ -351,7 +364,12 @@ export const getSubscriptionsColumns = ({
|
||||
fixed: 'right',
|
||||
width: 160,
|
||||
render: (text, record) =>
|
||||
renderOperations(text, record, { openEdit, setPlanEnabled, t }),
|
||||
renderOperations(text, record, {
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
t,
|
||||
complianceConfirmed,
|
||||
}),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ const SubscriptionsTable = (subscriptionsData) => {
|
||||
setPlanEnabled,
|
||||
t,
|
||||
enableEpay,
|
||||
complianceConfirmed,
|
||||
} = subscriptionsData;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
@@ -43,8 +44,9 @@ const SubscriptionsTable = (subscriptionsData) => {
|
||||
openEdit,
|
||||
setPlanEnabled,
|
||||
enableEpay,
|
||||
complianceConfirmed,
|
||||
});
|
||||
}, [t, openEdit, setPlanEnabled, enableEpay]);
|
||||
}, [t, openEdit, setPlanEnabled, enableEpay, complianceConfirmed]);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode
|
||||
|
||||
+39
-3
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useContext } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Banner } from '@douyinfe/semi-ui';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import SubscriptionsTable from './SubscriptionsTable';
|
||||
@@ -28,12 +28,14 @@ import { useSubscriptionsData } from '../../../hooks/subscriptions/useSubscripti
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
import { StatusContext } from '../../../context/Status';
|
||||
import { API } from '../../../helpers';
|
||||
|
||||
const SubscriptionsPage = () => {
|
||||
const subscriptionsData = useSubscriptionsData();
|
||||
const isMobile = useIsMobile();
|
||||
const [statusState] = useContext(StatusContext);
|
||||
const enableEpay = !!statusState?.status?.enable_online_topup;
|
||||
const [complianceConfirmed, setComplianceConfirmed] = useState(true);
|
||||
|
||||
const {
|
||||
showEdit,
|
||||
@@ -47,6 +49,22 @@ const SubscriptionsPage = () => {
|
||||
t,
|
||||
} = subscriptionsData;
|
||||
|
||||
useEffect(() => {
|
||||
const loadComplianceStatus = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/user/topup/info');
|
||||
if (res.data?.success) {
|
||||
setComplianceConfirmed(
|
||||
res.data.data?.payment_compliance_confirmed !== false,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep the page usable if status loading fails; backend still enforces.
|
||||
}
|
||||
};
|
||||
loadComplianceStatus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddEditSubscriptionModal
|
||||
@@ -71,7 +89,11 @@ const SubscriptionsPage = () => {
|
||||
<div className='flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full'>
|
||||
{/* Mobile: actions first; Desktop: actions left */}
|
||||
<div className='order-1 md:order-0 w-full md:w-auto'>
|
||||
<SubscriptionsActions openCreate={openCreate} t={t} />
|
||||
<SubscriptionsActions
|
||||
openCreate={openCreate}
|
||||
t={t}
|
||||
disabled={!complianceConfirmed}
|
||||
/>
|
||||
</div>
|
||||
<Banner
|
||||
type='info'
|
||||
@@ -94,7 +116,21 @@ const SubscriptionsPage = () => {
|
||||
})}
|
||||
t={t}
|
||||
>
|
||||
<SubscriptionsTable {...subscriptionsData} enableEpay={enableEpay} />
|
||||
{!complianceConfirmed && (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t(
|
||||
'订阅套餐创建和变更已锁定,管理员需先在支付设置中确认合规声明。',
|
||||
)}
|
||||
closeIcon={null}
|
||||
className='!rounded-lg mb-3'
|
||||
/>
|
||||
)}
|
||||
<SubscriptionsTable
|
||||
{...subscriptionsData}
|
||||
enableEpay={enableEpay}
|
||||
complianceConfirmed={complianceConfirmed}
|
||||
/>
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ const InvitationCard = ({
|
||||
setOpenTransfer,
|
||||
affLink,
|
||||
handleAffLinkClick,
|
||||
complianceConfirmed = true,
|
||||
}) => {
|
||||
return (
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
@@ -81,6 +82,7 @@ const InvitationCard = ({
|
||||
theme='solid'
|
||||
size='small'
|
||||
disabled={
|
||||
!complianceConfirmed ||
|
||||
!userState?.user?.aff_quota ||
|
||||
userState?.user?.aff_quota <= 0
|
||||
}
|
||||
@@ -91,6 +93,16 @@ const InvitationCard = ({
|
||||
{t('划转到余额')}
|
||||
</Button>
|
||||
</div>
|
||||
{!complianceConfirmed && (
|
||||
<Text
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{t('邀请奖励划转已禁用,管理员需先确认合规声明。')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 统计数据 */}
|
||||
<div className='grid grid-cols-3 gap-6 mt-4'>
|
||||
|
||||
+58
-48
@@ -96,6 +96,7 @@ const RechargeCard = ({
|
||||
activeSubscriptions = [],
|
||||
allSubscriptions = [],
|
||||
reloadSubscriptionSelf,
|
||||
enableRedemption = true,
|
||||
}) => {
|
||||
const onlineFormApiRef = useRef(null);
|
||||
const redeemFormApiRef = useRef(null);
|
||||
@@ -566,57 +567,66 @@ const RechargeCard = ({
|
||||
</Card>
|
||||
|
||||
{/* 兑换码充值 */}
|
||||
<Card
|
||||
className='!rounded-xl w-full'
|
||||
title={
|
||||
<Text type='tertiary' strong>
|
||||
{t('兑换码充值')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
getFormApi={(api) => (redeemFormApiRef.current = api)}
|
||||
initValues={{ redemptionCode: redemptionCode }}
|
||||
{enableRedemption ? (
|
||||
<Card
|
||||
className='!rounded-xl w-full'
|
||||
title={
|
||||
<Text type='tertiary' strong>
|
||||
{t('兑换码充值')}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Form.Input
|
||||
field='redemptionCode'
|
||||
noLabel={true}
|
||||
placeholder={t('请输入兑换码')}
|
||||
value={redemptionCode}
|
||||
onChange={(value) => setRedemptionCode(value)}
|
||||
prefix={<IconGift />}
|
||||
suffix={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={topUp}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t('兑换额度')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
extraText={
|
||||
topUpLink && (
|
||||
<Text type='tertiary'>
|
||||
{t('在找兑换码?')}
|
||||
<Text
|
||||
type='secondary'
|
||||
underline
|
||||
className='cursor-pointer'
|
||||
onClick={openTopUpLink}
|
||||
<Form
|
||||
getFormApi={(api) => (redeemFormApiRef.current = api)}
|
||||
initValues={{ redemptionCode: redemptionCode }}
|
||||
>
|
||||
<Form.Input
|
||||
field='redemptionCode'
|
||||
noLabel={true}
|
||||
placeholder={t('请输入兑换码')}
|
||||
value={redemptionCode}
|
||||
onChange={(value) => setRedemptionCode(value)}
|
||||
prefix={<IconGift />}
|
||||
suffix={
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
onClick={topUp}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t('购买兑换码')}
|
||||
{t('兑换额度')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
extraText={
|
||||
topUpLink && (
|
||||
<Text type='tertiary'>
|
||||
{t('在找兑换码?')}
|
||||
<Text
|
||||
type='secondary'
|
||||
underline
|
||||
className='cursor-pointer'
|
||||
onClick={openTopUpLink}
|
||||
>
|
||||
{t('购买兑换码')}
|
||||
</Text>
|
||||
</Text>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
<Banner
|
||||
type='warning'
|
||||
description={t('兑换码功能已禁用,管理员需先确认合规声明。')}
|
||||
closeIcon={null}
|
||||
className='!rounded-xl'
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
|
||||
+13
-1
@@ -110,6 +110,8 @@ const TopUp = () => {
|
||||
const [topupInfo, setTopupInfo] = useState({
|
||||
amount_options: [],
|
||||
discount: {},
|
||||
enable_redemption: true,
|
||||
payment_compliance_confirmed: true,
|
||||
});
|
||||
|
||||
const confirmPayMethods = [
|
||||
@@ -645,7 +647,7 @@ const TopUp = () => {
|
||||
? data.waffo_min_topup
|
||||
: enableWaffoPancakeTopUp
|
||||
? data.waffo_pancake_min_topup
|
||||
: 1;
|
||||
: 1;
|
||||
setEnableOnlineTopUp(enableOnlineTopUp);
|
||||
setEnableStripeTopUp(enableStripeTopUp);
|
||||
setEnableCreemTopUp(enableCreemTopUp);
|
||||
@@ -657,6 +659,14 @@ const TopUp = () => {
|
||||
setMinTopUp(minTopUpValue);
|
||||
setTopUpCount(minTopUpValue);
|
||||
setTopUpLink(data.topup_link || '');
|
||||
setTopupInfo((prev) => ({
|
||||
...prev,
|
||||
enable_redemption: data.enable_redemption !== false,
|
||||
payment_compliance_confirmed:
|
||||
data.payment_compliance_confirmed !== false,
|
||||
payment_compliance_terms_version:
|
||||
data.payment_compliance_terms_version || '',
|
||||
}));
|
||||
|
||||
// 设置 Creem 产品
|
||||
try {
|
||||
@@ -984,6 +994,7 @@ const TopUp = () => {
|
||||
activeSubscriptions={activeSubscriptions}
|
||||
allSubscriptions={allSubscriptions}
|
||||
reloadSubscriptionSelf={getSubscriptionSelf}
|
||||
enableRedemption={topupInfo.enable_redemption !== false}
|
||||
/>
|
||||
<InvitationCard
|
||||
t={t}
|
||||
@@ -992,6 +1003,7 @@ const TopUp = () => {
|
||||
setOpenTransfer={setOpenTransfer}
|
||||
affLink={affLink}
|
||||
handleAffLinkClick={handleAffLinkClick}
|
||||
complianceConfirmed={topupInfo.payment_compliance_confirmed !== false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user