274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { PlusOutlined } from "@ant-design/icons";
|
||
import { useGetIdentity } from "@refinedev/core";
|
||
import { useTable } from "@refinedev/antd";
|
||
import { List } from "@refinedev/antd";
|
||
import {
|
||
App,
|
||
Button,
|
||
Col,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
type TableColumnsType,
|
||
} from "antd";
|
||
import { useState } from "react";
|
||
|
||
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
||
import { ReasonActionModal } from "./reason-action-modal";
|
||
import { formatAdminDate } from "./resource-table";
|
||
|
||
const { Text } = Typography;
|
||
|
||
type Entitlement = {
|
||
featureKey: string;
|
||
allowanceType: string;
|
||
allowanceCount: number | null;
|
||
resetPeriod: string;
|
||
modelTier?: string | null;
|
||
fairUsePolicyId?: string | null;
|
||
metadata: Record<string, unknown>;
|
||
};
|
||
|
||
type Product = {
|
||
id: string;
|
||
code: string;
|
||
version: number;
|
||
name: string;
|
||
description: string;
|
||
productType: "credit_pack" | "trial" | "subscription";
|
||
billingPeriod: "none" | "day" | "month" | "year";
|
||
intervalCount: number;
|
||
priceCents: number;
|
||
currency: string;
|
||
enabled: boolean;
|
||
status: string;
|
||
sortOrder: number;
|
||
oneTimePerUser: boolean;
|
||
effectiveFrom: string | null;
|
||
updatedAt: string;
|
||
entitlements: Entitlement[];
|
||
};
|
||
|
||
type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "effectiveFrom" | "updatedAt" | "entitlements"> & {
|
||
id?: string;
|
||
priceYuan: number;
|
||
entitlementsJson: string;
|
||
};
|
||
|
||
type PendingProductSave = Record<string, unknown>;
|
||
type ProductFilters = { q?: string; status?: string };
|
||
|
||
const defaultEntitlements: Entitlement[] = [{
|
||
featureKey: "chat.standard",
|
||
allowanceType: "unlimited",
|
||
allowanceCount: null,
|
||
resetPeriod: "billing_period",
|
||
modelTier: "standard",
|
||
fairUsePolicyId: null,
|
||
metadata: { minuteLimit: 6, dayLimit: 100 },
|
||
}];
|
||
|
||
export default function ProductManagement() {
|
||
const { message } = App.useApp();
|
||
const { data: identity } = useGetIdentity<AdminIdentity>();
|
||
const table = useTable<Product, { message: string; statusCode: number }, ProductFilters>({
|
||
resource: "products",
|
||
syncWithLocation: true,
|
||
pagination: { pageSize: 20 },
|
||
onSearch: ({ q, status }) => [
|
||
{ field: "q", operator: "contains", value: q },
|
||
{ field: "status", operator: "eq", value: status },
|
||
],
|
||
});
|
||
const [form] = Form.useForm<ProductForm>();
|
||
const [editing, setEditing] = useState<Product | null>(null);
|
||
const [open, setOpen] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [publishingId, setPublishingId] = useState<string | null>(null);
|
||
const [publishTarget, setPublishTarget] = useState<Product | null>(null);
|
||
const [pendingSave, setPendingSave] = useState<PendingProductSave | null>(null);
|
||
const canWrite = Boolean(identity?.permissions.includes("billing.products.write"));
|
||
const canPublish = Boolean(identity?.permissions.includes("billing.products.publish"));
|
||
|
||
function openProduct(product?: Product) {
|
||
setEditing(product ?? null);
|
||
form.setFieldsValue(product ? {
|
||
id: product.id,
|
||
code: product.code,
|
||
name: product.name,
|
||
description: product.description,
|
||
productType: product.productType,
|
||
billingPeriod: product.billingPeriod,
|
||
intervalCount: product.intervalCount,
|
||
priceYuan: product.priceCents / 100,
|
||
currency: product.currency,
|
||
enabled: product.enabled,
|
||
sortOrder: product.sortOrder,
|
||
oneTimePerUser: product.oneTimePerUser,
|
||
entitlementsJson: JSON.stringify(product.entitlements, null, 2),
|
||
} : {
|
||
code: "",
|
||
name: "",
|
||
description: "",
|
||
productType: "subscription",
|
||
billingPeriod: "month",
|
||
intervalCount: 1,
|
||
priceYuan: 99,
|
||
currency: "CNY",
|
||
enabled: false,
|
||
sortOrder: 0,
|
||
oneTimePerUser: false,
|
||
entitlementsJson: JSON.stringify(defaultEntitlements, null, 2),
|
||
});
|
||
setOpen(true);
|
||
}
|
||
|
||
function prepareSave(values: ProductForm) {
|
||
let entitlements: unknown;
|
||
try {
|
||
entitlements = JSON.parse(values.entitlementsJson);
|
||
} catch {
|
||
message.error("权益 JSON 格式不正确");
|
||
return;
|
||
}
|
||
setPendingSave({
|
||
action: "save",
|
||
id: editing?.id ?? null,
|
||
code: values.code.trim(),
|
||
name: values.name.trim(),
|
||
description: values.description?.trim() ?? "",
|
||
productType: values.productType,
|
||
billingPeriod: values.billingPeriod,
|
||
intervalCount: values.intervalCount,
|
||
priceCents: Math.round(values.priceYuan * 100),
|
||
currency: values.currency.toUpperCase(),
|
||
enabled: values.enabled,
|
||
sortOrder: values.sortOrder,
|
||
oneTimePerUser: values.oneTimePerUser,
|
||
entitlements,
|
||
});
|
||
}
|
||
|
||
async function save(reason: string) {
|
||
if (!pendingSave) return;
|
||
setSaving(true);
|
||
try {
|
||
await adminRequestJson("/api/admin/products", {
|
||
method: "POST",
|
||
body: JSON.stringify({ ...pendingSave, reason }),
|
||
});
|
||
message.success("商品草稿已保存");
|
||
setPendingSave(null);
|
||
setOpen(false);
|
||
form.resetFields();
|
||
await table.tableQuery.refetch();
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function publish(product: Product, reason: string) {
|
||
setPublishingId(product.id);
|
||
try {
|
||
await adminRequestJson("/api/admin/products", {
|
||
method: "POST",
|
||
body: JSON.stringify({ action: "publish", id: product.id, reason }),
|
||
});
|
||
message.success("商品已发布");
|
||
await table.tableQuery.refetch();
|
||
setPublishTarget(null);
|
||
} finally {
|
||
setPublishingId(null);
|
||
}
|
||
}
|
||
|
||
const columns: TableColumnsType<Product> = [
|
||
{
|
||
title: "商品",
|
||
dataIndex: "name",
|
||
render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code} · v{item.version}</Text></Space>,
|
||
},
|
||
{ title: "类型", dataIndex: "productType", render: (value) => <Tag>{value}</Tag> },
|
||
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` },
|
||
{ title: "价格", render: (_, item) => `¥${(item.priceCents / 100).toFixed(2)}` },
|
||
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}: {item.allowanceType}</Tag>)}</Space> },
|
||
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue">可售</Tag> : <Tag>停用</Tag>}</Space> },
|
||
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
|
||
{
|
||
title: "操作",
|
||
fixed: "right",
|
||
render: (_, item) => <Space>
|
||
{canWrite && <Button type="link" onClick={() => openProduct(item)}>编辑草稿</Button>}
|
||
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}>发布</Button>}
|
||
</Space>,
|
||
},
|
||
];
|
||
|
||
return <List title="商品与权益" headerButtons={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openProduct()}>新建商品</Button> : null}>
|
||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||
<Form {...table.searchFormProps} layout="inline" style={{ rowGap: 8 }}>
|
||
<Form.Item name="q" label="搜索"><Input.Search allowClear placeholder="商品名称或代码" /></Form.Item>
|
||
<Form.Item name="status" label="状态">
|
||
<Select
|
||
allowClear
|
||
placeholder="全部状态"
|
||
style={{ minWidth: 140 }}
|
||
options={["draft", "published", "retired"].map((value) => ({ value, label: value }))}
|
||
/>
|
||
</Form.Item>
|
||
</Form>
|
||
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
|
||
</Space>
|
||
<Modal title={editing ? `编辑 ${editing.name}` : "新建商品"} open={open} width={860} confirmLoading={saving} okText="保存草稿" cancelText="取消" onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
|
||
<Form<ProductForm> form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional">
|
||
<Row gutter={16}>
|
||
<Col xs={24} md={8}><Form.Item name="code" label="商品代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_]{1,79}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item></Col>
|
||
<Col xs={24} md={8}><Form.Item name="name" label="名称" rules={[{ required: true }, { max: 80 }]}><Input /></Form.Item></Col>
|
||
<Col xs={24} md={8}><Form.Item name="priceYuan" label="价格(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: "100%" }} /></Form.Item></Col>
|
||
</Row>
|
||
<Row gutter={16}>
|
||
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={["credit_pack", "trial", "subscription"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
|
||
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={["none", "day", "month", "year"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
|
||
<Col xs={24} md={12} lg={6}><Form.Item name="intervalCount" label="周期数量" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||
<Col xs={24} md={12} lg={6}><Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}><InputNumber precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||
</Row>
|
||
<Form.Item name="description" label="购买页说明" rules={[{ max: 1000 }]}><Input.TextArea rows={2} showCount maxLength={1000} /></Form.Item>
|
||
<Form.Item name="entitlementsJson" label="权益 JSON" extra="每项必须包含 featureKey、allowanceType、allowanceCount、resetPeriod、metadata。" rules={[{ required: true }]}><Input.TextArea rows={10} spellCheck={false} /></Form.Item>
|
||
<Row gutter={16}>
|
||
<Col xs={24} md={8}><Form.Item name="currency" label="币种" rules={[{ required: true }, { len: 3 }]}><Input /></Form.Item></Col>
|
||
<Col xs={24} md={8}><Form.Item name="enabled" label="可售" valuePropName="checked"><Switch /></Form.Item></Col>
|
||
<Col xs={24} md={8}><Form.Item name="oneTimePerUser" label="每人限购一次" valuePropName="checked"><Switch /></Form.Item></Col>
|
||
</Row>
|
||
</Form>
|
||
</Modal>
|
||
<ReasonActionModal
|
||
open={Boolean(pendingSave)}
|
||
title="保存商品草稿"
|
||
okText="验证并保存"
|
||
confirmLoading={saving}
|
||
reauthPermission="billing.products.write"
|
||
onCancel={() => setPendingSave(null)}
|
||
onSubmit={save}
|
||
/>
|
||
<ReasonActionModal
|
||
open={Boolean(publishTarget)}
|
||
title={`发布商品${publishTarget ? `:${publishTarget.name}` : ""}`}
|
||
okText="确认发布"
|
||
confirmLoading={Boolean(publishingId)}
|
||
reauthPermission="billing.products.publish"
|
||
onCancel={() => setPublishTarget(null)}
|
||
onSubmit={(reason) => publish(publishTarget!, reason)}
|
||
/>
|
||
</List>;
|
||
}
|