Files
Jyotisha/frontend/src/components/admin/package-management.tsx
T
Jesse_Chen 945d61fa1c
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(admin): remove redundant confirmations and repair code access
2026-08-16 17:55:15 +08:00

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>
);
}