470 lines
18 KiB
TypeScript
470 lines
18 KiB
TypeScript
import React, { useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Button, Card, DatePicker, Form, Input, InputNumber, message, Modal, Popconfirm,
|
|
Select, Space, Switch, Table, Tabs, Tag, Typography,
|
|
} from 'antd';
|
|
import {
|
|
CheckCircleOutlined, DeleteOutlined, EditOutlined, PlusOutlined, ShoppingOutlined, StopOutlined,
|
|
} from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import {
|
|
createCreditProduct,
|
|
getCreditProducts,
|
|
setCreditProductRenewal,
|
|
setCreditProductStatus,
|
|
softDeleteCreditProduct,
|
|
updateCreditProduct,
|
|
} from '../api';
|
|
import type { CreditProduct, CreditProductType } from '../types';
|
|
|
|
const cycleLabel: Record<string, string> = { monthly: '月', quarterly: '季', yearly: '年' };
|
|
|
|
const formatMoney = (value?: number | null): string => {
|
|
const amount = Number(value);
|
|
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : '-';
|
|
};
|
|
|
|
const isSubscriptionType = (value: CreditProductType) => value === 'subscription' || value === 'team_subscription';
|
|
|
|
const AdminCreditProducts: React.FC = () => {
|
|
const [type, setType] = useState<CreditProductType>('subscription');
|
|
const [items, setItems] = useState<CreditProduct[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [editing, setEditing] = useState<CreditProduct | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [renewalEnabled, setRenewalEnabled] = useState(true);
|
|
const [form] = Form.useForm();
|
|
|
|
const replaceItem = (product: CreditProduct) => {
|
|
setItems((current) => current.map((item) => (item.id === product.id ? product : item)));
|
|
};
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
setItems(await getCreditProducts(type));
|
|
} catch (error: any) {
|
|
message.error(error?.message || '加载积分商品失败');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [type]);
|
|
|
|
const openEditor = (item?: CreditProduct) => {
|
|
const nextRenewalEnabled = item ? item.renewalEnabled === true : true;
|
|
setEditing(item || null);
|
|
setRenewalEnabled(nextRenewalEnabled);
|
|
setOpen(true);
|
|
form.resetFields();
|
|
if (item) {
|
|
form.setFieldsValue({
|
|
productCode: item.productCode,
|
|
name: item.name,
|
|
description: item.description || undefined,
|
|
featuresText: (item.features || []).join('\n'),
|
|
tierCode: item.tierCode,
|
|
tierRank: item.tierRank,
|
|
billingCycle: item.billingCycle,
|
|
monthlyGrantCredits: item.monthlyGrantCredits,
|
|
firstPurchasePrice: item.firstPurchasePrice,
|
|
regularPrice: item.regularPrice,
|
|
activityPrice: item.activityPrice,
|
|
activityRange: item.activityStartAt && item.activityEndAt
|
|
? [dayjs(item.activityStartAt), dayjs(item.activityEndAt)]
|
|
: undefined,
|
|
renewalEnabled: nextRenewalEnabled,
|
|
price: item.price,
|
|
grantCredits: item.grantCredits,
|
|
validityMonths: item.validityMonths ?? 1,
|
|
sortOrder: item.sortOrder,
|
|
isActive: item.isActive,
|
|
});
|
|
return;
|
|
}
|
|
form.setFieldsValue({
|
|
productCode: undefined,
|
|
name: undefined,
|
|
description: undefined,
|
|
featuresText: undefined,
|
|
isActive: true,
|
|
sortOrder: 0,
|
|
billingCycle: 'monthly',
|
|
tierRank: 1,
|
|
renewalEnabled: true,
|
|
validityMonths: 1,
|
|
});
|
|
};
|
|
|
|
const save = async () => {
|
|
try {
|
|
const values = await form.validateFields();
|
|
const activityRange = values.activityRange || [];
|
|
const payload: Record<string, unknown> = {
|
|
name: values.name,
|
|
description: values.description || null,
|
|
features: String(values.featuresText || '')
|
|
.split('\n')
|
|
.map((value) => value.trim())
|
|
.filter(Boolean),
|
|
credit_level: 'general',
|
|
currency: 'CNY',
|
|
sort_order: values.sortOrder ?? 0,
|
|
};
|
|
if (!editing) {
|
|
payload.product_code = values.productCode;
|
|
payload.product_type = type;
|
|
payload.is_active = values.isActive ?? true;
|
|
}
|
|
|
|
if (isSubscriptionType(type)) {
|
|
if (!editing) {
|
|
Object.assign(payload, {
|
|
tier_code: values.tierCode,
|
|
tier_rank: values.tierRank,
|
|
billing_cycle: values.billingCycle,
|
|
});
|
|
}
|
|
Object.assign(payload, {
|
|
monthly_grant_credits: values.monthlyGrantCredits,
|
|
first_purchase_price: values.firstPurchasePrice,
|
|
regular_price: values.regularPrice,
|
|
activity_price: values.activityPrice ?? null,
|
|
activity_start_at: activityRange[0]?.toISOString() || null,
|
|
activity_end_at: activityRange[1]?.toISOString() || null,
|
|
renewal_enabled: renewalEnabled,
|
|
price: values.regularPrice,
|
|
});
|
|
} else {
|
|
Object.assign(payload, {
|
|
price: values.price,
|
|
grant_credits: values.grantCredits,
|
|
validity_months: values.validityMonths,
|
|
});
|
|
}
|
|
|
|
let savedProduct = editing
|
|
? await updateCreditProduct(editing.id, payload)
|
|
: await createCreditProduct(payload);
|
|
|
|
if (editing && savedProduct.isActive !== (values.isActive === true)) {
|
|
savedProduct = await setCreditProductStatus(editing.id, values.isActive === true);
|
|
}
|
|
|
|
if (isSubscriptionType(type) && savedProduct.renewalEnabled !== renewalEnabled) {
|
|
throw new Error('续费开关保存结果与提交值不一致,请刷新后重试');
|
|
}
|
|
|
|
if (editing) {
|
|
replaceItem(savedProduct);
|
|
} else {
|
|
setItems((current) => [...current, savedProduct].sort((a, b) => {
|
|
const sortDiff = Number(a.sortOrder || 0) - Number(b.sortOrder || 0);
|
|
return sortDiff !== 0 ? sortDiff : a.id.localeCompare(b.id);
|
|
}));
|
|
}
|
|
message.success(editing ? '商品已更新' : '商品已创建');
|
|
setOpen(false);
|
|
setEditing(null);
|
|
setRenewalEnabled(true);
|
|
form.resetFields();
|
|
} catch (error: any) {
|
|
if (error?.errorFields) return;
|
|
message.error(error?.message || '保存失败');
|
|
}
|
|
};
|
|
|
|
const toggleRenewal = async (product: CreditProduct, enabled: boolean) => {
|
|
const previous = product.renewalEnabled === true;
|
|
const optimistic = { ...product, renewalEnabled: enabled };
|
|
replaceItem(optimistic);
|
|
if (editing?.id === product.id) {
|
|
setEditing(optimistic);
|
|
setRenewalEnabled(enabled);
|
|
form.setFieldValue('renewalEnabled', enabled);
|
|
}
|
|
|
|
try {
|
|
const savedProduct = await setCreditProductRenewal(product.id, enabled);
|
|
if (savedProduct.renewalEnabled !== enabled) {
|
|
throw new Error('续费开关保存结果与提交值不一致');
|
|
}
|
|
replaceItem(savedProduct);
|
|
if (editing?.id === product.id) {
|
|
setEditing(savedProduct);
|
|
setRenewalEnabled(savedProduct.renewalEnabled === true);
|
|
form.setFieldValue('renewalEnabled', savedProduct.renewalEnabled === true);
|
|
}
|
|
message.success(enabled ? '已开启续费' : '已关闭续费');
|
|
} catch (error: any) {
|
|
const reverted = { ...product, renewalEnabled: previous };
|
|
replaceItem(reverted);
|
|
if (editing?.id === product.id) {
|
|
setEditing(reverted);
|
|
setRenewalEnabled(previous);
|
|
form.setFieldValue('renewalEnabled', previous);
|
|
}
|
|
message.error(error?.message || '更新续费状态失败');
|
|
}
|
|
};
|
|
|
|
const toggleStatus = async (row: CreditProduct) => {
|
|
try {
|
|
const saved = await setCreditProductStatus(row.id, !row.isActive);
|
|
replaceItem(saved);
|
|
message.success(saved.isActive ? '已上架' : '已下架');
|
|
} catch (error: any) {
|
|
message.error(error?.message || '商品状态更新失败');
|
|
}
|
|
};
|
|
|
|
const removeProduct = async (row: CreditProduct) => {
|
|
try {
|
|
await softDeleteCreditProduct(row.id);
|
|
message.success('商品已软删除,商品编码永久保留且不能恢复');
|
|
await load();
|
|
} catch (error: any) {
|
|
message.error(error?.message || '商品删除失败');
|
|
}
|
|
};
|
|
|
|
const columns = useMemo(() => isSubscriptionType(type) ? [
|
|
{
|
|
title: '套餐',
|
|
key: 'name',
|
|
render: (_: unknown, row: CreditProduct) => <>
|
|
<Typography.Text strong>{row.name}</Typography.Text>
|
|
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
|
|
</>,
|
|
},
|
|
{
|
|
title: '等级',
|
|
key: 'tier',
|
|
render: (_: unknown, row: CreditProduct) => <Tag>{row.tierCode || '-'} / {row.tierRank ?? '-'}</Tag>,
|
|
},
|
|
{
|
|
title: '周期',
|
|
dataIndex: 'billingCycle',
|
|
render: (value?: string | null) => value ? cycleLabel[value] || value : '-',
|
|
},
|
|
{
|
|
title: '每月积分',
|
|
dataIndex: 'monthlyGrantCredits',
|
|
render: (value?: number) => Number(value || 0).toLocaleString(),
|
|
},
|
|
{
|
|
title: '首充价',
|
|
dataIndex: 'firstPurchasePrice',
|
|
render: (value?: number | null) => formatMoney(value),
|
|
},
|
|
{
|
|
title: '原价',
|
|
dataIndex: 'regularPrice',
|
|
render: (value?: number | null) => formatMoney(value),
|
|
},
|
|
{
|
|
title: '活动价',
|
|
key: 'activity',
|
|
render: (_: unknown, row: CreditProduct) => row.activityPrice == null
|
|
? '-'
|
|
: <Tag color="red">{formatMoney(row.activityPrice)}</Tag>,
|
|
},
|
|
{
|
|
title: '允许续费',
|
|
dataIndex: 'renewalEnabled',
|
|
render: (enabled: boolean, row: CreditProduct) => row.isDeleted
|
|
? <Typography.Text type="secondary">-</Typography.Text>
|
|
: <Switch
|
|
checked={enabled === true}
|
|
checkedChildren="开启"
|
|
unCheckedChildren="关闭"
|
|
onChange={(checked) => void toggleRenewal(row, checked)}
|
|
/>,
|
|
},
|
|
{
|
|
title: '状态',
|
|
key: 'status',
|
|
render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
|
|
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
|
|
</Tag>,
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'action',
|
|
render: (_: unknown, row: CreditProduct) => row.isDeleted
|
|
? <Typography.Text type="secondary">已软删除,不可恢复</Typography.Text>
|
|
: <Space>
|
|
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
|
<Popconfirm
|
|
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
|
|
onConfirm={() => void toggleStatus(row)}
|
|
>
|
|
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
|
{row.isActive ? '下架' : '上架'}
|
|
</Button>
|
|
</Popconfirm>
|
|
<Popconfirm
|
|
title="确认软删除该商品?"
|
|
description="软删除后不能恢复,商品编码永久保留且不能复用;历史订单和订阅不受影响。"
|
|
onConfirm={() => void removeProduct(row)}
|
|
>
|
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
|
</Popconfirm>
|
|
</Space>,
|
|
},
|
|
] : [
|
|
{
|
|
title: '增值包',
|
|
key: 'name',
|
|
render: (_: unknown, row: CreditProduct) => <>
|
|
<Typography.Text strong>{row.name}</Typography.Text>
|
|
<div><Typography.Text type="secondary">{row.productCode}</Typography.Text></div>
|
|
</>,
|
|
},
|
|
{ title: '积分', dataIndex: 'grantCredits', render: (value?: number) => Number(value || 0).toLocaleString() },
|
|
{ title: '价格', dataIndex: 'price', render: (value?: number | null) => formatMoney(value) },
|
|
{ title: '有效期', dataIndex: 'validityMonths', render: (value?: number | null) => `${Number(value || 1)} 个月` },
|
|
{
|
|
title: '状态', key: 'status', render: (_: unknown, row: CreditProduct) => <Tag color={row.isDeleted ? 'default' : row.isActive ? 'green' : 'default'}>
|
|
{row.isDeleted ? '已删除' : row.isActive ? '上架' : '下架'}
|
|
</Tag>,
|
|
},
|
|
{
|
|
title: '操作',
|
|
key: 'action',
|
|
render: (_: unknown, row: CreditProduct) => row.isDeleted
|
|
? <Typography.Text type="secondary">已软删除,不可恢复</Typography.Text>
|
|
: <Space>
|
|
<Button type="link" icon={<EditOutlined />} onClick={() => openEditor(row)}>编辑</Button>
|
|
<Popconfirm
|
|
title={`确认${row.isActive ? '下架' : '重新上架'}该商品?`}
|
|
onConfirm={() => void toggleStatus(row)}
|
|
>
|
|
<Button type="link" danger={row.isActive} icon={row.isActive ? <StopOutlined /> : <CheckCircleOutlined />}>
|
|
{row.isActive ? '下架' : '上架'}
|
|
</Button>
|
|
</Popconfirm>
|
|
<Popconfirm
|
|
title="确认软删除该商品?"
|
|
description="软删除后不能恢复,商品编码永久保留且不能复用。"
|
|
onConfirm={() => void removeProduct(row)}
|
|
>
|
|
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
|
|
</Popconfirm>
|
|
</Space>,
|
|
},
|
|
], [type, items, editing]);
|
|
|
|
const createButtonLabel = type === 'subscription'
|
|
? '新增个人订阅套餐'
|
|
: type === 'team_subscription'
|
|
? '新增团队订阅套餐'
|
|
: '新增积分增值包';
|
|
|
|
return <Card
|
|
title={<Space><ShoppingOutlined />积分产品</Space>}
|
|
extra={<Button type="primary" icon={<PlusOutlined />} onClick={() => openEditor()}>{createButtonLabel}</Button>}
|
|
>
|
|
<Tabs
|
|
activeKey={type}
|
|
onChange={(key) => setType(key as CreditProductType)}
|
|
items={[
|
|
{ key: 'subscription', label: '个人订阅套餐' },
|
|
{ key: 'team_subscription', label: '团队订阅套餐' },
|
|
{ key: 'credit_addon', label: '积分增值包' },
|
|
]}
|
|
/>
|
|
<Table rowKey="id" loading={loading} dataSource={items} columns={columns as any} scroll={{ x: 1200 }} />
|
|
<Modal
|
|
open={open}
|
|
title={editing ? '编辑积分商品' : '新增积分商品'}
|
|
onOk={save}
|
|
onCancel={() => { setOpen(false); setEditing(null); setRenewalEnabled(true); form.resetFields(); }}
|
|
width={760}
|
|
destroyOnClose
|
|
>
|
|
<Form form={form} layout="vertical">
|
|
<Space align="start" style={{ width: '100%' }} size={16}>
|
|
<Form.Item
|
|
name="productCode"
|
|
label="商品编码"
|
|
rules={[{ required: true }]}
|
|
extra={editing ? '商品编码创建后永久不可修改。' : undefined}
|
|
>
|
|
<Input disabled={!!editing} />
|
|
</Form.Item>
|
|
<Form.Item name="name" label="商品名称" rules={[{ required: true }]}>
|
|
<Input style={{ width: 260 }} />
|
|
</Form.Item>
|
|
<Form.Item name="sortOrder" label="排序"><InputNumber /></Form.Item>
|
|
<Form.Item name="isActive" label="上架" valuePropName="checked"><Switch /></Form.Item>
|
|
</Space>
|
|
{isSubscriptionType(type) ? <>
|
|
<Space align="start" style={{ width: '100%' }} size={16}>
|
|
<Form.Item name="tierCode" label="套餐等级编码" rules={[{ required: true }]}><Input disabled={!!editing} /></Form.Item>
|
|
<Form.Item name="tierRank" label="等级顺序" rules={[{ required: true }]}><InputNumber min={1} disabled={!!editing} /></Form.Item>
|
|
<Form.Item name="billingCycle" label="订阅周期" rules={[{ required: true }]}>
|
|
<Select
|
|
disabled={!!editing}
|
|
style={{ width: 140 }}
|
|
options={[
|
|
{ value: 'monthly', label: '月' },
|
|
{ value: 'quarterly', label: '季' },
|
|
{ value: 'yearly', label: '年' },
|
|
]}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="monthlyGrantCredits" label="每月发放积分" rules={[{ required: true }]}>
|
|
<InputNumber min={0.01} />
|
|
</Form.Item>
|
|
</Space>
|
|
<Space align="start" size={16} wrap>
|
|
<Form.Item name="firstPurchasePrice" label="首充价格" rules={[{ required: true }]}>
|
|
<InputNumber min={0} precision={2} />
|
|
</Form.Item>
|
|
<Form.Item name="regularPrice" label="原价/续费价" rules={[{ required: true }]}>
|
|
<InputNumber min={0} precision={2} />
|
|
</Form.Item>
|
|
<Form.Item name="activityPrice" label="活动价"><InputNumber min={0} precision={2} /></Form.Item>
|
|
<Form.Item name="activityRange" label="活动周期"><DatePicker.RangePicker showTime /></Form.Item>
|
|
<Form.Item
|
|
label="允许续费"
|
|
tooltip="关闭后,已失去对应首购资格的用户不能再次购买该套餐;不影响已创建待付款订单、已持有订阅和季/年卡内部月度发放。"
|
|
>
|
|
<Switch
|
|
checked={renewalEnabled}
|
|
checkedChildren="开启"
|
|
unCheckedChildren="关闭"
|
|
onChange={(checked) => {
|
|
setRenewalEnabled(checked);
|
|
form.setFieldValue('renewalEnabled', checked);
|
|
}}
|
|
/>
|
|
</Form.Item>
|
|
</Space>
|
|
</> : <Space align="start" size={16}>
|
|
<Form.Item name="grantCredits" label="积分数量" rules={[{ required: true }]}>
|
|
<InputNumber min={0.01} />
|
|
</Form.Item>
|
|
<Form.Item name="price" label="价格" rules={[{ required: true }]}>
|
|
<InputNumber min={0} precision={2} />
|
|
</Form.Item>
|
|
<Form.Item name="validityMonths" label="有效期" rules={[{ required: true, message: '请选择有效期' }]}>
|
|
<Select
|
|
style={{ width: 140 }}
|
|
options={Array.from({ length: 36 }, (_, index) => ({ value: index + 1, label: `${index + 1} 个月` }))}
|
|
/>
|
|
</Form.Item>
|
|
</Space>}
|
|
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
|
|
<Form.Item name="featuresText" label="权益说明(每行一项)"><Input.TextArea rows={4} /></Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</Card>;
|
|
};
|
|
|
|
export default AdminCreditProducts;
|