Files
video-gen/video-gen-admin/src/pages/AdminCreditProducts.tsx
T
2026-08-14 15:23:46 +08:00

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;