| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import React, { useEffect, useState, useRef } from 'react'; |
| import { |
| Avatar, |
| Button, |
| Card, |
| Col, |
| Form, |
| Row, |
| Select, |
| SideSheet, |
| Space, |
| Spin, |
| Tag, |
| Typography, |
| } from '@douyinfe/semi-ui'; |
| import { |
| IconCalendarClock, |
| IconClose, |
| IconCreditCard, |
| IconSave, |
| } from '@douyinfe/semi-icons'; |
| import { Clock, RefreshCw } from 'lucide-react'; |
| import { API, showError, showSuccess } from '../../../../helpers'; |
| import { |
| quotaToDisplayAmount, |
| displayAmountToQuota, |
| } from '../../../../helpers/quota'; |
| import { useIsMobile } from '../../../../hooks/common/useIsMobile'; |
|
|
| const { Text, Title } = Typography; |
|
|
| const durationUnitOptions = [ |
| { value: 'year', label: '年' }, |
| { value: 'month', label: '月' }, |
| { value: 'day', label: '日' }, |
| { value: 'hour', label: '小时' }, |
| { value: 'custom', label: '自定义(秒)' }, |
| ]; |
|
|
| const resetPeriodOptions = [ |
| { value: 'never', label: '不重置' }, |
| { value: 'daily', label: '每天' }, |
| { value: 'weekly', label: '每周' }, |
| { value: 'monthly', label: '每月' }, |
| { value: 'custom', label: '自定义(秒)' }, |
| ]; |
|
|
| const AddEditSubscriptionModal = ({ |
| visible, |
| handleClose, |
| editingPlan, |
| placement = 'left', |
| refresh, |
| t, |
| }) => { |
| const [loading, setLoading] = useState(false); |
| const [groupOptions, setGroupOptions] = useState([]); |
| const [groupLoading, setGroupLoading] = useState(false); |
| const isMobile = useIsMobile(); |
| const formApiRef = useRef(null); |
| const isEdit = editingPlan?.plan?.id !== undefined; |
| const formKey = isEdit ? `edit-${editingPlan?.plan?.id}` : 'create'; |
|
|
| const getInitValues = () => ({ |
| title: '', |
| subtitle: '', |
| price_amount: 0, |
| currency: 'USD', |
| duration_unit: 'month', |
| duration_value: 1, |
| custom_seconds: 0, |
| quota_reset_period: 'never', |
| quota_reset_custom_seconds: 0, |
| enabled: true, |
| sort_order: 0, |
| max_purchase_per_user: 0, |
| total_amount: 0, |
| upgrade_group: '', |
| stripe_price_id: '', |
| creem_product_id: '', |
| }); |
|
|
| const buildFormValues = () => { |
| const base = getInitValues(); |
| if (editingPlan?.plan?.id === undefined) return base; |
| const p = editingPlan.plan || {}; |
| return { |
| ...base, |
| title: p.title || '', |
| subtitle: p.subtitle || '', |
| price_amount: Number(p.price_amount || 0), |
| currency: 'USD', |
| duration_unit: p.duration_unit || 'month', |
| duration_value: Number(p.duration_value || 1), |
| custom_seconds: Number(p.custom_seconds || 0), |
| quota_reset_period: p.quota_reset_period || 'never', |
| quota_reset_custom_seconds: Number(p.quota_reset_custom_seconds || 0), |
| enabled: p.enabled !== false, |
| sort_order: Number(p.sort_order || 0), |
| max_purchase_per_user: Number(p.max_purchase_per_user || 0), |
| total_amount: Number( |
| quotaToDisplayAmount(p.total_amount || 0).toFixed(2), |
| ), |
| upgrade_group: p.upgrade_group || '', |
| stripe_price_id: p.stripe_price_id || '', |
| creem_product_id: p.creem_product_id || '', |
| }; |
| }; |
|
|
| useEffect(() => { |
| if (!visible) return; |
| setGroupLoading(true); |
| API.get('/api/group') |
| .then((res) => { |
| if (res.data?.success) { |
| setGroupOptions(res.data?.data || []); |
| } else { |
| setGroupOptions([]); |
| } |
| }) |
| .catch(() => setGroupOptions([])) |
| .finally(() => setGroupLoading(false)); |
| }, [visible]); |
|
|
| const submit = async (values) => { |
| if (!values.title || values.title.trim() === '') { |
| showError(t('套餐标题不能为空')); |
| return; |
| } |
| setLoading(true); |
| try { |
| const payload = { |
| plan: { |
| ...values, |
| price_amount: Number(values.price_amount || 0), |
| currency: 'USD', |
| duration_value: Number(values.duration_value || 0), |
| custom_seconds: Number(values.custom_seconds || 0), |
| quota_reset_period: values.quota_reset_period || 'never', |
| quota_reset_custom_seconds: |
| values.quota_reset_period === 'custom' |
| ? Number(values.quota_reset_custom_seconds || 0) |
| : 0, |
| sort_order: Number(values.sort_order || 0), |
| max_purchase_per_user: Number(values.max_purchase_per_user || 0), |
| total_amount: displayAmountToQuota(values.total_amount), |
| upgrade_group: values.upgrade_group || '', |
| }, |
| }; |
| if (editingPlan?.plan?.id) { |
| const res = await API.put( |
| `/api/subscription/admin/plans/${editingPlan.plan.id}`, |
| payload, |
| ); |
| if (res.data?.success) { |
| showSuccess(t('更新成功')); |
| handleClose(); |
| refresh?.(); |
| } else { |
| showError(res.data?.message || t('更新失败')); |
| } |
| } else { |
| const res = await API.post('/api/subscription/admin/plans', payload); |
| if (res.data?.success) { |
| showSuccess(t('创建成功')); |
| handleClose(); |
| refresh?.(); |
| } else { |
| showError(res.data?.message || t('创建失败')); |
| } |
| } |
| } catch (e) { |
| showError(t('请求失败')); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| return ( |
| <> |
| <SideSheet |
| placement={placement} |
| title={ |
| <Space> |
| {isEdit ? ( |
| <Tag color='blue' shape='circle'> |
| {t('更新')} |
| </Tag> |
| ) : ( |
| <Tag color='green' shape='circle'> |
| {t('新建')} |
| </Tag> |
| )} |
| <Title heading={4} className='m-0'> |
| {isEdit ? t('更新套餐信息') : t('创建新的订阅套餐')} |
| </Title> |
| </Space> |
| } |
| bodyStyle={{ padding: '0' }} |
| visible={visible} |
| width={isMobile ? '100%' : 600} |
| footer={ |
| <div className='flex justify-end bg-white'> |
| <Space> |
| <Button |
| theme='solid' |
| onClick={() => formApiRef.current?.submitForm()} |
| icon={<IconSave />} |
| loading={loading} |
| > |
| {t('提交')} |
| </Button> |
| <Button |
| theme='light' |
| type='primary' |
| onClick={handleClose} |
| icon={<IconClose />} |
| > |
| {t('取消')} |
| </Button> |
| </Space> |
| </div> |
| } |
| closeIcon={null} |
| onCancel={handleClose} |
| > |
| <Spin spinning={loading}> |
| <Form |
| key={formKey} |
| initValues={buildFormValues()} |
| getFormApi={(api) => (formApiRef.current = api)} |
| onSubmit={submit} |
| > |
| {({ values }) => ( |
| <div className='p-2'> |
| {/* 基本信息 */} |
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> |
| <div className='flex items-center mb-2'> |
| <Avatar |
| size='small' |
| color='blue' |
| className='mr-2 shadow-md' |
| > |
| <IconCalendarClock size={16} /> |
| </Avatar> |
| <div> |
| <Text className='text-lg font-medium'> |
| {t('基本信息')} |
| </Text> |
| <div className='text-xs text-gray-600'> |
| {t('套餐的基本信息和定价')} |
| </div> |
| </div> |
| </div> |
| |
| <Row gutter={12}> |
| <Col span={24}> |
| <Form.Input |
| field='title' |
| label={t('套餐标题')} |
| placeholder={t('例如:基础套餐')} |
| required |
| rules={[ |
| { required: true, message: t('请输入套餐标题') }, |
| ]} |
| showClear |
| /> |
| </Col> |
| |
| <Col span={24}> |
| <Form.Input |
| field='subtitle' |
| label={t('套餐副标题')} |
| placeholder={t('例如:适合轻度使用')} |
| showClear |
| /> |
| </Col> |
| |
| <Col span={12}> |
| <Form.InputNumber |
| field='price_amount' |
| label={t('实付金额')} |
| required |
| min={0} |
| precision={2} |
| rules={[{ required: true, message: t('请输入金额') }]} |
| style={{ width: '100%' }} |
| /> |
| </Col> |
| |
| <Col span={12}> |
| <Form.InputNumber |
| field='total_amount' |
| label={t('总额度')} |
| required |
| min={0} |
| precision={2} |
| rules={[{ required: true, message: t('请输入总额度') }]} |
| extraText={`${t('0 表示不限')} · ${t('原生额度')}:${displayAmountToQuota( |
| values.total_amount, |
| )}`} |
| style={{ width: '100%' }} |
| /> |
| </Col> |
| |
| <Col span={12}> |
| <Form.Select |
| field='upgrade_group' |
| label={t('升级分组')} |
| showClear |
| loading={groupLoading} |
| placeholder={t('不升级')} |
| extraText={t( |
| '购买或手动新增订阅会升级到该分组;当套餐失效/过期或手动作废/删除后,将回退到升级前分组。回退不会立即生效,通常会有几分钟延迟。', |
| )} |
| > |
| <Select.Option value=''>{t('不升级')}</Select.Option> |
| {(groupOptions || []).map((g) => ( |
| <Select.Option key={g} value={g}> |
| {g} |
| </Select.Option> |
| ))} |
| </Form.Select> |
| </Col> |
| |
| <Col span={12}> |
| <Form.Input |
| field='currency' |
| label={t('币种')} |
| disabled |
| extraText={t('由全站货币展示设置统一控制')} |
| /> |
| </Col> |
| |
| <Col span={12}> |
| <Form.InputNumber |
| field='sort_order' |
| label={t('排序')} |
| precision={0} |
| style={{ width: '100%' }} |
| /> |
| </Col> |
| |
| <Col span={12}> |
| <Form.InputNumber |
| field='max_purchase_per_user' |
| label={t('购买上限')} |
| min={0} |
| precision={0} |
| extraText={t('0 表示不限')} |
| style={{ width: '100%' }} |
| /> |
| </Col> |
| |
| <Col span={12}> |
| <Form.Switch |
| field='enabled' |
| label={t('启用状态')} |
| size='large' |
| /> |
| </Col> |
| </Row> |
| </Card> |
| |
| {/* 有效期设置 */} |
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> |
| <div className='flex items-center mb-2'> |
| <Avatar |
| size='small' |
| color='green' |
| className='mr-2 shadow-md' |
| > |
| <Clock size={16} /> |
| </Avatar> |
| <div> |
| <Text className='text-lg font-medium'> |
| {t('有效期设置')} |
| </Text> |
| <div className='text-xs text-gray-600'> |
| {t('配置套餐的有效时长')} |
| </div> |
| </div> |
| </div> |
| |
| <Row gutter={12}> |
| <Col span={12}> |
| <Form.Select |
| field='duration_unit' |
| label={t('有效期单位')} |
| required |
| rules={[{ required: true }]} |
| > |
| {durationUnitOptions.map((o) => ( |
| <Select.Option key={o.value} value={o.value}> |
| {o.label} |
| </Select.Option> |
| ))} |
| </Form.Select> |
| </Col> |
| |
| <Col span={12}> |
| {values.duration_unit === 'custom' ? ( |
| <Form.InputNumber |
| field='custom_seconds' |
| label={t('自定义秒数')} |
| required |
| min={1} |
| precision={0} |
| rules={[{ required: true, message: t('请输入秒数') }]} |
| style={{ width: '100%' }} |
| /> |
| ) : ( |
| <Form.InputNumber |
| field='duration_value' |
| label={t('有效期数值')} |
| required |
| min={1} |
| precision={0} |
| rules={[{ required: true, message: t('请输入数值') }]} |
| style={{ width: '100%' }} |
| /> |
| )} |
| </Col> |
| </Row> |
| </Card> |
| |
| {/* 额度重置 */} |
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> |
| <div className='flex items-center mb-2'> |
| <Avatar |
| size='small' |
| color='orange' |
| className='mr-2 shadow-md' |
| > |
| <RefreshCw size={16} /> |
| </Avatar> |
| <div> |
| <Text className='text-lg font-medium'> |
| {t('额度重置')} |
| </Text> |
| <div className='text-xs text-gray-600'> |
| {t('支持周期性重置套餐权益额度')} |
| </div> |
| </div> |
| </div> |
| |
| <Row gutter={12}> |
| <Col span={12}> |
| <Form.Select |
| field='quota_reset_period' |
| label={t('重置周期')} |
| > |
| {resetPeriodOptions.map((o) => ( |
| <Select.Option key={o.value} value={o.value}> |
| {o.label} |
| </Select.Option> |
| ))} |
| </Form.Select> |
| </Col> |
| <Col span={12}> |
| {values.quota_reset_period === 'custom' ? ( |
| <Form.InputNumber |
| field='quota_reset_custom_seconds' |
| label={t('自定义秒数')} |
| required |
| min={60} |
| precision={0} |
| rules={[{ required: true, message: t('请输入秒数') }]} |
| style={{ width: '100%' }} |
| /> |
| ) : ( |
| <Form.InputNumber |
| field='quota_reset_custom_seconds' |
| label={t('自定义秒数')} |
| min={0} |
| precision={0} |
| style={{ width: '100%' }} |
| disabled |
| /> |
| )} |
| </Col> |
| </Row> |
| </Card> |
| |
| {/* 第三方支付配置 */} |
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> |
| <div className='flex items-center mb-2'> |
| <Avatar |
| size='small' |
| color='purple' |
| className='mr-2 shadow-md' |
| > |
| <IconCreditCard size={16} /> |
| </Avatar> |
| <div> |
| <Text className='text-lg font-medium'> |
| {t('第三方支付配置')} |
| </Text> |
| <div className='text-xs text-gray-600'> |
| {t('Stripe/Creem 商品ID(可选)')} |
| </div> |
| </div> |
| </div> |
| |
| <Row gutter={12}> |
| <Col span={24}> |
| <Form.Input |
| field='stripe_price_id' |
| label='Stripe PriceId' |
| placeholder='price_...' |
| showClear |
| /> |
| </Col> |
| |
| <Col span={24}> |
| <Form.Input |
| field='creem_product_id' |
| label='Creem ProductId' |
| placeholder='prod_...' |
| showClear |
| /> |
| </Col> |
| </Row> |
| </Card> |
| </div> |
| )} |
| </Form> |
| </Spin> |
| </SideSheet> |
| </> |
| ); |
| }; |
|
|
| export default AddEditSubscriptionModal; |
|
|