164 lines
7.5 KiB
TypeScript
164 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { PlusOutlined } from "@ant-design/icons";
|
|
import { List } from "@refinedev/antd";
|
|
import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
const { Text } = Typography;
|
|
|
|
type PaymentPackage = {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
priceCents: number;
|
|
credits: number;
|
|
sortOrder: number;
|
|
enabled: boolean;
|
|
};
|
|
|
|
type PackageFormValues = Omit<PaymentPackage, "id" | "priceCents"> & { priceYuan: number };
|
|
|
|
function formatMoney(cents: number) {
|
|
return `¥${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
async function responsePayload(response: Response) {
|
|
const payload = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(payload.error || "请求失败");
|
|
return payload;
|
|
}
|
|
|
|
export function PackageManagement() {
|
|
const { message } = App.useApp();
|
|
const [packageForm] = Form.useForm<PackageFormValues>();
|
|
const [packages, setPackages] = useState<PaymentPackage[]>([]);
|
|
const [packagesLoading, setPackagesLoading] = useState(true);
|
|
const [packagesError, setPackagesError] = useState("");
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingPackage, setEditingPackage] = useState<PaymentPackage | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [disablingId, setDisablingId] = useState<string | null>(null);
|
|
|
|
const loadPackages = useCallback(async () => {
|
|
setPackagesLoading(true);
|
|
setPackagesError("");
|
|
try {
|
|
const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" }));
|
|
setPackages(payload.packages);
|
|
} catch (error) {
|
|
setPackagesError(error instanceof Error ? error.message : "读取套餐失败");
|
|
} finally {
|
|
setPackagesLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => void loadPackages(), 0);
|
|
return () => window.clearTimeout(timer);
|
|
}, [loadPackages]);
|
|
|
|
function openCreateModal() {
|
|
setEditingPackage(null);
|
|
packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true });
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function openEditModal(item: PaymentPackage) {
|
|
setEditingPackage(item);
|
|
packageForm.setFieldsValue({
|
|
name: item.name,
|
|
description: item.description,
|
|
priceYuan: item.priceCents / 100,
|
|
credits: item.credits,
|
|
sortOrder: item.sortOrder,
|
|
enabled: item.enabled,
|
|
});
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function closeModal() {
|
|
if (saving) return;
|
|
setModalOpen(false);
|
|
setEditingPackage(null);
|
|
packageForm.resetFields();
|
|
}
|
|
|
|
async function savePackage(values: PackageFormValues) {
|
|
setSaving(true);
|
|
try {
|
|
await responsePayload(await fetch("/api/admin/packages", {
|
|
method: editingPackage ? "PATCH" : "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
...(editingPackage ? { id: editingPackage.id } : {}),
|
|
name: values.name.trim(),
|
|
description: values.description?.trim() ?? "",
|
|
priceCents: Math.round(values.priceYuan * 100),
|
|
credits: values.credits,
|
|
sortOrder: values.sortOrder,
|
|
enabled: values.enabled,
|
|
}),
|
|
}));
|
|
message.success(editingPackage ? "套餐已更新" : "套餐已添加");
|
|
setModalOpen(false);
|
|
setEditingPackage(null);
|
|
packageForm.resetFields();
|
|
await loadPackages();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "保存套餐失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function disablePackage(id: string) {
|
|
setDisablingId(id);
|
|
try {
|
|
await responsePayload(await fetch("/api/admin/packages", {
|
|
method: "DELETE",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ id }),
|
|
}));
|
|
message.success("套餐已停用");
|
|
await loadPackages();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "停用套餐失败");
|
|
} finally {
|
|
setDisablingId(null);
|
|
}
|
|
}
|
|
|
|
const columns: TableColumnsType<PaymentPackage> = [
|
|
{ title: "名称", dataIndex: "name", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.description || "暂无描述"}</Text></Space> },
|
|
{ title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
|
|
{ title: "点数", dataIndex: "credits", align: "right" },
|
|
{ title: "排序", dataIndex: "sortOrder", align: "right" },
|
|
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Text type={enabled ? undefined : "secondary"}>{enabled ? "启用" : "停用"}</Text> },
|
|
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}>编辑</Button>{item.enabled && <Button type="link" danger loading={disablingId === item.id} onClick={() => void disablePackage(item.id)}>停用</Button>}</Space> },
|
|
];
|
|
|
|
return (
|
|
<List title="套餐管理">
|
|
<Card title="套餐设置" extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>添加套餐</Button>}>
|
|
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
|
{packagesError && <Alert type="error" showIcon message="套餐列表读取失败" description={packagesError} action={<Button size="small" onClick={() => void loadPackages()}>重试</Button>} />}
|
|
<Table<PaymentPackage> rowKey="id" columns={columns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
|
|
</Space>
|
|
</Card>
|
|
<Modal title={editingPackage ? "编辑套餐" : "添加套餐"} open={modalOpen} okText={editingPackage ? "保存修改" : "添加套餐"} cancelText="取消" confirmLoading={saving} onOk={() => packageForm.submit()} onCancel={closeModal} destroyOnHidden afterClose={() => packageForm.resetFields()} maskClosable={!saving} keyboard={!saving}>
|
|
<Form<PackageFormValues> form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}>
|
|
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入套餐名称" }, { max: 80 }]}><Input autoFocus /></Form.Item>
|
|
<Row gutter={16}>
|
|
<Col span={12}><Form.Item name="priceYuan" label="价格(元)" extra="保存时转换为 API 的 priceCents" rules={[{ required: true, message: "请输入价格" }]}><InputNumber min={0.01} max={1_000_000} precision={2} step={1} style={{ width: "100%" }} prefix="¥" /></Form.Item></Col>
|
|
<Col span={12}><Form.Item name="credits" label="点数" rules={[{ required: true, message: "请输入点数" }]}><InputNumber min={1} max={10_000_000} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
|
</Row>
|
|
<Form.Item name="sortOrder" label="排序" rules={[{ required: true, message: "请输入排序" }]}><InputNumber min={-100_000} max={100_000} precision={0} style={{ width: "100%" }} /></Form.Item>
|
|
<Form.Item name="description" label="描述" rules={[{ max: 500 }]}><Input.TextArea rows={3} showCount maxLength={500} /></Form.Item>
|
|
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</List>
|
|
);
|
|
}
|